深入 Android Task Stack 与 Activity 启动模式全链路

一个导航 Bug 引出的问题

去年做电商 App 时遇到一个诡异的问题:用户从推送通知点进商品详情页,按返回键后直接退回了桌面,而不是回到首页。产品经理在群里@了我三次。

排查后发现,通知点击跳转时用了一个不带 FLAG_ACTIVITY_NEW_TASK 的 Intent,导致详情页被塞进了通知所在的系统任务栈。用户退出后,App 自己的任务栈还在后台躺着,但用户压根回不去。

本质上就是任务栈管理出了岔子。这篇文章把 Activity 的启动模式和 Task 管理机制从头过一遍。

Task 与返回栈的核心模型

Android 用 Task 组织一组 Activity,每个 Task 内部维护一个回退栈(Back Stack)。用户按返回键时,系统从当前 Task 的栈顶弹出 Activity。Task 之间可以相互切换,但各自保持独立的栈状态。

Task 是 Activity 的容器,不是进程。 一个进程可以持有多个 Task,一个 Task 内的 Activity 也可以来自不同进程。

系统管理 Task 靠三个要素:

  • launchMode:决定 Activity 实例的复用策略
  • Intent Flag:在每次启动时动态覆盖默认行为
  • taskAffinity:指定 Activity 归属哪个 Task

三者叠加,最终决定了”点一个按钮到底会打开哪个页面、落在哪个栈里”。

launchMode 四种模式的行为差异

standard

默认模式。每次启动都创建新实例,放入调用者的 Task。调用者和被调用者必须在同一个 Task 内,这也是绝大部分场景的默认行为。

// A 在 Task1,B 用 standard 启动
// 结果:B 的新实例创建在 Task1,栈:A → B
startActivity(Intent(this, BActivity::class.java))

singleTop

如果目标 Activity 已在当前 Task 栈顶,则复用该实例,回调 onNewIntent()。不在栈顶则创建新实例。

适合搜索页、详情页这类”连续点击不会堆叠”的场景。

// 搜索页连续搜索,栈不会变成 Search → Search → Search
class SearchActivity : AppCompatActivity() {
    override fun onNewIntent(intent: Intent?) {
        super.onNewIntent(intent)
        // 用新 query 刷新搜索结果,不创建新页面
    }
}

singleTask

系统中只保留该 Activity 的一个实例。启动时,如果目标 Task 中已有实例,系统会把它之上的所有 Activity 清掉(clearTop),然后回调 onNewIntent()

singleTask 和 singleTop 的关键区别就在这里:它不只是复用,还会破坏性地清理栈。从子页面跳回首页时,中间所有页面都会被干掉——这不是 Bug,是 clearTop 的语义。

<activity
    android:name=".MainActivity"
    android:launchMode="singleTask" />

singleInstance

singleTask 的加强版。该 Activity 独占一个 Task,且这个 Task 不能再容纳其他 Activity。

实际项目中我用得很少。适合视频通话浮窗、独立设置页这种需要完全隔离的场景。代价是跳转到其他页面时需要跨 Task,动画和返回逻辑处理起来比较折腾。

Intent Flag:运行时动态控制

launchMode 在 Manifest 中静态声明,Intent Flag 在每次 startActivity() 时动态调整。两者冲突时,Flag 优先级更高

几个高频 Flag:

Flag行为
FLAG_ACTIVITY_NEW_TASK在新 Task 中启动 Activity
FLAG_ACTIVITY_CLEAR_TOP清掉目标之上的所有 Activity
FLAG_ACTIVITY_SINGLE_TOP同 singleTop 行为
FLAG_ACTIVITY_CLEAR_TASK启动前清空目标 Task(需配合 NEW_TASK)
// 推送通知跳转:清空目标 Task,避免栈残留
val intent = Intent(context, MainActivity::class.java).apply {
    flags = Intent.FLAG_ACTIVITY_NEW_TASK or 
            Intent.FLAG_ACTIVITY_CLEAR_TASK
}
// 然后跳转到详情页
intent.putExtra("target", "detail")
intent.putExtra("id", productId)

CLEAR_TASK 配合 NEW_TASK 的效果:把目标 Activity 所在的 Task 整个清空,让目标 Activity 成为新的根。通知跳转、Deep Link 入口这些场景下基本是必用组合。

taskAffinity:被低估的栈分离工具

taskAffinity 默认值是应用的包名。当 Activity 的 taskAffinity 与调用者不同,且配合 FLAG_ACTIVITY_NEW_TASK 时,系统会在单独的 Task 中启动它。

<activity
    android:name=".WebViewActivity"
    android:taskAffinity=".webview"
    android:launchMode="singleTask" />

这样一来,WebView 页面在最近任务列表中显示为独立卡片,用户可以从后台单独切回来,不跟主 App 的导航栈混在一起。我在项目里用这招处理过第三方登录和支付页面的隔离——用户付完款切回 App,看到的是独立的任务卡片,体验上干净很多。

三个实战场景

推送通知的”回家”跳转

问题:用户从通知进入任意页面后,按返回键应该回到首页而非退出 App。

方案:

fun buildNotificationIntent(context: Context, targetPage: String, id: String): Intent {
    // 先启动首页,清空旧栈
    val intent = Intent(context, MainActivity::class.java).apply {
        flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
        putExtra("navigateTo", targetPage)
        putExtra("itemId", id)
    }
    return intent
}

MainActivity 在 onCreateonNewIntent 中解析 navigateTo 参数,自动跳转到对应页面。返回栈变为 MainActivity → DetailActivity,用户按返回键能回到首页。

主页复用与 singleTask 的坑

首页设为 singleTask 后,从子页面调用 startActivity(MainActivity::class.java) 会清掉 MainActivity 之上的所有页面——包括子页面自己。

如果你只想回到首页但不销毁中间页面,应该用:

val intent = Intent(this, MainActivity::class.java).apply {
    flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
}

REORDER_TO_FRONT 把 MainActivity 提到栈顶,但不清除栈中其他 Activity。这个 Flag 知道的人不多,但处理主页返回时比 singleTask 更温和。

singleInstance 与 onActivityResult 的冲突

singleInstance Activity 启动另一个 Activity 时,新 Activity 会被放到另一个 Task 中。这导致 startActivityForResult 的回调异常——Android 在跨 Task 启动时不会传递 result。

踩过的坑:在 singleInstance 的登录页中用 startActivityForResult 拉起第三方授权,回调永远收不到。改成 ActivityResultLauncher 配合 NEW_TASK 标记才解决。

几个容易踩的坑

坑一:singleTask 触发的是 onNewIntent 而非 onCreateonCreate 里写的初始化逻辑可能不会执行,需要在 onNewIntent 中同步处理一遍。我的习惯是把页面初始化抽成一个 handleIntent(intent) 方法,两个生命周期都调它。

坑二:FLAG_ACTIVITY_NEW_TASK 不一定真的创建新 Task。 如果目标 Activity 的 taskAffinity 和当前 Task 一致,系统会忽略这个 Flag,仍然放入当前 Task。这个行为并不反直觉——同一个 affinity 的 Activity 本就应该在同一个 Task 里。

坑三:singleInstance + clearTop 组合在部分 ROM 上行为不一致。 某些厂商修改了 Task 管理逻辑,建议用 finishAffinity() 手动清理栈后再启动,比依赖厂商行为可靠得多。

实际开发中我更倾向于用 Intent Flag 而非 Manifest launchMode 来控制行为。Flag 按场景配置,灵活且不易产生全局副作用。Manifest 中的 launchMode 我只用在极少数确有全局复用需求的 Activity 上——比如首页和 WebView 容器页。