存儲優化系列專題,來聊一聊開發過程中常見存儲方法的優缺點,希望可以幫你在日常工作中如何做出更好的選擇。
Android 存儲優化系列專題
- SharedPreferences 系列
《Android 之不要濫用 SharedPreferences(上)》
《Android 之不要濫用 SharedPreferences(下)》
- ContentProvider 系列(待更)
《Android 存儲選項之 ContentProvider 的啟動性能》
《Android 存儲選項之 ContentProvider 深入分析》
- 對象序列化系列
《Android 對象序列化之你不知道的 Serializable》
《Android 對象序列化之 Parcelable 取代 Serializable ?》
《Android 對象序列化之追求性能完美的 Serial》
- 數據序列化系列(待更)
《Android 數據序列化之 JSON》
《Android 數據序列化之 Protocol Buffer》
《基于 MMAP 的高性能 K-V 組件之 MMKV》
- SQLite 存儲系列
《Android 存儲選項之 SQLiteDatabase 創建過程源碼分析》
《Android 存儲選項之 SQLiteDatabase 源碼分析》
《數據庫連接池 SQLiteConnectionPool 源碼分析》
《SQLiteDatabase 啟用事務源碼分析》
《SQLite 數據庫 WAL 模式工作原理簡介》
《SQLite 數據庫鎖機制與事務簡介》
《SQLite 數據庫優化那些事兒》
大多數開發者都曾經遇到過在存儲設計上的問題,該問題也是被廣泛討論的老話題了,時至現在的 Android 10 仍然有很多問題在系統層面沒有被很好的解決。
Android 存儲優化系列專題先后為大家介紹了系統為我們提供的多種持久化存儲方案,存儲就是把特定的數據結構轉換成可以被記錄和還原的格式。如果文件涉及到敏感或不想被其他應用訪問時該如何選擇呢?今天我們就來聊一聊 Android 存儲的權限管理。
所有 Android 設備都有兩個文件存儲區域:內部存儲區和外部存儲區。這主要是由于 Android 早期,當時大多數設備都提供了內置的非意易失性內存(內部存儲),以及可移動的外置存儲介質,例如 micro SD 卡(外部存儲)。
1. 有限的內部存儲
由于早期 Android 手機自帶存儲空間只有內部存儲,而且空間很有限。也是因為這樣的原因,應用一般要將語音、圖片、視頻等都放在外置 SD 卡上,否則放入內部存儲會很快被用盡存儲空間,導致用戶看到手機還有空間,而 App 卻不能正常使用。
現在,許多設備將永久存儲空間劃分為單獨的內部和外部分區。因此,即使沒有可移動的存儲介質,這兩個存儲空間也始終存在,并且無論外部存儲是否可移動,API 行為都是相同的。
2. 目前還不安全的外部(私有)儲存
雖然 Android 也提供了不獲取權限直接可用的外部私有存儲目錄如 Context.getExternalFilesDir()。但目前這樣的設計對應用數據的安全來說沒有幫助,因為外置(私有)存儲仍然可以被有權限的應用讀取。
由于外部存儲的不確定性,因此兩種選項之間會存在一些差異,如下圖所示:
乍一看,可能還是不太好理解,到底該怎么理解內部存儲和外部存儲呢?當要確保其他應用或用戶都無法訪問的存儲時,此時內部存儲是最佳選擇;而外部存儲是存放那些不需要訪問限制,允許其他應用或用戶通過手機可以直接訪問的文件。
- 應用程序默認被安裝到內部存儲區域,但是也可以通過清單文件配置來指定應用允許被安裝在外部存儲。這種情況適用于 APK 文件較大且外部空間大于內部存儲空間時。具體可以參閱 App 安裝位置。
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:installLocation="preferExternal"
... >
內部存儲
默認情況下,保存至內部存儲的文件屬于應用私有文件,其他應用(或用戶)不能訪問這些文件(除非擁有 Root 訪問權限)。如此一來,內部存儲非常適合保存不需要讓用戶直接訪問的應用內部數據。在文件系統中,系統會為每個應用提供私有存儲目錄,在該目錄下我們可以進一步創建應用所需的任何文件。
當應用被卸載時,保存在內部存儲中的文件也隨之被移除。所以,不能依賴內部存儲來保存用戶希望獨立于應用而保留的任何數據。
按照訪問存儲目錄 API,內部存儲可以簡單劃分為:內部文件存儲和內部緩存存儲。它們都屬于應用私有存儲目錄,訪問應用內部存儲目錄 API 如下:
// 內部文件存儲
File filesDir = getFilesDir();
// 內部緩存存儲
File cacheDir = getCacheDir();
// 在應用唯一目錄內創建/打開一個新的目錄
getDir(String name, int mode)
// 應用內部存儲的核心 API,以上目錄全都依賴于它
getDataDir()
內部緩存文件
如果想要暫時保存某些數據,系統提供了特殊的私有緩存目錄來保存這些數據。當設備存儲空間不足時,Android 可能會刪除這些緩存文件以回收空間。但是不要過度依賴于系統提供的清理工作,而始終應該自行維護這些緩存文件的大小。以便使占用空間保持在合理的限制范圍內。當應用被卸載時,這些文件也會隨之被刪除。
內部緩存存儲在設備存儲空間不足時,可能會被系統清理掉,這是區別于內部文件存儲的最重要特征。
將數據保存在內部存儲中
內部存儲是根據程序的包名在 Android 文件系統的特殊位置被創建。注意與外部存儲目錄不同,應用不需要任何系統權限即可讀寫內部存儲。
1. 查詢可用空間
如果我們事先知道要保存數據的大小,可以通過如下兩個 API 查找是否有足夠的可用空間。這樣可以有效避免將存儲量填滿到某個閾值以上。
- getFreeSpace():獲取當前存儲空間的剩余空間大小。
- getTotalSpace():獲取當前存儲空間總大小。
2. 寫入文件
將數據保存到內部存儲時,可以通過以下兩種方式獲取適當的存儲目錄:
- getFileDir():獲取內部文件存儲目錄
- getCacheDir():獲取內部緩存存儲目錄
注意:如果系統存儲空間不足,則可能在沒有警告的情況下刪除緩存目錄下文件。
如果需要創建新的目錄,可以使用 File 重新指定,將上述指定的內部存儲目錄作為參數:
File newFile = new File(getFilesDir(), fileName)
Google 推薦使用 Jetpack 安全性庫 方式寫入文件:
// Although you can define your own key generation parameter specification, it's
// recommended that you use the value specified here.
KeyGenParameterSpec keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC;
String masterKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec);
// Creates a file with this name, or replaces an existing file
// that has the same name. Note that the file name cannot contain
// path separators.
String fileToWrite = "my_sensitive_data.txt";
try {
EncryptedFile encryptedFile = new EncryptedFile.Builder(
new File(directory, fileToWrite),
context,
masterKeyAlias,
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build();
// Write to a file.
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(
encryptedFile.openFileOutput()));
writer.write("MY SUPER-SECRET INFORMATION");
} catch (GeneralSecurityException gse) {
// Error occurred getting or creating keyset.
} catch (IOException ex) {
// Error occurred opening file for writing.
}
或者,可以直接使用系統提供的 openFileOutput() 獲取一個 FileOutputStream 對象如下:
final String fileName = "testInternalFile";
final String fileContent = "Hello World!";
FileOutputStream fos;
try{
fos = context.openFileOutput(fileName, Context.MODE_PRIVATE);
fos.write(fileContent.toByteArray());
fos.close();
}catch(Exception e){
e.printStackTrace();
}
注意:openFileOutput() 方法需要指定文件模式,通過 MODE_PRIVATE 使它對您的應用變為私有。從 API Level 17 開始,其他模式選項 MODE_WROLD_READABLE 和 MODE_WORLD_WRITEABLE 已被棄用。從 Android 7.0(API Level 24)開始,如果再使用它們將會拋出 SecurityException。如果需要與其他應用程序共享私有文件,則應該使用帶有 FLAG_GRANT_READ_URI_PERMISSION 屬性的 FileProvider。
3. 寫入緩存文件
如果需要暫時緩存某些數據,可以使用 createTempFile()。例如從 URL 對象中提取文件名,并為其創建緩存目錄:
File file;
try{
final String fileName = Uri.parse(url).getLastPathSegmetn();
file = File.createTempFile(fileName, null, context.getCacheDir());
}catch(Exception e){
// Error while creating file
}
return file;
正如前面所述,我們應該定期清除不再需要的緩存文件,而不是依賴系統的清理工作。
4. 打開已有文件
Google 推薦使用 Jetpack 庫 以更安全的方式讀取文件,如下:
// Although you can define your own key generation parameter specification, it's
// recommended that you use the value specified here.
KeyGenParameterSpec keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC;
String masterKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec);
String fileToRead = "my_sensitive_data.txt";
EncryptedFile encryptedFile = new EncryptedFile.Builder(
File(directory, fileToRead),
context,
masterKeyAlias,
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build();
StringBuffer stringBuffer = new StringBuffer();
try (BufferedReader reader =
new BufferedReader(new FileReader(encryptedFile))) {
String line = reader.readLine();
while (line != null) {
stringBuffer.append(line).append('\n');
line = reader.readLine();
}
} catch (IOException e) {
// Error occurred opening raw file for reading.
} finally {
String contents = stringBuffer.toString();
}
同樣,也可以直接使用 openFileInput(name) 傳遞文件名方式獲取到 FileInputStream。
注意:如果需要在安裝時將文件打包為可在應用程序中訪問的文件,可以將文件保存在項目 /res/raw 目錄中。傳入帶有前綴的文件名 R.raw 作為資源 ID 通過 openRawResource() 打開對應文件。此方法返回 InputStream,但是無法寫回數據。
外部存儲
外部存儲在概念上非常容易被誤解,它屬于 Android 發展的歷史產物,早期的手機設備支持插入新的存儲介質,例如 micro SD 卡(外部存儲)。現在已經很難看到了。外部存儲是相對于內部存儲而言的,如果數據允許被其他應用訪問或者允許用戶在手機中進行訪問,此時外部存儲便非常適合。
外部存儲可以劃分為公共目錄和應用私有目錄。
公共目錄:可以被其他應用或用戶自由使用的文件。該目錄下文件不會受到應用的卸載而被移除。
應用私有目錄:存儲在特定于應用程序目錄的文件,可以使用 Context.getExternalFilesDir() 或 Context.getExternalCacheDir() 獲取。當應用被卸載時該目錄下文件也會被移除。注意,雖然它們也屬于應用私有目錄,但它們位于外部存儲上,從技術上來講用戶和其他應用程序都可以訪問這些文件,一般用于存放一些不是特別敏感的應用數據,但由不想與其他應用程序共享的文件。
注意:如果用戶卸下或斷開外部存儲設備(例如 SD 卡)的連接,則存儲在外部存儲中的文件可能變得不可用。如果應用程序的功能取決于這些文件,則應修改為將數據寫入內部存儲。
1. 請求外部存儲權限
使用外部存儲之前必須申請以下權限:
允許應用訪問外部存儲設備中的文件。
允許應用寫入和修改外部設備中的文件。擁有此權限的應用程序也會自動獲取到 READ_EXTARNAL_STORAGE 權限。
從 Android 4.4 (API 級別 19)開始,在特定于應用的目錄中讀寫文件不需要任何與存儲相關的權限。因此,如果您的應用程序支持 Android 4.3(API Level 18)及更低版本,并且您只想訪問特定于應用程序的目錄,則通過添加以下 maxSdkVersion 屬性來聲明僅在較低版本的 Android 上請求權限。
<manifest ...>
<!-- If you need to modify files in external storage, request
WRITE_EXTERNAL_STORAGE instead. -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="18" />
</manifest>
注意:如果您的應用程序僅使用內部存儲,則不需要聲明任何與存儲相關的權限,除非需要訪問其他應用程序文件。
2. 驗證外部存儲是否可用
由于外部存儲可能被移除,所以外部存儲并不總是可用,因此在訪問之前,始終應該驗證是否可用。可以通過查詢外部存儲狀態 getExternalStorageState()。如果返回狀態為 MEDIA_MOUNTED,則可以讀取和寫入文件。如果是 MEDIA_MOUNTED_READ_ONLY,則只能讀取文件。
/* Checks if external storage is available for read and write */
public boolean isExternalStorageWritable() {
String state = Environment.getExternalStorageState();
if (Environment.MEDIA_MOUNTED.equals(state)) {
return true;
}
return false;
}
/* Checks if external storage is available to at least read */
public boolean isExternalStorageReadable() {
String state = Environment.getExternalStorageState();
if (Environment.MEDIA_MOUNTED.equals(state) ||
Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
return true;
}
return false;
}
保存到公共目錄
如果要將文件保存在其他應用程序可以訪問的外部存儲上,可以使用以下 API:
保存照片、音頻或視頻剪輯,請使用 MediaStore API。
要保存其他文件(如 PDF),請使用 ACTION_CREATE_DOCUMENT Intent,它是 Storage Access Framework 的一部分。
注意,如果想在“媒體掃描器”中隱藏文件,可以在特定應用程序的目錄中包含一個名為 .nomedia 的空文件。這樣便可以防止媒體掃描器讀取您的媒體文件并通過 MediaStore API 將它們提供給其他應用程序。
保存到私有目錄
注意外部存儲雖然也有應用私有目錄,但是它并不向內部存儲那樣安全,并且也會跟隨應用的卸載而被移除。
如果需要保存一些不是那么敏感的應用私有數據到外部存儲上,可以使用 getExternalFilesDir() 或 getExternalCacheDir() 獲取應用程序在外部存儲的私有目錄,它們在功能上類似于內部存儲的 getFileDir() 和 getCacheDir()。
public File getPrivateAlbumStorageDir(Context context, String albumName) {
// Get the directory for the app's private pictures directory.
File file = new File(context.getExternalFilesDir(
Environment.DIRECTORY_PICTURES), albumName);
if (!file.mkdirs()) {
Log.e(LOG_TAG, "Directory not created");
}
return file;
}
注意,如果預定義的子目錄(Environment.DIRECTORY_PICTURES)都不合適作為要存儲的目錄,那么可以使用 getExternalFilesDir(null) 返回應用程序在外部存儲上私有目錄的根目錄。
在多個外部存儲之間進行選擇
當設備也插入外置 SD 卡時,此時設備便有兩個不同的外部存儲目錄,因此將“私有”文件寫入外部存儲時,需要選擇使用哪個目錄。
從 Android 4.4(API Level 19) 開始,可以通過 getExternalFilesDirs() 來訪問這些目錄,它返回一個 File 數組,數組的第一條被默認為是主要的外部存儲,應用應該優先使用該位置作為第一外部存儲(除非空間已滿)。
或者使用 ContextCompat.getExternalFilesDir() 來兼容 Android 4.3 及更低版本。需要注意,在 4.3 及更低版本該方法僅返回一個主外部存儲目錄,即在 Android 4.3 及更低的版本無法訪問第二個存儲位置。
最后
隨著 Android 手機硬件配置的提升,內置存儲空間越來越大;出于兼容低端機的考慮,我們還是不能簡單粗暴的將數據遷入內部存儲。綜合來看,遷移還需慢慢做,外部存儲的敏感數據加密混淆也要配合。
Android 的權限管理只防君子不防小人,SD 卡存儲讀寫權限只要應用申請了基本都可以獲取到。所以建議對于敏感的外置存儲的文件進行加密或混淆處理。剩下的等待明年的 Android 11 相對更完善的外部私有存儲空間進行權限隔離控制。
Android 10 計劃將外部(私有)存儲的共享權限徹底收回,不過對 native 支持的不好,及一次性改變太大開發者對其反映強烈,計劃被推遲一年。具體可以參考 Android 10 功能和 API。
便于大家理解,最后我將數據存儲目錄的相關路徑整理出,大家需要對照查看發現它們的異同之處:
我們也曾在項目中遇到存儲敏感數據設計上的問題,本文正好借此來跟大家聊一聊,文中如有不妥或有更好的分析結果歡迎您的指正。
文章如果對你有幫助,請留個贊吧!
擴展閱讀
Android 存儲優化系列專題
Android 之不要濫用 SharedPreferences(上)
Android 對象序列化之 Parcelable 取代 Serializable ?
Android 存儲選項之 SQLite 優化那些事兒
其他系列專題
深入 Activity 三部曲(1)View 繪制流程之 setContentView() 到底做了什么 ?
深入 Activity 三部曲(2)View 繪制流程之 DecorView 添加至窗口的過程
深入 Activity 三部曲(3)之 View 繪制流程
關于 UI 渲染,你需要了解什么?
Android 之如何優化 UI 渲染(上)
Android 之你真的了解 View.post() 原理嗎?
Android 之 ViewTreeObserver 全面解析