阅读 1091

结合Android去水印程序谈谈分区存储

前言

为了方便个人更新微信状态,上周花半天时间编写简单的抖音去水印APP。热心的小伙伴发现在Android11上无法保存视频。震惊,土豪竟然都是高端大气Android11。于是乎,分区存储的适配工作必须给土豪安排上。

分区存储

为了让用户更好地管理自己的文件并减少混乱,以 Android 10(API 级别 29)及更高版本为目标平台的应用在默认情况下被赋予了对外部存储空间的分区访问权限,即分区存储。此类应用在不请求任何与存储相关的用户权限时,只能访问外部存储空间上的应用专属目录,以及本应用所创建的特定类型的媒体文件

文件位置所需权限方法卸载时是否移除
应用专属文件(外部)getExternalFilesDir()
getExternalCacheDir()
可共享的媒体文件(图片、音频文件、视频)在 Android 10(API 级别 29)或更高版本中,访问其他应用的文件需要 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE 权限
在 Android 9(API 级别 28)或更低版本中,访问所有文件均需要相关权限
MediaStore API
下载内容(文档、PDF等)存储访问框架SAF(Storage Access Framework)

而Android 11(API 级别 30)进一步增强了平台功能,为外部存储设备上的应用和用户数据提供了更好的保护,强制执行分区存储

如何暂避适配

【不推荐】

虽然Google提供了requestLegacyExternalStoragepreserveLegacyExternalStorage两个属性来帮助开发者平滑过渡适配分区存储的工作,但是大部分开发者已经把这个当成了首选项。真正勤劳的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()**方法来处理。

删除媒体文件

媒体文件更新和媒体文件删除的场景比较类似,下面以媒体文件删除为例进行说明。

考虑三种场景:

  1. 只删除属于当前应用的媒体文件;
  2. 不申请WRITE_EXTERNAL_STORAGE权限,删除MediaStore内其他应用的媒体文件;
  3. 申请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