从点击图标到首帧"上菜":一篇吃透 Android 启动优化原理

  • 平台源码基线:AOSP android-17.0.0_r1(Android 17 / API 37)
  • 内核基线:android17-6.18
  • 文中源码为保留主流程的简化版,锚点均可在 AOSP 对应文件中找到

前言:把 Android 想象成一家巨型商场

你有没有想过,当你的手指在桌面图标上轻轻一点,到屏幕上画出 App 的第一帧画面,这中间系统到底干了多少事?

我打赌比你想象的要多------跨了 4 个进程、至少 6 次 IPC、一次进程复制和一整套安全体检。

为了让你愉快地读完这篇文章,我们先立一个贯穿全文的故事设定:

Android 系统是一家巨型商场,你的 App 是商场里新开的一家网红火锅店。

  • 商场运营中心 = system_server(AMS/ATMS/WMS 都在这里办公)
  • 商场的中央厨房 = Zygote 进程
  • 桌面图标 = 迎宾台,点击图标 = 顾客说"我要开一家分店吃火锅"
  • App 进程 = 即将诞生的火锅分店
  • 首帧画面 = 第一盘菜端上桌
  • 页面完全可用 = 顾客点的一桌菜全部上齐

带着这家火锅店,我们从点击图标的那一刻开始,把整条链路拆开揉碎讲清楚。


一、开胃菜:三种"开门营业"方式与两块计时牌

1.1 冷启动、温启动、热启动

类型 启动前状态 系统要做的事 类比
冷启动 进程不存在 创建进程 → 绑定 Application → 创建 Activity → 首帧 从毛坯房开新店
温启动 进程存活,Activity 需重建 复用进程和 Application,重建 Activity/UI 店还在,翻台重新摆桌
热启动 进程和 Activity 都在 把已有 Activity 带回前台 顾客去趟洗手间回来坐下

启动优化 90% 的战场在冷启动,因为只有冷启动要走完全部流程。所以下文如果不特别说明,都在讲冷启动。

💡 判别技巧:不要靠生命周期日志猜启动类型(onCreate 出现不代表冷启动)。用 am start -W 输出、Perfetto 的 App Startups 指标,或 API 35+ 的 ApplicationStartInfo.getStartType() 来定性。

1.2 两块计时牌:TTID 和 TTFD

启动优化最忌讳的一件事:连"优化到什么时候算完"都没定义,就开始改代码。

  • TTID (Time To Initial Display,首帧时间):从系统收到启动请求,到第一盘菜端上桌------但第一盘菜可能只是菜单(骨架屏)。
  • TTFD (Time To Full Display,完整显示时间):到顾客点的菜全部上齐 ,由应用调用 reportFullyDrawn() 主动声明。

Android Vitals 把冷启动 TTID ≥ 5s(温 2s / 热 1.5s)判定为"启动时间过长"------但这是及格线不是目标线。官方性能指南的建议严格得多:冷启动 < 500ms。你的项目应该按 P50/P90/P95/P99 分位数自己定预算。


二、全链路时序图:一张图看懂冷启动

先把整个调用过程用一张时序图挂在这,后面每一幕都会回到这张图上找位置:

scss 复制代码
用户(顾客)Launcher(迎宾)ATMS/AMS(运营中心)WMS(前台大屏)Zygote(中央厨房)App进程(新分店)SurfaceFlinger(传菜合成口)点击桌面图标1startActivity(Binder 同步调用)2解析 Intent、权限、Task/Activity 复用关系先上 starting window(SplashScreen 占位)3Process.start(本地 Socket,不走 Binder!)4fork 复制子进程 + specialize 特化(UID/GID、SELinux、seccomp、进程名...)子进程诞生,入口 ActivityThread.main()5attachApplication(Binder:经理报到)6bindApplication(发送开业资料包)7① 创建 Application 对象② 安装 ContentProvider(onCreate!)③ Application.onCreate()ClientTransaction 快递:LaunchActivityItem + ResumeActivityItem8onCreate → onStart → onResumesetContentView / Compose 首次组合addWindow / relayout(注册窗口、申请 Surface)9VSync → Choreographer → performTraversalsmeasure / layout / draw10RenderThread 执行 DrawFrame,提交首帧 buffer11app windows drawn12⏱ 记录 TTID(START_TIMESTAMP_FIRST_FRAME)latch → 合成 → HWC → present,像素点亮13主内容就绪后 reportFullyDrawn() → ⏱ 记录 TTFD用户(顾客)Launcher(迎宾)ATMS/AMS(运营中心)WMS(前台大屏)Zygote(中央厨房)App进程(新分店)SurfaceFlinger(传菜合成口)

整条链路可以归成四幕:点单派单 → 中央厨房复制分店 → 报到与开荒 → 摆台上菜。下面逐幕拆解。


三、第一幕:点单与派单(Launcher → system_server)

3.1 迎宾台把需求转给运营中心

顾客(用户)点击图标后,Launcher 进程通过 Binder 发起 startActivity,请求最终落进 system_server 里的 ActivityTaskManagerService(ATMS) 。系统要在这里完成:

  1. 解析 Intent、校验权限和后台启动限制;
  2. 决定 Task/Activity 是否可以复用(决定冷温热);
  3. 先给等位区上小零食------创建 starting window(Android 12 起统一为 SplashScreen API);
  4. 判断目标进程是否存在,不存在则创建进程。

starting window 是个容易误解的东西:它是系统在目标应用首帧之前显示的过渡画面 ,用来缓解"白等"的焦虑,但它不会替你减少任何 CPU 工作,也不会让首帧提前。更糟的是,如果你专门写了一个透明的"跳板 Activity"(trampoline)当启动页,还会额外多一次 Activity 创建 + 窗口转场------纯粹帮倒忙。

3.2 进程去哪儿了?源码说话

冷启动与温/热启动的分岔口在这里:

java 复制代码
// frameworks/base/services/core/java/com/android/server/wm/ActivityTaskSupervisor.java(简化)
void startSpecificActivity(ActivityRecord r, boolean andResume, boolean checkStart) {
    final WindowProcessController wpc = ...; // 按进程名查找目标进程
    // ① 进程活着,且应用主线程(ApplicationThread)已注册?
    if (wpc != null && wpc.hasThread()) {
        try {
            // ② 温/热启动:跳过进程创建,直接派发生命周期事务
            realStartActivityLocked(r, wpc, andResume, checkStart);
            return;
        } catch (RemoteException e) { ... }
    }
    // ③ 冷启动:进程不存在,异步请求创建新进程
    mService.startProcessAsync(r, knownToBeDead, isTop,
            hostingType, hostingName);
}

记住这个判断:hasThread() 为真 → 复用;为假 → startProcessAsync。一条 if 分割了冷启动和温启动的整个世界。


四、第二幕:中央厨房的复制术(Zygote fork & specialize)

这是整个启动流程中最精妙的一段,也是面试和实践中误解最多的部分。

4.1 中央厨房为什么存在?

假设商场每开一家分店都要从零开始:砌墙、装锅、熬第一锅高汤、培训全部员工......那每家店开业都得三天。Android 的解法是------

先建一间永不营业的中央厨房(Zygote),把所有分店都会用的东西提前备齐;开分店时直接把整间厨房"复制"一份。

Zygote 在开机时就把通用运行时准备完了。看它的预加载清单(Android 17 的 ZygoteInit.preload() 实际执行顺序):

scss 复制代码
// frameworks/base/core/java/com/android/internal/os/ZygoteInit.java(简化)
static void preload(...) {
    beginPreload();
    preloadClasses();                     // 读取 preloaded-classes,逐个类加载
    cacheNonBootClasspathClassLoaders();
    Resources.preloadResources();         // 常用资源、主题、drawable
    nativePreloadAppProcessHALs();        // 目前只预热 GraphicBufferMapper HAL
    maybePreloadGraphicsDriver();         // eglGetDisplay / vkEnumerateInstanceVersion
    preloadSharedLibraries();
    preloadTextResources();
    preloadCompatConfig();
    WebViewFactory.prepareWebViewInZygote();
    endPreload();
    warmUpJcaProviders();
}

其中 preloadClasses() 读的是 /system/etc/preloaded-classes------在 android-17.0.0_r1 里,去掉注释和空行后有 18,784 条 。这些都是每个 App 几乎都会用到的框架类(四大组件基类、View 体系、String/ArrayList 的底层支撑......),预加载一次,全系统所有 App 共享。

预加载完,Zygote fork 出 SystemServer,然后进入死循环等待派单:

scss 复制代码
// ZygoteInit.main()(简化)
if (startSystemServer) {
    Runnable r = forkSystemServer(abiList, socketName, zygoteServer);
    if (r != null) { r.run(); return; }   // SystemServer 从这里出生
}
...
caller = zygoteServer.runSelectLoop(...); // 死循环:等 system_server 的 fork 请求

4.2 派单走的是 Socket,不是 Binder!

高频误区预警 :system_server 请求 Zygote 创建进程,不通过 Binder,而是通过本地 Socket(/dev/socket/zygote) 。

ruby 复制代码
// frameworks/base/core/java/android/os/ProcessList.java(简化)
// entryPoint 就是 "android.app.ActivityThread"
startResult = Process.start(entryPoint, processName, uid, uid, gids,
        runtimeFlags, mountExternal, seInfo, requiredAbi, instructionSet, invokeWith,
        startTime, zygotePolicyFlags, packageNames...);
scss 复制代码
// ZygoteProcess.startViaZygote()(简化)
// ① 按 ABI 选择主(64 位)或次(32 位)Zygote 的 socket
openZygoteSocketIfNeeded(abi);
// ② 把 UID、GID、targetSdk、seInfo、进程名等参数拼成命令写入 socket
zygoteSendArgsAndGetResult(openZygoteSocketIfNeeded(abi),
        zygotePolicyFlags, argsForZygote);

为什么要用 Socket?因为 Zygote 的使命是把自己复制出去,它不能被 Binder 多线程污染------Binder 线程会带来不可预期的线程状态和锁,fork 之后这些状态全都会被子进程继承,后患无穷。Socket 单线程串行处理,fork 时进程状态干净可控。

4.3 fork:整间厨房的"影分身"

收到请求后,Zygote 执行经典的 fork 流程:

arduino 复制代码
// frameworks/base/core/java/com/android/internal/os/Zygote.java(简化)
static int forkAndSpecialize(int uid, int gid, int[] gids, ...) {
    ZygoteHooks.preFork();          // ART 做 fork 前准备(如暂停 GC 线程,保证复制安全)
    int pid = nativeForkAndSpecialize(uid, gid, gids, ...);  // 真正的 fork
    if (pid == 0) {
        Trace.traceBegin(TRACE_TAG_ACTIVITY_MANAGER, "PostFork");  // 子进程标记
    }
    ZygoteHooks.postForkCommon();
    return pid;
}

fork 的魔力在于 COW(Copy-on-Write,写时复制) :

中央厨房熬的一大锅高汤(预加载的类、资源、已映射的 so),复制分店时并不是真把汤倒一份 ------两家店共用同一口锅。只要大家只"喝"(只读),相安无事;哪天某家分店想往锅里加辣(写入),系统才立刻给它单独开一锅新的。

物理页在 fork 后仍是共享的,只有某一方首次写入 时才触发缺页异常(do_wp_page → wp_page_copy)复制出新页。这就是为什么 Android 上千个进程能共享一套框架运行时,内存还不爆炸。

4.4 specialize:给分店办全套证照

fork 出来的子进程此刻还是 Zygote 的"克隆体"------UID 是 Zygote 的、SELinux 域是 Zygote 的,啥合法身份都没有。接下来要把这个通用克隆特化(specialize) 成"某某火锅分店"。Android 17 的原生实现(com_android_internal_os_Zygote.cpp 中 SpecializeCommon)依次要做:

  • 关闭/替换不该继承的文件描述符;
  • 建立应用专属的 mount namespace(它看到的文件系统视图);
  • 设置附加用户组与资源上限(rlimit);
  • 切换 GID / UID(营业执照);
  • 安装 seccomp 系统调用过滤器(门禁系统);
  • 设置调度策略、收缩 Linux capabilities;
  • 执行 SELinux 域切换(domain transition,消防验收);
  • 设置进程名、运行 ART 的 fork 后回调。

💡 实战意义:如果 Perfetto 里看到某次启动的 PostFork 区段特别长 ,问题大概率出在这些特化步骤(挂载、SELinux、fd 清理),而不是 fork() 系统调用本身------分段归因,别让 fork 背锅。

4.5 USAP:提前备好的"空白分店"

Android 还有条加速通道:USAP(Unspecialized App Process,未特化应用进程池) 。Zygote 预先 fork 好几间空白分店放在池子里,请求来了直接办证照(只 specialize,不 fork),省掉一次进程复制。

不过注意:Android 17 上 USAP 默认关闭 (ZygoteConfig.USAP_POOL_ENABLED_DEFAULT = false),且仅对延迟敏感的非系统请求生效。排查时先看 dalvik.vm.usap_pool_enabled 属性,别看到 trace 里的 PostFork 就断言"刚执行过 fork"------USAP 命中也会打这个标记。


五、第三幕:报到与开荒(bindApplication 与 Provider 抢跑之谜)

5.1 分店经理拿起对讲机:attachApplication

子进程特化完成后,入口类 android.app.ActivityThread 被拉起:

arduino 复制代码
// frameworks/base/core/java/android/app/ActivityThread.java(简化)
public static void main(String[] args) {
    ...
    Looper.prepareMainLooper();          // 准备主线程消息循环
    ActivityThread thread = new ActivityThread();
    thread.attach(false, startSeq);      // ★ 向 system_server 报到
    ...
    Looper.loop();                       // 主线程进入消息死循环,再也没回来过
}
java 复制代码
private void attach(boolean system, long startSeq) {
    ...
    final IActivityManager mgr = ActivityManager.getService();
    try {
        mgr.attachApplication(mAppThread);   // Binder 调用:报告"我到位了,可以接客"
    } catch (RemoteException ex) { throw ex.rethrowFromSystemServer(); }
    ...
}

这通电话打回 system_server 后,AMS 确认身份无误,便通过应用的 Binder 代理 IApplicationThread 回发 bindApplication ------把"开业资料包"(APK 信息、Provider 清单、配置等)发过去。应用主线程收到 BIND_APPLICATION 消息,进入 handleBindApplication()。

5.2 ⚠️ 全文最重要的一个顺序:Provider 比 Application.onCreate 还早

handleBindApplication() 干的活远不止 Application.onCreate(),Android 17 的实际执行顺序是:

scss 复制代码
// ActivityThread.handleBindApplication()(简化,保留影响启动的关键步骤)
...
setupGraphicsSupport(appContext);   // 图形环境:ANGLE 选择、可更新驱动选择
...                                 // 字体、网络安全、进程级配置
mInstrumentation = new Instrumentation();  // 初始化 Instrumentation
...
// ② 创建 Application 对象(注意:还没执行 onCreate!)
app = data.info.makeApplicationInner(data.restrictedBackupMode, null);
mInitialApplication = app;
...
// ③ ★ 安装 ContentProvider------Provider.onCreate() 在这里同步执行!
if (!data.restrictedBackupMode && !ArrayUtils.isEmpty(data.providers)) {
    installContentProviders(app, data.providers);
}
...
// ④ 最后才轮到 Application.onCreate()
mInstrumentation.callApplicationOnCreate(app);

用火锅店的话说:供应商(ContentProvider)先进场布货,店长(Application)才开晨会。

这就是无数 App 启动慢的头号隐形杀手:WorkManager、Firebase、图片库、APM、推送、广告 SDK......都爱借 ContentProvider 实现"免接入自动初始化" 。它们的 onCreate() 全部运行在主线程、全部排在你的 Application.onCreate() 之前、全部计入冷启动关键路径------而你在代码里搜 Application 类,根本看不到它们。

🔍 自查命令:查看合并后的 Manifest,揪出所有混进来的 Provider:

bash 复制代码
./gradlew :app:processReleaseMainManifest
rg -n '<provider|<meta-data' app/build/intermediates/merged_manifests

在 Perfetto 中对应查看 BindApplication 区段里的 Provider 安装 slice。只测量 Application.onCreate() 的耗时,会漏掉 Provider 这一整段开销。


六、第四幕:摆台上菜(Activity 生命周期与首帧渲染)

6.1 生命周期"快递":ClientTransaction

进程附着完成后,ActivityTaskSupervisor 给新分店打包发货。它构造的是 ClientTransaction(客户端事务),里面塞两个包裹:LaunchActivityItem + ResumeActivityItem,经 Binder 发到应用侧:

