App 启动流程源码分析(五):Application 启动阶段与启动框架设计

文章内容

本文内容整理自享学团队直播课《App 启动流程源码分析(五)》

一、从启动屏动画到 Application

有一个很容易复现的现象:给 Application.onCreate 的头尾各打一个 Trace.beginSection / endSection,测出来只有 30ms;但用 adb shell am start -W 量整体,接入几个三方 SDK 前后 TotalTime 涨了一百多毫秒。多出来的时间既不在 onCreate 里,也不在 Activity 里,打点区间完全没覆盖到它。

问题由此产生:进程创建之后、入口 Activity 之前的这一段,系统替 App 做了哪些事?那些不在 onCreate 里的耗时藏在哪个环节?

原因:Application 启动阶段由系统在 bindApplication 触达主线程后,通过 handleBindApplication 一次性装配——构建 Context → makeApplication(用 Instrumentation.newApplication 反射创建 Application)→ 触发 attachBaseContext → 系统同步 install 所有 ContentProvider(这是被多数人忽略的隐藏耗时)→ 回到 callApplicationOnCreate。App 自己能写代码的位置只有 attachBaseContext 和 onCreate 两处;在 onCreate 里初始化几十个 SDK 时如果全部各起一个线程,会换来线程爆炸和上下文切换损耗,需要一套基于图论的依赖调度加统一线程池。

沿用前几篇的分段:阶段①进程创建 App 改不动,阶段②Application 和阶段③Activity 是可优化区。本篇把阶段②整个摊开。

二、Application 启动阶段由谁、按什么顺序装配

Application 启动阶段装配流水线:bindApplication → handleBindApplication → 构建 Context → makeApplication → attachBaseContext → installContentProviders → onCreate

system_server 进程只出现一次:AMS 发出 bindApplication,oneway 调用,发完就不管了。前一篇讲过这条链的时序保证,这里只需要记住它到达 App 进程之后被折进了主线程的消息队列。

App 进程主线程是主场:

  • ActivityThread:handleBindApplication 是整段装配的总入口,跑在主线程。
  • ContextImpl:Context 的真实实现,createAppContext 在这里把 APK 的代码和资源装载起来。
  • LoadedApk:一个已加载 APK 的运行期描述,mApplication 挂在它身上。
  • Instrumentation:用 ClassLoader 反射实例化 Application,并负责回调 onCreate。
  • Application(你写的子类)和 ContentProvider(你或三方 SDK 写的子类):这一段里仅有的两处 App 代码执行位置。

装配流水线按顺序是:createAppContext → makeApplication → newApplication 反射实例化 → app.attach → attachBaseContext → installContentProviders → callApplicationOnCreate → onCreate。这条线上只有 attachBaseContext、Provider 的 onCreate、Application.onCreate 三处会执行到 App 写的代码,其余全是系统动作。

三、handleBindApplication 执行流程

java
// handleBindApplication:主线程真正创建并装配 Application 的入口,由 BIND_APPLICATION 消息触发private void handleBindApplication(AppBindData data) {    // 第一件事:为应用构建运行上下文,加载 APK 里的资源与代码    ContextImpl appContext = ContextImpl.createAppContext(this, data.info);    // makeApplication 创建 Application 对象,内部会走反射 newApplication + attach    Application app = data.info.makeApplication(data.restrictedBackupMode, null);    // 同步启动该应用声明的所有 ContentProvider(只做安装,不阻塞主线程等待各 provider 初始化完成)    installContentProviders(app, data.providers);    // 最后回调 Application.onCreate,App 自己的初始化代码在这一步执行    mInstrumentation.callApplicationOnCreate(app);// handleBindApplication 方法体结束}

输入是 AppBindData,也就是上一篇里 ApplicationThread 打包投递过来的那份数据,里面带着 ApplicationInfo 和 List<ProviderInfo>;输出是 void。
执行流程:进程里有了 ContextImpl,有了唯一的 Application 实例,清单里声明的所有 Provider 被装进了进程,Application.onCreate 跑完。

