Activity 启动模式与任务栈-《Android深水区(三)》

0 阅读18分钟

Activity 启动模式与任务栈

前言

Activity 启动模式最常见的误区,是把它背成四个词:standardsingleTopsingleTasksingleInstance。但真实项目里的问题通常不是“某个模式定义是什么”,而是下面这些现象:

  • 点击通知后打开了两个首页,返回路径变得混乱。
  • Deep Link 命中了已经存在的页面,但页面数据没有刷新。
  • 登录页、首页、扫码页用了 singleTask,结果任务栈被清掉,用户返回不到预期页面。
  • 从非 Activity 场景启动页面时漏了 FLAG_ACTIVITY_NEW_TASK
  • Android 12 以后在根启动页按返回,表现和旧系统不一致。
  • 多窗口、最近任务、文档模式让“一个 App 一个任务栈”的假设失效。

本文的核心目标不是鼓励你到处配置启动模式,而是建立一个可调试的模型:Android 启动 Activity 时,系统要先决定“复用哪个实例、放进哪个 Task、是否清理上方页面、是否回调 onNewIntent()”。理解这个决策链,才能在通知、Deep Link、第三方 SDK、登录态重置和跨 App 跳转中做出稳定的导航设计。

本文基于 Android Developers 官方文档、Android 16 / API 36 文档以及 AOSP main 分支源码复查。不同厂商系统和具体 Android 分支可能有定制,涉及任务栈边界时建议用 adb shell dumpsys activity activities 做实机验证。

前置知识

建议先掌握:

  • A020:Activity 生命周期完整解析。
  • IntentActivityAndroidManifest.xml 的基本用法。
  • onCreate()onResume()onDestroy()onNewIntent() 的区别。
  • Jetpack Navigation 或自研路由的基本返回栈概念。

A020 关注单个 Activity 实例如何创建、暂停、停止和销毁;本文关注多个 Activity 实例如何被组织到 Task 与 Back Stack 中。下一篇 A022 会讨论 Fragment 生命周期与状态恢复。

核心概念

Task 与 Back Stack

官方文档把 Task 定义为用户完成某件事情时交互的一组 Activity;这些 Activity 按打开顺序形成 Back Stack。默认情况下,新 Activity 会压入当前任务栈顶部,用户按返回时,栈顶 Activity 被结束,下面的 Activity 恢复。

可以把默认行为理解成:

A -> start B -> start C

Task 1:
bottom [A, B, C] top

Back:
C finish, B resume
Back:
B finish, A resume

重点有三个:

  • Activity 实例可以重复出现。默认 standard 模式下,多次启动同一个 Activity 会产生多个实例。
  • Task 可以进入后台并保留栈。用户回到最近任务或启动器入口时,通常会恢复栈顶 Activity。
  • 多窗口环境下,系统会按窗口管理任务或任务组,不能简单假设全局只有一个当前任务。

launchMode 与 Intent flags

控制启动行为有两类入口:

类型配置位置代表能力特点
Manifest launchMode<activity android:launchMode="...">声明目标 Activity 的复用规则稳定、全局生效,适合入口页或特殊容器
Intent flagsintent.addFlags(...)调用方表达本次启动诉求更灵活,适合通知、Deep Link、跨任务启动

官方文档强调:Manifest 和 Intent flags 都能影响启动模式;两者不是完全一一对应。实际源码里还会根据 documentLaunchModetaskAffinity、调用方是否有 Activity、是否请求 result、是否多窗口等条件修正 flags。

五种 launchMode

截至 Android 16 / API 36 文档,android:launchMode 支持五种主要取值:

