Android 10 應(yīng)用分區(qū)存儲(chǔ)適配實(shí)踐

前言

正式進(jìn)入文章之前,我們必須對(duì)當(dāng)前最新Android系統(tǒng)版本的功能特性和影響應(yīng)用的行為變更有個(gè)大致的了解。

Android 10 分區(qū)存儲(chǔ)

為了讓用戶(hù)能更好地管理自己的文件并減少混亂,Android 10 引入了稱(chēng)為分區(qū)存儲(chǔ)的隱私權(quán)變更,即以 Android 10及更高版本為目標(biāo)平臺(tái)的應(yīng)用,在默認(rèn)情況下,只能看到本應(yīng)用專(zhuān)有的目錄(/sdcard/Android/data/{package_name}/,使用 getExternalFilesDir() 訪問(wèn))以及特定類(lèi)型的媒體(照片、視頻、音頻等,使用 MediaStore 來(lái)訪問(wèn))。如果應(yīng)用嘗試打開(kāi)此目錄之外的文件,則會(huì)發(fā)生錯(cuò)誤(即使擁有 READ_EXTERNAL_STORAGE 權(quán)限)。

Android 11 強(qiáng)制執(zhí)行分區(qū)存儲(chǔ)

開(kāi)發(fā)者在應(yīng)用完全兼容分區(qū)存儲(chǔ)之前,可以通過(guò)添加 requestLegacyExternalStorage 清單屬性,暫時(shí)選擇停用分區(qū)存儲(chǔ)。但當(dāng)將應(yīng)用更新為以 Android 11 為目標(biāo)平臺(tái)后,系統(tǒng)會(huì)忽略該屬性,即會(huì)強(qiáng)制執(zhí)行分區(qū)存儲(chǔ)。因此,在以 Android 11 為目標(biāo)平臺(tái)之前,開(kāi)發(fā)者需將數(shù)據(jù)遷移到與分區(qū)存儲(chǔ)兼容的目錄。

    <manifest ... >
      <!-- This attribute is "false" by default on apps targeting
           Android 10 or higher. -->
      <application android:requestLegacyExternalStorage="true" ... >
        ...
      </application>
    </manifest>

對(duì)于如何遷移,Android開(kāi)發(fā)者平臺(tái)提供了以下建議的遷移步驟:

1.檢查應(yīng)用的工作文件是否位于 /sdcard/ 目錄或其任何子目錄中。
2. 將任何私有應(yīng)用文件從 /sdcard/ 下的當(dāng)前位置移至 getExternalFilesDir() 方法所返回的目錄。
3.將任何共享的非媒體文件從 /sdcard/ 下的當(dāng)前位置移至 Downloads/ 目錄的應(yīng)用專(zhuān)用子目錄。
4.從 /sdcard/ 目錄中移除應(yīng)用的舊存儲(chǔ)目錄。

那么,具體到我們實(shí)際項(xiàng)目中該如何實(shí)踐呢?

內(nèi)部存儲(chǔ)空間/外部存儲(chǔ)空間

首先,Android使用的文件系統(tǒng)區(qū)分為兩個(gè)存儲(chǔ)區(qū)域:內(nèi)部存儲(chǔ)空間和外部存儲(chǔ)空間。我們通過(guò)比較兩個(gè)選項(xiàng)之間的異同點(diǎn),來(lái)了解它們的特點(diǎn)。

共同點(diǎn)

1.都包括用于存儲(chǔ)持久性文件緩存數(shù)據(jù)的兩種目錄。
2.如果用戶(hù)卸載應(yīng)用,系統(tǒng)會(huì)移除保存在應(yīng)用專(zhuān)屬存儲(chǔ)空間中的文件。由于這一特點(diǎn),我們不應(yīng)使用此存儲(chǔ)空間保存那些應(yīng)該獨(dú)立于應(yīng)用之外的內(nèi)容。
3.不需要任何系統(tǒng)權(quán)限即可讀取和寫(xiě)入這些目錄中的文件(使用分區(qū)存儲(chǔ)的應(yīng)用對(duì)自己創(chuàng)建的文件始終擁有讀/寫(xiě)權(quán)限,無(wú)論文件是否位于應(yīng)用的專(zhuān)有目錄內(nèi))。
4.當(dāng)設(shè)備的內(nèi)部存儲(chǔ)空間不足時(shí),Android 可能會(huì)刪除緩存數(shù)據(jù)目錄下的文件以回收空間。因此,請(qǐng)?jiān)谧x取前檢查緩存文件是否存在。

不同點(diǎn)

內(nèi)部存儲(chǔ)空間

