相册适配 Android 11 绕的那些弯路!

刘望舒

共 8559字,需浏览 18分钟

 ·

2021-04-17 14:44

刘望舒 专注于大前端和Java领域的个人技术号
公众号回复Android加入安卓技术群


作者:Android_ZzT

https://juejin.cn/post/6924270961889902599

一、背景

最近公司中的相册组件被业务方反馈了新问题,在 targetSdk=30 的 Android 10 手机上运行相册,缩略图会加载不出来,于是就开启了这次的趟坑之路。

定位问题

首先,我在相册Demo中把 targetSdk 设置到 30, 然后在 Android 10 测试机上运行,发现缩略图完美的显示了出来。

很懵逼,为啥相同的代码 demo 上正常,业务方的 app 不正常?

一定是有什么配置不一样,才导致了这样的结果。

经过了各种找不同 ...

我发现,demo 的 AndroidManifest.xml 中多了一个属性

<application
  android:requestLegacyExternalStorage="true"
  ...>

于是,正式开启了我的适配之路...

二、requestLegacyExternalStorage 是什么?

通过翻查官方文档,大概知道了这个属性的意思:在配置targetSdk >= 29,应用搭载在Android 10及以上版本的手机运行时,可以暂时停用「分区存储」

1.「分区存储」又是什么?

分区存储

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

在搭载 Android 9(API 级别 28)或更低版本的设备上,只要其他应用具有相应的存储权限,任何应用都可以访问外部存储空间中的应用专属文件。为了让用户更好地管理自己的文件并减少混乱,以 Android 10(API 级别 29)及更高版本为目标平台的应用在默认情况下被授予了对外部存储空间的分区访问权限(即分区存储)。启用分区存储后,应用将无法访问属于其他应用的应用专属目录。

这是摘自官方文档的一段话,我们可以把「分区存储」简单解释为,Android 10 开启分区存储后,你的应用在有权限的情况下也无法随便访问其他外部存储空间中的公有文件夹

2.「分区存储」会造成什么影响?

比如在App中展示相册缩略图的时候,我们会把 filepath 传给图片加载框架去帮助渲染缩略图,像这样

ImageLoader.load(imageView, Uri.fromFile(path);

这里的 path 一般为 sdcard/DCIM/...,这明显为外部存储空间中的文件夹,且不是应用专属文件,这时在图片加载框架层就会抛出异常java.io.FileNotFoundException

假如你用的是 Glide,会在图中的代码位置抛出异常

三、Android 11 中 requestLegacyExternalStorage 属性失效

在继续翻阅官方文档后,又得知了一个信息:

注意:当您将应用更新为以 Android 11(API 级别 30)为目标平台后,如果应用在搭载 Android 11 的设备上运行,系统会忽略 requestLegacyExternalStorage 属性,因此您的应用必须做好支持分区存储并为这些设备上的用户迁移应用数据的准备。

这段信息,简单可以理解为 requestLegacyExternalStorage=true 只能解燃眉之急,到了 Android 11 上,还是要做适配工作。

这也成功为我走上弯路,埋下了伏笔 ...

四、开始走弯路

1. 只适配 Android 10 (不推荐)

在Manifest中添加

<application
  android:requestLegacyExternalStorage="true"
  ...>

我们刚才知道了,如果应用在 Android 11 的设备上运行,系统会忽略 requestLegacyExternalStorage 属性,强制开启分区存储。可能还是会出现异常(此处我并没有真正用 Android 11 的机器验证)。所以我默认认为,requestLegacyExternalStorage=true 只能解近忧,但不解本质问题。

2. 放弃 File path,使用 Uri

前文已经提到,我们用访问 File path 的方式加载缩略图,会抛出 java.io.FileNotFoundException

那么,官方推荐我们怎么做呢?大致如下三步

  1. 获取媒体数据 id
  2. 获取缩略图 uri
  3. 用 uri 加载缩略图
val projection = arrayOf(
    MediaStore.Video.Media._ID,
    MediaStore.Video.Media.DISPLAY_NAME,
    MediaStore.Video.Media.DURATION,
    MediaStore.Video.Media.SIZE
)

...

val query = ContentResolver.query(
    MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
    projection,
    selection,
    selectionArgs,
    sortOrder
)
query?.use { cursor ->

  media.id = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
  
  ...
  
  media.thumbnailUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, media.id)
}

