一、背景
前段时间处理了一个 App 内草稿丢失的反馈,很多用户反馈连续存了多个草稿之后,草稿箱都只有一个草稿,显然是发生了草稿丢失。从用户反馈的数据来看,反馈用户的系统版本都在 Android 7.0 以下。
经过一段时间的排查,最后发现是草稿被覆盖了,直接原因是:存草稿后拍摄链路页面(录制页、编辑页、发布页)未被关闭,再次进拍摄之后录制页复用(状态也复用了)导致存草稿的时候发生了草稿替换。
那么问题来了,为什么会发生这样的情况呢?这得从我们存草稿那一刻说起。
二、发现问题:为什么 clear top 不生效?
正常来说,存草稿之后我们会关闭拍摄链路的页面,把拍摄链路上的一串 Activity 都关闭掉。从代码中可以看到,我们是使用 Intent#FLAG_ACTIVITY_CLEAR_TOP | Intent#FLAG_ACTIVITY_NEW_TASK
来实现这一目的的:
逻辑简单明了,就是加个 clear_top 的 flag,然后 setClass
指定跳转到首页,这里的
getPublishContainerActivityClass
返回的是 MainActivity
,看起来没什么异常。
试着打了一个包在小米10 Pro 上试一下,存草稿,没有复现问题,回到首页之后按返回,直接退出 App 了,说明拍摄链路的页面被关闭了。万幸,在换了好几台手机之后,终于在一台 Android 5.1 上复现了这个 case,发现存完草稿回到首页如果再按返回键,就回到了发布页,拍摄链路还在。
所以核心问题就是:为什么在这台 Android 5.1 上 clear_top
这个 flag 没生效?
三、提出问题:FLAG_ACTIVITY_CLEAR_TOP 真的会 clear top 吗?
关于 Intent#FLAG_ACTIVITY_CLEAR_TOP
,简单地概括一下就是:设置这个 flag 后,如果发现目标 Activity 已经存在,会将目标 Activity 所在的 Task 移到前台,然后 finish 掉目标 Activity 上层的所有 Activity,最后打开目标 Activity。至于如何判断目标 Activity 已经存在了,注释中并没有提到。
回到 Aweme 工程。一般情况下,首页、录制页、编辑页、发布页是在同一个任务栈里,从 adb 打印的 activity 堆栈信息也可以看出这一点。
adb shell dumpsys activity activities | grep 'com.zhiliaoapp.musically'
看了下这几个 Activity 的启动模式,并没发现什么不一样的地方: VideoRecordNewActivity: singleTask VideoPublishEditActivity: standard(default) VideoPublishActivity: singleTask
大胆猜想一下,难道是不同版本的 ROM 对 Intent#FLAG_ACTIVITY_CLEAR_TOP
的处理有差异导致这个 flag 没生效?
四、尝试在 Demo 上复现问题
建了个 Demo 工程,创建了 A, B, C 三个空页面,分别对应首页、拍摄页、发布页,跳转路径是:A --> B --> C --> A。其中 A、B、C 的启动模式分别为 singleTop, singleTask, singleTask,与 App 内的拍摄链路一致。
在线上问题复现的这台 Android 5.1 上测试发现,C 用 clear_top 回到 A 之后,整个栈就清空了,在 A 点返回直接退出 App 了,没有发现任何异常。
陷入沉思,Demo 上没复现,难道是 Aweme 工程里对 Activity 或者 Intent 这块做了骚操作?(盲猜一手)。打印一下复现问题的这台 Android 5.1 的 App 存草稿之后的任务栈,发现了一些不一样的东西:
从上图我们可以看到,任务栈里最底层的竟然是 SplashActivity,那我们的首页呢?MainActivity 哪去了,我们明明是从首页进拍摄然后到发布页的......随便在 MainActivity 中打个断点可以发现断点能生效,说明展示的确实是 MainActivity,但是任务栈中指向的是 SplashActivity,这是什么操作?是的,这就是 activity-alias,Android 1.0 开始就支持的一个机制。
在 AndroidManifest 中查看 SplashActivity 和 MainActivity 的声明,可以发现 SplashActivity 被声明成了 activity-alias,其 targetActivity 指向的是 MainActivity。
关于 activity-alias 的细节暂且不深入,先改下 Demo,加个 Splash,声明为 activity-alias。现在 Demo 的启动流程变成了:Splash --> A --> B --> C --> A。
修改好之后跑起来试试,结果,在 Android 5.1 的测试机上竟然复现了:C 用 clear top 打开 A 之后并没有将 B 和 C 关闭掉!那么有没有可能是这个 ROM 的问题?在模拟器上运行试试,发现也复现了,难道是 Android 的 Bug?不会吧,不会吧,不会吧......
五、解决问题
Talk is cheap. Show me the code.
既然在模拟器中运行 Demo App 也复现了,可以尝试从 AOSP 源码中追溯这个问题。
5.1 先回顾一下启动流程中的几个概念
5.1.1 ActivityRecord
An entry in the history stack, representing an activity.
Activity 以 ActivityRecord 对象的形式存放在任务栈中。在 Activity 的启动过程中会创建 ActivityRecord,代表待启动的 Activity。
- ActivityRecord resultTo
- who started this entry, so will get our reply
5.1.2 ActivityInfo
Information you can retrieve about a particular application activity or receiver. This corresponds to information collected from the AndroidManifest.xml's and tags.
存放的是我们在 AndroidManifest 中声明 Activity 时指定的一堆 Activity 配置。
- taskAffinity
- The affinity this activity has for another task in the system. The string here is the name of the task, often the package name of the overall package.
5.1.3 TaskRecord
代表一个任务栈,栈中可能有一堆同栈的 Activity。
- Intent intent
- The original intent that started the task
- ArrayList mActivities
- List of all activities in the task arranged in history order
- ActivityStack stack
- The ActivityStack it belongs to.
5.1.4 ActivityStack
State and management of a single stack of activities.
任务栈由 ActivityStack 持有。
- ArrayList mTaskHistory
- The back history of all previous (and possibly still running) activities. It contains #TaskRecord objects.
- 0 ~ size - 1, the last is the top task.
ActivityRecord, TaskRecord, ActivityStack 的关系可以简单用下面这个图来表示:
5.1.5 activity-alias
Activity 别名,这是 AndroidManifest 中支持的一个标签,使用方式和 activity 标签类似,用于表示某个 Activity 是另一个 Activity 的别名 (targetActivity)。通过 Intent 启动 activity-alias 类型的 Activity 时,最终只会启动 targetActivity,不会走 activity-alias 这个 Activity 本身的任何生命周期。
5.2 从源码看 Activity 启动时 clear top 的处理逻辑
5.2.1 Android 6.0
通过查阅 Android 6.0 的源码,我们可以整理出如下的 Activity 启动链路调用时序图。
注意:
- 因为只分析 clear top 的逻辑,这里的调用链路只考虑了设置了 clear top flag 的场景
TaskRecord
从上面的时序图可以看到,启动 Activity 时会先帮它找到一个任务栈,找到这个任务栈之后,会根据 Intent 的 flag 对这个任务栈进行处理。如果设置了 Intent#FLAG_ACTIVITY_CLEAR_TASK
,则会清空这个任务栈中已有的 Activity。
如果设置了 Intent#FLAG_ACTIVITY_CLEAR_TOP
,则会执行 clear top 操作,将任务栈中目标 Activity 之上的其他 Activity 给 finish 掉。具体的处理逻辑如下:
TaskRecord#performClearTaskLocked
在执行 performClearTaskLocked 的过程中,会对任务栈中的 Activity 进行遍历,如果判断某个 Activity 的 realActivity 属性和待启动 Activity 的 realActivity 是同一个,就会执行 clear top 操作,将任务栈中这个 Activity 之上的 Activity 都干掉。
ActivityRecord
那么 ActivityRecord#realActivity 是在哪里设置的呢?继续看 ActivityRecord 的源码,可以发现 realActivity 是 ActivityRecord 中的一个 final 成员变量,事情貌似变得简单了。
在 Activity 的启动过程中,在执行到 ActivityStackSupervisor#startActivityLocked 的时候,会创建一个 ActivityRecord 对象,这是待启动 Activity 的 ActivityRecord 对象。在初始化这个 ActivityRecord 的时候,会对其 realActivity 进行赋值:
在对 realActivity 赋值的时候,满足三个条件之一就会将传入 Intent 的 component 设置给 realActivity,这三个条件是:
- targetActivity 为空。说明不是 activity-alias
- AndroidManifest 中指定 activity-alias 时才会指定 targetActivity
- launchMode 是 LAUNCH_MULTIPLE。说明是 standard 启动模式
- launchMode 是 LAUNCH_SINGLE_TOP。说明是 singleTop 启动模式
到此为止,我们已经知道了 Android 6.0 是如何处理 clear top 的。此处的 realActivity 的赋值逻辑很关键,下面会重新提到。
5.2.2 Android 7.0
Android 7.0 和 Android 6.0 的调用链路有一定变化,主要是把原先 ActivityStackSupervisor 中的启动逻辑拆到了一个新的 ActivityStarter 类中。
依旧是在 TaskRecord#performClearTaskLocked 里处理 clear top。
TaskRecord
这里执行 clear top 的逻辑没有变化,依旧是对 realActivity 的判断。
TaskRecord#performClearTaskLocked
ActivityRecord
也可以看到,realActivity 依旧是 final 变量。
但是!Android 7.0 对 ActivityRecord#realActivity 的赋值逻辑做了调整,新加了个判断:aInfo.targetActivity.equals(_intent.getComponent().getClassName()
Android 7.0 新加的这个判断导致的差异可以用如下这个例子来说明:
- 第一步:直接启动 activity-alias 这个 Activity,创建的 ActivityRecord 插入到任务栈中时,该 ActivityRecord 的 realActivity 属性指向该 activity-alias 对应的 targetActivity
- 第二步:在同一个任务栈中再打开几个中间 Activity,然后通过 clear_top 的方式直接启动 targetActivity (指定 class 是 targetActivity),clear top 会生效,任务栈会被清空
需要说明的是声明为 activity-alias 的 Activity 不支持指定 launchMode,所以它的 launchMode 是默认值 standard,也很容易理解,毕竟只是个占位符。
为什么 clear top 会生效呢,因为在启动 targetActivity 时创建的 ActivityRecord 的 realActivity 也是指向的 targetActivity,所以当执行到 TaskRecord#performClearTaskLocked 的时候,就会发现和启动 activity-alias 时创建的 ActivityRecord 的 realActivity 相等,因为都是指向的 targetActivity,从而 clear top 正常生效。
在 Android 7.0 之前,执行上述同样的两步操作,clear top 不会生效,任务栈不会被清理。因为直接启动 activity-alias 时所创建 ActivityRecord 对象的 realActivity 指向 activity-alias 本身,而再次直接启动 targetActivity 时创建的 ActivityRecord#realActivity 指向的是 targetActivity。
5.2.3 Android 10.0
Android 10.0 的启动流程有很多变化,但是 clear_top 执行的核心逻辑与 Android 7.0 没有区别。
TaskRecord
TaskRecord#performClearTaskLocked
ActivityRecord
那么 ActivityRecord#mActivityComponent 是在哪里设置的呢?
5.2.4 总结一下
用 Intent 启动某个 launchMode 为 singleTop 的 Activity 时,如果在 Intent 中设置了 clear_top 的 flag,Android 在处理 clear_top 的时候,会遍历整个任务栈,通过判断 ActivityRecord#realActivity 在任务栈中寻找已经存在的 Activity 实例。如果找到了目标 Activity,就会将目标 Activity 之上的 Activity 全部 finish 掉。
ActivityRecord#realActivity 是在 ActivityRecord 的构造方法中初始化的,其初始化逻辑在 Android 6.0 以下(包含 6.0)和 Android 7.0+ 有差异。
Android 5.0/6.0(含以下)在初始化 ActivityRecord 的时候,未对 activity-alias 做判断,realActivity 指向的是 activity-alias 这个 Activity 自身。Android 7.0+ 以后对 activity-alias 进行了判断,realActivity 指向的是 activity-alias 的 targetActivity。
上述这个差异也就导致了:如果待启动 Activity 是某个 activity-alias 的 targetActivity,Android 6.0 和 Android 7.0+ 在处理 Intent 中 clear_top flag 时可能会有不同表现,前者 clear top 不生效,后者 clear top 生效。
5.3 真相大白
回到我们最初的问题:为什么我们的 App 在 Android 7.0 以下的版本上,发布页加 clear top flag 跳转到首页 MainActivity 没能清空任务栈?
再来看下我们是怎么跳首页的。从下图可以看到跳首页是指定的 class 是 AVEnv.APPLICATION_SERVICE.getPublishContainerActivityClass()
,看了下具体的实现发现这个类就是 MainActivity,所以确实是直接跳到首页的。
在前面我们有提到,我们 App 的 Launch Activity 是 SplashActivity,但是它只是个 activity-alias,其 targetActivity 指向 MainActivity,MainActivity 的 launchMode 是 singleTop。经过上面对 Android 6.0, Android 7.0, Android 10.0 启动流程的分析,我想答案已经比较明显了。
在 Android 5.1 上启动 App 时,启动 Launch Activity 实际启动的是 MainActivity,但是插入到任务栈中的 ActivityRecord 的 realActivity 指向的是 SplashActivity 。当走完发布流程,在发布页点击存草稿时,再次启动 MainActivity,创建的这个 ActivityRecord 的 realActivity 指向 MainActivity,在任务栈中找不到 realActivity 等于 MainActivity 的,所以 clear top 不会生效。
在 Android 7.0+ 上的版本 clear top 会生效,原因不再赘述。
如果要避免上述这种差异导致的 clear_top flag 不生效的问题,将 clear_top 的 Intent 指向 activity-alias 这个 Activity 即可。所以修复(准确地说是适配)这个问题的话可以把存草稿后打开首页的 intent 的 class 设置成 SplashActivity,这么改动之后在 Android 5.1 的测试机上运行发现问题确实修复了。
当然,既然 AOSP 是从 Android 7.0+ 做出的逻辑改动,也就解释了为什么反馈用户都是 Android 7.0 以下的系统。
5.4 在 Demo 上验证
在 Android 5.1 上启动 Demo App,然后用 adb 打印一下任务栈看看。打印出来发现任务栈中 ActivityRecord#realActivity 指向 Splash
adb shell dumpsys activity activities | grep 'com.yongf.android.myapplication'
同样的包在 Android 7.0 上打印的信息如下,可以看到任务栈中 ActivityRecord#realActivity 已经不再指向 Splash,而是指向 A 了。
adb shell dumpsys activity activities | grep 'com.yongf.android.myapplication'
将 Demo 上的启动链路改成:Splash --> A --> B --> C --> Splash,可以发现在 Android 5.1 的测试机和模拟器上 B 和 C 都被干掉了,clear top 生效了。
行文到最后,再提一个有意思的点,估计是 6.0 上才发现这个问题,所以在 Android 7.0 上修复了这个问题,因为在 ActivityRecord 构造方法里 realActivity 的赋值逻辑上源码里 7.0 新增了这样一段注释:
最后,回到本文的标题,Intent#FLAG_ACTIVITY_CLEAR_TOP 真的会 clear top 吗,你知道了吗?^_^
六、Demo 工程
待上传。
七、推荐几个工具
- Carbon
- Carbon is the easiest way to create and share beautiful images of your source code.
- Android Source Code Viewer
八、本文更新历史
- 2020/10/25:更新图源,修复文章图片展示问题
九、关于作者
微信公众号:
本文使用 mdnice 排版