3

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

 3 years ago
source link: http://www.androidchina.net/11640.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
结合Android去水印程序谈谈分区存储 – Android开发中文站
你的位置:Android开发中文站 > Android开发 > 开发进阶 > 结合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


Recommend

  • 42

    之所以会写这篇文章,是因为在自学Go语言的过程,总会有些困惑和迷茫,总希望更好地学习下去,因此有了一些想法,在这里梳理一下,以便日后回过头来也可以看看此时此刻的想法。 关于基础 基础,除了学习Go语言的基础外,也应该加强计算机基础的学习。 对于Go语言的...

  • 27
    • 微信 mp.weixin.qq.com 4 years ago
    • Cache

    结合 Sentinel 专栏谈谈我的源码阅读方法

    点击上方 “中间件兴趣圈” , 选择 “设为星标” 做积极的人,越努力越幸运!

  • 26

    进入主题之前,先从背景简述下最近前端界的热点词汇 Serverless,其实,Serverless 这个概念在其他领域已经提出来很久了。 Serverless 概念 Serverless 直译为无服务器,代表一种 无服务器架构

  • 12

    花三天读完了《增长黑客》,去掉煽情部分,砍掉铺垫和转折,捎上18个血里呼啦的有关增长的案例,给大家捞捞干货。 第一部分:增长黑客...

  • 18
    • 微信 mp.weixin.qq.com 4 years ago
    • Cache

    一文带你了解适配 Android 11 分区存储

    本文字数: 5037

  • 8

    V2EX  ›  程序员 不是我不想支持 Android 10 的分区存储啊 Orz   xloger · 7 小时 31 分钟前...

  • 8
    • segmentfault.com 3 years ago
    • Cache

    谈谈水印实现的几种方式

    谈谈水印实现的几种方式日常工作中,经常会遇到很多敏感的数据,为防止数据的泄露,我们要在数据上做一些”包装“。目的就是让那些有心泄露数据的”不法分子“迫于严重的”舆论压力“而放弃不法...

  • 8
    • my.oschina.net 3 years ago
    • Cache

    Android 分区存储常见问题解答

    Android 分区存储常见问题解答 要在 Google Play 上发布,开发者需要将应用的 目标 API 级...

  • 6
    • www.woshipm.com 2 years ago
    • Cache

    结合实践,谈谈进度管理

    在推动实际业务的发展进程中,产品经理有时会需要做好进度管理的工作,即在“速度”和“进展”等维度上进行把控,以推动业务的实际落地。那么,进度管理应该怎么做,才更加有效和高效?在本篇文章里,作者便结合实践经验,针对进度管理这件事进行了解读,一...

  • 3
    • www.51cto.com 1 year ago
    • Cache

    Android 10的分区存储

    Android 10的分区存储 作者:Reathin 2023-11-10 11:02:28 Android的分区存储机制为应用程序提供了灵活的存储方式,既保护了用户的隐私,又方便了数据的共享和传输。 在Android系统中,分区存储是一种用...

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK