Activity 进阶:重塑对任务栈、启动模式与 taskAffinity 的底层认知
1. 机制内幕:Task 并非数组栈,而是系统容器树的节点
在探讨任何调度规则之前,必须看穿内存里的实际物理结构。所谓的“任务栈”,在早期的 AMS(Android 9.0 及其以前)中由 ActivityStackSupervisor、ActivityStack、TaskRecord 到 ActivityRecord 表示。而随着系统演进,为了消弭 AMS 和 WMS(窗口管理系统)同步延迟导致的黑屏/冻屏现象,Android 从底层将 Activity 的生命周期管理与屏幕渲染树彻底合体,也就是采用统一的 WindowContainer 树形结构进行表达:
RootWindowContainer
└── DisplayContent (代表一块真正的物理屏幕,或虚拟屏幕)
└── TaskDisplayArea (显示区域)
└── Task (这就是我们常说的任务栈)
├── Task (自 Android 11 起,Task 可嵌套以支持分屏)
└── ActivityRecord (叶子物理节点,代表一个物理存在的 Activity 实例)
在这个模型中:
ActivityRecord
它是系统中真正的、唯一代表你在代码里写的 MyActivity 类实例的镜像。应用进程里 new 出来的一个 Activity 对象,在 ATMS 侧必然对应着一个 ActivityRecord 实例对象。在此节点内不仅存在此 Activity 的 Intent、生命周期当前状态,它还直接持有 appToken,WMS 借此与屏幕上的 WindowState(窗口 UI 缓冲)建立绑定关系。这也就是为何当你在应用层杀死某个 Activity 时,由于 WMS 中的持有关系没有正确卸载会导致内存泄漏的根本物理原因。
Task
不要把它理解为 List<Activity>,在底层源码 Task.java 中它是一个具有显示调度能力的高级渲染容器单元。Task 是 Android 中处理应用层切换、状态保存、并在 Recents(最近任务列表)中呈现的基础物理切片单元。当你从应用后台呼出多任务管理界面,你划掉的一个个卡片,杀死的就是一个 Task 容器及其包含的所有 ActivityRecord 树。
2. ATMS 源码级剖析:LaunchMode 到底做了什么权衡?
当你的应用调用 startActivity() 时,这个跨进程请求最终沿着 Binder 打入 system_server 进程,由 ATMS 签收,并转交 ActivityStarter.java 开始进行极为复杂的合法性校验。这不仅涉及权限、黑灰产调用拦截,其核心动作就是根据你声明的 LaunchMode 决定:我要不要复用现有的节点?我要不要销毁现有的节点?我要不要挂载到新的容器?
standard:默认但暴力的克隆机制
源码逻辑最为直白:没有任何复用查询判定。ATMS 会直接组装一个新的 ActivityRecord,并按照当前调用者的上下文将它 addChild 到调用者所在的 Task 中。
Why this way? 它将构建成本推给应用层自己承担。系统假设在这个普通页面上,你不在乎多占用一部分堆内存或者窗口缓冲去隔离这几份完全独立的 UI 节点。
singleTop:短命的栈顶拦截器
当带有 singleTop 模式的路由请求到达时,ActivityStarter 中的核心路由逻辑会读取目标 Task 的 mChildren 末尾元素(即最顶部的 ActivityRecord),比对它的 ComponentName。如果命中,ATMS 将直接绕开新建和压栈行为,触发目标所在的 ActivityRecord 的 deliverNewIntent() 进行分发。最终,你的代码中就会执行回调 onNewIntent 并且在生命周期的重入中完成界面刷新而避免重建开销。
工程坑点防范:在
onNewIntent中由于默认的getIntent()内部指针仍然指向该实例初次建立时的旧 Intent,这是框架的刻意设计(保留追溯原始唤起条件的能力)。如果依赖新的入参刷新业务,必须首行无脑追加setIntent(intent);来刷新该 Record 的内置指针!
singleTask:带有破坏语义的全局调度器
singleTask 并不是简单“在这个栈里只保留一次”的意思,它的行为是系统级、具破坏性的:
- 全局寻址(Find Task):
ActivityStarter并不局限在当前的Task甚至当前应用的进程中查找。它会顺着当前的显示区域向上遍历寻找是否有哪个Task满足它期望的taskAffinity,并在该Task中检索是否曾挂载过同样 ComponentName 的ActivityRecord节点。 - 栈内截断(Clear Top):一旦它在一棵已存在的
Task树深处发现了该目标ActivityRecord,为了将它浮出水面,系统没有将底层提取出来插入顶部的功能(破坏了入出栈规律的线性时间约束)。ATMS 的做法非常冷酷:它会执行performClearTaskStarting()方法,遍历所有在这个目标节点之上的ActivityRecord,直接触发它们销毁,相当于直接砍断了该节点的枝叶。 - “新建”只是一场迫不得已的备逃(条件触发):很多教材将“
singleTask+ 不同的taskAffinity”生硬等同于“必定新建任务栈”,这在底层逻辑上是错漏的。当你以此种配置启动时,ATMS 首要是去全栈检索是否在后台沉睡着与该taskAffinity匹配的旧 Task 树。只有在整个 OS 中完全找不到匹配的旧树时,此时它才会执行创建动作(Create a new Task)。如果万一找到了一个曾经遗留的匹配树,它会直接一记“擒拿”,将那棵老树强行提拉到系统前台,然后把你的 Activity 直接挂载或复用进去(绝不新建)。
设计动机:这种强行裁剪行为常常用于应用首页等重量级根节点的保护。从系统内存管理及架构稳定性角度出发,像微信首页等维持着大量全局观察者监听以及强业务聚合逻辑的根节点,如果允许随意栈顶入栈克隆会极大暴露 OOM 及状态不同步的脆弱性,砍去栈顶枝叶是一种安全着陆保障。
singleInstance:纯粹无垢的独占领域与边缘推演
比 singleTask 更极端,它严格规定了 ActivityRecord 及 Task 容器的双向唯一性约束。系统必须为该 ActivityRecord 建立一个仅挂载它的孤雏 Task。
一旦置身于这个“绝对隔离”的容器内,当你在其中再次发起路由请求时,会触发极具迷惑性的底层调度突变:
-
同类相斥(A 启动 A itself):由于双向唯一性,ATMS 在全局寻址时会立刻命中那棵现有的孤雏
Task。它绝不会因为重新调起而分裂出第二个平行的栈,而是直接走栈内复用机制,调用现有 A 实例的onNewIntent()。此时任务栈数量不变,Activity 实例依然仅有 1 个。 -
异类排异(A 启动 B - standard 模式):这立刻触发了
singleInstance的防污染安全底线——在这个 VIP 隔离室里,严禁进入任何人。当你在 A 内部startActivity(B)时,底层ActivityStarter察觉到当前源头 Task 被排他性锁定,它会强行给这次隐式调用打上FLAG_ACTIVITY_NEW_TASK引擎标志。随后拔出 B 的taskAffinity探针去外界寻址:- 就算 B 的 affinity 和 A 完全一样,系统也会在遍历 Task 时刻意避开 A 所在的 VIP 栈。
- 系统去寻找其余与之 affinity 匹配的常规 Task(例如应用主进程退居后台的那个默认 Task)。找到了,就直接把 B 挂载过去,并把主 Task 瞬间拖至前台覆盖掉 A。
- 找不到?直接再克隆一棵全新的树形容器 Task 专门给 B 安家。
-
群星孤立(启动完全不同的另一个 singleInstance C):因为每一个声明了此属性的实例都在宣誓独立主权。所以哪怕你连续启动了 5 个不同类名的
singleInstanceActivity,OS 的WindowContainer会毫不犹豫地横向分裂出 5 棵完全平行的不同 Task 树。
业务设计投射:为什么 OS 需要维护这套带有强烈“免疫排斥反应”的机制?这是抵御**渲染级抢夺与界面注入攻击(Tapjacking)**的最后防线。像“系统级来电弹窗”、“指静脉高秘级验证页”,绝不允许被同主体的第三方广告 SDK、暗藏钩子的 WebView 借着在同一个栈结构内的机会将恶意遮罩层悄悄压在上层掩人耳目。它是具备军事级真空物理隔离的“防生化污染服”。
3. taskAffinity 源码路由协议:寻址探针,不是静态标牌
如果把 Task 认为是集装箱,很多教材告诉你 taskAffinity 决定了 Activity 属于哪个集装箱,这是巨大的因果倒置。
在 ATMS 的源码定义中,taskAffinity 仅仅代表该 Activity 所携带的一条 String 类型的偏好字符(默认在构建时填充为主工程的包名名称 application.packageName)。它不是集装箱的 ID,而是用于在匹配新家时的“寻址条件探针(Affinity Router)”。
只有当触发跨任务启动(New Task Routing) 时,这根探针才会真正产生效果。
通常以下条件会激活跨任务计算引擎导致 taskAffinity 被触发检索:
- 你的清单中
launchMode写了singleTask/singleInstance。 - 你在运行时调用中使用了动态路由强制符
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)。
在执行 FLAG_ACTIVITY_NEW_TASK 时,系统在执行 getReusableTask() 内部遍历全系统所有的 Task 容器进行匹配时,就会提取目前 Task 头节点的 affinity 字符串进行暴力 Equals。匹配中则注入该 Task,未匹配则新造 Task。如果没有这些引发重造容器触发逻辑的标志,就算我们在清单配置了特殊的 taskAffinity="com.google.isolated",它也会默默认命的被按部就班填充到当前调用来源者的 Task 下,发挥不了任何探针偏好作用。
底层机制的互斥与结合:我们用
taskAffinity分离不同的独立业务线(如将特殊播放界面隔离)的唯一正确做法是:同时配置特定的taskAffinity加上FLAG_ACTIVITY_NEW_TASK(或对应的配置启动模式),两者缺一不可,这就是为了兼顾不破坏静态配置解析效率又能保持运行时动态路由寻址灵活性的一种巧妙解耦层。
4. Intent Flags:跨越清单配置的运行时越权指挥官
系统在 AndroidManifest 里允许我们写死配置,但在复杂的微服务、插件化,乃至不同模块之间的组合穿透时,静态的控制权远不能满足高级编程人员对于“上下文状态感知”的重置需求。因此提供的运行时 Intent Flag 实质上是拥有更高级判断优先权的系统调度越权指令:
-
FLAG_ACTIVITY_NEW_TASK: 它的存在核心不仅限于前述“触发跨 Task 查找机制”。从 OS 原理上看,这是系统防崩溃的安全底线所在。例如在Service后台驻留程序唤起 UI 页面,或你用极端的Application Context执行操作时。由于此种纯计算的内存下发流不在任何视觉渲染缓冲容器里,它根本不知道要挂进哪一棵Task树,如果不带此 Flag 系统会直接抛弃此请求导致极易捕获到典型的系统级异常(AndroidRuntimeException:Calling startActivity() from outside of an Activity context requires the FLAG_ACTIVITY_NEW_TASK flag)。 -
FLAG_ACTIVITY_CLEAR_TOP: 用于实现业务上的软着陆清理操作,当它配合通常的singleTop(即addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP)混用时),它的目的是:找回当前链条里那个节点实例,并暴力掐断它后续的发展分支退回本身节点,通过onNewIntent的更新完成重逢,而不是直接摧毁一切重建(如果不加SINGLE_TOP配合,只通过CLEAR_TOP默认是连本身这个节点也会杀死然后重建压入新的实例化 Record,这是一个极少人察觉的危险隐式细节!)。
5. 危险的工程失效边界模型:不可碰触的回退陷阱
作为具有工业深度的极客开发者,更应该洞悉这种层级架构设计在跨系统环境可能制造的所有回源死链,避免在开发中自造“按返回键凭空跳过了上一个 App”之类的神鬼乱象崩毁体验。
【失效边界一】: 回退路径穿刺导致的进程伪失联
当你在 A App 内拉起了 B App 的带有 FLAG_ACTIVITY_NEW_TASK 标志的活动页面时(常见于第三方应用拉起浏览器或者支付宝页面),ATMS 会计算到 B 的 taskAffinity 并不与 A 雷同。于是在 Recents(最近任务)和底层容器里,ATMS 直接剥离形成两个完全独立的容器。
但这并不意味着用户感受就真的是彼此独立,系统有一个隐藏的行为:ActivityStack 栈的切换连带。为了平滑的体验,当后起的 B B-Task 退栈空了后会自动 resume 前一个失焦 A A-Task 的前台渲染显示,但这只在正常的退栈流程中。一旦在此刻的 B-Task 中再次触发某种非常规的清理或者由于内存极剧压抑导致 B 的宿主服务强行重试,A 的栈很可能在焦点层跳跃的时候没有及时唤回前台成为 mResumedActivity 被挂起甚至休眠。这就是所谓第三方 SDK 支付弹窗没返回之前直接切后台掉线等灵异事件的发生来源。
【失效边界二】: 后台自启拦截(Android 10+ Background Start Restrictions)
在理解了 ActivityRecord 的前置控制逻辑后,我们应当知道启动并非系统对你的无条件服务。特别在 ATMS 中,为了克制无脑驻流的服务弹流氓诱导广告覆盖,Android 10 起底层 ActivityStarter 入口点新增了硬性的系统状态校验:即无论你的组件如何要求跳转,如果在底层校验调用进程内 UidRecord 并没有挂载正前台或者可见状态,直接打回或转录进 Notification(除非申请特殊悬浮权限 SYSTEM_ALERT_WINDOW 或正向由物理用户的按键/通知栏发动的 PendingIntent 指令触发)。
【失效边界三】: taskAffinity 伪造与 StrandHogg 任务劫持防线
很多极客会思考一个危险的漏洞:既然系统是靠匹配 taskAffinity 字符串来找 Target Task 的,那如果我写一个恶意 App,在 Manifest 里把我的 Activity 的 taskAffinity 强行声明为 com.android.settings(系统设置)或者 com.xxx.bank(某银行应用),然后由我在后台触发它,它是不是就会神不知鬼不觉地被注入到真实系统应用的栈顶?
答案是:在早期 Android 版本中,是的!这就是曾经震动安全界的 StrandHogg(任务栈劫持)漏洞。
黑客通过这种身份字符串伪造,将自己的钓鱼 Activity 挂载到知名 App 的 Task 顶部。当用户从桌面点击真实的银行 App 图标时,底层的 ATMS 因为发现该 Task 已经存在,于是直接把它切到前台——结果用户看到的却是黑客提前压在面上的钓鱼输入框(基于 Task 的天然信任,用户防不胜防)。
为此,从 Android 11 (API 30) 开始,ATMS 引入了极其严格的跨 Uid(跨进程所有者)防护校验。现代 Android 在执行 taskAffinity 的字符串 Equals 匹配时,会强制进行底层的 UidRecord 鉴权。如果发起挂载的 Activity 没有与目标 Task 拥有相同的签名或共享 Uid(android:sharedUserId),ATMS 会冷酷地拒绝这次“鸠占鹊巢”的入侵行为,强行阻断跨应用的黑盒挂载,彻底粉碎了利用 taskAffinity 伪造身份进行栈劫持的攻击路径。
被拒绝之后它去哪了? 既然被目标栈拒之门外,ATMS 在源码里会直接将该匹配判定为**“未找到可复用 Task(Task Match Failed)”**。后续的归宿将取决于你的启动参数:
- 新建栈隔离:如果你的启动携带了
FLAG_ACTIVITY_NEW_TASK(或配置了singleTask/singleInstance),既然无法并入目标,ATMS 就会启动备用方案——强行在系统里新建一棵完全属于你这个恶意 App Uid 控制下的 Task 树。虽然在内存中它携带的 Affinity 字符串仍是系统的名字,但在物理实体和多任务列表(Recents 卡片)上,它跟你觊觎的受害者容器彻底平铺、老死不相往来,根本无法完成重叠遮盖攻击。 - 禁锢在原地:如果是普通启动(没带 New Task 标志符,仅仅指望
allowTaskReparenting属性投机取巧),它甚至连新建的一棵树都混不到,判定失败后会自动回退,毫无悬念地被按压在你恶意 App 自己当前的调用者任务栈里。
6. allowTaskReparenting:跨越容器树的动态“认祖归宗”机制
既然我们深入到了 taskAffinity 的边界,就绝对绕不开 AndroidManifest 里一个最鬼魅的开关:android:allowTaskReparenting="true"。
如果说 launchMode 是发车时的交规,那么 allowTaskReparenting 就是运行过程中的时空跃迁漏洞——它允许一个存活着的 ActivityRecord 实例,在生命周期运转的中途,被 ATMS 从一棵 Task 树上强行连根拔起,移植嫁接到另一棵 Task 树上。
How: 它是怎么被触发的?
假设 A 应用中的页面配置了 allowTaskReparenting="true",并带有自己的 taskAffinity="com.A.app"。它的“跃迁”机制遵循以下物理规律:
<!-- A 应用(例如浏览器)的 AndroidManifest.xml -->
<application
android:taskAffinity="com.a.app"
... >
<!-- 普通首页 -->
<activity
android:name=".HomeActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- 网页浏览页:声明可认祖归宗 -->
<activity
android:name=".WebViewActivity"
android:taskAffinity="com.a.app"
android:allowTaskReparenting="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="https" />
</intent-filter>
</activity>
</application>
B 应用(知乎)拉起这个页面时,不需要任何特殊 Flag——普通隐式 startActivity 即可触发后续的认祖归宗逻辑:
// 知乎内部点击外链,普通隐式 startActivity,不加 NEW_TASK
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com/article"))
startActivity(intent)
// A.WebViewActivity 被压入知乎的 Task 栈顶,此时按返回会回到知乎
// 但用户切出后再主动点开 A 的图标,该页面就会"回家"挂到 A 的栈顶
- 寄人篱下(初始态):B 应用在自己的 Task 里,未加任何 Flags 地隐式拉起了 A 应用的这个页面。因为没带
NEW_TASK,A 页面被作为普通的子节点,寄生在了 B 的 Task 树的顶端。此时用户按返回键,A 出栈,B 露出来。这很符合用户的连贯认知。 - 认祖归宗(跃迁态):用户不按返回,而是按 Home 键回到桌面,随后在桌面点击了 A 应用的启动图标。
- ATMS 移花接木(源码级):当系统准备把 A 的主 Task 从后台拉起至前台时,ATMS 会触发一次全局扫描,去寻找所有声明了
allowTaskReparenting并且affinity匹配为com.A.app的孤立节点。 它发现 B 栈顶那个 A 页面正符合条件!出于“亲属召唤”,底层的WindowContainer会执行物理重定向——直接把该ActivityRecord的父指针从 B-Task 强制抹去,重新 addChild 到 A-Task 的树顶。 当屏幕重新亮起,用户看到的仍是刚才被 B 拉起的那个页面,只不过你再按返回键,底下退回的不再是 B 应用,而是 A 应用的首页了。
Why this way? 底层为什么要设计这么一套偷梁换柱的行为?
这套机制的核心是对组件服务化体验的妥协与高级封装。
最具代表性的业务投影:浏览器核心页面。 试想,你在使用“知乎(B App)”时点击了一条外链,知乎通过 Intent 拉起了你的“Chrome 系统浏览器(A App)”的网页浏览 Activity。 为了体验的沉浸感,这个网页默认是盖在知乎的栈顶的(用户觉得我还在刷知乎)。 但如果用户此时按 Home 键切出去,主动点开了 Chrome 图标。系统如果没有 Reparent 机制,Chrome 只能新开一个空白主页,而那个你刚才从知乎打开的、可能正看到了高潮部分的文章页面,被永远锁死、遗忘在了知乎底层的隐形任务栈中!
为了彻底缝合**“跨应用借用容器”和“组件本源归属”**之间的体验撕裂感,Google 在极早的底层就植入了这套动态移栈方案: 当你通过快捷方式唤醒容器主权时(点开 Chrome),之前所有飘在外头“打工”的组件,自动回防母巢本阵。
这也是为何到了现代安全框架里(正如前文失效边界三所述),这种允许越权合并的“认祖归宗”能力,被严格圈禁在了共享底层 Uid 或被明确信赖的调用源体系下,防止其沦为流氓劫持用户的木马屠城计。
一切表面的繁杂跳转参数(Mode, Affinity, Reparenting, Intent Flags),在剥开表板抵达其底层的结构编排、效率与容器污染安全的折中计算框架时,这套体系就能顺着源码控制回路直接在脑内自行发端。这,才是不写 Bug 的根本内功。