1.系統(tǒng)會(huì)阻止其他應(yīng)用訪問(wèn)我們應(yīng)用的內(nèi)部存儲(chǔ)空間
2.(Android 10及更高版本設(shè)備)系統(tǒng)會(huì)對(duì)這些位置進(jìn)行加密。
3.這些目錄的空間通常比較小。在寫(xiě)入之前,應(yīng)用應(yīng)查詢(xún)?cè)O(shè)備上的可用空間。
基于以上特點(diǎn),可以看出內(nèi)部存儲(chǔ)空間非常適合存儲(chǔ)只有應(yīng)用本身才能訪問(wèn)的敏感數(shù)據(jù)。

外部存儲(chǔ)空間

1.(Android 9或更低版本設(shè)備)其他應(yīng)用可以在具有適當(dāng)權(quán)限的情況下訪問(wèn)我們應(yīng)用的外部存儲(chǔ)空間。
2.(Android 10及更高版本設(shè)備)啟用分區(qū)存儲(chǔ)后,應(yīng)用將無(wú)法訪問(wèn)屬于其他應(yīng)用的應(yīng)用專(zhuān)屬目錄。
3.位于用戶(hù)可能能夠移除的物理卷上,因此在嘗試讀取或?qū)懭胫?,需要?yàn)證該卷是否可訪問(wèn)。

我繪制了一張雙對(duì)比圖,可以更直觀地看一下:


雙對(duì)比圖.png

有了以上的知識(shí)儲(chǔ)備,我們就可以著手開(kāi)始數(shù)據(jù)遷移前的一些準(zhǔn)備了:

準(zhǔn)備

1.理清現(xiàn)有項(xiàng)目中使用到文件存儲(chǔ)的業(yè)務(wù)及其對(duì)應(yīng)的存儲(chǔ)目錄,區(qū)分哪些文件是需要保留遷移的,哪些是可以丟棄的。

以一款即時(shí)通訊APP為例,聊天記錄中的頭像、圖片、語(yǔ)音、視頻等緩存數(shù)據(jù)是最重要的,直接影響APP的可用性,必然需要保留和遷移。
而像啟動(dòng)圖這類(lèi)與用戶(hù)關(guān)聯(lián)性不強(qiáng)的數(shù)據(jù),則可以選擇性丟棄,犧牲掉部分用戶(hù)體驗(yàn),從而減少遷移的數(shù)據(jù)量。

2.根據(jù)應(yīng)用業(yè)務(wù)特點(diǎn),設(shè)計(jì)新的文件目錄層次架構(gòu)
a.將步驟1中理清的存儲(chǔ)目錄根據(jù)業(yè)務(wù)特點(diǎn),進(jìn)一步劃分到持久性文件目錄和緩存文件目錄

還是以即時(shí)通訊APP為例,為保證聊天記錄中的圖片、語(yǔ)音、視頻等文件可后續(xù)不定期查看,需要長(zhǎng)期保存,因此這類(lèi)型的文件需要存儲(chǔ)到持久性文件目錄。而像表情雨(關(guān)鍵詞觸發(fā)飄落動(dòng)畫(huà))等資源文件,以壓縮包形式下載后解壓,過(guò)程中產(chǎn)生的一些過(guò)渡文件,就可以放到緩存文件目錄,交由系統(tǒng)的清除策略管理。

b.《訪問(wèn)應(yīng)用專(zhuān)屬文件》一文中提到,為確保系統(tǒng)能正確處理媒體文件,建議開(kāi)發(fā)者使用 DIRECTORY_PICTURES 等 API 常量作為預(yù)定義的子目錄名稱(chēng)。因此我們將第一層作為預(yù)留給這些子目錄,并參考這種形式,我們應(yīng)用本身的數(shù)據(jù)也使用了自定義的 DIRECTORY_DATA 常量來(lái)建立目錄。
c.基于應(yīng)用本身的用戶(hù)體系,需要建立不同用戶(hù)的專(zhuān)有目錄,方便進(jìn)行基于用戶(hù)粒度的緩存文件管理;
d.與用戶(hù)關(guān)聯(lián)性不強(qiáng)的資源,放置在公用的目錄,避免重復(fù)下載多套資源,浪費(fèi)流量與存儲(chǔ)空間;
e.在用戶(hù)的專(zhuān)屬目錄或公用目錄下,再建立不同業(yè)務(wù)對(duì)應(yīng)的存儲(chǔ)目錄

以下是我們公司正在開(kāi)發(fā)的,基于以上描述所繪制的項(xiàng)目文件目錄層次架構(gòu)圖:


內(nèi)部存儲(chǔ)(1).jpg

