明修"栈"道——越过 Android 启动栈陷阱
作者:vivo 互联网大前端团队- Zhao Kaiping
本文从一例业务中遇到的问题出发,以 FLAG_ACTIVITY_NEW_TASK 这一 flag 作为切入点,带大家探究 Activity 启动前的一项重要的工作——栈校验。
文中列举一系列业务中可能遇到的异常状况,详细描述了使用 FLAG_ACTIVITY_NEW_TASK 时可能遇到的“坑”,并从源码中探究其根源。只有合理使用 flag、launchMode,才能避免因为栈机制的特殊性,导致一系列与预期不符的启动问题。
一、问题及背景
应用间相互联动、相互跳转,是实现系统整体性、体验一致性的重要手段,也是最简单的一种方法。
当我们用最常用的方法去 startActivity 时,竟也会遇到失败的情况。在真实业务中,就遇到了这样一例异常:用户点击某个按钮时,想要“简简单单”跳转另一个应用,却没有任何反应。
经验丰富的你,脑海中是否涌现出了各种猜想:是不是目标 Activity 甚至目标 App 不存在?是不是目标 Activty 没有对外开放?是不是有权限的限制或者跳转的 action/uri 错了……
真实的原因被 flag、launchMode、Intent 等特性层层藏匿,可能超出你此时的思考。
本文将从源码出发,探究前因后果,展开讲讲在 startActivity()真正准备启动一个 Activity 前,需要经过哪些“磨难”,怎样有据可依地解决由栈问题导致的启动异常。
1.1 业务中遇到的问题
业务中的场景是这样的,存在 A、B、C 三个应用。
(1)从应用 A-Activity1 跳转至应用 B-Activity2;
(2)应用 B-Activity2 继续跳转到应用 C-Activity3;
(3)C 内某个按钮,会再次跳转 B-Activity2,但点击后没有任何反应。如果不经过前面 A 到 B 的跳转,C 直接跳到 B 是可以的。

1.2 问题代码
3 个 Activity 的 Androidmanifest 配置如下,均可通过各自的 action 拉起,launchMode 均为标准模式。
A-1 到 B-2 的代码,指定 flag 为 FLAG_ACTIVITY_NEW_TASK
B-2 到 C-3 的代码,未指定 flag
C-3 到 B-2 的代码,与 A-1 到 B-2 的完全一致,指定 flag 为 FLAG_ACTIVITY_NEW_TASK
1.3 代码初步分析
仔细查看问题代码,在实现上非常简单,有两个特征:
(1)如果直接通过 C-3 跳 B-2,没有任何问题,但 A-1 已经跳过 B-2 后,C-3 就失败了。
(2)在 A-1 和 C-3 跳到 B-2 时,都设置了 flag 为 FLAG_ACTIVITY_NEW_TASK。
依据经验,我们推测与栈有关,尝试将跳转前栈的状态打印出来,如下图。

由于 A-1 跳到 B-2 时设置了 FLAG_ACTIVITY_NEW_TASK,B-2 跳到 C-3 时未设置,所以 1 在独立栈中,2、3 在另一个栈中。示意如下图。

C-3 跳转 B-2 一般有 3 种可能的预期,如下图:预想 1,新建一个 Task,在新 Task 中启动一个 B-2;预想 2,复用已经存在的 B-2;预想 3,在已有 Task 中新建一个实例 B-2。

但实际上 3 种预期都没有实现,所有 Activity 的任何声明周期都没有变化,界面始终停留在 C-3。
看一下 FLAG_ACTIVITY_NEW_TASK 的官方注释和代码注释,如下图:


重点关注这一段:
When using this flag, if a task is already running for the activity you are now starting, then a new activity will not be started; instead, the current task will simply be brought to the front of the screen with the state it was last in.
使用此 flag 时,如果你正在启动的 Activity 已经在一个 Task 中运行,那么一个新 Activity 不会被启动;相反,当前 Task 将简单地显示在界面的前面,并显示其最后的状态。
——显然,官方文档与代码注释的表述与我们的异常现象是一致的,目标 Activity2 已经在 Task 中存在,则不会被启动;Task 直接显示在前面,并展示最后的状态。由于目标 Activty3 就是来源 Activity3,所以页面没有任何变化。
看起来官方还是很靠谱的,但实际效果真的能一直与官方描述一致吗?我们通过几个场景来看一下。
二、场景拓展与验证
2.1 场景拓展
在笔者依据官方描述进行调整、复现的过程中,发现了几个比较有意思的场景。
PS:上面业务的案例中,B-2 和 C-3 在不同应用内,又在相同的 Task 内,但实际上是否是同一个应用,对结果的影响并不大。为了避免不同应用和不同 Task 造成阅读混乱,同一个栈的跳转,我们都在本应用内进行,故业务中的场景等价于下面的【场景 0】
【场景 0】把业务中 B-2 到 C-3 的应用间跳转改为 B-2 到 B-3 的应用内跳转
如下图,A-1 设置 NEW_TASK 跳转 B-2,再跳转 B-3,最终设置 NEW_TASK 想跳转 B-2。虽然跳 C-3 改为了跳 B-3,但与之前问题的表现一致,没有反应,停留在 B-3。

