百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术文章 > 正文

Android 10(Q)/11(R) 分区存储适配

haoteby 2025-01-09 12:59 8 浏览

作者:连续三届村草


Android 10(Q)/11(R) 分区存储适配

大部分应用都会请求 ( READ_EXTERNAL_STORAGE ) ( WRITE_EXTERNAL_STORAGE ) 存储权限,来做一些诸如在 SD 卡中存储文件或者读取多媒体文件等常规操作。这些应用可能会在磁盘中存储大量文件,即使应用被卸载了还会依然存在。另外,这些应用还可能会读取其他应用的一些敏感文件数据。

为此,Google 终于下定决心在 Android 10 中引入了分区存储,对权限进行场景的细分,按需索取,并在 Android 11 中进行了进一步的调整。


Android 存储分区情况

Android 中存储可以分为两大类:私有存储和共享存储

  • 私有存储 (Private Storage) : 每个应用在都拥有自己的私有目录,其它应用看不到,彼此也无法访问到该目录:内部存储私有目录 (/data/data/packageName) ;外部存储私有目录 (/sdcard/Android/data/packageName),
  • 共享存储 (Shared Storage) : 存储其他应用可访问文件, 包含媒体文件、文档文件以及其他文件,对应设备DCIM、Pictures、Alarms、Music、Notifications、Podcasts、Ringtones、Movies、Download等目录。


Android 10(Q) :

Android 10 中主要对共享目录进行了权限详细的划分,不再能通过绝对路径访问。

受影响的接口:

访问不同分区的方式:

  1. 私有目录:和以前的版本一致,可通过 File() API 访问,无需申请权限。
  2. 共享目录:需要通过MediaStore和Storage Access Framework API 访问,视具体情况申请权限,下面详细介绍。

其中,对共享目录的权限进行了细分:

  1. 无需申请权限的操作:
    通过 MediaStore API对媒体集、文件集进行媒体/文件的添加、对 自身APP 创建的 媒体/文件 进行查询、修改、删除的操作。
  2. 需要申请READ_EXTERNAL_STORAGE 权限:
    通过 MediaStore API对所有的媒体集进行查询、修改、删除的操作。
  3. 调用 Storage Access Framework API :
    会启动系统的文件选择器向用户申请操作指定的文件

新的访问方式:


Android 11 (R):

Android 11 (R) 在 Android 10 (Q) 中分区存储的基础上进行了调整

1. 新增执行批量操作

为实现各种设备之间的一致性并增加用户便利性,Android 11 向 MediaStore API 中添加了多种方法。对于希望简化特定媒体文件更改流程(例如在原位置编辑照片)的应用而言,这些方法尤为有用。

MediaStore API 新增的方法

系统在调用以上任何一个方法后,会构建一个 PendingIntent 对象。应用调用此 intent 后,用户会看到一个对话框,请求用户同意应用更新或删除指定的媒体文件。


2. 使用直接文件路径和原生库访问文件

为了帮助您的应用更顺畅地使用第三方媒体库,Android 11 允许您使用除 MediaStore API 之外的 API 访问共享存储空间中的媒体文件。不过,您也可以转而选择使用以下任一 API 直接访问媒体文件:

File API。
原生库,例如 fopen()。

简单来说就是,可以通过 File() 等API 访问有权限访问的媒体集了。

性能:

通过 File () 等直接通过路径访问的 API 实际上也会映射为MediaStore API 。
按文件路径顺序读取的时候性能相当;随机读取和写入的时候则会更慢,所以还是推荐直接使用 MediaStoreAPI。


3. 新增权限

MANAGE_EXTERNAL_STORAGE : 类似以前的 READ_EXTERNAL_STORAGE + WRITE_EXTERNAL_STORAGE ,除了应用专有目录都可以访问。