ruby 复制代码
// frameworks/base/core/java/android/app/servertransaction/LaunchActivityItem.java(简化)
@Override
public void execute(ClientTransactionHandler client, PendingTransactionActions pendingActions) {
    Trace.traceBegin(TRACE_TAG_ACTIVITY_MANAGER, "activityStart");
    client.handleLaunchActivity(mActivityInfo, mIntent, mIdent, ...);  // → performLaunchActivity
    Trace.traceEnd(TRACE_TAG_ACTIVITY_MANAGER);
}
scss 复制代码
// ActivityThread.performLaunchActivity()(简化)
private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
    ContextImpl appContext = createBaseContextForActivity(r);           // 创建 Context
    activity = mInstrumentation.newActivity(cl, component.getClassName(), r.intent); // 反射 new Activity
    ...
    activity.attach(appContext, this, r.token, ...);                    // 挂上 Window/ parent 等
    ...
    mInstrumentation.callActivityOnCreate(activity, r.state);           // ★ 你最熟悉的 onCreate()
    ...
}

随后 TransactionExecutor 按目标状态继续推进 onStart() → onResume()。这一段的首屏开销大户包括:

  • setContentView() 里的 XML inflate(解析 + 反射构造 View 树),或 Compose setContent {} 的首次组合;
  • ViewModel、依赖注入图构建、saved state 恢复;
  • 同步资源解析、主题与 drawable 创建;
  • 首屏数据的同步读取。

6.2 首帧渲染:从 onResume 到像素点亮

Activity 进入 resume 后,ViewRootImpl 创建并向 WMS 注册窗口(IWindowSession.addToDisplayAsUser / relayout,拿到 Surface)。然后是首帧的应用侧标准路径:

scss 复制代码
主线程(UI Thread)Choreographer(出菜节拍器)RenderThread(猛火灶)SurfaceFlinger(传菜合成口)ViewRootImpl.scheduleTraversals()1注册 traversal 回调2VSync 到来(节拍器打点)3performTraversals():measure → layout → draw录制渲染指令更新 RenderNode4同步渲染指令5DrawFrame:Skia/GPU 执行绘制命令6提交 buffer(queueBuffer)7latch → 合成策略(HWC/GPU)→ present8像素点亮(晚于框架记录的 TTID)主线程(UI Thread)Choreographer(出菜节拍器)RenderThread(猛火灶)SurfaceFlinger(传菜合成口)

三个对优化很重要的细节:

  1. Choreographer 由 VSync 驱动:主线程绘制是"踩着节拍器干活",错过一拍就要等下一拍(16.6ms @60Hz)。这就是为什么主线程哪怕只卡 10ms,也可能造成实际感知的延迟翻倍。
  2. 绘制分两个线程 :主线程只负责"抄菜单"(measure/layout/draw 录制渲染指令),真正的"炒菜"(GPU 执行)在 RenderThread。排查首帧慢时两个线程的 track 都要看。
  3. app windows drawn 不等于像素发光:Framework 记录首帧的时间点,早于 SurfaceFlinger 合成、HWC present 和面板扫描。

6.3 TTID 的精确计量点在哪?

scss 复制代码
// frameworks/base/services/core/java/com/android/server/wm/ActivityRecord.java(简化)
void onWindowsDrawn(long elapsedRealtime) {
    ...
    // 通知 ActivityMetricsLogger 结束启动计时
    mAtmService.getActivityMetricsLogger().notifyWindowsDrawn(...);
}

Android 17 中,ActivityMetricsLogger.notifyWindowsDrawn() 通过 SystemClock.uptimeNanos() 记录 START_TIMESTAMP_FIRST_FRAME ,结束框架统计的启动区间------这就是 Logcat 里 Displayed 一行的来源,也就是 TTID 的平台计量边界。

kotlin 复制代码
// TTFD:主内容就绪后上报(ComponentActivity 推荐 FullyDrawnReporter)
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_home)
​
    fullyDrawnReporter.addReporter()          // 挂一个"未就绪"计数
    lifecycleScope.launch {
        try {
            viewModel.awaitInitialContent()   // 等首屏数据
        } finally {
            fullyDrawnReporter.removeReporter()  // 就绪/失败都要释放
        }
    }
}