有的读者会指出这样的问题:如果同一个应用内使用 NEW_TASK 跳转,而不指定目标的 taskAffinity 属性,实际是无法在新 Task 中启动的。请大家忽略该问题,可以认为笔者的操作是已经加了 taskAffinity 的,这对最终结果并没有影响。
【场景 1】如果目标 Task 和来源 Task 不是同一个,情况是否会如官方文档所说复用已有的 Task 并展示最近状态?我们改为 B-3 启动一个新 Task 的新 Activity C-4,再通过 C-4 跳回 B-2
如下图,A-1 设置 NEW_TASK 跳转 B-2,再跳转 B-3,再设置 NEW_TASK 跳转 C-4,最终设置 NEW_TASK 想跳转 B-2。

预想的结果是:不会跳到 B-2,而是跳到它所在 Task 的顶层 B-3。
实际的结果是:与预期一致,确实是跳到了 B-3。
【场景 2】把场景 1 稍做修改:C-4 到 B-2 时,我们不通过 action 来跳,改为通过 setClassName 跳转。
如下图,A-1 设置 NEW_TASK 跳转 B-2,再跳转 B-3,再设置 NEW_TASK 跳转 C-4,最终设置 NEW_TASK 想跳转 B-2。

预想的结果是:与场景 0 一致,会跳到 B-2 所在 Task 的已有顶层 B-3。
实际的结果是:在已有的 Task2 中,产生了一个新的 B-2 实例。
仅仅是改变了一下重新跳转 B-2 的方式,效果就完全不一样了!这与官方文档中提到该 flag 与"singleTask" launchMode 值产生的行为并不一致!
【场景 3】把场景 1 再做修改:这次 C-4 不跳栈底的 B-2,改为跳转 B-3,且还是通过 action 方式。
如下图,A-1 设置 NEW_TASK 跳转 B-2,再跳转 B-3,再设置 NEW_TASK 跳转 C-4,最终设置 NEW_TASK 想跳转 B-3。

预想的结果是:与场景 0 一致,会跳到 B-2 所在 Task 的顶层 B-3。
实际的结果是:在已有的 Task2 中,产生了一个新的 B-3 实例。
不是说好的,Activity 已经存在时,展示其所在 Task 的最新状态吗?明明 Task2 中已经有了 B-3,并没有直接展示它,而是生成了新的 B-3 实例。
【场景 4】既然 Activity 没有被复用,那 Task 一定会被复用吗?把场景 3 稍做修改,直接给 B-3 指定一个单独的 affinity。
如下图,A-1 设置 NEW_TASK 跳转 B-2,再跳转 B-3,再设置 NEW_TASK 跳转 C-4,最终设置 NEW_TASK 想跳转 B-3。

