作者简介
Symeon,携程高级移动开发工程师,关注Android前沿技术。
Google Play 商店在 2021 年第 3、4 季度正式加强对应用 targetSdkVersion 的限制,要求应用必须以 API 级别 30 (Android 11) 或更高版本为目标运行环境。
作为第一个强制要求分区存储的 API 级别,Android 11无疑是近几年适配工作较为复杂的版本,各个 APP 的适配进度也被寄予期盼。Trip.com APP 在 2021 年第一季度进行了 Android 11 的适配,本文将从方案设计和技术改造等⻆度,聊一聊我们的实践与感想。
假如你在 Android 大版本更新后第一时间升级了仍处在 Beta 阶段的新系统,也许你会发现手机里安装的应用出现了各种奇怪的问题,随着应用更新,闪退等状况才逐渐减少。
从应用的⻆度来看,我们可以把这段时间分为三个阶段,分别是:
“应用什么都不做,直接在新系统上使用” → 此时应用可能会闪退
“应用针对‘面向所有应用的行为变更’做了对应的更新” → 保证了应用的基本功能可以正常运行,并受到新系统版本的少量限制
“应用将 targetSdkVersion 更新至新系统级别” → 能够使用所有新系统特性,并受到新系统版本的完全限制
为了在本篇文章的表述中统一概念、避免翻译和措辞带来误解,下文暂且将这三个阶段称为“未兼容”、“已兼容“、”已适配“。
2019 年的 Google I/O 大会上,Google 演示了 Android 10 的新特性。IMEI(唯一设备标识符)和设备 MAC address(媒体访问控制地址)的访问受到了限制。同时,Android 10 首次正式带来了分区存储 (Scoped storage) 这个期盼已久的功能,但作为一个大型变更,Android 10 的正式版里最后还是留下了一个开关,如果在AndroidManifest.xml文件内设置了android:requestLegacyExternalStorage="true",就相当于关闭了分区存储,仍采用旧版存储模型。
这相当于留下了一个系统版本的缓冲时间,让各个应用可以逐渐迁移。而当 targetSdkVersion 升级到 Android 11 后,分区存储功能会被强制启用。
适配 Android 11 之前,APP可以获取到手机已安装的应用列表信息。作为一个国际化的产品,用户隐私是我们非常重要的考量范围,所以这个能力仅在极少数场景下会用到,比如在导航前检查是否有安装地图类APP,或是在点击客服电话时唤起拨号APP。
而在 targetSdkVersion 调整之后,当我们调用 getInstalledPackages() 时,获取到的则是空列表。检查单个 APP是否已经安装也无法正确得知结果。
正因为我们谨慎地使用这个能力,所以适配的工作量也不大,要得知某个 APP 是否安装以及触发某种 Intent 时,就需要在 AndroidManifest.xml 文件内配置对应的 Intent 。例如添加部分导航 APP 的包名、加入拨打电话的 Intent 。参考的配置格式如下:
<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>
从 Android 10 开始,Google 限制了对 IMEI 的获取,Android 11 延续了隐私保护的趋势,对其他的有可能作为唯一标识的方法进行了限制,所有不可重置、难以重置的标识符,都会逐步被要求更改为可重置、可变更的标识符。适配 Android 11 后,Mac 地址和 ICCID 的获取都受限了。
在 Android 11 之前的版本,Android 的文件存储可以分成以下几类:
其中 4 包含了 2 和 3,这里的“包含”指的是,当我们申请了外置存储的读写权限之后,对外置存储内的所有文件都拥有了操作的能力。APP 无需权限就可以读写属于它的应用私有目录,这点在适配 Android 11前后都没有变化。
Android 的存储权限问题一直为人诟病,主要问题在于外置存储里的“媒体”相关权限和“文件”相关权限均被归类在 WRITE_EXTERNAL_STORAGE ,同时“文件”的权限过大,导致应用可以在外置存储里建立文件夹、读取到非本应用的文件,令用户产生不少隐私方面的担忧。
对应到代码里,当我们调用 getExternalStorageDirectory() 时,对应的是外置存储的根目录。
当我们调用 getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM) 时,对应的是外置存储的相册目录。
在分区存储开启之后,将受到以下限制:
私有目录访问权限不变
可以直接访问本应用共享的媒体文件
可以申请权限访问其他应用共享的媒体文件
可以在弹窗确认后修改或删除其他应用共享的媒体文件
外置存储的非媒体文件不能直接访问
外置存储的文件可以通过SAF (Storage Access Framework) 访问
简单来说,私有目录不用修改,媒体目录需要适配,非媒体目录不能直接访问。
还有一些变化不大,但也需要适配的变更,下面举两个例子。
自定义 Toast:适配 Android 11 之后,自定义 Toast 受限,限制的思路是将 Toast 承载的信息限制在纯文本。
SP 空安全:适配 Android 11 之后, OnSharedPreferenceChangeListener 增加了 null 的⻛险,每次调用 Editor.clear 时,都会使用 null 键回调 OnSharedPreferenceChangeListener.onSharedPreferenceChanged 。
这便成了一个 NPE ⻛险,需要特别检查。
还有一些变更,如前台服务场景细分与后台权限限制、自动重置授权与单次授权、对非公开接口的限制更新,适配难度不大,在这里就不展开了。
Trip.com APP 模块数量多,涉及的产线不少,适配工作需要大家协同配合来完成。所以整体的适配思路是,精确定位范围、简化适配工作、提供回退方案。
部分业务较多的产线比较少直接操作存储或调用了需适配的 API,因此 Android 11 的适配理论上应该尽可能少地对他们产生影响。对于这样一个协作项目来说,首先就需要确认到底有多少范围需要适配。
对于包可⻅性来说,我们主要检查两个方面的 API 调用,一是获取应用列表,如上文提到的 getInstalledPackages() ,二是检查单个包名是否已安装。除了代码的扫描之外,对于常⻅的使用场景也要做好回归测试工作,例如上文提到过的分享、支付、导航等。
对于唯一标识的变更,我们搜索了 getIccid() 方法的使用,以及检查了标识相关的工具类。对于分区存储,其涉及的函数众多,我们通过以下几类来搜索:
直接获取外置存储的根路径,如 getExternalStorageDirectory
直接获取外置存储的媒体路径,如 getExternalStoragePublicDirectory
直接用字符串拼接的外置存储路径
这里补充一下,在 Android 11 上,虽然文件操作是通过 MediaStore,但是用 File 相关的 API 仍然可以生效,仅是性能效率上有所损失,考虑到从 File 相关 API 变更到MediaStore的复杂度,实际适配过程中根据场景来判断, 并非完全要替换成 MediaStore,因此在搜索范围时,也无需去检查 File 相关 API 的调用。
关于包可⻅性的处理,我们统一收口在 AndroidManifest.xml 文件,所有的 Intent 和包名统一管理。分区存储较为复杂,我们提供了一个工具类 IBUStorageEnvironment ,里面实现了和 Environment 相似的函数,以及一些封装好的判断方法,供产线使用。
其中适配的部分细节如下,要适配分区存储,我们需要明确以下几个问题:
什么情况下会启用分区存储?
不同场景如何适配分区存储?
对于媒体文件,是否一定要用 MediaStore ?
1)什么情况下会启用分区存储?
类似于API 29 的 requestLegacyExternalStorage 开关,在API 30 上也有一个停用分区存储的开关 preserveLegacyExternalStorage ,在第一期的适配中,我们将这两个开关都启用,然后将 targetSdkVersion 升级至30,当且仅当使用Android 11的用户新安装 APP 时,才会启用分区存储(包括新用户和卸载重装)。采用这个方案可以减少新旧数据迁移的范围,也能在最大程度上保障现有用户的体验不受影响。对于数据量不大的场景,业务方也可以考虑全部迁移到分区存储。
2)不同场景如何适配分区存储?
举几个例子:
推荐的适配方式:满足分区存储条件时,当有性能要求时,使用 MediaStore 来读取媒体文件,无性能要求仍可以通过File来读取。写入场景较少,单独适配。
通过 getExternalStorageDirectory() 获取根目录后,拼接了 "/log.txt" 来建立文件或文件夹。
推荐的适配方式:对于这一类文件,首先推荐都存到私有目录下,如果对“应用卸载后仍要保存”有强烈的需求,可以在开发阶段考虑通过 MediaStore 保存到 Downloads 或者 Documents 文件夹内,正式上线的版本应避免这类操作。
操作了其他应用创建的文件
推荐的适配方式:做好权限申请的适配,如图片编辑等场景。
3)对于媒体文件,是否一定要用 MediaStore ?
在 Android 11上,如果操作的是本应用共享的媒体文件,使用原有的 File API也是可以的,但会有性能损耗,所以需要根据具体场景来取舍。
迁移的过程中如果严格按照 isExternalStorageLegacy 进行判断,那么通过小版本回退的方式可以重新让应用的 target API 从 30 降低到 29 并重新启用旧逻辑。
不过 targetSdkVersion 的升级伴随着 buildToolsVersion 等更新,而后者升级会带来诸如可空性 (nullable) 等方面的编译期报错,这也是迁移的一部分,如果准备了回退的预案也需要把这部分的改动⻛险考量在内。
在适配工作开始之后,我们也遇到了一些计划之外的波折,这里列举比较典型的几个问题。
适配新系统除了要升级 targetSdkVersion 之外,也需要把 compileSdkVersion 和 buildToolsVersion 一并升级。这里会带来一些编译期的问题,举例来说, ActivityLifecycleCallbacks 的回调里,原本是可空的 activity 参数,适配后变为不可空,而 intent.getStringExtra() 则变成了 @nullable 。这些问题主要来自于 Java 与 Kotlin 混编时,调用的一部分系统 Java 函数在升级后增加了可空性注解,所以在我们的 Kotlin 代码里需要明确做空处理。同时 Lint 也会有少许更新,表现出来的状况是所有模块的开发人员都需要检查各自模块是否在编译和 Lint 环节失败了。
这个部分⻛险较低,因为编译失败或者 Lint 失败的话,会体现在 Merge Request 的 Pipeline 失败,必须修复后才能合并到主分支。考虑上文提到的回退方案时,也需要检查版本回退后新代码是否有不兼容而需要一并 revert 的情况。
除了上述更新之外,因为 Android 11 的包可⻅性用到了 <queries> 标签,而该标签对 AGP (Android Gradle plugin) 的版本有硬性要求。如果直接使用的话,可能会遇到如下问题:
unexpected element <queries> found in <manifest>
此时我们需要升级 AGP 的版本,具体的限制如下:
AGP 的升级同样是需要谨慎评估的,好在 Google 考虑到适配的复杂度,对多个版本都增设了一个小版本,从 AGP 官方的 Release Note 里可以看到小版本升级的变更很少,所以不会引入太多的⻛险因素。但变更很少不代表没有,例如我们也遇到了 xml 解析上面的一些问题,部分模块编译时报如下错误:
Android resource linking failed
这是因为一部分自定义的 attr 没有显式声明其 format,举例如下:
<attr name="inColor" /> //编译失败
<attr name="inColor" format="color" /> //编译成功
在测试阶段,我们遇到了如下报错:
java.lang.SecurityException: getDataNetworkTypeForSubscriber: uid xxx does not have
android.permission.READ_PHONE_STATE
以及一个类似的报错:
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,也是需要做兼容处理。
上文也提到了,AGP 等相关升级对Lint等相关检查会产生影响,所以对自动化流程也会产生一些适配的工作,譬如需要确认新的 Lint 规则是否需要列入我们的自定义规则范畴,并且因为更新了新的 SDK 版本,也需要确保对应的机器上已经下载好了相应的依赖,避免在正常的 Pipeline 内触发了下载 SDK 的行为。
除此之外,因为 Android 11引入了分区存储,在 UI 自动化相关的流程里也有不少需要改造的地方,这里举两个比较典型的问题。
Android 11的设备无法通过ADB写入外置存储的应用私有目录
APP 的文件导出需要一致
但升级 Android 11 之后失效了,我们来梳理一下具体是哪些功能受到了影响。
首先,直接读写外置存储的应用私有目录,这代表了应用卸载后配置不会继续留存在测试机里,也就是天然地支持了单个测试单元的配置独立性。其次,应用私有目录对于 APP 来说,是无需存储权限即可访问的,也就意味着这个配置的读取不依赖于运行时的授权,在自动化阶段是非常方便好用的。最后,对于 APP 来说,自动化的侵入性不强,因为配置文件本身通过私有目录存储就是比较常⻅的做法,自动化的动态修改并不需要 APP 做太多的适配。
要解决上述问题,也有很多方案可以选择,例如把自动化标识打进包里、通过运行时参数来传递等等,但都有其局限性,最后我们用了⻛格较为一致、兼容性比较好的方案:首先找到一个 ADB 直接可写、APP 直接可读的目录, 然后把配置文件写入,修改 APP 代码,兼容该目录的读取,最后给自动化流程内增设一个参数重置的环节。
然后就是上面说到的文件导出问题,如上文所说,Android 11开始应用无法在外置存储的根目录直接创建文件夹以读写文件了,所以一些文件的导出操作也需要同步修改,因为自动化流程只在测试流程内使用,并不会影响真实用户,所以相关导出可以直接写入至媒体文件夹,然后通过ADB导出即可。
adb: error: stat failed when trying to push to /mnt/sdcard/: Permission denied
而当我们用 adb shell 来查看其变化时,会发现它实际上是个符号链接(Symbolic link),在 Android 11设备上, 它显示如下:
adb shell ls -al /mnt | grep sdcard
l????????? ? ? ? ? ? sdcard -> ?
而在Android 11之前的设备上,它显示如下:
adb shell ls -al /mnt | grep sdcard
lrwxrwxrwx root root 2021-02-16 11:17 sdcard -> /sdcard
这里的有趣之处在于,我们知道 Android 底层仍然是 Linux,所以分区存储等行为变更会在 Linux 的文件系统里有所体现,当我们用相同的办法来查看媒体文件夹时,也能够发现端倪,感兴趣的朋友可以自行探索。
系统的升级适配往往涉及面广、⻛险高、收益不明显,我们前后花了一个月的时间,在第一季度结束之际 Trip.com APP 适配了 Android 11,自动化相关的流程也在第二季度初完成了基本适配,上线后稳定运转至今。
回顾这次升级,我们也能从变化上感受到隐私保护愈发增强以及用户体验逐步优化的趋势,篇幅所限,详尽的行为变更和方案细节不再逐一列举。希望本文能够对开发者们有所帮助,在日常工作过程中关切隐私安全、注重用户体验,共建良好发展的 Android 生态。
我们是携程国际业务研发团队,致力于国际业务的探索和深耕,在技术上追求极致的用户体验。
如果你向往在一个国际化团队中学习成⻓,期待技术上有突⻜猛进的提升,欢迎加入我们。目前我们客户端/前端/ 后台/数据/测试开发均有开放职位。
简历投递邮箱:tech@trip.com,邮件标题:【姓名】-【携程国际业务】- 【职位】
【推荐阅读】
“携程技术”公众号
分享,交流,成长