所有 reporter 移除后框架才调用 reportFullyDrawn()。注意两个边界:TTFD 绝不会早于 TTID(框架有保护);但也不要过早上报------把骨架屏就上报成"完全显示",TTFD 就失去意义了。


七、四把尺子:科学测量,拒绝"我感觉快了"

工具 定位 适用场景
adb shell am start -W 掐表 快速冒烟,看 TotalTime/WaitTime
Logcat Displayed 行 小票 定位单次样本(Framework 的 TTID)
Macrobenchmark 标准化考核 固定启动类型/编译模式/迭代数,A/B 对比
Perfetto 监控回放 逐阶段归因:慢在哪个线程、哪笔 Binder、哪次 IO
bash 复制代码
# 一分钟冷启动冒烟:-S 先强停进程构造冷启动
adb shell am start -S -W \
  -a android.intent.action.MAIN \
  -c android.intent.category.LAUNCHER \
  -n com.example.app/.MainActivity
ini 复制代码
// Macrobenchmark:受控多轮冷启动基准
@Test
fun coldStartup() = benchmarkRule.measureRepeated(
    packageName = "com.example.app",
    metrics = listOf(StartupTimingMetric()),      // 同时产出 TTID 与 TTFD
    compilationMode = CompilationMode.Partial(),  // 固定编译状态!
    startupMode = StartupMode.COLD,
    iterations = 10,
    setupBlock = { pressHome() },
) { startActivityAndWait() }

API 35+ 还有一个线上利器 ApplicationStartInfo :getHistoricalProcessStartReasons() 能取回每次启动的类型、原因和分阶段纳秒时间戳(fork、bindApplication、Application onCreate、first frame、SurfaceFlinger composition complete......),直接在 App 内做启动分位数监控。

⚠️ 三条纪律:① 比较优化前后必须固定 CompilationMode,否则 Baseline Profile 的差异会被误当成代码优化的收益;② 模拟器与宿主机共享资源,结论一律以物理设备为准;③ 单次快慢不作数,只认分位数。


八、优化实战:沿着时序图逐段"排雷"

时序图就是你的排雷地图。原则一句话:先删、再推、后缩、最后才是并发------顺序不能反。

8.1 用关键路径(DAG)思考,而不是"全部异步化"

启动任务是一个有向无环图,总时长由最长依赖链决定,不是所有任务耗时之和:

复制代码
Application 绑定
读取本地启动配置
创建首屏状态
inflate / compose 首屏
提交应用首帧(TTID)
恢复账户快照
加载首页主数据
提交完整内容
reportFullyDrawn(TTFD)
统计、推送、预取等首帧后任务

关键洞察:如果 D(首屏 UI 构建)是 TTID 关键路径上最慢的节点,把 F、G 并发到八个线程也不会让首帧提前一毫秒------反而可能抢走主线程的 CPU,让 TTID 变得更差。

给每项初始化立一份"任务契约",评审时有据可依:

字段 要写清楚什么
owner 谁维护、出问题找谁
trigger 进程绑定 / 首帧后 / 登录后 / 功能调用
deadline TTID 前 / TTFD 前 / 无启动期限
dependencies 依赖谁、完成语义
thread affinity 必须主线程还是 CPU/IO worker
failure policy 阻断 / 降级 / 重试 / 禁用功能
idempotent 重复执行是否安全

8.2 ContentProvider 治理(收益最大的第一刀)

按 §5.2 的方法清点所有 Provider 后:

  • 能删则删 :SDK 支持关闭自动初始化的,用 tools:node="remove" 移除,改成按需初始化:
xml 复制代码
<provider
    android:name="androidx.startup.InitializationProvider"
    android:authorities="${applicationId}.androidx-startup"
    android:exported="false"
    tools:node="merge">
    <!-- 把 Analytics 从启动路径上摘掉 -->
    <meta-data
        android:name="com.example.analytics.AnalyticsInitializer"
        tools:node="remove" />
</provider>
  • 该合并合并 :Jetpack App Startup 用一个 InitializationProvider 统一收纳多个 Initializer(靠 dependencies() 声明顺序),减少 N 个 Provider 的框架开销。
  • 但要对 App Startup 有清醒认知 :它只做"统一发现 + 按依赖串行执行",不会自动并行 ,create() 的耗时照样计入启动;不着急的组件应移除 <meta-data>,在业务触发点手动 AppInitializer.initializeComponent()。

