Android 10 应用分区存储适配实践

前言

正式进入文章之前,我们必须对当前最新Android系统版本的功能特性和影响应用的行为变更有个大致的了解。

Android 10 分区存储

为了让用户能更好地管理自己的文件并减少混乱,Android 10 引入了称为分区存储的隐私权变更,即以 Android 10及更高版本为目标平台的应用,在默认情况下,只能看到本应用专有的目录(/sdcard/Android/data/{package_name}/,使用 getExternalFilesDir() 访问)以及特定类型的媒体(照片、视频、音频等,使用 MediaStore 来访问)。如果应用尝试打开此目录之外的文件,则会发生错误(即使拥有 READ_EXTERNAL_STORAGE 权限)。

Android 11 强制执行分区存储

开发者在应用完全兼容分区存储之前,可以通过添加 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.位于用户可能能够移除的物理卷上,因此在尝试读取或写入之前,需要验证该卷是否可访问。

我绘制了一张双对比图,可以更直观地看一下:


双对比图.png

有了以上的知识储备,我们就可以着手开始数据迁移前的一些准备了:

准备

1.理清现有项目中使用到文件存储的业务及其对应的存储目录,区分哪些文件是需要保留迁移的,哪些是可以丢弃的。

以一款即时通讯APP为例,聊天记录中的头像、图片、语音、视频等缓存数据是最重要的,直接影响APP的可用性,必然需要保留和迁移。
而像启动图这类与用户关联性不强的数据,则可以选择性丢弃,牺牲掉部分用户体验,从而减少迁移的数据量。

2.根据应用业务特点,设计新的文件目录层次架构
a.将步骤1中理清的存储目录根据业务特点,进一步划分到持久性文件目录和缓存文件目录

还是以即时通讯APP为例,为保证聊天记录中的图片、语音、视频等文件可后续不定期查看,需要长期保存,因此这类型的文件需要存储到持久性文件目录。而像表情雨(关键词触发飘落动画)等资源文件,以压缩包形式下载后解压,过程中产生的一些过渡文件,就可以放到缓存文件目录,交由系统的清除策略管理。

b.《访问应用专属文件》一文中提到,为确保系统能正确处理媒体文件,建议开发者使用 DIRECTORY_PICTURES 等 API 常量作为预定义的子目录名称。因此我们将第一层作为预留给这些子目录,并参考这种形式,我们应用本身的数据也使用了自定义的 DIRECTORY_DATA 常量来建立目录。
c.基于应用本身的用户体系,需要建立不同用户的专有目录,方便进行基于用户粒度的缓存文件管理;
d.与用户关联性不强的资源,放置在公用的目录,避免重复下载多套资源,浪费流量与存储空间;
e.在用户的专属目录或公用目录下,再建立不同业务对应的存储目录

以下是我们公司正在开发的,基于以上描述所绘制的项目文件目录层次架构图:


内部存储(1).jpg

一切准备就绪之后,下面就以我提供的Demo为主要参考,开始核心的数据迁移步骤了。
先纵览一下核心的几个类:

OldStorageManager:旧版存储管理器。文件保存在 /sdcard/ 目录中,需要适配。
ScopedStorageManager:分区存储管理类。工具类,封装了【访问应用专属内部/外部存储空间的缓存/持久性文件目录】、【从旧版存储位置迁移现有文件】等公共方法,与业务剥离。
TestStorageManager:业务存储管理类,负责具体业务下的文件存储和数据迁移,与业务耦合。

示例代码可以在GitHub上下载。

数据迁移

具体实现的步骤如下:
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各项业务功能是否正常使用

后续

文件存储规范建立之后,当有新的业务需要建立单独文件目录时,可以遵循以下规律决定存放的位置:


流程图1.jpg

至此,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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345

推荐阅读更多精彩内容