——这次,连 Task 也不会再被复用了……Activity3 在一个新的栈中被实例化了。
再回看官方的注释,就会显得非常不准确,甚至会让开发者对该部分的认知产生严重错误!稍微改变过程中的某个毫无关联的属性(如跳转目标、跳转方式……),就会产生很大差异。
在看 flag 相关注释时,我们要树立一个意识:Task 和 Activity 跳转的实际效果,是 launchMode、taskAffinity、跳转方式、Activity 在 Task 中的层级等属性综合作用的结果,不要相信“一面之词”。
回到问题本身,究竟是哪些原因造就了上面的不同效果呢?只有源码最值得信赖了。
三、场景分析与源码探索
本文以 Android 12.0 源码为基础,进行探究。上述场景在不同 Android 版本上的表现是一致的。
3.1 源码调试注意事项
源码的调试方法,许多文章已经有了详细的教学,本文不再赘述。此处只简单总结其中需要注意的事项:
下载模拟器时,不要使用 Google Play 版本,该版本类似 user 版本,无法选择 system_process 进程进行断点。
即使是 Google 官方模拟器和源码,在断点时,也会有行数严重不对应的情况(比如:模拟器实际会运行到方法 A,但在源码中打断点时,实际不能定位到方法 A 的对应行数),该问题并没有很好的处理方法,只能尽量规避,如使模拟器版本与源码版本保持一致、多打一些断点增加关键行数被定位到的几率。
3.2 初步断点,明确启动结果
以【场景 0】为例,我们初步确认一下,为什么 B-3 跳转 B-2 会无反应,系统是否告知了原因。
3.2.1 明确启动结果及其来源
在 Android 源码的断点调试中,常见的有两类进程:应用进程和 system_process 进程。
在应用进程中,我们能获取到应用启动结果的状态码 result,这个 result 用来告诉我们启动是否成功。涉及堆栈如下图(标记 1)所示:
Activity 类::startActivity() → startActivityForResult() → Instrumentation 类::execStartActivity(),返回值 result 则是 ATMS(ActivityTaskManagerService)执行的结果。

如上图(标记 2)标注,ATMS 类::startActivity()方法,返回了 result=3。
在 system_process 进程中,我们看一下这个 result=3 是怎样被赋值的。略去详细断点步骤,实际堆栈如下图(标注 1)所示:
ATMS 类::startActivity() → startActivityAsUser() → ActivityStarter 类::execute()
→ executeRequest() → startActivityUnchecked() → startActivityInner() → recycleTask(),在 recycleTask()中返回了结果。

如上图(标注 2)所示,result 在 mMovedToFront=false 时被赋值,即 result=START_DELIVERED_TO_TOP=3,而 START_SUCCESS=0 才代表创建成功。
看一下源码中对 START_DELIVERED_TO_TOP 的说明,如下图:

Result for IActivityManaqer.startActivity: activity wasn't really started, but the given Intent was given to the existing top activity.
(IActivityManaqer.startActivityActivity 的结果:Activity 并未真正启动,但给定的 Intent 已提供给现有的顶层 Activity。)
“Activity 并未真正启动”——是的,因为可以复用
“给定的 Intent 已提供给现有的顶层 Activity”——实际没有,顶层 Activity3 并没有收到任何回调,onNewIntent()未执行,甚至尝试通过 Intent::putExtra()传入新的参数,Activity3 也没有收到。官方文档又带给了我们一个疑问点?我们把这个问题记录下来,在后面分析。
满足什么条件,才会造成 START_DELIVERED_TO_TOP 的结果呢?笔者的思路是,通过与正常启动流程对比,找出差异点。
3.3 过程断点,探索启动流程
一般来说,在定位问题时,我们习惯通过结果反推原因,但反推的过程只能关注到与问题强关联的代码分支,并不能能使我们很好地了解全貌。
所以,本节内容我们通过顺序阅读的方法,正向介绍 startActivity 过程中与上述【场景 01234】强相关的逻辑。再次简述一下:
【场景 0】同一个 Task 内,从顶部 B-3 跳转 B-2——停留在 B-3
【场景 1】从另一个 Task 内的 C-4,跳转 B-2——跳转到 B-3
【场景 2】把场景 1 中,C-4 跳转 B-2 的方式改为 setClassName()——创建新 B-2 实例
【场景 3】把场景 1 中,C-4 跳转 B-2 改为跳转 B-3——创建新 B-3 实例
【场景 4】给场景 3 中的 B-3,指定 taskAffinity——创建新 Task 和新 B-3 实例
3.3.1 流程源码概览
源码中,整个启动流程很长,涉及的方法和逻辑也很多,为了便于大家理清方法调用顺序,方便后续内容的阅读,笔者将本文涉及到的关键类及方法调用关系整理如下。
后续阅读中如果不清楚调用关系,可以返回这里查看:
3.3.2 关键流程分析
(1)初始化
startActivityInner()是最主要的方法,如下列几张图所示,该方法会率先调用 setInitialState(),初始化各类全局变量,并调用 reset(),重置 ActivityStarter 中各种状态。
在此过程中,我们记下两个关键变量 mMovedToFront 和 mAddingToTask,它们均在此被重置为 false。
其中,mMovedToFront 代表当 Task 可复用时,是否需要将目标 Task 移动到前台;mAddingToTask 代表是否要将 Activity 加入到 Task 中。