8.3 Application.onCreate 瘦身

常见雷区清单:同步读 SharedPreferences/数据库/keystore、主线程等 Binder 或锁、一口气 new 出整个依赖图、初始化当前入口用不到的 SDK、大量反射与 so 加载......以及最容易翻车的:把任务"异步化"后一次性全撒出去 ------后台线程占满大核、抢 IO 带宽、持有主线程要的锁,TTID 反而恶化。移到后台只是换了执行位置,没有改变资源总量。

8.4 SplashScreen:控制过渡画面,而不是隐藏等待

kotlin 复制代码
override fun onCreate(savedInstanceState: Bundle?) {
    val splash = installSplashScreen()   // ★ 必须在 super.onCreate() 之前
    super.onCreate(savedInstanceState)
    splash.setKeepOnScreenCondition {
        viewModel.blocksFirstDraw.value  // 条件须快速、无锁、无 IO
    }
    setContentView(R.layout.activity_main)
}

两个红线:① 保持 Splash 的每一毫秒都推迟 TTID,只允许等待"短暂且确定的本地条件",绝不等待网络/广告/大配置下发;② 条件必须有失败出口(超时、协程取消、页面销毁时都允许绘制)。

8.5 首屏布局:减少必须创建的节点

inflate 的成本 = 资源解析 + 类加载 + 反射构造 + 属性解析 + 子树创建,之后还有 measure/layout/draw。可做的是:删掉首屏不可见节点、View 构造器里禁做 IO/解码、列表首屏数据量贴着屏幕给、可选子树用 ViewStub 按需 inflate。AsyncLayoutInflater 有严格前提(View 构造须线程安全、不支持含 Fragment 的布局、失败回 UI 线程重试)------如果首帧必须等它完成,线程换了但 TTID 一毫秒没省。

8.6 Baseline Profile:让代码"免预热"直接快跑

冷启动的早期,大量代码还躺在 DEX 里以解释模式执行,JIT 边跑边编译。Baseline Profile 随 APK/AAB 携带热点方法规则,让 ART 在安装时就对启动路径完成 AOT 编译,从第一次运行起就是全速。配套两件套:

  • Startup Profile :其中用于构建期 DEX layout 的子集,让 R8/D8 把启动类方法排布到连续位置,减少启动期跨 DEX 读取和缺页;
  • Cloud Profile:Google Play 汇聚真实用户热点后分发的补充画像(API 28+)。

官方经验值是覆盖路径约 30% 提速 ------注意这是总体经验值:如果瓶颈在 IO、锁、Binder 或网络,Profile 帮不了多少。改完一定要用 CompilationMode.None() vs CompilationMode.Partial(baselineProfileMode = Require) 做 Macrobenchmark 对照验收。

8.7 线上回归:把收益变成指标,而不是一次性的感动

分批发布后按版本 × 入口 × 设备层级 × 启动类型 观察 P50/P90/P95 和超阈值占比。线上事件至少带上:应用版本、设备型号、启动类型、入口来源、reportFullyDrawn() 的业务定义。CI 门禁不要用"单次慢 5% 就失败"------温度、后台负载、动态调频的噪声会把它变成狼来了;要用受控物理设备 + 固定编译状态 + 足够迭代 + 连续构建趋势。

🍒 Android 17 加餐:targetSdk 37 起系统为应用启用无锁版 MessageQueue (减少队列锁竞争与丢帧)。老的通过反射读 mMessages 私有字段的性能 SDK 和 Hook 框架会集体失效------升级前记得用 adb shell am compat enable/disable USE_NEW_MESSAGEQUEUE <pkg> 做兼容性对照。