顺着这段代码,有几个点要单独说:

  • Provider 的安装是这条流水线上的一环,而不是一个独立的、后续才发生的事件,它执行在callApplicationOnCreate 上面一行,跑在同一个主线程调用栈里。
  • data.providers 是从 system_server 带过来的,来自 PMS 对清单的解析结果,App 侧不需要也无法干预这个列表的生成(能干预的是清单本身)。
  • 整个方法体没有任何线程切换,四步串行。

installContentProviders 那行的注释里写着"只做安装,不阻塞主线程等待各 provider 初始化完成"。这句需要拆开理解:安装动作本身是同步执行的,它会在当前调用栈里实例化每个 Provider 并调用其 onCreate;不阻塞指的是不会去等待 Provider 内部自己开的异步任务。所以一个 Provider 的 onCreate 里写了 200ms 的同步初始化,这 200ms 会记在主线程上。

四、makeApplication 与 Application 实例化

Application 的子类名可以叫 MyApp、App、XApplication,随便什么都行,只要清单里 android:name 对得上。这个自由度来自实例化方式。

java
// makeApplication:真正实例化 Application 的地方public Application makeApplication(boolean forceDefaultAppClass, Instrumentation instrumentation) {    // mApplication 已存在就直接返回,保证整个进程只有一个 Application 实例    if (mApplication != null) {        // 已创建过,直接复用同一个实例        return mApplication;    // 复用分支结束    }    // 通过 Instrumentation 用类加载器(ClassLoader)反射创建 Application    Application app = instrumentation.newApplication(cl, appClass, appContext);    // 缓存实例,后续 attach / onCreate 都围绕它    mApplication = app;    // 返回本进程唯一的 Application 实例    return app;// makeApplication 方法体结束}
java
// newApplication:Instrumentation 负责用反射把 Application 类名变成实例public Application newApplication(ClassLoader cl, String className, Context context) {    // 用类加载器按 className 反射加载并实例化;因此你的类叫 MyApp / MyApplication 都不影响    Application app = getFactory(context).instantiateApplication(cl, className);    // 创建后立刻把 Application 绑定到一个 Context 上(内部触发 attachBaseContext)    app.attach(context);    // 返回已完成 Context 绑定的 Application    return app;// newApplication 方法体结束}

输入是 ClassLoader、一个字符串形式的 className(来自清单 android:name)和一个 Context;输出是完成 Context 绑定的 Application 实例。状态变化有两处:LoadedApk.mApplication 被写入,以及这个实例的 mBase 被 attach 填上。

这两段代码放到一起看,结论就出来了:Application 的创建走的是"字符串类名 + 反射",不是 new 一个固定类型。类名只是 PMS 从清单里解析出来的一个字符串,一路传到 instantiateApplication,所以取什么名字系统都不关心;同理,它也解释了为什么清单里 android:name 写错会在启动瞬间抛 ClassNotFoundException 而不是编译期报错。

mApplication != null 的判空前一篇已经用过,它保证进程内 Application 的唯一性,也让 performLaunchActivity 里那次 makeApplication 调用只是一次取值,不会重复构造。

还有一个顺序细节:app.attach(context) 写在 newApplication 内部、return 之前。也就是说 Application 对象一构造出来立刻就被绑定 Context,attachBaseContext 在 makeApplication 返回之前就已经执行完了。

五、Application 里第一行代码不是 onCreate

顺着上一节的 app.attach(context) 往下,App 侧第一行被执行的代码在这里。

java
// MyApplication:你自己写的 Application 子类public class MyApplication extends Application {    // attachBaseContext 是 Application 里最早执行的方法,早于 onCreate    @Override    protected void attachBaseContext(Context base) {        // 必须调用 super,Context 的绑定在这里完成        super.attachBaseContext(base);    // attachBaseContext 方法体结束    }    // onCreate 才是真正的初始化入口(放 SDK 初始化的地方)    @Override    public void onCreate() {        // 调用父类实现        super.onCreate();    // onCreate 方法体结束    }// MyApplication 类体结束}