(2)计算确认启动时的 flag
该步骤会通过 computeLaunchingTaskFlags()方法,根据 launchMode、来源 Activity 的属性等进行初步计算,确认 LaunchFlags。
此处重点处理来源 Activity 为空的各类场景,与我们上文中的几种场景无关,故不再展开讲解。
(3)获取可以复用的 Task
该步骤通过调用 getReusableTask()实现,用来查找有没有可以复用的 Task。
先说结论:场景 0123 中,都能获取到可以复用的 Task,而场景 4 中,未获取到可复用的 Task。
为什么场景 4 不可以复用?我们看一下 getReusableTask()的关键实现。

上图(标注 1)中,putIntoExistingTask 代表是否能放入已经存在的 Task。当 flag 含有 NEW_TASK 且不含 MULTIPLE_TASK 时,或指定了 singleInstance 或 singleTask 的 launchMode 等条件,且没有指定 Task 或要求返回结果 时,场景 01234 均满足了条件。
然后,上图(标注 2)通过 findTask()查找可以复用的 Task,并将过程中找到的栈顶 Activity 赋值给 intentActivity。最终,上图(标注 3)将 intentActivity 对应的 Task 作为结果。
findTask()是怎样查找哪个 Task 可以复用呢?

主要是确认两种结果 mIdealRecord——“理想的 ActivityRecord” 和 mCandidateRecord——"候选的 ActivityRecord",作为 intentActivity,并取 intentActivity 对应的 Task 作为复用 Task。
什么 ActivityRecord 才是理想或候选的 ActivityRecord 呢?在 mTmpFindTaskResult.process()中确认。

程序会将当前系统中所有的 Task 进行遍历,在每个 Task 中,进行如上图所示的工作——将 Task 的底部 Activity realActivity 与目标 Activity cls 进行对比。
场景 012 中,我们想跳转 Activity2,即 cls 是 Activity2,与 Task 底部的 realActivity2 相同,则将该 Task 顶部的 Activity3 r 作为“理想的 Activity”;
场景 3 中,我们想跳转 Activity3,即 cls 是 Activity3,与 Task 底部的 realActivity2 不同,则进一步判断 task 底部 Activity2 与目标 Activity3 的栈亲和行,具有相同亲和性,则将 Task 的顶部 Activity3 作为“候选 Activity”;
场景 4 中,所有条件都不满足,最终没能找到可复用的 Task。在执行完 getReusableTask()后将 mAddingToTask 赋值为 true
由此,我们就能解释【场景 4】中,新建了 Task 的现象。
(4)确定是否需要将目标 Task 移动到前台
如果存在可复用的 Task,场景 0123 会执行 recycleTask(),该方法中会相继进行几个操作:setTargetRootTaskIfNeeded()、complyActivityFlags()。
首先,程序会执行 setTargetRootTaskIfNeeded(),用来确定是否需要将目标 Task 移动到前台,使用 mMovedToFront 作为标识。


在【场景 123】中,来源 Task 和目标 Task 是不同的,differentTopTask 为 true,再经过一系列 Task 属性对比,能够得出 mMovedToFront 为 true;
而场景 0 中,来源 Task 和目标 Task 相同,differentTopTask 为 false,mMovedToFront 保持初始的 false。
由此,我们就能解释【场景 0】中,Task 不会发生切换的现象。
(5)通过对比 flag、Intent、Component 等确认是否要将 Activity 加入到 Task 中
还是在【场景 0123】中,recycleTask()会继续执行 complyActivityFlags(),用来确认是否要将 Activity 加入到 Task 中,使用 mAddingToTask 作为标识。
该方法会对 FLAG_ACTIVITY_NEW_TASK、FLAG_ACTIVITY_CLEAR_TASK、FLAG_ACTIVITY_CLEAR_TOP 等诸多 flag、Intent 信息进行一系列判断。

上图(标注 1)中,会先判断后续是否需要重置 Task,resetTask,判断条件则是 FLAG_ACTIVITY_RESET_TASK_IF_NEEDED,显然,场景 0123 的 resetTask 都为 false。继续执行。
接着,会有多种条件判断按顺序执行。
在【场景 3】中,目标 Component(mActivityComponent)是 B-3,目标 Task 的 realActivity 则是 B-2,两者不相同,进入了 resetTask 相关的判断(标注 2)。
之前 resetTask 已经是 false,故【场景 3】的 mAddingToTask 脱离原始值,被置为 true。
在【场景 012】中,相对比的两个 Activity 都是 B-2(标注 3),可以进入下一级判断——isSameIntentFilter()。



