点击上方“小白学视觉”,选择加"星标"或“置顶”
重磅干货,第一时间送达
前言
方便个人更新微信状态,上周花半天时间编写简单的抖音去水印APP。热心的小伙伴发现在Android11上无法保存视频。震惊,土豪竟然都是高端大气Android11。于是乎,分区存储的适配工作必须给土豪安排上。
分区存储
为了让用户更好地管理自己的文件并减少混乱,以 Android 10(API 级别 29)及更高版本为目标平台的应用在默认情况下被赋予了对外部存储空间的分区访问权限,即分区存储。此类应用在不请求任何与存储相关的用户权限时,只能访问外部存储空间上的应用专属目录,以及本应用所创建的特定类型的媒体文件。
文件位置 | 所需权限 | 方法 | 卸载时是否移除 |
---|---|---|---|
应用专属文件(外部) | 无 | getExternalFilesDir() getExternalCacheDir() | 是 |
可共享的媒体文件(图片、音频文件、视频) | 在 Android 10(API 级别 29)或更高版本中,访问其他应用的文件需要 READ_EXTERNAL_STORAGE 或 WRITE_EXTERNAL_STORAGE 权限在 Android 9(API 级别 28)或更低版本中,访问所有文件均需要相关权限 | MediaStore API | 否 |
下载内容(文档、PDF等) | 无 | 存储访问框架SAF(Storage Access Framework) | 否 |
而Android 11(API 级别 30)进一步增强了平台功能,为外部存储设备上的应用和用户数据提供了更好的保护,强制执行分区存储。
如何暂避适配
【不推荐】
虽然Google提供了requestLegacyExternalStorage和preserveLegacyExternalStorage两个属性来帮助开发者平滑过渡适配分区存储的工作,但是大部分开发者已经把这个当成了首选项。真正勤劳的Android打工人要敢于面对疾风,硬刚适配工作,毕竟应用的targetSdkVersion不可能永远不更新,适配工作宜早不宜迟。但是巧用这两个属性可以帮助开发者争取足够的适配时间:
以 Android 9(API 级别 28)或更低版本为目标平台,即targetSdkVersion<=28;
若targetSdkVersion>28,在AndroidManifest.xml文件中将
requestLegacyExternalStorage
的值设置为true
。
<manifest ... >
<!-- This attribute is "false" by default on apps targeting
Android 10 or higher. -->
<application android:requestLegacyExternalStorage="true" ... >
...
</application>
</manifest>
注意:若targetSdkVersion=30,且应用在搭载 Android 11 的设备上运行,系统会忽略requestLegacyExternalStorage属性,即默认强制开启分区存储。
大多数应用都不需要使用
preserveLegacyExternalStorage
。此标记仅适用于这样一种情况:将应用数据迁移到了与分区存储兼容的位置,并且希望用户在更新应用时保留对数据的访问权限。使用此标记会导致更难以测试分区存储对用户有何影响,因为当用户更新应用时,它会继续使用旧版存储模型。
适配方式
新增媒体文件
针对私有数据,我们直接采用getExternalFilesDir()
或者getExternalCacheDir()
自行处理即可。而共享媒体文件场景,对于开发者来说,更常见且更不易处理。下面以保存视频
为例,说明如何处理新数据的存储问题,针对图片,音频,处理方式类似。
个人开发的抖音去水印程序中视频下载并存储逻辑主要有三步:
网络请求视频数据;
媒体库插入一条记录并生成Uri;
视频数据写入Uri对应的文件。
不同Android版本差异主要集中在第二步:媒体库插入一条记录并生成Uri。
Android10及以上:视频默认保存在Movies文件夹下,通过MediaStore.MediaColumns.RELATIVE_PATH字段来指定子目录
val values = ContentValues().apply {
put(MediaStore.Video.Media.TITLE, "$videoName.mp4")
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
put(
MediaStore.MediaColumns.RELATIVE_PATH,
Environment.DIRECTORY_MOVIES + "/DouYin"
)
}
uri = contentResolver.insert(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
values
)
Android10以下:通过MediaStore.Video.Media.DATA字段来指定文件存放位置
val path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES)
if (!path.exists()) {
path.mkdirs()
}
val pathStr = path.absolutePath + "/DouYin"
val file = File(pathStr)
if (!file.exists()) {
file.mkdirs()
}
val videoPath = pathStr + File.separator + videoName + ".mp4"
val values = ContentValues().apply {
put(MediaStore.Video.Media.DISPLAY_NAME, videoName)
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
put(MediaStore.Video.Media.DATA, videoPath)
put(MediaStore.Video.Media.DATE_MODIFIED, System.currentTimeMillis() / 1000)
}
uri = contentResolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)
经过差异化处理以后,我们获取到了一个Uri,剩下的就是写入视频数据。
uri?.let { localUri ->
val fileDescriptor: ParcelFileDescriptor? =
contentResolver.openFileDescriptor(localUri, "w")
val inStream: InputStream = body.byteStream()
val outStream = FileOutputStream(fileDescriptor?.fileDescriptor)
try {
outStream.use { outPut ->
var read: Int
val buffer = ByteArray(2048)
while (inStream.read(buffer).also { read = it } != -1) {
outPut.write(buffer, 0, read)
}
}
return true
} catch (e: IOException) {
e.printStackTrace()
} finally {
inStream.close()
outStream.close()
}
return false
}
查询媒体文件
如果只是查询应用专属媒体文件,不需要申请
READ_EXTERNAL_STORAGE
权限,但是若是查询媒体库内所有媒体文件,则需要申请该权限。
还是以查询视频文件为例,在分区存储中,我们只能借助MediaStore API获取到视频的Uri,而无法再使用绝对路径的方式来获取视频。如果没有请求READ_EXTERNAL_STORAGE
权限,通过如下方式只能获取属于当前应用的视频文件,无法获取共享媒体库内的所有视频文件。
fun getMovies() {
val contentResolver = getApplication<Application>().contentResolver
_isRefresh.value = true
viewModelScope.launch(Dispatchers.IO) {
val cursor = contentResolver.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
null,
null,
null,
"${MediaStore.MediaColumns.DATE_ADDED} desc"
)
val movies = ArrayList<Movie>()
if (cursor != null) {
while (cursor.moveToNext()) {
val id =
cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
val title =
cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE))
val uri =
ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
movies.add(Movie(id, title, uri))
}
cursor.close()
}
_movieList.postValue(movies)
_isRefresh.postValue(false)
}
}
如上,通过ContentResolver获取到了媒体库内所有视频的id,然后再借助ContentUris将id拼装成一个完整的Uri对象。获取Uri以后,我们有多种方式处理,可以使用支持Uri加载的第三方开源库,如Glide,也可以参考上一章节中的做法,使用ContentResolver的openFileDescriptor()方法来处理。
删除媒体文件
媒体文件更新和媒体文件删除的场景比较类似,下面以媒体文件删除为例进行说明。
考虑三种场景:
只删除属于当前应用的媒体文件;
不申请
WRITE_EXTERNAL_STORAGE
权限,删除MediaStore内其他应用的媒体文件;申请
WRITE_EXTERNAL_STORAGE
权限,删除MediaStore内其他应用的媒体文件
第一种场景不用多说,删除属于当前应用的媒体文件,可以直接删除。
第三种场景也不用多说,用户在被授权的情况下,可以直接删除MediaStore下的任意文件。
上面两种场景,均可采用下列代码来执行删除操作。但是第二种场景,只有在未启动分区存储的情况下,下列代码才能完成删除操作。
getApplication<Application>().contentResolver.delete(
movie.uri,
"${MediaStore.Video.Media._ID} = ?",
arrayOf(movie.id.toString())
)
如果启用了分区存储,我们就需要为应用要移除的每个文件捕获 RecoverableSecurityException
来处理后续逻辑。具体代码如下:
private suspend fun performDeleteMovie(movie: Movie) {
withContext(Dispatchers.IO) {
try {
getApplication<Application>().contentResolver.delete(
movie.uri,
"${MediaStore.Video.Media._ID} = ?",
arrayOf(movie.id.toString())
)
} catch (securityException: SecurityException) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val recoverableSecurityException =
securityException as? RecoverableSecurityException
?: throw securityException
pendingDeleteImage = movie
_permissionNeededForDelete.postValue(
recoverableSecurityException.userAction.actionIntent.intentSender
)
} else {
throw securityException
}
}
}
}
viewModel.permissionNeededForDelete.observe(viewLifecycleOwner, { intentSender ->
intentSender?.let {
startIntentSenderForResult(
intentSender,
DELETE_PERMISSION_REQUEST,
null,
0,
0,
0,
null
)
}
})
因为在启用分区存储以后,应用在没有WRITE_EXTERNAL_STORAGE
权限时,无法直接对MediaStore中的媒体文件修改或者删除,此时,Android系统会给我们抛出RecoverableSecurityException
,该异常中包含一个IntentSender
,我们可以通过捕获此类异常,并利用其中包含的IntentSender来提示用户授权修改或者删除被选中的媒体文件。考虑下Google这种实现方式的目的,难道是不希望我们申请WRITE_EXTERNAL_STORAGE
权限?毕竟删除其他应用的媒体文件属于危险操作。通过这种抛特殊异常的方式提示用户授权,单个媒体文件二次确认,安全性提高不少。
文件选择器
以上是针对图片、音频、视频等媒体文件的操作方式,但是日常开发中,我们还会经常使用到其他类型的文件,比如PDF文件,这个时候就无法再使用MediaStore API了,而是要使用文件选择器。具体用法如下:
private fun pickFile() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "*/*"
startActivityForResult(intent, PICK_FILE)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
PICK_FILE -> {
if (resultCode == Activity.RESULT_OK && data != null) {
val uri = data.data
if (uri != null) {
Toast.makeText(context, uri.toString(), Toast.LENGTH_SHORT).show()
}
}
}
}
}
源码
抖音去水印Android源码:https://github.com/onlyloveyd/CleanDouYin
文中示例源码:https://github.com/onlyloveyd/AndroidPlusSample
下载1:OpenCV-Contrib扩展模块中文版教程
在「小白学视觉」公众号后台回复:扩展模块中文教程,即可下载全网第一份OpenCV扩展模块教程中文版,涵盖扩展模块安装、SFM算法、立体视觉、目标跟踪、生物视觉、超分辨率处理等二十多章内容。
下载2:Python视觉实战项目52讲
在「小白学视觉」公众号后台回复:Python视觉实战项目,即可下载包括图像分割、口罩检测、车道线检测、车辆计数、添加眼线、车牌识别、字符识别、情绪检测、文本内容提取、面部识别等31个视觉实战项目,助力快速学校计算机视觉。
下载3:OpenCV实战项目20讲
在「小白学视觉」公众号后台回复:OpenCV实战项目20讲,即可下载含有20个基于OpenCV实现20个实战项目,实现OpenCV学习进阶。
交流群
欢迎加入公众号读者群一起和同行交流,目前有SLAM、三维视觉、传感器、自动驾驶、计算摄影、检测、分割、识别、医学影像、GAN、算法竞赛等微信群(以后会逐渐细分),请扫描下面微信号加群,备注:”昵称+学校/公司+研究方向“,例如:”张三 + 上海交大 + 视觉SLAM“。请按照格式备注,否则不予通过。添加成功后会根据研究方向邀请进入相关微信群。请勿在群内发送广告,否则会请出群,谢谢理解~