整理分区存储适配修改代码思路
背景
Target30项目中,需要做的一个重要适配部分就是分区存储。为了成功完成对文件选择器的适配改造,现在想要借鉴Image库对图片选择器的修改以及一些有用的文档,提炼出一个可以用来修改文件选择器的适配思路。
后记-总结复盘
本次任务涉及到的Hermes模块在原先提供文件选择器的能力时,思路是直接从Environment获取设备外部存储的路径来获取。可以看到相关的代码片段如下:
1 | mDefaultPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).getPath(); |
之后的代码逻辑都是基于上述两个变量mDefaultPath
mRootPath
来进行的,整体的流程被我梳理了一个函数调用栈,方便我在做适配的时候找到适合插手的地方。整个栈全部展示会显得冗余,所以栈中只保留最核心的函数,不显示一些无关紧要的函数,但是这并不代表在实际调用过程中函数并没有被调用。
大致的函数调用栈梳理出来之后对整个文件选择器能力的实现便有了一个整体的感知,代码修改起来也可以有的放矢。在做Target30适配方案调研时了解到访问安卓11的Document或者Download下的文件时需要使用SAF框架来打开。
根据之前存储适配的思路,在原先的文件选择器逻辑中新增一层DataSource的抽象,并且根据目标平台的版本号来区分不同的处理方式,例如:在api level< 29时,使用原先的逻辑;在api level >29时使用saf框架的逻辑。
代码架构的问题由抽象出的一层DataSource解决之后,涉及到核心能力的编码。SAF框架中需要在选择文件时,先唤起系统的文件选择器。现在根据师姐整理的最后版本,提炼出核心代码如下:
1 | public boolean startSystemDocumentExplorerForResult(Activity activity, int requestCode) { |
1 |
|
之后是在对应的Context容器中重现回调函数:
1 |
|
仔细阅读了师姐整理的代码后,着重分析了该处理方式中值得我学习的细节
- SAF的框架写在Activity内,并且将该部分逻辑单独封装成一个类
SystemFileExploreDataSource
,在context的onCreate()
调用时进行初始化; - 涉及
startActivityForResult
的函数返回结果设置成Boolean形式来对函数是否调用进行判断 - 回调函数
onActivityResult
的代码处理失败时的情况,调用activity的finish()函数来销毁页面 - 要在
SystemFileExploreDataSource
类中调用一些其他类具有的通用逻辑时,可以将此类逻辑定义成static的,避免在不相干但是同时继承同一个父类的类中添加无用的方法实现
纵观适配后的代码逻辑,可以整理成下图,可以看出现在的工作逻辑是:在唤起文件选择器后,获取对uri的query结果之后直接使用send的能力将选择的文件发送出去。
提炼Image库代码修改思路
学习完本节下面两节的部分之后,对分区存储造成影响的用例及解决方案、SAF、FileProvider等概念有了基本的了解之后,开始回顾Image库当时的代码修改情况。
代码扫描结果
涉及到文件存储API调用的代码行数结果如下:
8.27 commit记录还原代码修改步骤
8.27的commit从图片选择器开始着手
需要先在清单文件中加权限:
1 | <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> |
改造的函数1- getSelection() -> getSelects()
1 | // 原先的函数签名 |
2 - query的调用方式
1 | + String[] PROJECTIONS = new String[]{ |
3- 上述修改的query方法调用逻辑修改
1 | //API超过26的版本,ContentResolver翻页查询的数据使用Bundle的形式进行处理,而非SQL拼接 |
9.13 commit记录还原适配步骤
基于这次的commit,可以看到引入了新的依赖sdk NirvanaCoreCompat
:http://gitlab.alibaba-inc.com/SourcingAndroidSDK/NirvanaCoreCompat.git
这个SDK对涉及到的esd,espd等api调用修改方式进行了封装,提供了做好适配逻辑的接口,在调用场景直接使用该sdk提供的接口即可。
对调用到之前的espd api的代码
1 | File storageDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM); |
应用compat库修改之后
1 | File storageDir = EnvironmentCompat.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM); |
该sdk目前属于开发阶段,重要思路是将适配过程中对原数据进行一层DataSource的抽象,sdk会提供不同sdk版本的获取数据逻辑的封装,而调用代码的部分只需要提供原数据的信息,具体的适配逻辑由compat库给出。
NirvanaCoreCompat - 封装适配工作的sdk
核心类 - EnvironmentCompat
该类封装了安卓系统提供的Context,Build,Environment的一些逻辑,并且在调用getExternalStoragePublicDirectory
这类场景做了分api版本的兼容处理
核心类 - ContentUriCompat
用来做appendId的功能封装
9.15 代码分层的重构
这条commit开始,Image库在包内新建了data目录,里面包括了model和source两个模块。
model下的类表示数据元素,例如AlbumItem,ImageVideoItem分别表示相册的数据元素和图片视频的封面,他们包含了一些标签性质的信息例如时长,path
source下的类主要是对数据源进行一层抽象,DataSource,以相册场景来说,datasource表示相册detail数据,获取详细数据的底层逻辑实际上就是
1 |
|
谷歌官方给出的最佳实践
分区存储改变了应用在设备的外部存储设备中存储和访问文件的方式。迁移至支持分区存储,谷歌给出的最佳实践用例,且分为两类:处理媒体文件和处理非媒体文件。
处理媒体文件(视频、图片和音频文件)
显示多个文件夹中的图片或者视频文件-各安卓版本一致
使用 [query()
](https://developer.android.com/reference/android/content/ContentResolver#query(android.net.Uri, java.lang.String[], java.lang.String, java.lang.String[], java.lang.String, android.os.CancellationSignal)) API 查询媒体集合。如需对媒体文件进行过滤或排序,请调整 projection
、selection
、selectionArgs
和 sortOrder
参数。
显示特定文件夹中的图片或视频-各安卓版本一致
- 按照请求应用权限中所述的最佳做法,请求
READ_EXTERNAL_STORAGE
权限。 - 根据
MediaColumns.DATA
的值检索媒体文件,该值包含磁盘上的媒体项的绝对文件系统路径。
访问照片中的位置信息(拍摄地点)-存在分区存储区别
如果应用使用分区存储,请按照媒体存储指南的照片中的位置信息部分的步骤操作。
⚠️ 即使停用分区存储,您也需要
ACCESS_MEDIA_LOCATION
权限才能读取使用MediaStore
API 访问的图片中的未编辑位置信息。
Exif - 可交换图像文件格式,是专门为数码相机的照片设定的文件格式,可以记录数码照片的属性信息和拍摄数据。
在一次操作中修改或删除多个媒体文件
在 Android 11 中,请使用一种方法。在 Android 10 中,请停用分区存储并改用适用于 Android 9 及更低版本的方法。
Android11上运行
- 使用 [
MediaStore.createWriteRequest()
](https://developer.android.com/reference/android/provider/MediaStore#createWriteRequest(android.content.ContentResolver, java.util.Collection)) 或 [MediaStore.createTrashRequest()
](https://developer.android.com/reference/android/provider/MediaStore#createTrashRequest(android.content.ContentResolver, java.util.Collection, boolean)) 为应用的写入或删除请求创建待定 intent,然后通过调用该 intent 提示用户授予修改一组文件的权限。 - 评估用户的响应:
- 如果授予了权限,请继续修改或删除操作。
- 如果未授予权限,请向用户说明您的应用中的功能为何需要该权限。
在 Android 10 上运行
如果您的应用以 Android 10(API 级别 29)为目标平台,请停用分区存储,继续使用适用于 Android 9 及更低版本的方法来执行此操作。
在 Android 9 或更低版本上运行
请使用以下方法:
- 按照请求应用权限中所述的最佳做法,请求
WRITE_EXTERNAL_STORAGE
权限。 - 使用
MediaStore
API 修改或删除媒体文件。
导入已经存在的单张图片 -各版本相同
当您要导入已经存在的单张图片(例如,用作用户个人资料的照片)时,应用可以将自己的界面用于此操作,也可以使用系统选择器。
拍摄单张图片- 各版本相同
当您想拍摄单张图片在应用中使用(例如,用作用户个人资料的照片)时,请使用 ACTION_IMAGE_CAPTURE
intent 要求用户使用设备的摄像头拍照。系统会将拍摄的照片存储在 MediaStore.Images
表中。
与其他应用共享媒体文件- 各版本相同
使用 [insert()
](https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri, android.content.ContentValues)) 方法将记录直接添加到 MediaStore 中。如需了解详情,请参阅媒体存储指南的添加项目部分。
与特定应用共享媒体文件 -各版本相同
按照设置文件共享指南中所述,使用 Android FileProvider
组件。
从代码或依赖库中使用直接文件路径访问文件 -存在区别
在Android11上运行
- 按照请求应用权限中所述的最佳做法,请求
READ_EXTERNAL_STORAGE
权限。 - 使用直接文件路径访问文件。
如需了解详情,请参阅使用原始路径访问文件。
在 Android 10 上运行
如果您的应用以 Android 10(API 级别 29)为目标平台,请停用分区存储,继续使用适用于 Android 9 及更低版本的方法来执行此操作。
在 Android 9 或更低版本上运行
请使用以下方法:
- 按照请求应用权限中所述的最佳做法,请求
WRITE_EXTERNAL_STORAGE
权限。 - 使用直接文件路径访问文件。
一个请求的权限是READ,一个是WRITE
处理非媒体文件
打开文档文件 - 各版本相同
使用 ACTION_OPEN_DOCUMENT
intent 要求用户使用系统选择器选择要打开的文件。如果您想过滤系统选择器提供给用户选择的文件类型,您可以使用 setType()
或 EXTRA_MIME_TYPES
。
例如,可以使用一下代码查询所有pdf,odt,txt文件
1 | Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); |
从旧版存储位置迁移现有文件 - 各版本相同
如果目录不是应用专属目录或公开共享目录,则被视为旧版存储位置。如果您的应用要在旧版存储位置中创建文件或使用其中的文件,我们建议您将应用的文件迁移到可通过分区存储进行访问的位置,并对应用进行必要的更改以使用分区存储中的文件。
保留对旧版存储位置的访问权限以进行数据迁移
如果应用以 Android 11 为目标平台
使用
preserveLegacyExternalStorage
标记保留旧版存储模型,以便在用户升级到以 Android 11 为目标平台的新版应用时,应用可以迁移用户的数据。注意:如果您使用
preserveLegacyExternalStorage
,旧版存储模型只在用户卸载您的应用之前保持有效。如果用户在搭载 Android 11 的设备上安装或重新安装您的应用,那么无论preserveLegacyExternalStorage
的值是什么,您的应用都无法停用分区存储模型。继续停用分区存储,以便您的应用可以继续在搭载 Android 10 的设备上访问旧版存储位置中的文件。
如果应用以 Android 10 为目标平台
停用分区存储,更轻松地在不同 Android 版本之间保持应用行为不变。
迁移应用数据
当应用准备就绪,可以迁移时,请使用以下方法:
- 检查应用的工作文件是否位于
/sdcard/
目录或其任何子目录中。 - 将任何私有应用文件从
/sdcard/
下的当前位置移至getExternalFilesDir()
方法所返回的目录。 - 将任何共享的非媒体文件从
/sdcard/
下的当前位置移至Downloads/
目录的应用专用子目录。 - 从
/sdcard/
目录中移除应用的旧存储目录。
与其他应用共享内容
如需与一个其他应用共享应用的文件,请使用 FileProvider
。对于全部需要在彼此之间共享文件的应用,我们建议您对每个应用使用内容提供程序,然后在将应用添加到集合中时同步数据。
缓存非媒体文件
方法取决于文件类型:
- 小文件或包含敏感信息的文件:请使用
Context#getCacheDir()
。 - 大型文件或不含敏感信息的文件:请使用
Context#getExternalCacheDir()
。
暂停使用分区存储
- 以 Android 9(API 级别 28)或更低版本为目标平台。
- 如果您以 Android 10(API 级别 29)或更高版本为目标平台,请在应用的清单文件中将
requestLegacyExternalStorage
的值设置为true
最佳实践中提到概念的详细解释
可以在上述官方文档中,看到在分区存储启用的之后应对各类用例提供的有效方案
方案中提到的词汇诸如SAF
,FileProvider
等的详细解释如下:
SAF-使用存储访问框架打开文件
api19引入的存储访问框架 (SAF)。借助 SAF,用户可轻松浏览和打开各种文档、图片及其他文件,而不用管这些文件来自其首选文档存储提供程序中的哪一个。用户可通过易用的标准界面,跨所有应用和提供程序以统一的方式浏览文件并访问最近用过的文件。
SAF 包含以下元素:
- 文档提供程序 - 一种==内容提供程序==,可让存储服务(如 Google 云端硬盘)提供其管理的文件。文档提供程序以
DocumentsProvider
类的子类形式实现。文档提供程序的架构基于传统的文件层次结构,但其实际的数据存储方式由您决定。Android 平台包含若干内置的文档提供程序,如 Downloads、Images 和 Videos。 - 客户端应用 - 一种定制化的应用,它会调用
ACTION_CREATE_DOCUMENT
、ACTION_OPEN_DOCUMENT
和ACTION_OPEN_DOCUMENT_TREE
intent 操作并接收文档提供程序返回的文件。 - 选择器 - 一种系统界面,可让用户访问所有文档提供程序内满足客户端应用搜索条件的文档。
SAF提供的功能包括:
- 让用户浏览所有文档提供程序的内容,而不仅仅是单个应用的内容。
- 让您的应用获得对文档提供程序所拥有文档的长期、持续访问权限。用户可通过此访问权限添加、修改、保存和删除提供程序中的文件。
- 支持多个用户帐号和临时根目录,如只有在插入 U 盘后才会出现的“USB 存储提供程序”。
SAF数据模型
核心是一个内容提供程序,它是 DocumentsProvider
类的一个子类。在文档提供程序内,数据结构采用传统的文件层次结构:
关于以上模型有几点需要注意:
每个文档提供程序都会报告一个或多个根目录(文档树结构的起点)。每个根目录都有唯一的
COLUMN_ROOT_ID
,并且指向一个表示该根目录下内容的文档(目录)。根目录采用==动态设计==,以支持多个帐号、临时 USB 存储设备或用户登录/退出等用例。每个根目录下都只有一个文档。该文档指向 1 到 N 个文档,其中每个文档又可指向 1 至 N 个文档。
每个存储后端都会使用唯一的
COLUMN_DOCUMENT_ID
来引用各个文件和目录,以便将其呈现出来。文档 ID 必须具有==唯一性==,且一经发出便不得更改,因为这些 ID 会用于实现 URI 持久授权(不受设备重启影响)。文档可以是可打开的文件(具有特定的 MIME 类型),也可以是包含其他文档的目录(具有
MIME_TYPE_DIR
MIME 类型)。媒体类型(通常称为 Multipurpose Internet Mail Extensions 或 MIME 类型 )是一种标准,用来表示文档、文件或字节流的性质和格式。
每个文档可拥有不同的功能,具体在
COLUMN_FLAGS
中指定。例如,FLAG_SUPPORTS_WRITE
、FLAG_SUPPORTS_DELETE
和FLAG_SUPPORTS_THUMBNAIL
。多个目录中可包含相同的COLUMN_DOCUMENT_ID
。
SAF控制流
文档提供程序数据模型基于传统的文件层次结构,不过,只要能通过 DocumentsProvider
API 访问数据,您实际上就可以采用自己喜欢的任何方式来存储数据。例如,您可以使用基于标记的云存储空间来存储数据。
在控制流中注意以下几点:
- 在 SAF 中,提供程序和客户端并不直接交互。客户端会请求与文件进行交互(即读取、修改、创建或删除文件)的权限。
- 当应用(在示例图片中为照片应用)触发
ACTION_OPEN_DOCUMENT
或ACTION_CREATE_DOCUMENT
intent 后,交互便会开始。intent 可包含过滤器,用于进一步细化条件,例如“为我提供所有 MIME 类型为‘图片’”的可打开文件”。 - 当 intent 触发后,==系统选择器==会联络每个已注册的提供程序,并向用户显示匹配内容的根目录。
- 选择器会为用户提供标准的文档访问界面,即使底层的文档提供程序可能相互之间差异很大,一致性也不受影响。例如,图 2 展示了一个 Google 云端硬盘提供程序、一个 USB 提供程序和一个云提供程序。
图3展示了一个选择器,某位搜索图片的用户在其中选择了 Downloads 文件夹。该图还展示了可供客户端应用使用的所有根目录。
在用户选择 Downloads 文件夹后,系统会显示图片。图 4 显示了此过程的结果。用户现在即能以提供程序和客户端应用支持的方式与这些图片进行交互。
编写客户端应用
在 Android 4.3 及更低版本中,如果您想让应用从其他应用中检索文件,则该应用必须调用 ACTION_PICK
或 ACTION_GET_CONTENT
等 intent。然后,用户必须选择一个要从中选取文件的应用,并且所选应用必须提供用户界面,以便用户浏览和选择可用文件。
在 Android 4.4(API 级别 19)及更高版本中,您还可选择使用 ACTION_OPEN_DOCUMENT
intent,此 intent 会显示由系统控制的选择器界面,以便用户浏览其他应用提供的所有文件。借助此界面,用户便可从任何受支持的应用中选择文件。
在 Android 5.0(API 级别 21)及更高版本中,您还可以使用 ACTION_OPEN_DOCUMENT_TREE
intent,借助此 intent,用户可以选择供客户端应用访问的目录。
注意:
ACTION_OPEN_DOCUMENT
并非用于替代ACTION_GET_CONTENT
。您应使用的 intent 取决于应用的需要:
- 如果您只想让应用读取/导入数据,请使用
ACTION_GET_CONTENT
。使用此方法时,应用会导入数据(如图片文件)的副本。- 如果您想让应用获得对文档提供程序所拥有文档的长期、持续访问权限,请使用
ACTION_OPEN_DOCUMENT
。例如,照片编辑应用可让用户编辑存储在文档提供程序中的图片
FileProvider-文件共享
若要安全地将应用中的文件提供给其他应用,您需要配置应用,以内容 URI 的形式提供文件的安全句柄。Android FileProvider
组件会根据您在 XML 中指定的内容生成文件的内容 URI。本课程介绍了如何在您的应用中添加 FileProvider
的默认实现,以及如何指定要提供给其他应用的文件。
该部分大部分是有关xml文件的定义,需要在清单文件中给出provider
定义。
1 | <manifest xmlns:android="http://schemas.android.com/apk/res/android" |
之后需要在res/xml/
子目录中创建filepaths.xml
文件,该文件中的xml元素置顶想要共享的文件的目录。下代码段展示了如何共享内部存储区域中的 files/
目录的子目录:
1 | <paths> |
在本例中,<files-path>
标记共享了应用内部存储空间的 files/
目录中的目录。path
属性共享了 files/
的 images/
子目录。name
属性指示 FileProvider
将路径段 myimages
添加到 files/images/
子目录中文件的内容 URI 中。
<paths>
元素可以有多个子元素,每个子元素指定一个不同的共享目录。除了 <files-path>
元素之外,您还可以使用 <external-path>
元素共享==外部存储空间==中的目录,使用 <cache-path>
元素共享内部缓存目录中的目录。如需详细了解指定共享目录的子元素,请参阅 FileProvider
参考文档。
完整地指定了 FileProvider
,该提供器可用于为应用内部存储空间中的 files/
目录中的文件或 files/
的子目录中的文件生成内容 URI。当应用为文件生成内容 URI 时,会包含 <provider>
元素中指定的授权 (com.example.myapp.fileprovider
)、路径 myimages/
以及文件的名称。
例如,如果您根据本课程中的代码段定义 FileProvider
,并请求文件 default_image.jpg
的内容 URI,FileProvider
将返回以下 URI:
1 | content://com.example.myapp.fileprovider/myimages/default_image.jpg |