launchMode实例策略Task 策略典型场景风险
standard每次创建新实例默认进入调用方所在 Task普通页面重复打开时栈变深
singleTop栈顶已是目标实例则复用仍在当前 Task通知刷新当前页、搜索页目标不在栈顶时仍会新建
singleTask查找可复用实例,存在则回调 onNewIntent()通常作为任务根或复用同 affinity 任务启动器入口、浏览器式入口会清理目标上方 Activity
singleInstance全局独占实例所在 Task 只容纳它自己极少数强隔离入口返回路径和多任务体验容易异常
singleInstancePerTask每个 Task 最多一个实例可配合 FLAG_ACTIVITY_MULTIPLE_TASK 或文档模式生成多个任务文档式、多实例入口行为复杂,需要实测最近任务

大多数业务页面应保持 standardsingleTasksingleInstance 不是“防重复点击”的通用解法,它们改变的是任务栈语义,而不是按钮节流。

onNewIntent 不是 onCreate 的替代品

当系统复用已有 Activity 实例时,通常不会重新走 onCreate(),而是调用 onNewIntent()。AOSP Activity.java 的 API 注释也提醒:getIntent() 默认仍返回最初启动 Activity 的 Intent;如果后续逻辑依赖最新 Intent,应在 onNewIntent() 中调用 setIntent(intent)

class ProductActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        renderFromIntent(intent)
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)
        renderFromIntent(intent)
    }

    private fun renderFromIntent(intent: Intent) {
        val productId = intent.getStringExtra(EXTRA_PRODUCT_ID) ?: return
        // 将 productId 交给 ViewModel,而不是直接在 Activity 中堆业务逻辑。
    }

    companion object {
        const val EXTRA_PRODUCT_ID = "product_id"
    }
}

如果页面是 Compose + ViewModel 架构,推荐把 Intent 解析成稳定事件,再交给 ViewModel 更新 UI State;不要在 onNewIntent() 里直接重建整棵 UI。

整体架构

启动模式的决策跨越 App 进程和 system_server。调用方发起 startActivity(),Framework 通过 Binder 进入 ActivityTaskManagerService 体系,最终由 WindowManager/Task 相关类决定目标 Activity 放在哪里。