一切準(zhǔn)備就緒之后,下面就以我提供的Demo為主要參考,開(kāi)始核心的數(shù)據(jù)遷移步驟了。
先縱覽一下核心的幾個(gè)類(lèi):

OldStorageManager:舊版存儲(chǔ)管理器。文件保存在 /sdcard/ 目錄中,需要適配。
ScopedStorageManager:分區(qū)存儲(chǔ)管理類(lèi)。工具類(lèi),封裝了【訪問(wèn)應(yīng)用專(zhuān)屬內(nèi)部/外部存儲(chǔ)空間的緩存/持久性文件目錄】、【從舊版存儲(chǔ)位置遷移現(xiàn)有文件】等公共方法,與業(yè)務(wù)剝離。
TestStorageManager:業(yè)務(wù)存儲(chǔ)管理類(lèi),負(fù)責(zé)具體業(yè)務(wù)下的文件存儲(chǔ)和數(shù)據(jù)遷移,與業(yè)務(wù)耦合。

示例代碼可以在GitHub上下載。

數(shù)據(jù)遷移

具體實(shí)現(xiàn)的步驟如下:
1.檢查 /sdcard/ 目錄中應(yīng)用的舊版存儲(chǔ)根目錄是否存在
2.列入需要處理的舊版存儲(chǔ)目錄,可能包括:

a.位于/sdcard/的Test目錄下,不需要遷移直接刪除的子目錄
b.位于/sdcard/的Test目錄下,需要保留并遷移的子目錄
c.包含以上目錄的父目錄,該目錄下的子目錄保留并遷移之后,需要?jiǎng)h除該目錄
d. /sdcard/ 目錄中應(yīng)用的舊版存儲(chǔ)根目錄,需要移除

3.從舊版存儲(chǔ)位置遷移現(xiàn)有文件,建議將此工作放在應(yīng)用升級(jí)后的重新啟動(dòng)階段,監(jiān)聽(tīng)數(shù)據(jù)遷移情況并在啟動(dòng)頁(yè)提供遷移進(jìn)度顯示。

TestStorageManager.kt

class TestStorageManager {