// Load thumbnail of a specific media item.
val thumbnail: Bitmap =
        applicationContext.contentResolver.loadThumbnail(
        media.thumbnailUri, Size(640480), null)

完整代码,可参考 developer.android.com/training/da…

由于这个变动涉及到数据源的变化,改动点非常多,并且还要用 if else 区分版本,所以写了很多胶水代码 ...

但是,最终还是成功在 targetSdk=29 Android 10 的手机上成功显示出了缩略图。

3. 新问题又出现

相册的图片预览功能也不能用了,经过排查,发现是一样的问题,胶水代码已经写好,都在射程范围内。于是,用了半小时又改掉了图片预览的问题。

正当我兴奋地觉得马上要完工的时候,点了一下视频预览 ... 好吧,看到了熟悉却又令人绝望的错误信息,依赖的播放器库抛出了熟悉的异常 java.io.FileNotFoundException open failed: EACCES (Permission denied)。播放器中也是通过 file path 传给 ffmpeg 进行播放的,但在初始化播放器的时候就因为没有权限就直接挂了。

4. 绕弯想方案

首先,我找到了播放器的开发同学进行沟通,能否用传递 uri 或者 FileDescriptor 的方式进行初始化。得到了几个不太友好的结论:

  1. 传 uri 到 Native 层,content://media/external/images/media/{media_id},这种 Uri Native 层貌似无法打开(没再细查有没有办法
  2. 传 fd 到 Native 层,可能会涉及 java 层 fd 被 Native 引用,然后无法释放的问题,如果要释放还需要开放释放 fd 的接口
  3. 除了相册,还有很多地方在将 File path 传到 Native 层

