前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >干货 | Trip.com Android 11 适配之旅

干货 | Trip.com Android 11 适配之旅

作者头像
携程技术
发布于 2021-09-10 06:22:38
发布于 2021-09-10 06:22:38
1.8K00
代码可运行
举报
文章被收录于专栏:携程技术携程技术
运行总次数:0
代码可运行

作者简介

Symeon,携程高级移动开发工程师,关注Android前沿技术。

Google Play 商店在 2021 年第 3、4 季度正式加强对应用 targetSdkVersion 的限制,要求应用必须以 API 级别 30 (Android 11) 或更高版本为目标运行环境。

作为第一个强制要求分区存储的 API 级别,Android 11无疑是近几年适配工作较为复杂的版本,各个 APP 的适配进度也被寄予期盼。Trip.com APP 在 2021 年第一季度进行了 Android 11 的适配,本文将从方案设计和技术改造等⻆度,聊一聊我们的实践与感想。

一、背景

1.1 当我们说 “适配” 的时候

假如你在 Android 大版本更新后第一时间升级了仍处在 Beta 阶段的新系统,也许你会发现手机里安装的应用出现了各种奇怪的问题,随着应用更新,闪退等状况才逐渐减少。

从应用的⻆度来看,我们可以把这段时间分为三个阶段,分别是:

  • “应用什么都不做,直接在新系统上使用” → 此时应用可能会闪退
  • “应用针对‘面向所有应用的行为变更’做了对应的更新” → 保证了应用的基本功能可以正常运行,并受到新系统版本的少量限制
  • “应用将 targetSdkVersion 更新至新系统级别” → 能够使用所有新系统特性,并受到新系统版本的完全限制

为了在本篇文章的表述中统一概念、避免翻译和措辞带来误解,下文暂且将这三个阶段称为“未兼容”、“已兼容“、”已适配“。

1.2 Android 11 的特别之处

2019 年的 Google I/O 大会上,Google 演示了 Android 10 的新特性。IMEI(唯一设备标识符)和设备 MAC address(媒体访问控制地址)的访问受到了限制。同时,Android 10 首次正式带来了分区存储 (Scoped storage) 这个期盼已久的功能,但作为一个大型变更,Android 10 的正式版里最后还是留下了一个开关,如果在AndroidManifest.xml文件内设置了android:requestLegacyExternalStorage="true",就相当于关闭了分区存储,仍采用旧版存储模型。

这相当于留下了一个系统版本的缓冲时间,让各个应用可以逐渐迁移。而当 targetSdkVersion 升级到 Android 11 后,分区存储功能会被强制启用。

二、变更要点

2.1 包可见性

适配 Android 11 之前,APP可以获取到手机已安装的应用列表信息。作为一个国际化的产品,用户隐私是我们非常重要的考量范围,所以这个能力仅在极少数场景下会用到,比如在导航前检查是否有安装地图类APP,或是在点击客服电话时唤起拨号APP。

而在 targetSdkVersion 调整之后,当我们调用 getInstalledPackages() 时,获取到的则是空列表。检查单个 APP是否已经安装也无法正确得知结果。

正因为我们谨慎地使用这个能力,所以适配的工作量也不大,要得知某个 APP 是否安装以及触发某种 Intent 时,就需要在 AndroidManifest.xml 文件内配置对应的 Intent 。例如添加部分导航 APP 的包名、加入拨打电话的 Intent 。参考的配置格式如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
<manifest package="com.example.game">
    <queries>
        <package android:name="com.example.store" />
        <package android:name="com.example.services" />
        <intent>
            <action android:name="android.intent.action.SEND" />
            <data android:mimeType="image/jpeg" />
        </intent>
</queries>
...
</manifest>

2.2 唯一标识

从 Android 10 开始,Google 限制了对 IMEI 的获取,Android 11 延续了隐私保护的趋势,对其他的有可能作为唯一标识的方法进行了限制,所有不可重置、难以重置的标识符,都会逐步被要求更改为可重置、可变更的标识符。适配 Android 11 后,Mac 地址和 ICCID 的获取都受限了。

2.3 分区存储

在 Android 11 之前的版本,Android 的文件存储可以分成以下几类:

1)内置存储里的应用私有目录

2)外置存储里的应用私有目录

3)外置存储里的媒体文件

4)外置存储里的文件

其中 4 包含了 2 和 3,这里的“包含”指的是,当我们申请了外置存储的读写权限之后,对外置存储内的所有文件都拥有了操作的能力。APP 无需权限就可以读写属于它的应用私有目录,这点在适配 Android 11前后都没有变化。

Android 的存储权限问题一直为人诟病,主要问题在于外置存储里的“媒体”相关权限和“文件”相关权限均被归类在 WRITE_EXTERNAL_STORAGE ,同时“文件”的权限过大,导致应用可以在外置存储里建立文件夹、读取到非本应用的文件,令用户产生不少隐私方面的担忧。

对应到代码里,当我们调用 getExternalStorageDirectory() 时,对应的是外置存储的根目录。

当我们调用 getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM) 时,对应的是外置存储的相册目录。

在分区存储开启之后,将受到以下限制:

  • 私有目录访问权限不变
  • 可以直接访问本应用共享的媒体文件
  • 可以申请权限访问其他应用共享的媒体文件
  • 可以在弹窗确认后修改或删除其他应用共享的媒体文件
  • 外置存储的非媒体文件不能直接访问
  • 外置存储的文件可以通过SAF (Storage Access Framework) 访问

简单来说,私有目录不用修改,媒体目录需要适配,非媒体目录不能直接访问。

2.4 其他变更

还有一些变化不大,但也需要适配的变更,下面举两个例子。

自定义 Toast:适配 Android 11 之后,自定义 Toast 受限,限制的思路是将 Toast 承载的信息限制在纯文本。

  • 从后台发送的自定义 Toast 无法弹出
  • Toast 的自定义能力受限,setView() 被标为废弃
  • getView() 方法返回 null 以下方法的返回值并不反映实际值:
    • getHorizontalMargin()
    • getVerticalMargin()
    • getGravity()
    • getXOffset()
    • getYOffset()
  • 以下方法是空操作:
    • setMargin()
    • setGravity()

SP 空安全:适配 Android 11 之后, OnSharedPreferenceChangeListener 增加了 null 的⻛险,每次调用 Editor.clear 时,都会使用 null 键回调 OnSharedPreferenceChangeListener.onSharedPreferenceChanged 。

这便成了一个 NPE ⻛险,需要特别检查。

还有一些变更,如前台服务场景细分与后台权限限制、自动重置授权与单次授权、对非公开接口的限制更新,适配难度不大,在这里就不展开了。

三、适配思路

Trip.com APP 模块数量多,涉及的产线不少,适配工作需要大家协同配合来完成。所以整体的适配思路是,精确定位范围、简化适配工作、提供回退方案。

3.1 精确定位范围

部分业务较多的产线比较少直接操作存储或调用了需适配的 API,因此 Android 11 的适配理论上应该尽可能少地对他们产生影响。对于这样一个协作项目来说,首先就需要确认到底有多少范围需要适配。

对于包可⻅性来说,我们主要检查两个方面的 API 调用,一是获取应用列表,如上文提到的 getInstalledPackages() ,二是检查单个包名是否已安装。除了代码的扫描之外,对于常⻅的使用场景也要做好回归测试工作,例如上文提到过的分享、支付、导航等。

对于唯一标识的变更,我们搜索了 getIccid() 方法的使用,以及检查了标识相关的工具类。对于分区存储,其涉及的函数众多,我们通过以下几类来搜索:

  • 直接获取外置存储的根路径,如 getExternalStorageDirectory
  • 直接获取外置存储的媒体路径,如 getExternalStoragePublicDirectory
  • 直接用字符串拼接的外置存储路径

这里补充一下,在 Android 11 上,虽然文件操作是通过 MediaStore,但是用 File 相关的 API 仍然可以生效,仅是性能效率上有所损失,考虑到从 File 相关 API 变更到MediaStore的复杂度,实际适配过程中根据场景来判断, 并非完全要替换成 MediaStore,因此在搜索范围时,也无需去检查 File 相关 API 的调用。

3.2 简化适配工作

关于包可⻅性的处理,我们统一收口在 AndroidManifest.xml 文件,所有的 Intent 和包名统一管理。分区存储较为复杂,我们提供了一个工具类 IBUStorageEnvironment ,里面实现了和 Environment 相似的函数,以及一些封装好的判断方法,供产线使用。

其中适配的部分细节如下,要适配分区存储,我们需要明确以下几个问题:

  • 什么情况下会启用分区存储?
  • 不同场景如何适配分区存储?
  • 对于媒体文件,是否一定要用 MediaStore ?

我们可以通过 Environment.isExternalStorageLegacy() API 来得知分区存储的启用状态。

1)什么情况下会启用分区存储?

类似于API 29 的 requestLegacyExternalStorage 开关,在API 30 上也有一个停用分区存储的开关 preserveLegacyExternalStorage ,在第一期的适配中,我们将这两个开关都启用,然后将 targetSdkVersion 升级至30,当且仅当使用Android 11的用户新安装 APP 时,才会启用分区存储(包括新用户和卸载重装)。采用这个方案可以减少新旧数据迁移的范围,也能在最大程度上保障现有用户的体验不受影响。对于数据量不大的场景,业务方也可以考虑全部迁移到分区存储。

2)不同场景如何适配分区存储?

举几个例子:

通过 getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM) 获取了相册的文件路径,然后读写照片。

推荐的适配方式:满足分区存储条件时,当有性能要求时,使用 MediaStore 来读取媒体文件,无性能要求仍可以通过File来读取。写入场景较少,单独适配。

通过 getExternalStorageDirectory() 获取根目录后,拼接了 "/log.txt" 来建立文件或文件夹。

推荐的适配方式:对于这一类文件,首先推荐都存到私有目录下,如果对“应用卸载后仍要保存”有强烈的需求,可以在开发阶段考虑通过 MediaStore 保存到 Downloads 或者 Documents 文件夹内,正式上线的版本应避免这类操作。

操作了其他应用创建的文件

推荐的适配方式:做好权限申请的适配,如图片编辑等场景。

3)对于媒体文件,是否一定要用 MediaStore ?

在 Android 11上,如果操作的是本应用共享的媒体文件,使用原有的 File API也是可以的,但会有性能损耗,所以需要根据具体场景来取舍。

3.3 提供回退方案

迁移的过程中如果严格按照 isExternalStorageLegacy 进行判断,那么通过小版本回退的方式可以重新让应用的 target API 从 30 降低到 29 并重新启用旧逻辑。

不过 targetSdkVersion 的升级伴随着 buildToolsVersion 等更新,而后者升级会带来诸如可空性 (nullable) 等方面的编译期报错,这也是迁移的一部分,如果准备了回退的预案也需要把这部分的改动⻛险考量在内。

四、踩过的坑

在适配工作开始之后,我们也遇到了一些计划之外的波折,这里列举比较典型的几个问题。

4.1 Gradle插件升级适配

适配新系统除了要升级 targetSdkVersion 之外,也需要把 compileSdkVersion 和 buildToolsVersion 一并升级。这里会带来一些编译期的问题,举例来说, ActivityLifecycleCallbacks 的回调里,原本是可空的 activity 参数,适配后变为不可空,而 intent.getStringExtra() 则变成了 @nullable 。这些问题主要来自于 JavaKotlin 混编时,调用的一部分系统 Java 函数在升级后增加了可空性注解,所以在我们的 Kotlin 代码里需要明确做空处理。同时 Lint 也会有少许更新,表现出来的状况是所有模块的开发人员都需要检查各自模块是否在编译和 Lint 环节失败了。

这个部分⻛险较低,因为编译失败或者 Lint 失败的话,会体现在 Merge Request 的 Pipeline 失败,必须修复后才能合并到主分支。考虑上文提到的回退方案时,也需要检查版本回退后新代码是否有不兼容而需要一并 revert 的情况。

除了上述更新之外,因为 Android 11 的包可⻅性用到了 <queries> 标签,而该标签对 AGP (Android Gradle plugin) 的版本有硬性要求。如果直接使用的话,可能会遇到如下问题:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
unexpected element <queries> found in <manifest>

此时我们需要升级 AGP 的版本,具体的限制如下:

AGP 的升级同样是需要谨慎评估的,好在 Google 考虑到适配的复杂度,对多个版本都增设了一个小版本,从 AGP 官方的 Release Note 里可以看到小版本升级的变更很少,所以不会引入太多的⻛险因素。但变更很少不代表没有,例如我们也遇到了 xml 解析上面的一些问题,部分模块编译时报如下错误:

Android resource linking failed

这是因为一部分自定义的 attr 没有显式声明其 format,举例如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
<attr name="inColor" /> //编译失败
<attr name="inColor" format="color" /> //编译成功

4.2 第三方库的内部崩溃及间接API的调用

在测试阶段,我们遇到了如下报错:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
java.lang.SecurityException: getDataNetworkTypeForSubscriber: uid xxx does not have
android.permission.READ_PHONE_STATE

以及一个类似的报错:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
Caused by: java.lang.SecurityException: getDataNetworkTypeForSubscriber
        at android.os.Parcel.createExceptionOrNull(Parcel.java:2373)
        at android.os.Parcel.createException(Parcel.java:2357)
        at android.os.Parcel.readException(Parcel.java:2340)
        at android.os.Parcel.readException(Parcel.java:2282)
        at
com.android.internal.telephony.ITelephony$Stub$Proxy.getNetworkTypeForSubscriber(ITelep
hony.java:8762)
        at
android.telephony.TelephonyManager.getNetworkType(TelephonyManager.java:3031)
        at
android.telephony.TelephonyManager.getNetworkType(TelephonyManager.java:2995)
at ...
at
io.reactivex.internal.operators.completable.CompletableFromRunnable.subscribeActual(Com
pletableFromRunnable.java:35)
        at io.reactivex.Completable.subscribe(Completable.java:2185)
        at
io.reactivex.internal.operators.completable.CompletableSubscribeOn$SubscribeOnObserver.
run(CompletableSubscribeOn.java:64)
        at io.reactivex.Scheduler$DisposeTask.run(Scheduler.java:578)
        at
io.reactivex.internal.schedulers.ScheduledRunnable.run(ScheduledRunnable.java:66)
        at
io.reactivex.internal.schedulers.ScheduledRunnable.call(ScheduledRunnable.java:57)
        at java.util.concurrent.FutureTask.run(FutureTask.java:266)
        at
java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThrea
dPoolExecutor.java:301)
        at
java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
        at
java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
        at java.lang.Thread.run(Thread.java:923)

通过堆栈发现一部分问题来自使用的第三方库内部,因为对应的库都比较成熟,升级到对应的兼容版本即可解决。另一部分问题来自类似的内部库,更换 API 并做好异常捕获便解决了。

这里也是一个⻛险点,对于不太方便升级版本(例如使用了某个分支的衍生版本)或是版本跨度太大的第三方库,就会引入额外的复杂度,并且这个问题在最初确定影响范围的时候没有发现,是因为这个API改动属于间接影响到的变更。对于这类问题,我们的处理方式是在适配和测试阶段每发现一个,检查搜索使用了同样API的项目代码, 适配后更新到共享的文档中。

与之类似的还有第三方库内部使用了 BlackList API,也是需要做兼容处理。

4.3 CI/CD 的流程适配

上文也提到了,AGP 等相关升级对Lint等相关检查会产生影响,所以对自动化流程也会产生一些适配的工作,譬如需要确认新的 Lint 规则是否需要列入我们的自定义规则范畴,并且因为更新了新的 SDK 版本,也需要确保对应的机器上已经下载好了相应的依赖,避免在正常的 Pipeline 内触发了下载 SDK 的行为。

除此之外,因为 Android 11引入了分区存储,在 UI 自动化相关的流程里也有不少需要改造的地方,这里举两个比较典型的问题。

  • Android 11的设备无法通过ADB写入外置存储的应用私有目录
  • APP 的文件导出需要一致

具体来说,只要是升级到 Android 11 的设备,ADB便无法直接读写外置存储的应用私有目录了,这属于文章开头定义的“兼容”范畴。这类功能通常用来自定义配置,举个例子,绝大部分 APP 都有测试环境、正式环境的区分,我们在开发阶段可以方便地在不同环境里切换,这里的实现可以有很多种,假如某个 APP 在其私有目录下创建了一个 env.config 文件,在其中写入了环境变量,那么在自动化流程里,通过 ADB 操作该文件,即可实现环境切换的功能。

但升级 Android 11 之后失效了,我们来梳理一下具体是哪些功能受到了影响。

首先,直接读写外置存储的应用私有目录,这代表了应用卸载后配置不会继续留存在测试机里,也就是天然地支持了单个测试单元的配置独立性。其次,应用私有目录对于 APP 来说,是无需存储权限即可访问的,也就意味着这个配置的读取不依赖于运行时的授权,在自动化阶段是非常方便好用的。最后,对于 APP 来说,自动化的侵入性不强,因为配置文件本身通过私有目录存储就是比较常⻅的做法,自动化的动态修改并不需要 APP 做太多的适配。

要解决上述问题,也有很多方案可以选择,例如把自动化标识打进包里、通过运行时参数来传递等等,但都有其局限性,最后我们用了⻛格较为一致、兼容性比较好的方案:首先找到一个 ADB 直接可写、APP 直接可读的目录, 然后把配置文件写入,修改 APP 代码,兼容该目录的读取,最后给自动化流程内增设一个参数重置的环节。

然后就是上面说到的文件导出问题,如上文所说,Android 11开始应用无法在外置存储的根目录直接创建文件夹以读写文件了,所以一些文件的导出操作也需要同步修改,因为自动化流程只在测试流程内使用,并不会影响真实用户,所以相关导出可以直接写入至媒体文件夹,然后通过ADB导出即可。

在相关问题的排查过程中还有一个小插曲,当我们在 Android 11的设备上使用ADB来操作 /mnt/sdcard 时,会遇到如下报错:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
adb: error: stat failed when trying to push to /mnt/sdcard/: Permission denied

而当我们用 adb shell 来查看其变化时,会发现它实际上是个符号链接(Symbolic link),在 Android 11设备上, 它显示如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
$ adb shell ls -al /mnt | grep sdcard
l?????????  ? ?      ?           ?                ? sdcard -> ?

而在Android 11之前的设备上,它显示如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
$ adb shell ls -al /mnt | grep sdcard
lrwxrwxrwx root     root              2021-02-16 11:17 sdcard -> /sdcard

层层追溯的话可以发现 /mnt/sdcard 可以成功指向 /storage/emulated/0 这个路径。解决方案也很简单,把对 /mnt/sdcard 相关的操作都改为 /sdcard 即可。

这里的有趣之处在于,我们知道 Android 底层仍然是 Linux,所以分区存储等行为变更会在 Linux 的文件系统里有所体现,当我们用相同的办法来查看媒体文件夹时,也能够发现端倪,感兴趣的朋友可以自行探索。

五、总结

系统的升级适配往往涉及面广、⻛险高、收益不明显,我们前后花了一个月的时间,在第一季度结束之际 Trip.com APP 适配了 Android 11,自动化相关的流程也在第二季度初完成了基本适配,上线后稳定运转至今。

回顾这次升级,我们也能从变化上感受到隐私保护愈发增强以及用户体验逐步优化的趋势,篇幅所限,详尽的行为变更和方案细节不再逐一列举。希望本文能够对开发者们有所帮助,在日常工作过程中关切隐私安全、注重用户体验,共建良好发展的 Android 生态。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-08-26,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 携程技术中心 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、背景
    • 1.1 当我们说 “适配” 的时候
    • 1.2 Android 11 的特别之处
  • 二、变更要点
    • 2.1 包可见性
    • 2.2 唯一标识
    • 2.3 分区存储
    • 2.4 其他变更
  • 三、适配思路
    • 3.1 精确定位范围
    • 3.2 简化适配工作
    • 3.3 提供回退方案
  • 四、踩过的坑
    • 4.1 Gradle插件升级适配
    • 4.2 第三方库的内部崩溃及间接API的调用
    • 4.3 CI/CD 的流程适配
  • 五、总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档