作者:椎鋒陷陳
連結:
https://www.jianshu.com/p/af9903069ebe
本文由作者授權發布。
正式進入文章之前,我們必須對當前最新Android系統版本的功能特性和影響應用的行為變更有個大致的了解。
為了讓用戶能更好地管理自己的文件並減少混亂,Android 10 引入了稱為分區存儲的隱私權變更,即以 Android 10及更高版本為目標平臺的應用,在默認情況下,只能看到本應用專有的目錄(/sdcard/Android/data/{package_name}/,使用 getExternalFilesDir() 訪問)以及特定類型的媒體(照片、視頻、音頻等,使用 MediaStore 來訪問)。
如果應用嘗試打開此目錄之外的文件,則會發生錯誤(即使擁有 READ_EXTERNAL_STORAGE 權限)。
開發者在應用完全兼容分區存儲之前,可以通過添加 requestLegacyExternalStorage 清單屬性,暫時選擇停用分區存儲。
https://developer.android.google.cn/reference/android/R.attr#requestLegacyExternalStorage
但當將應用更新為以 Android 11 為目標平臺後,系統會忽略該屬性,即會強制執行分區存儲。因此,在以 Android 11 為目標平臺之前,開發者需將數據遷移到與分區存儲兼容的目錄。
<manifest ... >
<!-- This attribute is "false" by default on apps targeting
Android 10 or higher. -->
<application android:requestLegacyExternalStorage="true" ... >
...
</application>
</manifest>
對於如何遷移,Android開發者平臺提供了以下建議的遷移步驟:
1.檢查應用的工作文件是否位於 /sdcard/ 目錄或其任何子目錄中。
2. 將任何私有應用文件從 /sdcard/ 下的當前位置移至 getExternalFilesDir() 方法所返回的目錄。
3.將任何共享的非媒體文件從 /sdcard/ 下的當前位置移至 Downloads/ 目錄的應用專用子目錄。
4.從 /sdcard/ 目錄中移除應用的舊存儲目錄。
那麼,具體到我們實際項目中該如何實踐呢?
首先,Android使用的文件系統區分為兩個存儲區域:內部存儲空間和外部存儲空間。
我們通過比較兩個選項之間的異同點,來了解它們的特點。
共同點1.都包括用於存儲持久性文件和緩存數據的兩種目錄。
2.如果用戶卸載應用,系統會移除保存在應用專屬存儲空間中的文件。由於這一特點,我們不應使用此存儲空間保存那些應該獨立於應用之外的內容。
3.不需要任何系統權限即可讀取和寫入這些目錄中的文件(使用分區存儲的應用對自己創建的文件始終擁有讀/寫權限,無論文件是否位於應用的專有目錄內)。
4.當設備的內部存儲空間不足時,Android 可能會刪除緩存數據目錄下的文件以回收空間。因此,請在讀取前檢查緩存文件是否存在。
1.系統會阻止其他應用訪問我們應用的內部存儲空間
2.(Android 10及更高版本設備)系統會對這些位置進行加密。
3.這些目錄的空間通常比較小。在寫入之前,應用應查詢設備上的可用空間。
基於以上特點,可以看出內部存儲空間非常適合存儲只有應用本身才能訪問的敏感數據。
1.(Android 9或更低版本設備)其他應用可以在具有適當權限的情況下訪問我們應用的外部存儲空間。
2.(Android 10及更高版本設備)啟用分區存儲後,應用將無法訪問屬於其他應用的應用專屬目錄。
3.位於用戶可能能夠移除的物理卷上,因此在嘗試讀取或寫入之前,需要驗證該卷是否可訪問。
我繪製了一張雙對比圖,可以更直觀地看一下:
有了以上的知識儲備,我們就可以著手開始數據遷移前的一些準備了:
1.理清現有項目中使用到文件存儲的業務及其對應的存儲目錄,區分哪些文件是需要保留遷移的,哪些是可以丟棄的。
以一款即時通訊APP為例,聊天記錄中的頭像、圖片、語音、視頻等緩存數據是最重要的,直接影響APP的可用性,必然需要保留和遷移。
而像啟動圖這類與用戶關聯性不強的數據,則可以選擇性丟棄,犧牲掉部分用戶體驗,從而減少遷移的數據量。
2.根據應用業務特點,設計新的文件目錄層次架構
a.將步驟1中理清的存儲目錄根據業務特點,進一步劃分到持久性文件目錄和緩存文件目錄
還是以即時通訊APP為例,為保證聊天記錄中的圖片、語音、視頻等文件可後續不定期查看,需要長期保存,因此這類型的文件需要存儲到持久性文件目錄。而像表情雨(關鍵詞觸發飄落動畫)等資源文件,以壓縮包形式下載後解壓,過程中產生的一些過渡文件,就可以放到緩存文件目錄,交由系統的清除策略管理。
b.《訪問應用專屬文件》一文中提到,為確保系統能正確處理媒體文件,建議開發者使用 DIRECTORY_PICTURES 等 API 常量作為預定義的子目錄名稱。因此我們將第一層作為預留給這些子目錄,並參考這種形式,我們應用本身的數據也使用了自定義的 DIRECTORY_DATA 常量來建立目錄。
c.基於應用本身的用戶體系,需要建立不同用戶的專有目錄,方便進行基於用戶粒度的緩存文件管理;
d.與用戶關聯性不強的資源,放置在公用的目錄,避免重複下載多套資源,浪費流量與存儲空間;
e.在用戶的專屬目錄或公用目錄下,再建立不同業務對應的存儲目錄
以下是我們公司正在開發的,基於以上描述所繪製的項目文件目錄層次架構圖:
一切準備就緒之後,下面就以我提供的Demo為主要參考,開始核心的數據遷移步驟了。
先縱覽一下核心的幾個類:
OldStorageManager:舊版存儲管理器。文件保存在 /sdcard/ 目錄中,需要適配。
ScopedStorageManager:分區存儲管理類。工具類,封裝了【訪問應用專屬內部/外部存儲空間的緩存/持久性文件目錄】、【從舊版存儲位置遷移現有文件】等公共方法,與業務剝離。
TestStorageManager:業務存儲管理類,負責具體業務下的文件存儲和數據遷移,與業務耦合。
示例代碼可以在GitHub上下載。
https://github.com/madchan/MigrateDataDemo
具體實現的步驟如下:
1.檢查 /sdcard/ 目錄中應用的舊版存儲根目錄是否存在
2.列入需要處理的舊版存儲目錄,可能包括:
a.位於/sdcard/的Test目錄下,不需要遷移直接刪除的子目錄
b.位於/sdcard/的Test目錄下,需要保留並遷移的子目錄
c.包含以上目錄的父目錄,該目錄下的子目錄保留並遷移之後,需要刪除該目錄
d. /sdcard/ 目錄中應用的舊版存儲根目錄,需要移除
3.從舊版存儲位置遷移現有文件,建議將此工作放在應用升級後的重新啟動階段,監聽數據遷移情況並在啟動頁提供遷移進度顯示。
TestStorageManager.kt
class TestStorageManager {
companion object {
/** 子目錄-公共 */
private const val SUB_DIRECTORY_UNIVERSAL = "Universal"
/** 子目錄-特定用戶 */
private lateinit var SUB_DIRECTORY_SPECIFIC_USER : String
@JvmStatic
fun init(specificUser : String) {
SUB_DIRECTORY_SPECIFIC_USER = specificUser
}
/**
* 從舊版存儲位置遷移現有文件
*/
@JvmStatic
fun migrateExistingFilesFromLegacyStorageDir(listener: ScopedStorageManager.ProgressListener) {
// 舊版存儲位置已不復存在,不需要處理
if(!OldStorageManager.getOldStorageRootDir().exists()){
listener.onFinish()
return
}
// 列入需要遷移的舊版存儲目錄
var map = linkedMapOf(
// 需要保留並遷移的目錄
OldStorageManager.getAvatarStorageDir() to getAvatarStorageDir(),
OldStorageManager.getMessageThumbnailStorageDir() to getMessageThumbnailStorageDir(),
OldStorageManager.getMessageImageStorageDir() to getMessageImageStorageDir(),
OldStorageManager.getMessageAudioStorageDir() to getMessageAudioStorageDir(),
OldStorageManager.getMessageVideoStorageDir() to getMessageVideoStorageDir(),
// 不需要遷移直接刪除的目錄
OldStorageManager.getSplashStorageDir() to null
)
// 最後移除應用的舊存儲目錄
map[OldStorageManager.getOldStorageRootDir()] = null
ScopedStorageManager.migrateExistingFilesFromLegacyStorageDir(map, listener)
}
/**
* 頭像
*/
@JvmStatic
fun getAvatarStorageDir() = ScopedStorageManager.getExternalStorageDir(
BaseApplication.getContext(), null, "$SUB_DIRECTORY_UNIVERSAL/Avatar")
/**
* 消息-縮略圖
* 包含圖片、視頻等
*/
@JvmStatic
fun getMessageThumbnailStorageDir() = ScopedStorageManager.getExternalStorageDir(
BaseApplication.getContext(), null, "$SUB_DIRECTORY_UNIVERSAL/Message/Thumbnail")
/**
* 消息-原圖
*/
@JvmStatic
fun getMessageImageStorageDir() = ScopedStorageManager.getExternalStorageDir(
BaseApplication.getContext(), null, "$SUB_DIRECTORY_SPECIFIC_USER/Message/Image")
/**
* 消息-語音
*/
@JvmStatic
fun getMessageAudioStorageDir() = ScopedStorageManager.getExternalStorageDir(
BaseApplication.getContext(), null, "$SUB_DIRECTORY_SPECIFIC_USER/Message/Audio")
/**
* 消息-視頻
*/
@JvmStatic
fun getMessageVideoStorageDir() = ScopedStorageManager.getExternalStorageDir(
BaseApplication.getContext(), null, "$SUB_DIRECTORY_SPECIFIC_USER/Message/Video")
/**
* 閃屏圖
*/
@JvmStatic
fun getSplashStorageDir() = ScopedStorageManager.getExternalStorageDir(BaseApplication.getContext(), null, "$SUB_DIRECTORY_UNIVERSAL/Splash")
}
}
ScopedStorageManager.kt
/**
* 從舊版存儲位置遷移現有文件
* @param dirMap 目錄Map
* @param listener 遷移進度監聽器
*/
fun migrateExistingFilesFromLegacyStorageDir(dirMap : Map<File, File?>, listener: ProgressListener) {
Observable.create(ObservableOnSubscribe<Int> { emitter ->
var totalSize = 0L
// 計算需要遷移的總文件大小
for((src, destDir) in dirMap.entries){
if(!src.exists()){
LogUtil.w("源文件或目錄[${src.name}]不存在,不計入統計")
continue
}
if(destDir == null || !destDir.exists()){
LogUtil.w("目標目錄[(${destDir?.name}]為空或不存在,不計入統計")
continue
}
if(!destDir.isDirectory) {
LogUtil.w("destDir[${destDir?.name}]非目錄,不計入統計")
continue
}
totalSize += FileUtils.sizeOf(src)
}
emitter.onNext(0) // 遷移開始
LogUtil.d("需遷移的文件總大小 totalSize = ${FileUtils.byteCountToDisplaySize(totalSize)}")
var migratedSize = 0L // 已遷移的文件大小
for ((src, destSir) in dirMap.entries) {
if(!src.exists()) {
LogUtil.w("源文件或目錄[${src.name}]不存在,不執行遷移")
continue
}
if(src.isDirectory) {
for (file in src.listFiles()){
destSir?.let {
if(file.isDirectory){
FileUtils.copyDirectoryToDirectory(file, destSir)
LogUtil.d("遷移目錄[${file.name}]至目錄[${destSir.name}]...")
} else {
FileUtils.copyFileToDirectory(file, destSir)
LogUtil.d("遷移文件[${file.name}]至目錄[${destSir.name}]...")
}
migratedSize += FileUtils.sizeOf(file)
LogUtil.d("已遷移數據大小 migratedSize = ${FileUtils.byteCountToDisplaySize(migratedSize)}")
val progress = (migratedSize * 100 / totalSize.toFloat()).toInt();
LogUtil.d("遷移進度 progress = $progress")
emitter.onNext(progress) // 回調遷移進度
}
}
} else {
destSir?.let {
FileUtils.copyFileToDirectory(src, destSir)
LogUtil.d("遷移文件[${src.name}]至目錄[${destSir.name}]...")
migratedSize += FileUtils.sizeOf(src)
LogUtil.d("已遷移數據大小 migratedSize = ${FileUtils.byteCountToDisplaySize(migratedSize)}")
val progress = (migratedSize * 100 / totalSize.toFloat()).toInt();
LogUtil.d("遷移進度 progress = $progress")
emitter.onNext(progress)
}
}
LogUtil.d("遷移完成,刪除文件或目錄:[${src.name}]")
FileUtils.deleteQuietly(src)
}
emitter.onNext(100) // 遷移完成
})
.distinct()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
when(it) {
0 -> listener.onStart()
100 -> listener.onFinish()
else -> listener.onProgress(it.toLong())
}
}, {
t -> LogUtil.e("從舊版存儲位置遷移現有文件出錯:${t.message}")
t.printStackTrace()
listener.onError()
})
}
完成了數據遷移之後,我們就要著手開始對舊有的使用到文件存儲的業務及其對應的存儲目錄進行改造了,以確保能夠正確訪問到遷移後的文件,使應用正常的業務不受影響。
此處以縮略圖和適配為例:
private fun convertVideo(holder: BaseViewHolder, item: Message) {
val video = JSONUtil.fromJson(item.content, VideoContent::class.java)
val view = convertThumbnail(holder, video.thumbnail)
holder.setVisible(R.id.play_button, true)
view.setOnClickListener {
// VideoPlayActivity.startActivity(context, File(OldStorageManager.getMessageVideoStorageDir(), video.compressed).absolutePath)
VideoPlayActivity.startActivity(context, File(TestStorageManager.getMessageVideoStorageDir(), video.compressed).absolutePath)
}
}
private fun convertThumbnail(holder: BaseViewHolder, thumbnail: String) : View{
val viewStub = holder.getView<ViewStub>(R.id.thumbnail_view_stub)
val view =
(if (viewStub.parent != null) viewStub.inflate() else holder.getView(R.id.thumbnail_layout)) as View
val imageView = view.findViewById<ImageView>(R.id.thumbnail)
Glide.with(context)
// .load(File(OldStorageManager.getMessageThumbnailStorageDir(), thumbnail))
.load(File(TestStorageManager.getMessageThumbnailStorageDir(), thumbnail))
.override(500, 500)
.centerCrop()
.into(imageView)
return view
}
1.一臺裝有舊版本App的手機,積累的緩存文件足夠多(最好超過1G)
測試流程:1.檢查設備下的文件管理-內部存儲-Test文件夾是否存在
2.覆蓋安裝新版本
3.App啟動之後/閃屏圖顯示之前,是否有數據遷移的進度條顯示
4.進度條完整跑完,正常顯示啟動圖並進入主頁面
5.設備下的文件管理/內部存儲/Test文件夾是否已刪除
6.設備下的文件管理-內部存儲-Android-data-{package_name}-file下的文件目錄結構是否與上方繪製的架構圖一致。
7.App各項業務功能是否正常使用
文件存儲規範建立之後,當有新的業務需要建立單獨文件目錄時,可以遵循以下規律決定存放的位置:
至此,Android 10 應用分區存儲適配實踐就已全部完成。
可以明顯感受到,近年的Android系統迭代正逐漸往更關於隱私、也更規範的方向發展,雖然每一次的系統適配都是一次不可避免的陣痛,但一個成熟系統的發展必然要經歷從混亂無序到規範有序的過程,規範建立之後,以後就再也不用飽受碎片化帶來的諸多痛苦了。
那麼,就從今天開始,和我一起通過以上文章完成Android 10 應用分區存儲的適配吧。
參考文章管理分區外部存儲訪問
https://developer.android.google.cn/training/data-storage/files/external-scoped
將文件保存到外部存儲
https://developer.android.google.cn/training/data-storage/files/external
數據和文件存儲概覽
https://developer.android.google.cn/training/data-storage
選擇內部或外部存儲空間
https://developer.android.google.cn/training/data-storage/files#InternalVsExternalStorage
訪問應用專屬文件
https://developer.android.google.cn/training/data-storage/app-specific
用於數據存儲的應用兼容性功能
https://developer.android.google.cn/training/data-storage/compatibility
最後推薦一下我做的網站,玩Android: wanandroid.com ,包含詳盡的知識體系、好用的工具,還有本公眾號文章合集,歡迎體驗和收藏!
推薦閱讀:
掃一掃 關注我的公眾號
如果你想要跟大家分享你的文章,歡迎投稿~
┏(^0^)┛明天見!