flowchart TD
  Caller[&#34;调用方 Activity/Context&#34;]
  Intent[&#34;Intent + flags&#34;]
  ATMS[&#34;system_server: ActivityTaskManager&#34;]
  Starter[&#34;ActivityStarter&#34;]
  Record[&#34;ActivityRecord&#34;]
  Task[&#34;Task / Back Stack&#34;]
  Existing[&#34;复用已有实例&#34;]
  NewOne[&#34;创建新实例&#34;]
  Callback[&#34;app main thread: onNewIntent/onCreate&#34;]

  Caller --> Intent
  Intent --> ATMS
  ATMS --> Starter
  Starter --> Record
  Starter --> Task
  Task --> Existing
  Task --> NewOne
  Existing --> Callback
  NewOne --> Callback

这张图要关注两个边界:

  • 任务栈决策主要发生在 system_server,不是 Activity 自己决定。
  • onCreate()onNewIntent() 最终回到目标 App 进程主线程执行,所以回调里仍要避免阻塞。

工作流程

默认 standard:直接压栈

默认 standard 最符合栈模型:无论目标 Activity 是否已经存在,只要没有特殊 flags,系统都会创建新实例并压入当前 Task。

初始:
Task: A -> B

B start C:
Task: A -> B -> C

C start B:
Task: A -> B -> C -> B(new)

这种模式适合绝大多数内容页、编辑页和流程页,因为用户的返回路径和打开路径一致。

singleTop:只复用栈顶

singleTop 的判断条件很窄:目标 Activity 必须已经位于当前栈顶。否则它和 standard 一样会新建实例。

场景 1:
Task: A -> Search
start Search
结果: A -> Search(onNewIntent)

场景 2:
Task: A -> Search -> Detail
start Search
结果: A -> Search -> Detail -> Search(new)

它适合“当前页接收新参数刷新”的场景,例如搜索页再次收到关键词、通知点击刷新当前会话页。它不适合处理“栈中任意位置只保留一个实例”的需求。

singleTask:复用任务中的根或已有实例,并清理上方页面

singleTask 的典型效果是:如果系统找到可复用实例,会把它所在任务带到前台,并把目标 Activity 上方的 Activity 清掉,然后通过 onNewIntent() 分发新 Intent。

初始:
Task: Home(singleTask) -> Detail -> Settings

start Home:
Task: Home(onNewIntent)
Detail/Settings finish

这个特性常用于启动器入口、某些 Deep Link 分发页或浏览器式入口。但它的副作用很强:目标上方页面会被结束,用户可能丢失未提交状态。不要因为“首页只要一个实例”就盲目把首页改成 singleTask;更常见的现代做法是在单 Activity + Navigation 栈内做 popUpTo 或状态路由。

singleInstance 与 singleInstancePerTask:极少数场景再用

singleInstance 会让目标 Activity 成为所在 Task 的唯一成员;它启动的其他 Activity 会进入别的 Task。这个模式会明显改变最近任务和返回体验,普通业务页几乎不应使用。

singleInstancePerTask 是更细粒度的单任务单实例模式。它允许在不同 Task 中存在多个目标实例,尤其可能和 FLAG_ACTIVITY_MULTIPLE_TASKFLAG_ACTIVITY_NEW_DOCUMENT 等组合出现。它适合文档式、多实例入口,但调试成本高,必须结合最近任务、多窗口和 Deep Link 做完整测试。

Intent flags 的常见组合

flags行为常见用途注意点
FLAG_ACTIVITY_SINGLE_TOP栈顶是目标时复用通知刷新当前页不处理非栈顶实例
FLAG_ACTIVITY_NEW_TASK在新 Task 中启动或把已有匹配 Task 带前台非 Activity Context、通知、跨 App不能和 startActivityForResult 语义混用
FLAG_ACTIVITY_CLEAR_TOP如果目标已在当前 Task,清掉它上方页面回到首页、登录态失效后回根页目标可能被重建或走 onNewIntent(),取决于模式和 flags
FLAG_ACTIVITY_CLEAR_TASK + NEW_TASK清空目标 Task 后以新 Activity 为根退出登录后重建入口栈破坏性强,只用于明确重置流程
FLAG_ACTIVITY_MULTIPLE_TASK跳过复用匹配 Task,强制新 Task启动器或文档式多实例官方注释建议普通应用谨慎使用
FLAG_ACTIVITY_NEW_DOCUMENT创建或复用文档任务文档、文件、浏览上下文与 Manifest 文档模式、最近任务策略相关

一个实用原则:如果你只是想在 App 内部清理返回栈,优先使用 Navigation 或显式结束流程;如果你要跨通知、桌面图标、外部 Deep Link 影响系统任务,才考虑 Activity flags。

API 使用

1. Manifest 中声明启动模式

<activity
    android:name=".MainActivity"
    android:exported="true"
    android:launchMode="singleTop">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

启动器入口一般不需要随意改成 singleTask。如果只是希望重复点击桌面图标时复用当前顶部首页,singleTop 往往更温和;如果你的入口需要处理外部 Deep Link,也要确保 onNewIntent() 能正确更新路由。

2. 通知点击打开已有会话页

fun chatPendingIntent(context: Context, conversationId: String): PendingIntent {
    val intent = Intent(context, ChatActivity::class.java)
        .putExtra(ChatActivity.EXTRA_CONVERSATION_ID, conversationId)
        .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)

    return PendingIntent.getActivity(
        context,
        conversationId.hashCode(),
        intent,
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
    )
}

如果 ChatActivity 正在栈顶,这次点击会走 onNewIntent();如果用户当前在其他页面,系统仍可能创建新的 ChatActivity。如果产品要求“无论在哪里都回到唯一会话页”,就不该只靠 singleTop,而要设计明确的根入口和内部导航策略。

3. 退出登录后重建任务

fun restartToLogin(context: Context) {
    val intent = Intent(context, LoginActivity::class.java)
        .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
    context.startActivity(intent)
}

这个组合会清空关联 Task,再以登录页作为新根页面。它适合“登录态彻底失效,历史页面不能再返回”的安全场景;不适合普通页面跳转。

4. Deep Link 入口只做分发

class DeepLinkActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        dispatch(intent)
        finish()
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)
        dispatch(intent)
        finish()
    }

    private fun dispatch(intent: Intent) {
        val target = parseTarget(intent.data)
        startActivity(MainActivity.createIntent(this, target))
    }
}

分发页应尽量无 UI、无重业务、可重复处理 Intent。真正的页面状态由主 Activity 或业务导航栈接管。

源码分析

本文复查的主要源码路径是 AOSP main 分支:

  • frameworks/base/services/core/java/com/android/server/wm/ActivityStarter.java
  • frameworks/base/services/core/java/com/android/server/wm/ActivityRecord.java
  • frameworks/base/core/java/android/content/Intent.java
  • frameworks/base/core/java/android/app/Activity.java
  • frameworks/base/core/java/android/content/pm/ActivityInfo.java

启动决策主链路

在当前 AOSP ActivityStarter 中,启动请求大致经过:

executeRequest()
  -> startActivityUnchecked()
    -> startActivityInner()
      -> computeTargetTask()
      -> deliverToCurrentTopIfNeeded()
      -> setTargetRootTaskIfNeeded()
      -> resume or deliver new intent

这不是完整调用栈,而是与启动模式最相关的决策主线。

executeRequest() 负责前置校验、权限、调用方和参数整理。startActivityUnchecked()startActivityInner() 进入任务栈决策。源码里会读取 ActivityRecord.launchMode,并结合 Intent flags 得到最终启动策略。

singleTop 的源码判断

ActivityStarter.deliverToCurrentTopIfNeeded() 会检查当前顶部 Activity 是否与目标组件一致、用户一致、进程已附着,并且满足 FLAG_ACTIVITY_SINGLE_TOPLAUNCH_SINGLE_TOP。满足时,不创建新 Activity,而是把 Intent 交给已有实例。

这解释了为什么 singleTop 只对栈顶有效:源码判断的目标就是 top running Activity,而不是全栈搜索。

singleTask / CLEAR_TOP 的清栈行为

ActivityStarter 在处理 FLAG_ACTIVITY_CLEAR_TOP、文档复用、LAUNCH_SINGLE_TASKLAUNCH_SINGLE_INSTANCELAUNCH_SINGLE_INSTANCE_PER_TASK 时,会进入“找到目标并清理其上方 Activity”的路径。官方文档也明确说明,singleTasksingleInstancePerTask 会移除目标 Activity 上方的 Activity,并把新 Intent 交给已有实例。

工程上最重要的结论是:singleTask 的副作用不是“少创建一个对象”,而是“改变返回栈结构”。它可能触发上方页面 onPause()onStop()onDestroy(),未保存草稿、未完成支付确认、临时选择状态都可能丢失。

NEW_TASK 与 taskAffinity

ActivityRecord 保存了 taskAffinitylaunchMode 等来自 ActivityInfo 的信息。ActivityStarterFLAG_ACTIVITY_NEW_TASKsingleTasksingleInstance 等情况下,会尝试把 Activity 放入合适的已有 Task,或创建新 Task。

这也是 taskAffinity 容易引发迷惑的原因:它不是“包名分组”这么简单,而是参与系统选择目标 Task。除非你明确在做跨应用任务归属、文档模式或特殊入口,不建议随意改默认 affinity。

Activity.onNewIntent 的细节

Activity.javaonNewIntent() 的注释强调两点:

  • 复用已有实例时,新的 Intent 会分发到 onNewIntent()
  • getIntent() 仍可能返回最初的 Intent,如需后续使用新 Intent,应调用 setIntent(intent)

这解释了很多 Deep Link Bug:页面确实收到了新链接,但业务代码在 onResume() 里读取 getIntent(),拿到的却是旧参数。

Android 12+ 根启动页返回行为

官方任务栈文档说明:Android 12 及以上,用户在根 launcher Activity 按返回时,系统默认将 Activity 及其 Task 移到后台,而不是像 Android 11 及以下那样直接 finish。这个变化会影响“按返回退出 App”的历史实现。

如果你为了兼容旧逻辑在根 Activity 里覆写 onBackPressed() 并直接 finish(),可能会破坏 Android 12+ 预期体验。现代代码应使用 AndroidX Activity 的返回处理 API,并在没有拦截者时交给系统默认行为。

实战案例

案例一:通知点击导致重复首页

问题:通知 PendingIntent 打开 MainActivity,用户已经在 App 内某个二级页,点击通知后出现第二个首页,返回路径变成 Home -> Detail -> Home

推荐排查:

  • 检查 PendingIntent 是否每条通知 requestCode 相同导致参数覆盖。
  • 检查 MainActivity 是否真的需要系统级 singleTask
  • 如果是单 Activity 架构,优先让 MainActivity 接收 Intent 后交给 Navigation 处理目标页面。
  • 对重复点击通知,确保 onNewIntent() 能更新导航事件。

常见方案:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        handleRouteIntent(intent)
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)
        handleRouteIntent(intent)
    }

    private fun handleRouteIntent(intent: Intent) {
        val route = intent.getStringExtra(EXTRA_ROUTE) ?: return
        // 将 route 发送给导航层;避免在这里直接 new Fragment 或重建 Activity。
    }
}

案例二:退出登录清空历史页面

退出登录、账号被踢、Token 被服务端撤销时,历史页面不应该再通过返回键访问。此时使用 NEW_TASK | CLEAR_TASK 是合理的,因为需求本身就是重建任务根。

但如果只是“从详情页回首页”,不要用这个组合;它会让任务历史消失,用户也无法通过系统返回恢复原路径。

案例三:扫码页是否应该 singleTask

扫码页经常被误设成 singleTask,理由是“相机页面不能开多个”。更稳妥的做法通常是:

  • UI 层禁用重复点击或用互斥状态防止重复启动。
  • 相机资源绑定 onResume() / onPause() 或生命周期感知组件。
  • 扫码结果通过明确的返回协议传递。

singleTask 不能替代资源互斥管理。它只是在系统任务栈层面改变 Activity 复用与清栈行为。

性能优化

启动模式本身不是性能优化工具。错误使用启动模式反而会制造额外成本:

  • singleTask / CLEAR_TOP 可能批量 finish 上方页面,触发多个生命周期回调和状态保存。
  • onNewIntent() 中做重解析、同步 IO 或复杂路由,会阻塞主线程首帧。
  • CLEAR_TASK 会重建任务根,导致缓存页面全部失效。
  • 多任务和文档模式会增加最近任务条目,影响用户恢复路径。

建议:

  • 用启动模式解决任务归属问题,用防抖解决重复点击,用导航栈 API 解决 App 内返回路径。
  • onNewIntent() 只做轻量解析和事件分发,耗时工作交给 ViewModel 与协程。
  • 对 Deep Link、通知和桌面入口写自动化或手工回归用例,覆盖冷启动、热启动、后台恢复、进程死亡后恢复。
  • adb shell dumpsys activity activitiesadb shell am start -W ...、Logcat 生命周期日志验证真实栈结构。

示例调试命令:

adb shell am start -W \
  -n com.example.app/.MainActivity \
  -a android.intent.action.VIEW \
  -d "example://product/42"

adb shell dumpsys activity activities | sed -n '/ACTIVITY MANAGER ACTIVITIES/,/Run #0/p'

常见问题

1. singleTop 为什么有时不回调 onNewIntent()

因为目标 Activity 必须已经位于栈顶。只要目标不在顶部,singleTop 仍会创建新实例。还要确认启动的是同一个组件、同一个用户空间,并且没有被其他 flags 或任务边界改变。

2. singleTask 是否保证全 App 只有一个实例?

不要这样表述。官方语义与源码实现都和 Task、affinity、document/multiple task 条件有关。一般情况下它会复用已有任务中的实例并清理上方页面,但多窗口、文档模式、singleInstancePerTask 等场景会让“唯一”的直觉失效。

3. onNewIntent() 里必须调用 setIntent() 吗?

如果后续代码会通过 getIntent() 读取最新参数,就应该调用。否则 getIntent() 可能仍是初始 Intent。更好的方式是把 Intent 解析成业务事件,交给状态层处理。

4. 通知打开页面应该用哪个模式?

没有固定答案。普通内容页可以保持 standard;如果当前栈顶就是目标页并希望刷新,可用 FLAG_ACTIVITY_SINGLE_TOPsingleTop;如果通知必须回到统一主入口,再由内部导航分发,通常让 MainActivity 处理 Intent 更可控。

5. App 根页面按返回为什么 Android 12 以后不像以前那样 finish?

Android 12 及以上,根 launcher Activity 按返回默认更接近 Home 行为:把任务移到后台。这样用户再次进入时更可能从 warm state 恢复。不要为了复刻旧行为随意 finish() 根页面。

6. Jetpack Navigation 还需要 launchMode 吗?

需要,但少很多。Navigation 解决的是 App 内目的地栈;Activity launchMode 解决的是系统 Task 与 Activity 实例复用。单 Activity 架构下,更多问题应在 Navigation 层处理,Activity 启动模式只负责入口语义。

面试考点

基础题

  1. Task 和 Back Stack 的区别是什么?
  2. standardsingleTop 的差异是什么?
  3. 为什么 singleTop 不在栈顶时仍会创建新实例?
  4. onNewIntent()onCreate() 分别在什么情况下调用?
  5. FLAG_ACTIVITY_NEW_TASK 常见使用场景是什么?

进阶题

  1. singleTask 为什么会清理目标 Activity 上方页面?
  2. FLAG_ACTIVITY_CLEAR_TOPsingleTask 有什么相似和差异?
  3. Manifest launchMode 和 Intent flags 同时存在时应该如何分析?
  4. taskAffinity 如何影响目标 Task 选择?
  5. Android 12+ 根 launcher Activity 的返回行为有什么变化?

源码题

  1. ActivityStarter 在启动 Activity 时大致经历哪些方法?
  2. deliverToCurrentTopIfNeeded() 为什么能解释 singleTop 行为?
  3. ActivityRecord 保存了哪些与启动模式相关的信息?
  4. Activity.onNewIntent() 为什么建议配合 setIntent()
  5. 如何用 dumpsys activity 验证任务栈?

设计题

  1. 设计通知点击进入聊天页,如何避免重复页面和参数覆盖?
  2. 登录过期后如何清空历史页面,同时保证用户体验可解释?
  3. Deep Link 冷启动、热启动、后台恢复分别如何处理?
  4. 单 Activity 架构下,哪些问题应该交给 Navigation,而不是 launchMode?
  5. 第三方 SDK 要求入口页 singleTask,你如何评估副作用?

总结

Activity 启动模式可以用一个决策模型概括:

  • 默认 standard:新实例压栈,返回路径最直观。
  • singleTop:只复用栈顶,适合当前页接收新 Intent。
  • singleTask:复用已有任务实例并清理上方页面,适合少量入口,不适合普通防重复。
  • singleInstance / singleInstancePerTask:改变任务隔离语义,只在明确多任务或强隔离场景使用。
  • Intent flags 表达本次启动诉求,但最终行为还会受 launchMode、taskAffinity、document 模式、多窗口和系统版本影响。

工程实践中,先设计用户返回路径,再选择启动模式。App 内导航优先交给 Navigation 或自研路由;系统任务入口才使用 Activity launchMode 和 Intent flags。遇到疑难问题,不要靠猜,直接用生命周期日志和 dumpsys activity 看真实 Task。

扩展阅读