这一步判断的内容就很明显了,目标 Activity2 的已有 Intent 与 新的 Intent 做对比。很显然,场景 2 中由于改为了 setClassName 跳转,Intent 自然不一样了。
故【场景 2】的 mAddingToTask 脱离原始值,被置为 true。
总结看一下:
【场景 123】的 mMovedToFront 最先被置为 true,而【场景 0】经历重重考验,保持初始值为 false。
——这意味着当有可复用 Task 时,【场景 0】不需要把 Task 切换到前列;【场景 123】需要切换到目标 Task。
【场景 234】的 mAddingToTask 分别在不同阶段被置为 true,而【场景 01】,始终保持初始值 false。
——这意味着,【场景 234】需要将 Activity 加入到 Task 中,而【场景 01】不再需要。
(6)实际启动 Activity 或直接返回结果
被启动的各个 Activity 会通过 resumeFocusedTasksTopActivities()等一系列操作,开始真正的启动与生命周期的调用。
我们关于上述各个场景的探索已经得到答案,后续流程便不再关注。
四、问题修复及遗留问题解答
4.1 问题修复
既然前面总结了这么多必要条件,我们只需要破坏其中的某些条件,就可以修复业务中遇到的问题了,简单列举几个的方案。
方案一:修改 flag。B-3 跳转 B-2 时,增加 FLAG_ACTIVITY_CLEAR_TASK 或 FLAG_ACTIVITY_CLEAR_TOP,或者直接不设置 flag。经验证可行。
方案二:修改 intent 属性,即【场景 2】。A-1 通过 action 方式隐式跳转 B-2,则 B-3 可以通过 setClassName 方式,或修改 action 内属性的方式跳转 B-2。经验证可行。
方案三:提前移除 B-2。B-2 跳转 B-3 时,finish 掉 B-2。需要注意的是,finish()要在 startActivity()之前执行,以避免遗留的 ActivityRecord 和 Intent 信息对后续跳转的影响。尤其是当你把 B-2 作为自己应用的 deeplink 分发 Activity 时,更值得警惕。
4.2 遗留问题
还记得我们在文章开端的某个疑惑吗,为什么没有回调 onNewIntent()?
onNewIntent() 会通过 deliverNewIntent()触发,而 deliverNewIntent()仅通过以下两个方法调用。

complyActivityFlags()就是上文 3.3.1.5 中我们着重探讨的方法,可以发现 complyActivityFlags()中所有可能调用 deliverNewIntent()的条件均被完美避开了。
而 deliverToCurrentTopIfNeeded()方法则如下图所示。

mLaunchFlags 和 mLaunchMode,无法满足条件,导致 dontStart 为 false,无缘 deliverNewIntent()。
至此,onNewIntent()的问题得到解答。
五、结语
通过一系列场景假设,我们发现了许多出乎意料的现象:
文档提到 FLAG_ACTIVITY_NEW_TASK 等价于 singleTask,与事实并不完全如此,只有与其他 flag 搭配才能达到相似的效果。这一 flag 的注释非常片面,甚至会引发误解,单一因素无法决定整体表现。
官方文档提到 START_DELIVERED_TO_TOP 会将新的 Intent 传递给顶层 Activity,但事实上,并不是每一种 START_DELIVERED_TO_TOP 都会把新的 Intent 重新分发。
同一个栈底 Activity,前后两次都通过 action 或都通过 setClassName 跳转到时,第二次跳转竟然会失败,而两次用不同方式跳转时,则会成功。
单纯使用 FLAG_ACTIVITY_NEW_TASK 时,跳栈底 Activity 和跳同栈内其他 Activity 的效果大相径庭。
业务中遇到的问题,归根结底就是对 Android 栈机制不够了解造成的。
在面对栈相关的编码时,开发者务必要想清楚,承担新开应用栈的 Activty 在应用全局承担怎样的使命,要对 Task 历史、flag 属性、launchMode 属性、Intent 内容等全面评估,谨慎参考官方文档,才能避免栈陷阱,达成理想可靠的效果。
版权声明: 本文为 InfoQ 作者【vivo互联网技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/671e73d3161f6f8097e4460ad】。文章转载请联系作者。
评论