然后,开始想怎么能绕过这个问题,大概找到了 2个 不靠谱的方案:

  1. 因为不能访问公有目录,那么可以先 copy file 到私有目录(产品可能要骂街了

  2. 请求 MANAGE_EXTERNAL_STORAGE 权限

    这是一个有意思的权限,官方是这样说的

    绝大多数需要共享存储空间访问权限的应用都可以遵循共享媒体文件和共享非媒体文件方面的最佳做法。但是,某些应用的核心用例需要广泛访问设备上的文件,但无法采用注重隐私保护的存储最佳做法高效地完成这些操作。对于这些情况,Android 提供了一种名为“所有文件访问权限”的特殊应用访问权限

    这段话里说的某些应用,比如「杀毒应用」「文件浏览器」,需要扫描 sdcard 的所有文件,如果没有权限就没法正常工作(很明显,我们的App不是

    另外,对于这个权限的描述很有意思,长这样

如果我是用户,看到了一个不需要这些权限的App却申请了这种权限,无疑是一种劝退(产品又要骂街了

5.冷静下来,再看文档

做到第4步的时候,我开始意识到,很有可能绕弯路了,往常的适配工作还没有这么变态过。于是我又查了一些资料,找到了这个视频,https://www.youtube.com/watch?v=RjyYCUW-9tY&feature=youtu.be

视频中对我们有用的信息大概是这样,在 Android 10 的时候,很多开发者都反应了类似的问题,在使用一些 native 的库时,无法使用 File Api,造成了很多困难。于是,在 Android 11 中,又做了兼容,又可以通过 Java File Api 的方式访问媒体库文件了(此时的我不知道是不是应该高兴,Android 确实比苹果爸爸对开发者好)

后来,我又仔细的翻了翻官方文档,确实找到了一小段不起眼的文字

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

为了帮助您的应用更顺畅地使用第三方媒体库,Android 11 允许您使用除 MediaStore API 之外的 API 通过直接文件路径访问共享存储空间中的媒体文件。其中包括:

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

五、结论

好吧...

绕了一个大圈后,得到了几个结果:

  1. 胶水代码可能是白写了,在 targetSdk=29 运行在 Android 10 的应用上, requestLegacyExternalStorage 属性完全够用了(枉我开始我还鄙视它
  2. Android 11 的时候也不需要适配啥了,虽然 requestLegacyExternalStorage 属性失效,但相册里通过 File Api 访问的只是媒体库文件,不会有任何问题。
  3. 如果 App 中有通过 File Api 访问外部存储共有目录的代码,还是要需做适配的,至于怎么去做本文就不再讨论了

教训

绕了一圈之后,得出两个教训:

  1. 适配新版本的时候,最好先用真机测试一下,万一完美运行就不用适配了
  2. 认真读文档、认真读文档、认真读文档

Glide 加载缩略图

最后,说个与适配不太相干的话题,只想看适配内容的朋友可以先跳过了。

我在适配的过程中也跟了一下 glide 加载缩略图的流程,也搞清了一些问题,顺便分享给大家

1. 为什么向 Glide 传 content-uri 不会出错,传 file path 会报错?

上文刚才介绍过,官方提供的获取相册缩略图的做法是

// Load thumbnail of a specific media item.
val thumbnail: Bitmap =
        applicationContext.contentResolver.loadThumbnail(
        media.thumbnailUri, Size(640480), null)

但是我们平时开发,大多都直接用图片加载框架,比如 Glide

Glide
  .with(imageView)
  .asBitmap()
  .load(uri) //或者 file path
  .into()
复制代码

在我们没适配 Android 10 的时候,传 file path 会抛出异常,这我们之前已经解释了。适配之后我们传入了 content://media/external/images/media/{media_id} 给 Glide,Glide 又是怎么识别的然后加载出 bitmap 的呢?我带着问题跟踪了一下 Glide 加载图片的过程的源码,这里我们直接先说结论。

StreamLocalUriFetcher

  private InputStream loadResourceFromUri(Uri uri, ContentResolver contentResolver)
      throws FileNotFoundException 
{
    switch (URI_MATCHER.match(uri)) {
      case ID_CONTACTS_CONTACT:
        return openContactPhotoInputStream(contentResolver, uri);
      case ID_CONTACTS_LOOKUP:
      case ID_LOOKUP_BY_PHONE:
        // If it was a Lookup uri then resolve it first, then continue loading the contact uri.
        uri = ContactsContract.Contacts.lookupContact(contentResolver, uri);
        if (uri == null) {
          throw new FileNotFoundException("Contact cannot be found");
        }
        return openContactPhotoInputStream(contentResolver, uri);
      case ID_CONTACTS_THUMBNAIL:
      case ID_CONTACTS_PHOTO:
      case UriMatcher.NO_MATCH:
      default:
        return contentResolver.openInputStream(uri);
    }
  }

uri 经过匹配逻辑走到了 default 分支,使用 contentResolver.openInputStream(uri) 的方式来读取 bitmap,既然是通过系统的 contentResolver 获取,那一定是没问题的。

2. 浅谈 Glide 加载图片流程

img

这是我简单总结的 Glide 加载图片的流程,不做详细解释了,简单介绍一下图中的关键元素:

  1. 绿圈是时序
  2. 黄色方块代表输入、输出
  3. 粗实线框代表类
  4. 细实线框代表关键方法
  5. 虚线代表方法属于哪个类

图中的过程就是这段代码运行的过程

Glide
  .with(imageView)
  .asBitmap()
  .load(uri) //或者 file path
  .into()

参考

  1. Android 存储用例和最佳做法
  2. Android 11 中的存储机制更新
  3. 拖不得了,Android11真的要来了,最全适配实践指南奉上


·················END·················

推荐阅读

耗时2年,Android进阶三部曲第三部《Android进阶指北》出版!

『BATcoder』做了多年安卓还没编译过源码?一个视频带你玩转!

『BATcoder』是时候下载Android11系统源码和内核源码了!

重生!进阶三部曲第一部《Android进阶之光》第2版 出版!

BATcoder技术群,让一部分人先进大厂

你好,我是刘望舒,腾讯云最具价值专家TVP,著有畅销书《Android进阶之光》《Android进阶解密》《Android进阶指北》,蝉联四届电子工业出版社年度优秀作者,谷歌开发者社区特邀讲师。

前华为面试官,现大厂技术负责人。


想要加入 BATcoder技术群,公号回复Android 即可。

为了防止失联,欢迎关注我的小号


更文不易,点个“在看”支持一下👇
浏览 27
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报