九、常见误区盘点(每一条都来自真实事故)

  1. "冷启动优化 = 缩短 Application.onCreate" ❌ Provider、进程创建、类加载、Activity、首帧 UI、RenderThread、系统合成都可能比它慢,onCreate 只是其中一段。
  2. "Displayed 了用户就能点" ❌ Displayed 是 TTID,首帧可能是骨架屏。业务可用性请用 TTFD 表达。
  3. "看到 PostFork 说明刚 fork 完" ❌ USAP 命中也会打 PostFork 标记,结合 USAP 配置和 PID 判断。
  4. "后台初始化不影响启动" ❌ 后台任务与主线程共享 CPU 大核、IO 带宽、Binder 线程池和锁。
  5. "Splash 挂得越久首页越稳" ❌ 每挂一秒 TTID 晚一秒,只许等短暂确定条件,必须有失败出口。
  6. "App Startup 会自动并行无依赖的 Initializer" ❌ 它只统一发现 + 按依赖串行执行。
  7. "Zygote 预加载越多 App 越快" ❌ 预加载拉长开机时间、增加常驻内存;容易被子进程写脏的页还会破坏 COW 共享收益。
  8. "16KB 页大小让启动固定快 X%" ❌ 页表、COW 粒度、缺页行为同时变化,收益取决于访问模式,必须实测。

十、总结

最后回到火锅店,把整个故事串起来:

你点击图标(迎宾台接单)→ 运营中心先给等位区上小食(SplashScreen),同时给中央厨房打电话(Socket,不是 Binder)→ 中央厨房把整间厨房影分身复制一间(fork + COW 共享高汤),给分店办齐证照(specialize:UID/SELinux/seccomp)→ 分店经理拿对讲机报到(attachApplication),运营中心回发开业资料包(bindApplication)→ 供应商先进场布货(ContentProvider.onCreate),店长后开晨会(Application.onCreate) → 总部快递摆台指令(ClientTransaction:Launch + Resume)→ 摆桌炒菜(measure/layout/draw + RenderThread),第一盘菜上桌(TTID,app windows drawn)→ 全部菜品上齐(reportFullyDrawn,TTFD)。

对应到工程实践,就三句话:

  1. 测量先行:TTID/TTFD 口径统一,Macrobenchmark 建基线,Perfetto 做归因;
  2. 沿着时序图找关键路径:删除 → 推迟 → 缩短 → 受控并发,每一步用 trace 验证;
  3. 警惕隐形开销:Provider 抢跑、异步抢资源、Splash 遮羞、Profile 假装生效------每一个都用证据说话。

启动优化没有银弹,只有对关键路径的持续经营。


参考资料

  • Android Developers:App startup time、App startup analysis and optimization
  • Android Developers:Baseline Profiles、Macrobenchmark
  • Android Developers:Splash screens、Jetpack App Startup
  • AOSP android-17.0.0_r1:ActivityTaskSupervisor.java、ProcessList.java、ZygoteInit.java、ZygoteProcess.java、ActivityThread.java、LaunchActivityItem.java、ViewRootImpl.java、ActivityMetricsLogger.java
  • AOSP android-17.0.0_r1:system/core/init/main.cpp(init 三阶段)、system/core/rootdir/init.zygote64.rc
  • Perfetto stdlib:android.startup.startups、android.startup.time_to_display
  • 本文冷启动链路、Binder Trace 分析与 Zygote 细节参考自 Gracker/android-internals-wiki(第 1.3 章"系统启动、Zygote 与图形栈预加载"、第 8.2 章"App 冷启动链路与 Binder Trace 分析"、第 8.3 章"启动优化策略")

觉得有帮助的话,点个赞再走呗~ 有问题欢迎评论区讨论。

相关推荐
镜象科技2 小时前
职业院校AI心理健康解决方案是什么?从一台设备到全周期守护
android·人工智能
码上有光3 小时前
Linux:信号的认识、信号的产生、信号的保存、信号的处理
android·linux·运维·信号
silvamux_4 小时前
4款新模型商务邮件翻译横评:0.98到0.46
android·java·javascript
ynchyong4 小时前
创建MySQL只读账号
android·数据库·mysql
朱涛的自习室14 小时前
我给 Gemini 4 和 DeepSeek 搞了套「末位淘汰」,AI 干活会更猛吗?(Day 3)
android·前端·后端
应用市场20 小时前
离线小游戏做“每日挑战”:日期当随机种子,整局存成 JSON,再加一个“我不是机器人”
android·kotlin·游戏开发
一铭1119921 小时前
海豹办公 1.9.15 跨端办公软件实践 文档兼容 Access 数据库与移动 WebView 排错
android·数据库·electron·开源软件·access