应用可通过执行以下操作向用户请求名为所有文件访问权限的特殊应用访问权限:

  1. 在清单中声明 MANAGE_EXTERNAL_STORAGE 权限。
  2. 使用 ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION intent 操作将用户引导至一个系统设置页面,在该页面上,用户可以为您的应用启用以下选项:授予所有文件的管理权限。
  • 在 Google Play 上架的话,需要提交使用此权限的说明,只有指定的几种类型的 APP 才能使用。


Sample

  • 使用 MediaStore 增删改查媒体集
  • 使用 Storage Access Framework 访问文件集


1. 媒体集

1) 查询媒体集(需要 READ_EXTERNAL_STORAGE 权限)

实际上 MediaStore 是以前就有的 API ,不同的是过去主要通过 MediaStore.Video.Media._DATA这个 colum 请求原始数据,可以得到绝对Uri ,现在需要请求MediaStore.Video.Media._ID来得到相对Uri再进行处理。

// Need the READ_EXTERNAL_STORAGE permission if accessing video files that your
// app didn't create.

// Container for information about each video.
data class Video(
    val uri: Uri,
    val name: String,
    val duration: Int,
    val size: Int
)
val videoList = mutableListOf<Video>()

val projection = arrayOf(
    MediaStore.Video.Media._ID,
    MediaStore.Video.Media.DISPLAY_NAME,
    MediaStore.Video.Media.DURATION,
    MediaStore.Video.Media.SIZE
)

// Show only videos that are at least 5 minutes in duration.
val selection = "${MediaStore.Video.Media.DURATION} >= ?"
val selectionArgs = arrayOf(
    TimeUnit.MILLISECONDS.convert(5, TimeUnit.MINUTES).toString()
)

// Display videos in alphabetical order based on their display name.
val sortOrder = "${MediaStore.Video.Media.DISPLAY_NAME} ASC"

val query = ContentResolver.query(
    MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
    projection,
    selection,
    selectionArgs,
    sortOrder
)
query?.use { cursor ->
    // Cache column indices.
    val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
    val nameColumn =
            cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME)
    val durationColumn =
            cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION)
    val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE)

    while (cursor.moveToNext()) {
        // Get values of columns for a given video.
        val id = cursor.getLong(idColumn)
        val name = cursor.getString(nameColumn)
        val duration = cursor.getInt(durationColumn)
        val size = cursor.getInt(sizeColumn)

        val contentUri: Uri = ContentUris.withAppendedId(
            MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
            id
        )

        // Stores column values and the contentUri in a local object
        // that represents the media file.
        videoList += Video(contentUri, name, duration, size)
    }
}

2)插入媒体集(无需权限)

// Add a media item that other apps shouldn't see until the item is
// fully written to the media store.
val resolver = applicationContext.contentResolver

// Find all audio files on the primary external storage device.
// On API <= 28, use VOLUME_EXTERNAL instead.
val audioCollection = MediaStore.Audio.Media
        .getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

val songDetails = ContentValues().apply {
    put(MediaStore.Audio.Media.DISPLAY_NAME, "My Workout Playlist.mp3")
    put(MediaStore.Audio.Media.IS_PENDING, 1)
}

val songContentUri = resolver.insert(audioCollection, songDetails)

resolver.openFileDescriptor(songContentUri, "w", null).use { pfd ->
    // Write data into the pending audio file.
}

// Now that we're finished, release the "pending" status, and allow other apps
// to play the audio track.
songDetails.clear()
songDetails.put(MediaStore.Audio.Media.IS_PENDING, 0)
resolver.update(songContentUri, songDetails, null, null)

3)更新自己创建的媒体集(无需权限)

删除类似

// Updates an existing media item.
val mediaId = // MediaStore.Audio.Media._ID of item to update.
val resolver = applicationContext.contentResolver

// When performing a single item update, prefer using the ID
val selection = "${MediaStore.Audio.Media._ID} = ?"

// By using selection + args we protect against improper escaping of // values.
val selectionArgs = arrayOf(mediaId.toString())