   companion object {

       /** 子目錄-公共 */
       private const val SUB_DIRECTORY_UNIVERSAL = "Universal"
       /** 子目錄-特定用戶(hù) */
       private lateinit var SUB_DIRECTORY_SPECIFIC_USER : String

       @JvmStatic
       fun init(specificUser : String) {
           SUB_DIRECTORY_SPECIFIC_USER = specificUser
       }

       /**
        * 從舊版存儲(chǔ)位置遷移現(xiàn)有文件
        */
       @JvmStatic
       fun migrateExistingFilesFromLegacyStorageDir(listener: ScopedStorageManager.ProgressListener) {
           // 舊版存儲(chǔ)位置已不復(fù)存在,不需要處理
           if(!OldStorageManager.getOldStorageRootDir().exists()){
               listener.onFinish()
               return
           }

           // 列入需要遷移的舊版存儲(chǔ)目錄
           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
                   )

           // 最后移除應(yīng)用的舊存儲(chǔ)目錄
           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")

       /**
        * 消息-語(yǔ)音
        */
       @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

        /**
         * 從舊版存儲(chǔ)位置遷移現(xiàn)有文件
         * @param dirMap 目錄Map
         * @param listener 遷移進(jìn)度監(jiān)聽(tīng)器
         */
        fun migrateExistingFilesFromLegacyStorageDir(dirMap : Map<File, File?>, listener: ProgressListener) {
            Observable.create(ObservableOnSubscribe<Int> { emitter ->
                var totalSize = 0L
                // 計(jì)算需要遷移的總文件大小
                for((src, destDir) in dirMap.entries){
                    if(!src.exists()){
                        LogUtil.w("源文件或目錄[${src.name}]不存在,不計(jì)入統(tǒng)計(jì)")
                        continue
                    }

                    if(destDir == null || !destDir.exists()){
                        LogUtil.w("目標(biāo)目錄[(${destDir?.name}]為空或不存在,不計(jì)入統(tǒng)計(jì)")
                        continue
                    }

                    if(!destDir.isDirectory) {
                        LogUtil.w("destDir[${destDir?.name}]非目錄,不計(jì)入統(tǒng)計(jì)")
                        continue
                    }

                    totalSize += FileUtils.sizeOf(src)
                }
                emitter.onNext(0)   // 遷移開(kāi)始

                LogUtil.d("需遷移的文件總大小 totalSize = ${FileUtils.byteCountToDisplaySize(totalSize)}")

                var migratedSize = 0L   // 已遷移的文件大小
                for ((src, destSir) in dirMap.entries) {
                    if(!src.exists()) {
                        LogUtil.w("源文件或目錄[${src.name}]不存在,不執(zhí)行遷移")
                        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("已遷移數(shù)據(jù)大小 migratedSize = ${FileUtils.byteCountToDisplaySize(migratedSize)}")

                                val progress = (migratedSize * 100 / totalSize.toFloat()).toInt();
                                LogUtil.d("遷移進(jìn)度 progress = $progress")

                                emitter.onNext(progress)    // 回調(diào)遷移進(jìn)度
                            }
                        }
                    } else {
                        destSir?.let {
                            FileUtils.copyFileToDirectory(src, destSir)
                            LogUtil.d("遷移文件[${src.name}]至目錄[${destSir.name}]...")

                            migratedSize += FileUtils.sizeOf(src)
                            LogUtil.d("已遷移數(shù)據(jù)大小 migratedSize = ${FileUtils.byteCountToDisplaySize(migratedSize)}")

                            val progress = (migratedSize * 100 / totalSize.toFloat()).toInt();
                            LogUtil.d("遷移進(jìn)度 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("從舊版存儲(chǔ)位置遷移現(xiàn)有文件出錯(cuò):${t.message}")
                        t.printStackTrace()
                        listener.onError()
                    })
        }

版本兼容

完成了數(shù)據(jù)遷移之后,我們就要著手開(kāi)始對(duì)舊有的使用到文件存儲(chǔ)的業(yè)務(wù)及其對(duì)應(yīng)的存儲(chǔ)目錄進(jìn)行改造了,以確保能夠正確訪問(wèn)到遷移后的文件,使應(yīng)用正常的業(yè)務(wù)不受影響。
此處以縮略圖和適配為例:

    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
    }

測(cè)試

準(zhǔn)備:

1.一臺(tái)裝有舊版本App的手機(jī),積累的緩存文件足夠多(最好超過(guò)1G)

測(cè)試流程:

1.檢查設(shè)備下的文件管理-內(nèi)部存儲(chǔ)-Test文件夾是否存在
2.覆蓋安裝新版本
3.App啟動(dòng)之后/閃屏圖顯示之前,是否有數(shù)據(jù)遷移的進(jìn)度條顯示
4.進(jìn)度條完整跑完,正常顯示啟動(dòng)圖并進(jìn)入主頁(yè)面
5.設(shè)備下的文件管理/內(nèi)部存儲(chǔ)/Test文件夾是否已刪除
6.設(shè)備下的文件管理-內(nèi)部存儲(chǔ)-Android-data-{package_name}-file下的文件目錄結(jié)構(gòu)是否與上方繪制的架構(gòu)圖一致。
7.App各項(xiàng)業(yè)務(wù)功能是否正常使用

后續(xù)

文件存儲(chǔ)規(guī)范建立之后,當(dāng)有新的業(yè)務(wù)需要建立單獨(dú)文件目錄時(shí),可以遵循以下規(guī)律決定存放的位置:


流程圖1.jpg

至此,Android 10 應(yīng)用分區(qū)存儲(chǔ)適配實(shí)踐就已全部完成。

小結(jié)

可以明顯感受到,近年的Android系統(tǒng)迭代正逐漸往更封閉、也更規(guī)范的方向發(fā)展,雖然每一次的系統(tǒng)適配都是一次不可避免的陣痛,但一個(gè)成熟系統(tǒng)的發(fā)展必然要經(jīng)歷從混亂無(wú)序到規(guī)范有序的過(guò)程,規(guī)范建立之后,以后就再也不用飽受碎片化帶來(lái)的諸多痛苦了。那么,就從今天開(kāi)始,和我一起通過(guò)以上文章完成Android 10 應(yīng)用分區(qū)存儲(chǔ)的適配吧。

參考文章

管理分區(qū)外部存儲(chǔ)訪問(wèn)
https://developer.android.google.cn/training/data-storage/files/external-scoped
將文件保存到外部存儲(chǔ)
https://developer.android.google.cn/training/data-storage/files/external
數(shù)據(jù)和文件存儲(chǔ)概覽
https://developer.android.google.cn/training/data-storage
選擇內(nèi)部或外部存儲(chǔ)空間
https://developer.android.google.cn/training/data-storage/files#InternalVsExternalStorage
訪問(wèn)應(yīng)用專(zhuān)屬文件
https://developer.android.google.cn/training/data-storage/app-specific
用于數(shù)據(jù)存儲(chǔ)的應(yīng)用兼容性功能
https://developer.android.google.cn/training/data-storage/compatibility

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容