输入是系统传进来的 base Context;输出是 void。状态变化在于 ContextWrapper.mBase 被填上。在 super.attachBaseContext(base) 执行之前,这个 Application 上的任何 Context 方法(getResources、getSharedPreferences、getPackageName)都会因为 mBase 为 null 而抛 NullPointerException,这是这个方法里最常见的踩坑点:把初始化代码写在 super 调用之前。

执行顺序从这里开始:attachBaseContext 早于 onCreate,也早于所有 ContentProvider 的 onCreate。历史上 MultiDex.install(this) 必须写在这里,原因就是它得赶在任何二级 dex 里的类被加载之前完成,包括 Provider 里的类。

因为它是最早的位置,往这里塞东西的诱惑很大。需要清楚的代价:这一段跑完之前,Provider 装不了,Application.onCreate 进不去,Activity 更谈不上。在 attachBaseContext 里做的每一毫秒都顶在整条流水线最前面,且这个区间在只对 onCreate 打点的方案里同样不可见。

六、ContentProvider:藏在 Application 里的隐性耗时

ContentProvider:藏在 Application 里的隐性耗时

回到开头那个现象:接入 SDK 后启动变慢,但 Application.onCreate 的打点没涨。多出来的时间在 Provider 上。

或许你会想:Provider 不是我主动调的,我代码里从来没写过 getContentResolver().query(),它怎么就被启动了?

答案在 handleBindApplication 那一行 installContentProviders(app, data.providers)。清单里声明过的 Provider,系统在装配 Application 的时候就会全部实例化一遍,不需要任何人主动调用。

java
// installContentProviders:把清单里声明的 provider 全部安装到当前进程private void installContentProviders(Context context, List<ProviderInfo> providers) {    // 逐条安装清单文件里声明的 ContentProvider    for (ProviderInfo cpi : providers) {        // installProvider 内部会实例化并调用 provider.onCreate()        ContentProviderHolder holder = installProvider(context, ...);    // 循环体结束    }    // 待核实:安装过程是否有与 onCreate 的同步/顺序约束随版本变化// installContentProviders 方法体结束}

输入是 Context 和一份 List<ProviderInfo>(PMS 解析清单得到,随 bindApplication 跨进程带过来);输出是 void,代价是每个 Provider 被 new 出来、onCreate 被调用、并注册进 ActivityThread 的 Provider 表,进程里多了 N 个活着的 Provider 实例。

这里的问题在于:Provider 的 onCreate 是一个由系统在主线程串行触发的回调,数量等于清单里声明的条数,且完全不受 App 主动控制。循环体里没有任何异步调度,一个 Provider 的 onCreate 慢,后面所有 Provider 和 Application.onCreate 都要等它。

三方 SDK 正是看中了这个"免调用"的特性。

xml
<!-- 应用节点:声明的 provider 会在 Application 构建时由系统自动启动 --><application android:theme="@style/AppTheme">    <!-- ContentProvider:三方 SDK 常借它做"免调用"的启动初始化 -->    <provider android:name=".StartupProvider" android:authorities="${applicationId}.StartupProvider" />    <!-- 入口 Activity:主界面,桌面点击落点 -->    <activity android:name=".MainActivity">        <!-- 意图过滤器:声明它是可被 Launcher 启动的主入口 -->        <intent-filter>            <!-- 主动作:MAIN 表示这是主入口 -->            <action android:name="android.intent.action.MAIN" />            <!-- 启动器类别:桌面据此生成可点击图标 -->            <category android:name="android.intent.category.LAUNCHER" />        <!-- 意图过滤器声明结束 -->        </intent-filter>    <!-- 入口 Activity 声明结束 -->    </activity><!-- 应用节点声明结束 --></application>

androidx.startup 的 InitializationProvider、Firebase 的初始化 Provider、WorkManager、部分崩溃采集 SDK,走的都是这条路径:它们只需要在自己的 AAR 里声明一个 Provider,清单合并之后就自动出现在宿主的 AndroidManifest 里,宿主开发者甚至不知道它存在。authorities 用 ${applicationId} 占位是为了避免多个 App 装在同一台设备上时 authority 冲突(冲突会导致安装失败)。