// Update an existing song.
val updatedSongDetails = ContentValues().apply {
    put(MediaStore.Audio.Media.DISPLAY_NAME, "My Favorite Song.mp3")
}

// Use the individual song's URI to represent the collection that's
// updated.
val numSongsUpdated = resolver.update(
        myFavoriteSongUri,
        updatedSongDetails,
        selection,
        selectionArgs)

4)更新/删除其它媒体创建的媒体集

若已经开启分区存储则会抛出 RecoverableSecurityException,捕获并通过SAF请求权限

// Apply a grayscale filter to the image at the given content URI.
try {
    contentResolver.openFileDescriptor(image-content-uri, "w")?.use {
        setGrayscaleFilter(it)
    }
} catch (securityException: SecurityException) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        val recoverableSecurityException = securityException as?
            RecoverableSecurityException ?:
            throw RuntimeException(securityException.message, securityException)

        val intentSender =
            recoverableSecurityException.userAction.actionIntent.intentSender
        intentSender?.let {
            startIntentSenderForResult(intentSender, image-request-code,
                    null, 0, 0, 0, null)
        }
    } else {
        throw RuntimeException(securityException.message, securityException)
    }
}


2. 文件集 (通过 SAF)

1)创建文档

注:创建操作若重名的话不会覆盖原文档,会添加 (1) 最为后缀,如 document.pdf -> document(1).pdf

// Request code for creating a PDF document.
const val CREATE_FILE = 1

private fun createFile(pickerInitialUri: Uri) {
    val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
        addCategory(Intent.CATEGORY_OPENABLE)
        type = "application/pdf"
        putExtra(Intent.EXTRA_TITLE, "invoice.pdf")

        // Optionally, specify a URI for the directory that should be opened in
        // the system file picker before your app creates the document.
        putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
    }
    startActivityForResult(intent, CREATE_FILE)
}

2)打开文档

建议使用 type 设置 MIME 类型

// Request code for selecting a PDF document.
const val PICK_PDF_FILE = 2

fun openFile(pickerInitialUri: uri) {
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
        addCategory(Intent.CATEGORY_OPENABLE)
        type = "application/pdf"

        // Optionally, specify a URI for the file that should appear in the
        // system file picker when it loads.
        putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
    }

    startActivityForResult(intent, PICK_PDF_FILE)
}

3)授予对目录内容的访问权限

用户选择目录后,可访问该目录下的所有内容

Android 11 中无法访问 Downloads

fun openDirectory(pickerInitialUri: Uri) {
    // Choose a directory using the system's file picker.
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
        // Provide read access to files and sub-directories in the user-selected
        // directory.
        flags = Intent.FLAG_GRANT_READ_URI_PERMISSION

        // Optionally, specify a URI for the directory that should be opened in
        // the system file picker when it loads.
        putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
    }

    startActivityForResult(intent, your-request-code)
}

4)永久获取目录访问权限

上面提到的授权是临时性的,重启后则会失效。可以通过下面的方法获取相应目录永久性的权限

val contentResolver = applicationContext.contentResolver

val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
        Intent.FLAG_GRANT_WRITE_URI_PERMISSION
// Check for the freshest data.
contentResolver.takePersistableUriPermission(uri, takeFlags)

5)SAF API 响应

SAF API 调用后都是通过 onActivityResult来相应动作

override fun onActivityResult(
        requestCode: Int, resultCode: Int, resultData: Intent?) {
    if (requestCode == your-request-code
            && resultCode == Activity.RESULT_OK) {
        // The result data contains a URI for the document or directory that
        // the user selected.
        resultData?.data?.also { uri ->
            // Perform operations on the document using its URI.
        }
    }
}

6) 其它操作

除了上面的操作之外,对文档其它的复制、移动等操作都是通过设置不同的 FLAG 来实现,见 Document.COLUMN_FLAGS


3. 批量操作媒体集

构建一个媒体集的写入操作 createWriteRequest()