这就是"隐性"的两层含义:代码上不可见(宿主工程里搜不到调用点),打点上不可见(区间落在 attachBaseContext 结束到 Application.onCreate 开始之间,只对 onCreate 打点完全覆盖不到)。

七、耗时分析

Application 启动阶段的打点位置:attachBaseContext / onCreate / 首个 Activity.onCreate

要把上面那段区间量出来,打点位置需要覆盖流水线的三个边界,而不是只包住 onCreate。

三个点分别是:attachBaseContext 进入(super 之后立刻记,此时 Context 才可用);Application.onCreate 结束;入口 Activity.onCreate 开始。用 Trace.beginSection / Trace.endSection 成对埋,跑 Perfetto 时它们会以嵌套区间的形式出现在主线程轨道上。

三个点切出来的区间含义是:

  • attachBaseContext 自身的耗时:通常是 MultiDex、热修复、加固壳解密的开销。
  • 从 attachBaseContext 结束到 Application.onCreate 开始,这段区间里只有一件事在跑,就是 installContentProviders。所有 Provider 的 onCreate 全部计在这里。这是第六节那些隐性耗时唯一能被夹出来的方式。
  • Application.onCreate 自身——自己写的 SDK 初始化。
  • Application.onCreate 结束到 Activity.onCreate 开始,主线程消息队列的调度间隙,正常情况下很短;如果这里有明显空档,说明有别的消息插在中间抢了主线程。

除了自己埋点,Perfetto 抓下来的系统 trace 里还能直接看到 bindApplication、activityStart 这类系统段,以及每个 Provider 安装时的 ContentProvider.onCreate 段(待核实:系统 trace 里是否为每个 Provider 单独打段,取决于版本与 trace 分类是否开启)。两套点交叉对照,能确认自己的埋点没漏区间。

一个常见的错误做法:只在 Application.onCreate 的头尾打点,然后拿这个数字向外汇报"Application 阶段耗时 30ms"。这个数字漏掉了 attachBaseContext 和整个 Provider 区间,接入的三方 SDK 越多,漏得越厉害。

八、Application.onCreate 优化:异步、按需、懒加载

异步初始化 vs 统一线程池:50+ SDK 的线程开销与治理

onCreate 是唯一完全由 App 掌控的区间,优化空间也集中在这里。把要初始化的东西按"什么时候真正被用到"分三类:

必须在主线程同步完成的。崩溃采集(要尽早,晚一秒就漏一秒的崩溃)、埋点的会话初始化、多进程共享配置的读取、以及被首屏直接依赖的东西。这类保持原样,但要控制数量。每加一个都直接顶在首帧前面。

可以异步初始化的。网络库、图片库、推送、地图、支付等,它们的初始化和首屏渲染之间没有强依赖,可以丢到后台线程。要求是这些 SDK 的初始化本身线程安全,且首次调用点能容忍"初始化尚未完成"(多数 SDK 内部有自己的就绪判断,用之前需要确认)。

可以延后到首帧之后甚至用到才初始化的。分享、扫码、客服、直播这类只在特定页面用到的,做成懒加载:第一次进入相关页面时再初始化。判断标准是"用户这次启动有多大概率会用到它"。

多进程要单独判一次。带 :push、:remote 这类子进程的 App,子进程同样会走完整的 Application 装配流程,同样会跑 onCreate。在 onCreate 开头判断当前进程名,非主进程直接跳过绝大部分初始化,这一条经常能省掉可观的时间,尤其是推送进程被拉起时。

Provider 侧也能治理。三方 SDK 通过清单合并塞进来的自动初始化 Provider,可以在自己的清单里用 tools:node="remove" 移除,改成在需要的时候手动初始化。移除之前必须确认该 SDK 提供了手动初始化的 API,否则会在运行时崩。androidx.startup 自己就提供了这个开关(它的 InitializationProvider 支持逐个 Initializer 用 meta-data 声明 tools:node="remove")。