val urisToModify = /* A collection of content URIs to modify. */
val editPendingIntent = MediaStore.createWriteRequest(contentResolver,
        urisToModify)

// Launch a system prompt requesting user permission for the operation.
startIntentSenderForResult(editPendingIntent.intentSender, EDIT_REQUEST_CODE,
    null, 0, 0, 0)

//相应
override fun onActivityResult(requestCode: Int, resultCode: Int,
                 data: Intent?) {
    ...
    when (requestCode) {
        EDIT_REQUEST_CODE ->
            if (resultCode == Activity.RESULT_OK) {
                /* Edit request granted; proceed. */
            } else {
                /* Edit request not granted; explain to the user. */
            }
    }
}

createFavoriteRequest() createTrashRequest() createDeleteRequest() 同理



适配和兼容

在 targetSDK = 29 APP 中,在 AndroidManifes 设置 requestLegacyExternalStorage="true" 启用兼容模式,以传统分区模式运行。

   <manifest ... >
      <!-- This attribute is "false" by default on apps targeting
           Android 10 or higher. -->
      <application android:requestLegacyExternalStorage="true" ... >
        ...
      </application>
    </manifest>

注意:如果某个应用在安装时启用了传统外部存储,则该应用会保持此模式,直到卸载为止。无论设备后续是否升级为搭载 Android 10 或更高版本,或者应用后续是否更新为以 Android 10 或更高版本为目标平台,此兼容性行为均适用。

意思就是在新系统新安装的应用才会启用,覆盖安装会保持传统分区模式,例如:

  • 系统通过 OTA 升级到 Android 10/11
  • 应用通过更新升级到 targetSdkVersion >= 29

补充

Q:之前讨论过一些问题,APP 无需权限可以访问自己创建的媒体,那么系统如何进行判断?

A:创建媒体时系统会给媒体打上 packageName tag,应用被卸载则会清除 tag ,所以不会存在使用同样 packageName 进行欺骗的情况。

Q:我可以在媒体集文件夹下创建文档,就可以避开权限的问题了?

A:官方文档上写了只能创建相应类型的媒体/文件,具体如何限制的,没有说明。


总结

从 Android 10提出分区存储之后到现在已经一年多了,所以Google 从强制推行的态度到现在 targetSDK >=30 才强制启用分区存储来看,Google 还是渐渐地选择给开发者留更多的时间。缺点当然是不强制启用的话,国内 APP 适配进度估计得延后了。不过好消息是在查资料的时候,看到了国内大厂的相关适配文章,至少说明大厂在跟进了。

去年(19年)的文档描述是无论 targetSDK 多少,明年(20年)高版本强制启用。


今年(20)文档描述是 targetSDK >=30 才强制启用

关于适配的难度:

对绝对路径相关接口依赖比较深的 APP 适配还是改动挺多的;其次权限的划分很细,什么时候需要什么权限以及调用哪个接口,理解起来需要一定时间;MediaStore API SAF API 这类接口以前就设计好了,我也觉得也不算特别友好;最后测试也需要重新进行。

所以虽然明年才会强制执行分区存储,但还是建议尽早理解和 review 项目中需要适配的代码。


文末附上大厂学长给我的资料,内容包含:Android学习PDF+架构视频+面试文档+源码笔记高级架构技术进阶脑图、Android开发面试专题资料,高级进阶架构资料 这几块的内容

这些都是我现在闲暇还会反复翻阅的精品资料。里面对近几年的大厂面试高频知识点都有详细的讲解。相信可以有效的帮助大家掌握知识、理解原理。

分享给大家,非常适合近期有面试和想在技术道路上继续精进的朋友。也是希望可以帮助到大家提升进阶

如果你有需要的话,可以私信我【提升】我发给你


喜欢本文的话,不妨顺手给我点个赞、评论区留言或者转发支持一下呗~

相关推荐

能跑源码,还提供数据集:这里有一个入门企业级验证码识别项目

机器之心专栏作者:kerlomz网上关于验证码识别的开源项目众多,但大多是学术型文章或者仅仅是一个测试demo,那么企业级的验证码识别究竟是怎样的呢?1.前言网上关于验证么识别的开源项目众多,但大...

kdj源码_kdj源码公式描述

N:=9;M1:=3;M2:=3;...

QT实现抖动文字和滚动文字,附源码

前言不知道大家有没有发现今天的文章有什么不一样,哈哈,我自己胡拼乱凑弄了一个logo,好不好看就先不说了,最起码萌萌哒...当然这不是今天的重点,在做logo的时候,我原本想让文字动起来的,奈何技术有...

我试图通过这篇文章告诉你,这行源码有多牛逼。

你好呀,我是歪歪。这次给你盘一个特别有意思的源码,正如我标题说的那样:看懂这行源码之后,我不禁鼓起掌来,直呼祖师爷牛逼。...

想了解Python源代码加密吗?现总结如下5大加密混淆手段!

我们在进行...

Android系统基础(03) Android系统源码下载

常规官方网站说明:Android源码官方网站为(google你懂的):https://source.android.com官网参考链接,对应的tag(tag是一种标签,我们可以根据tag来判断下载的...

真香,Python爬取B站弹幕原来如此简单,源码已附在文末

B站的弹幕区一直是人才圣地。今天我就用python来手把手教大家爬取B站排行榜热门视频,Python爬取视频也可以如此简单。...

最详细的 maven 教程,可以收藏_maven步骤

链接|cnblogs.com/hzg110/p/6936101.html正文目前所有的项目都在使用maven,可是一直没有时间去整理学习,这两天正好有时间,好好的整理一下。...

Python黑科技-VIP视频破解源码分享

《利用Python制作自己的VIP视频解析软件》想看的电视剧更新了还要充VIP?喜欢的电影你是VIP还得付费?学了Python哪要这些花里胡哨的,打开我自己的VIP付费视频解析软件,想怎么看就怎么看!...

抖音无水印解析网站源码_抖音无水印解析平台

链接:https://share.weiyun.com/59Ah44S密码:hv4dm7上传到主机解压不用安装,直接打开域名就可以了原文地址:https://www.xigsc.com/post/...

「电脑知识」USBOS 3.0 v2022.1.24 超级PE启动维护工具标准增强版

前几天一直在发PE类工具就是为了制作U盘PE启动重装系统教程的,今天小编继续分享有一篇关于pe的之前小编发布过一款微PE工具箱,今天发布另外一个无任何流氓行为功能超级强大虽然体积大了一点,但是这个...

模版网站建设制作的八步流程_模板的网站

  模版网站比较简单,一般我们按照如下流程就可以制作出来。  一、网站定位:  在建站之前,一定要了解你要建的网站是什么。你必须考虑你网站的标题(关键词)、网站描述以及你想要建立的网站。  二、选择域...

求职季必备,这几个免费的个人简历模板网站,你可千万不要错过!

晃晃悠悠又到了春招的季节,相信一定有很多小伙伴趁着这个金三银四求职季,四处投递简历。这时候一个亮眼优秀的简历,可以很好的祝你吸引HR的注意。今天就把我珍藏很久的5个免费简历模板网站分享给大家,简历模板...

简约时尚作品博客商店网站HTML5模板源码

Meduza是简约时尚和现代的博客HTML模板,带商店电商元素的博客页面。考虑所有的作品集网站需求页可以设计一个旅游网站。原生响应设计HTML5和CSS3(台式机、平板电脑、手机…)简单,干净的和专业...

13 款免费样机网站合集,UI设计、产品设计、VI设计全都有!

俗话说得好,人靠衣装,在作完设计后不少设计师都会为自己的作品套一个「样机」好让设计看过去更加高端大气上档次!今天,我就总结了无论是UI设计、包装设计、服装设计、品牌设计、logo设计,都能用到...