九、异步的代价:50+ SDK 与线程池、启动框架设计

启动框架设计:基于图论的 SDK 依赖调度与统一线程池

"能异步的都异步"这条规则在 SDK 数量少的时候有效,数量上去之后会翻车。

假设有 50 个可异步的初始化任务,每个都 new Thread().start()。代价有几层:创建 50 个线程本身有开销(每个线程默认栈空间以 MB 计);这 50 个线程和主线程一起竞争 CPU,中低端机上核心数只有 4~8 个,调度器把时间片切给了后台初始化,主线程反而拿不到 CPU;线程上下文切换的成本随线程数上升。结果是 Application.onCreate 的打点确实变短了(任务都被扔出去了),但首帧时间没变甚至更长——耗时从一个可见的区间转移到了不可见的 CPU 争抢里。

第一层优化是统一线程池:所有初始化任务提交到同一个固定大小的线程池,池大小按 CPU 核心数设定。并发度可控,线程复用,不再有创建开销。

第二层修正来自一个更麻烦的现实:任务之间有依赖。埋点 SDK 要在网络库之后,网络库要在配置读取之后,某个业务初始化要在用户信息加载之后。手工用 CountDownLatch 串这些依赖,任务数一多就没法维护,加一个任务要改一堆等待关系。

这里把任务集合建模成一张有向无环图:节点是初始化任务,边是"A 必须先于 B"。启动时对这张图做拓扑排序(Kahn 算法:反复取出入度为 0 的节点),得到一个合法的执行序列;把入度为 0 的任务提交到线程池,某个任务完成后把它的后继入度减一,减到 0 就提交。这样依赖关系是声明式的:每个任务只声明自己依赖谁,调度顺序由框架算出来。图里有环就是配置错误,可以在启动时直接抛异常暴露出来,而不是运行时死锁。

除了拓扑调度,启动框架还要处理两件事:

  • 阻塞节点。有些任务必须在首帧之前完成(比如首屏要用的配置)。框架需要支持把某个任务标为"主线程等待",在合适的位置(通常是 Application.onCreate 末尾或首个 Activity 的 onCreate 开头)阻塞等它完成。等待点越靠后越好,给异步任务留出的时间越多,实际等待越短。

  • 让主线程也参与执行。Application.onCreate 提交完任务之后,主线程接下来要做的是等待消息队列里的 LAUNCH_ACTIVITY,这中间它是空闲的。把一部分任务安排给主线程自己跑,能避免"主线程闲着、线程池排队"的浪费。这一条属于推断层面的优化思路,实际收益取决于任务的粒度和机型,需要在目标机型上实测确认。

这套东西就是常说的"启动框架":任务声明 + 依赖图 + 拓扑调度 + 统一线程池 + 阻塞点管理。它没有减少任何一个 SDK 的初始化耗时,做的是把这些耗时排布到合适的线程和合适的时刻。

总结

进程创建之后到入口 Activity 之间,系统在主线程上串行做完了五件事:createAppContext 装载 APK 与资源、makeApplication 反射实例化 Application、attachBaseContext 完成 Context 绑定、installContentProviders 把清单里所有 Provider 实例化并逐个调 onCreate、callApplicationOnCreate 回到 App 自己的初始化代码。

耗时藏在哪里,答案是第四步。Provider 的 onCreate 由系统自动触发、由清单合并自动引入、区间又恰好落在多数人打点范围之外。这三件事凑到一起,就是那段查不到的时间。

在 onCreate 里初始化几十个 SDK 而不拖慢启动,路径是分类(同步 / 异步 / 懒加载)加调度(依赖图 + 拓扑排序 + 统一线程池),而不是给每个任务开一个线程。


本文由 AgentWorkFlow 生成

觉得不错?给作者点个赞吧

分类:Android系统

评论 (0)

无需注册,留言经站长审核后公开。

加载评论中…

App 启动流程源码分析(五):Application 启动阶段与启动框架设计 · Yinchen 的博客