点开一个应用图标,到屏幕上出现第一帧画面,这段时间通常只有几百毫秒,但它把鸿蒙系统里最核心的几条链路全串了一遍:输入事件分发、Ability 调度、应用进程孵化、ArkTS 运行时初始化、ArkUI 前端元素树构建、后端渲染合成上屏。这个系列写到第二十六篇,前面二十五篇基本是按能力域切的——包管理、分布式、图形、驱动各占一篇。这一次换个切法,不再按模块走,而是按时间轴走,把"鸿蒙源码分析"里最常被问、也最容易读散的那条启动链路完整拼一次。
这篇内容适合三类人:正在做鸿蒙应用开发、想搞清楚自己写的aboutToAppear到底什么时候被调用的同学;做系统方向适配、需要看懂 AMS 和 AppSpawn 怎么握手的同学;以及做启动优化的同学——优化本质上是在正确的锚点上做减法,锚点找不到,优化就是碰运气。全文的代码位置和函数名,来自我在 OpenHarmony 主干和几个 release 分支上实际读代码的记录,凡是版本之间差异较大的地方我都会点出来,凡是基于常见工程实践做的补充推论,我也会明确标注,避免你拿去做硬性结论。
1. 先搭一张启动链路全景图
1.1 为什么我建议按时间轴读,而不是按模块读
按模块读源码有个很现实的坑:foundation/ability/ability_runtime、foundation/appexecfwk、foundation/arkui/ace_engine、arkcompiler/ets_runtime,随便拎一个出来都是几十万行量级。你从入口函数往下跟,跟到第三层就会开始怀疑人生——这些代码在什么条件下才会被走到?谁调用的?调用频率多高?没有这些上下文,读源码就变成了背代码。
按时间轴读的好处是,每一段代码都能挂到一个明确的锚点上:"这段代码是在应用进程 fork 完成之后、ArkTS 虚拟机创建之前执行的"。锚点一旦清楚,代码的职责边界、生命周期、线程归属就都自洽了。我自己的习惯是先拿hitrace抓一条冷启动的 trace,把主要耗时块标出来,然后拿着 trace 去反查源码——哪一段耗时对应哪个函数,一目了然。这个顺序比先读代码再猜耗时高效得多,后面第 5 章会具体讲怎么抓。
1.2 六个阶段与各自的代码归属
一次冷启动,我会把它拆成六段。这个拆法不是官方定义,是我自己读代码和做优化时逐渐固定下来的,好处是每一段都对应一个可观测的耗时区间。
| 阶段 | 触发方 | 主要代码位置 | 阶段产物 |
|---|---|---|---|
| 输入事件分发 | MMI 服务 | foundation/multimodalinput | 点击坐标转成启动意图 |
| 启动调度 | AbilityManagerService | foundation/ability/ability_runtime/services | AbilityRecord 创建、状态迁移 |
| 进程孵化 | AppSpawn | base/startup/appspawn | 应用进程 fork 完成、沙箱就绪 |
| 运行时初始化 | ets_runtime + AbilityRuntime | arkcompiler/ets_runtime、foundation/ability/ability_runtime/frameworks | VM 就绪、模块解析完成 |
| 页面加载与元素树构建 | ArkUI 前端 | foundation/arkui/ace_engine/frameworks/core/components_ng | ElementTree 完成 |
| 布局绘制合成 | ArkUI 后端 + RenderService | ace_engine/adapter、foundation/graphic/graphic_2d | 首帧上屏 |
这六段里,1、2 跑在系统进程(前台服务),3 是父子进程交界点,4、5、6 跑在应用自己进程里。注意这个跨进程边界——它决定了你能用哪些工具排查:系统侧的问题看 AMS 日志,应用侧的问题看应用自己的hilog和 trace。很多同学查启动慢,一直在应用侧hilog里翻,其实瓶颈在 AMS 排队,翻到天亮也翻不出来。
1.3 关键角色与线程模型
读启动链路绕不开线程,因为鸿蒙应用进程里同时活着好几条线程,每条线程上的代码性质完全不同。
- 主线程(UI 线程):跑 ArkTS 逻辑、构建和更新 ElementTree。所有
aboutToAppear、onPageShow都在这条线程上,所以主线程一旦被同步 IO 或者大循环卡住,首帧必然推迟。 - IPC 线程池:Binder 驱动的通信线程组,AMS 的调用、RenderService 的回调都从这里进来。这些线程上的代码你基本写不到,但会消耗 CPU。
- 渲染相关线程:ArkUI 把渲染任务投给 RenderService 侧,RS 自己有主线程和多个工作线程。
- TaskPool / Worker 线程:应用侧并发用的,启动阶段一般还没起来。
一个很实用的判断:如果你的 trace 显示主线程在某个区间是空白(Sleeping),但首帧就是没出来,那八成是在等别的线程或者等 IPC 返回,这时候要顺着"谁该唤醒它"去查,而不是盯着主线程的调用栈看。
2. 进程诞生:AppSpawn 与 AMS 是怎么握手的
2.1 init 之后,谁负责把应用进程拉起来
设备开机之后,init读*.cfg启动一堆常驻服务,其中就包括appspawn。它在系统里扮演的角色有点像"统一的 fork 工厂":应用进程自己不负责创建自己,而是由 AMS 把启动参数通过本地 socket 发给appspawn,由它来fork出子进程。为什么要这么设计?我的理解有两点。
第一是安全边界。应用进程需要降权(换 UID/GID)、需要挂载沙箱目录、需要限制能力集,这些操作如果让应用自己来做,等于把权限收放的控制权交给了被限制方,逻辑上站不住。放在appspawn里做,父进程始终以高权限运行,子进程出生时就已经在笼子里了。第二是性能。fork之后子进程可以复用父进程已经加载好的动态库页表,冷启动时能省掉一大截dlopen和重定位的时间——这也是为什么鸿蒙的冷启动优化一直很重视appspawn侧的预加载。
2.2 从 AMS 下发参数到子进程真正跑起来
这一段代码在base/startup/appspawn目录下,父进程侧是一个循环监听 socket 的主流程,子进程侧走的是StartChildProcess回调。我按读到的结构整理成下面这段流程说明(函数名做了简化,具体命名各分支有差异):
// 基于 appspawn 子进程启动流程整理的伪代码,用于说明调用顺序 void AppSpawnChildEntry(int32_t socketFd) { // 1. 从 socket 读出 AMS 下发的启动参数 // 包括 bundleName、abilityName、进程名、UID/GID、沙箱配置 ParseSpawnMessage(socketFd); // 2. 设置进程属性:进程名、降权、能力集收敛 SetProcessName(processName); SetProcessUidGid(uid, gid); KeepCapability(...); // 3. 挂载沙箱:应用自己的数据目录、缓存目录 // 这一步决定了应用能访问哪些路径 MountSandbox(...); // 4. 加载应用侧的启动入口 // 真正进入 AbilityRuntime 从这里开始 LoadAppEntryAndStart(); }这四步里,第 3 步是最容易被忽略但影响很大的。沙箱挂载涉及目录绑定操作,如果应用的数据目录里文件特别多,或者挂载点配置复杂,这段会有可观测的耗时。我实测过一个场景:某个应用在首次启动时要初始化一个几千个文件的本地缓存目录,appspawn阶段的耗时比同类应用高出十几毫秒,最后是通过异步化目录扫描解决的,而不是去优化沙箱本身。
注意:
appspawn侧的代码在不同版本里重构过几次,尤其是沙箱相关逻辑,从早期集中在appspawn内,到后来部分能力下移到foundation/ability/ability_runtime侧。你在某个分支上看到的函数调用关系,换到另一个分支可能就对不上,读的时候先确认分支和版本号。
2.3 StartAbility 背后的任务栈与状态机
在appspawn干活的同时,AMS 侧也没闲着。它要做的事是把一个"启动请求"变成一个有状态的实体——AbilityRecord——并驱动它走状态机。这一套在foundation/ability/ability_runtime/services/abilitymgr下。
状态迁移大致是这样:请求进来先创建AbilityRecord,初始状态是INITIAL;调度到应用侧后进入INACTIVE,此时onCreate、onWindowStageCreate对应的时机已经铺好;窗口完成绘制进入ACTIVE,应用切到后台进入BACKGROUND。
| 状态 | 典型触发 | 应用侧对应回调 | 常见问题 |
|---|---|---|---|
| INITIAL | AMS 收到 StartAbility | 无 | 排队久了会体现为启动白屏 |
| INACTIVE | 进程就绪、窗口创建中 | onCreate、onWindowStageCreate | 窗口创建失败会卡在这 |
| ACTIVE | 首帧提交完成 | onForeground、onPageShow | 首帧慢就卡在这一步之前 |
| BACKGROUND | 用户切走或系统回收 | onBackground、onPageHide | 状态回收不干净导致二次启动异常 |
这张表的最大用处是做定位分流:如果你的应用启动后一直白屏,先看 AMS 日志里这条AbilityRecord卡在哪个状态。卡在INITIAL说明还在排队或调度没成功;卡在INACTIVE说明窗口没建起来;已经ACTIVE但画面还是白的,那问题就在 ArkUI 侧了,属于第 4 章的范畴。
3. ArkTS 虚拟机启动与模块加载
3.1 ets_runtime 的初始化顺序
进程起来之后,真正执行你写的 ArkTS 代码需要先把虚拟机搭好。这一步在arkcompiler/ets_runtime里,初始化顺序大致是:创建EcmaVM实例、绑定主线程、创建 JS 线程与协程调度、注册 NAPI 模块、最后把 napi 环境挂到 AbilityRuntime 上。
这里的顺序不能乱,原因是 NAPI 模块注册依赖 VM 已经存在,而 AbilityRuntime 侧的模块加载又依赖 NAPI 环境可用。我读代码时注意到一个细节:VM 的创建参数里带了堆大小、GC 策略这些配置,这些值不是拍脑袋定的,受应用配置和系统策略影响。你在做内存密集型应用时如果频繁触发 GC,可以顺着这条线去看 VM 创建时用的到底是不是你以为的那套参数。
初始化阶段的耗时有个特点——它和你的业务代码完全无关,纯粹是框架成本。这也是为什么首屏优化的第一优先级,永远是"让第一帧尽早出来",而不是"让第一帧之前少做业务逻辑"——你能砍的业务逻辑是有限的,框架成本是固定的,但你可以通过延迟加载把它推到首帧之后。
3.2 模块解析与 record 的加载
虚拟机能跑了,接下来要解决"加载哪些文件"。ArkTS 的模块化依赖编译期产物:module.json5描述模块信息,编译后变成 record 文件,运行时按 record 去定位 abc 字节码文件和资源。
这一段的调用链大致是:AbilityRuntime 读取模块描述 → 解析出入口 ability 和入口页面路径 → 通过模块管理器加载对应 abc → 触发页面代码执行。有三类文件要区分清楚,很多人会混:
- HAP:应用的主包,包含代码、资源、配置。
- HSP:动态共享包,多个 HAP 之间共享代码和资源,运行时按需加载。
- HAR:静态共享包,编译期就被打进使用方,运行时不单独存在。
这个区分直接影响启动性能。HSP 是运行时加载的,意味着首帧之前如果用到,就要付一次加载和解析成本;HAR 被打进主包,首次访问更快但包体积更大。做过一个对比实验:把几个只在一个页面用到的工具库从 HAR 改成 HSP,主包小了大概一兆多,代价是那个页面首次打开慢了几十毫秒。这就是典型的取舍,没有绝对答案,看你更在意启动还是包体积。
3.3 状态管理装饰器:编译期改写与运行期代理
到这一层,你的 ArkTS 代码终于在跑了。但有个很多人没意识到的事情:你写的@State、@Prop、@Link并不是运行时反射出来的,而是编译期就被改写了。ArkTS 编译器会把带装饰器的struct编译成一个继承自ViewPU的类,每个被装饰的成员变量都会被包装成对应的可观察对象。
我按常见实现的映射关系整理如下:
| 装饰器 | 运行期包装类型 | 数据流向 | 典型误用 |
|---|---|---|---|
@State | ObservedPropertySimplePU/ObservedPropertyObjectPU | 组件内部状态源 | 直接改对象内部字段,不触发刷新 |
@Prop | SynchedPropertySimpleOneWayPU | 父到子单向 | 期望子改父,结果只改了副本 |
@Link | SynchedPropertyTwoWayPU | 父子双向 | 父组件传值没加$前缀 |
@Provide/@Consume | SynchedPropertyTwoWayPU的跨层版本 | 跨层级双向 | 层级深时难以追踪数据来源 |
@ObjectLink/@Observed | ObservedPropertyObjectPU+ 代理 | 对象引用共享 | 只标@Observed忘了@ObjectLink |
理解这套包装的价值在哪?在于你能解释很多"诡异"现象。比如你写this.obj.name = 'x',界面没刷新——因为obj是简单类型包装或者没走代理,赋值动作没经过 setter。再比如@Prop传了对象进来,子组件改了属性,父组件没变——因为单向同步拷贝的是引用,但同步方向是单向的,子侧变更不会回写。这些都不是 bug,是机制本身的设计。
// 说明:以下为状态包装的示意代码,用于解释运行期行为,非框架真实实现 class ObservedPropertySimplePU<T> { private wrappedValue_: T; private subscribers_: Set<ViewPU> = new Set(); get(): T { return this.wrappedValue_; } set(newValue: T): void { if (this.wrappedValue_ === newValue) { return; } this.wrappedValue_ = newValue; // 关键:通知所有依赖该状态的组件,标记为脏 this.notifyPropertyHasChangedPU(); } private notifyPropertyHasChangedPU(): void { this.subscribers_.forEach((view) => view.markNeedUpdate()); } }上面这段是我为了说明机制手写的简化版,不是框架代码,但它准确表达了核心循环:读的时候建立依赖,写的时候触发脏标记,脏标记在下一个 VSync 被消化成实际的布局绘制。把这三步记住,第 4 章就容易理解了。
实操心得:判断一个状态有没有真正触发刷新,最省事的办法不是读源码,而是在
build里加一行日志,看它有没有被重新执行。build被重新执行 ≠ 界面重绘,但build都没执行,那界面一定没动。这个判断链条能帮你把问题快速收敛到状态层还是渲染层。
4. ArkUI 渲染管线:从元素树到上屏
4.1 前端 ElementTree 与后端 RenderNode 的映射
ArkUI 前端在components_ng下维护一棵 ElementTree,每个自定义组件、每个内置组件在树上都有对应节点。而渲染侧有一棵 RenderNode 树,由 RenderService 管理。两者不是一对一,也不是完全松耦合,而是通过Modifier机制建立映射:前端节点持有对应的 RenderNode,属性变更通过 Modifier 下发。
这个设计解决了一个很实际的问题:前端组件树的结构和渲染树的结构可以不完全一致,框架有机会做扁平化。比如你嵌套了五层Column,如果每层都没有实际视觉属性,后端有可能把它合并且不产生额外的 RenderNode。这就是为什么"减少布局层级"这个建议在 ArkUI 上有实际效果——不只是省了遍历成本,更可能省掉了后端节点的创建和维护成本。
4.2 布局、绘制、合成三阶段
从前端节点标记为脏,到画面上屏,中间有三个阶段。
布局阶段负责算大小和位置。这里要注意一个常见误解:Measure和Layout在 ArkUI 里的执行时机取决于具体布局容器,不是所有容器都是"先测子再定自己"。像Flex这类需要知道子节点总尺寸才能决定自身尺寸的容器,会先做一次子节点测量再决定自己的分配。嵌套多个这样的容器,测量次数会明显上升。
绘制阶段负责生成绘制指令。这里有个容易踩的坑:draw相关回调执行在主线程,里面有耗时操作会直接卡住整条管线。我见过一个案例,有人在自定义组件的绘制相关逻辑里做了字符串拼接和格式化,单次几毫秒,一屏几十个组件叠加起来就是上百毫秒,画面明显掉帧。
合成阶段由 RenderService 完成,把各层 RenderNode 合成到最终缓冲区。这一阶段在独立进程/线程上,理论上不受应用主线程影响,但前提是应用把要提交的数据都提交完了。
| 阶段 | 执行位置 | 输入 | 输出 | 耗时敏感点 |
|---|---|---|---|---|
| 布局 | 应用主线程 | 脏节点集合 | 几何信息 | 嵌套过深、测量次数爆炸 |
| 绘制 | 应用主线程 | 几何信息 | 绘制指令 | 绘制回调里有重计算 |
| 合成 | RenderService | 各层 RenderNode | 帧缓冲 | 层级过多、过度绘制 |
4.3 VSync 与脏节点标记的重刷策略
ArkUI 不会在你每次set状态就立刻重绘,而是攒到下一个 VSync 信号统一处理。这个机制的好处是避免同一帧内多次重复计算,坏处是如果你在一帧里改了十次状态,它们会被合并成一次——这通常是好事,但如果十次状态变更依赖中间结果,行为就可能和你预期不同。
脏标记的粒度也是分层的。属性脏和结构脏是两回事:改个颜色只触发属性更新,不会重建节点;但if条件变化导致节点增删,那就是结构变更,成本高得多。所以优化时有个明确的优先级——能改属性就别动结构。我在一个列表场景里做过对比,把"切换 item 内部某个元素的可见性"从if改成visibility属性控制,滚动时的卡顿明显减少,原因就是避免了每帧的结构变更。
5. 实操:把这条链路真正抓下来
5.1 用 hitrace 抓启动耗时块
看代码看多了容易自嗨,实际耗时还得靠工具说话。hitrace是这条链路上最有用的工具,没有之一。
# 抓 10 秒 trace,缓冲区 16MB,覆盖启动相关的几类 tag hdc shell hitrace -t 10 -b 16384 -o /data/local/tmp/launch.htrace \ ability app graphic input arkui # 触发启动后,把文件拉到本地 hdc file recv /data/local/tmp/launch.htrace ./launch.htrace打点时机很关键:先起hitrace,再点图标,这样才能覆盖完整链路。倒过来抓到的就不全了。常用的 tag 里,ability覆盖 AMS 和 AbilityRuntime 侧,app覆盖应用进程侧,graphic覆盖渲染合成,input是输入分发,arkui是前端管线。抓到的文件用常见的 trace 查看工具打开即可。
看 trace 我有个固定套路:先找应用进程的第一条打点,那是进程真正开始干活的时刻;再找第一帧提交的打点,那是首帧上屏的时刻。这两点之间的区间才是真正可优化的部分,前面的是调度排队,通常不是应用能控制的。
5.2 hilog 过滤与 hidumper 快照
trace 看宏观耗时,日志看具体原因。
# 只看能力框架相关日志,级别 D 以上 hdc shell hilog -T "AppKit" -L D # 打印指定域的信息快照,域号按实际版本调整 hdc shell hidumper -s 4606 -a "--print-ability" # 查看应用进程状态,确认是否已孵化、UID 是否正常 hdc shell ps -A | grep <bundleName>hilog的过滤参数里,-T按 tag 过滤比按关键字过滤靠谱得多,因为关键字匹配容易漏或者误伤。hidumper拿到的是一次快照,适合确认"此刻系统认为这个能力处于什么状态",和日志配合使用,一个看过程一个看结果。
5.3 用 Profiler 做耗时归因
DevEco Studio 自带的 Profiler 能看到调用栈级别的耗时分布,比 trace 更细。我的用法是:先用 trace 锁定可疑区间,再用 Profiler 在这个区间里看主线程在干什么。这个顺序能省掉大量时间,因为直接上 Profiler 容易被海量的调用栈淹没。
一个具体的归因例子。某次冷启动首帧偏慢,trace 显示主线程在某个区间有持续占用。Profiler 进去看,热点落在一个字符串处理函数上。回头查代码,是页面初始化时对一个较大的配置对象做了解析和格式化,同步执行在主线程。改法很简单:把这段逻辑挪到首帧之后,用异步任务处理。改完首帧时间下降明显,而功能行为完全没变。这就是"延迟到首帧之后"这个原则的实际价值。
提示:Profiler 采集本身有开销,会拉长实际耗时。用它做归因没问题,但别用它测出来的绝对数字去做性能结论,绝对值还是以不开采样的 trace 为准。
6. 常见问题与排查技巧实录
6.1 首帧慢的几种典型形态
启动慢不是一个问题,是一类问题。我先按现象分个型,因为不同形态的处理路径完全不同。
第一种:整体均匀偏慢。trace 上看每个阶段都比正常值高一截,没有明显尖峰。这种通常是设备负载高、或者应用体积过大导致加载成本整体上升。处置方向是瘦身和资源加载策略,而不是针对某段代码优化。
第二种:某一段有明确尖峰。这种最好办,尖峰位置基本就是答案。我之前遇到过尖峰落在模块加载阶段,原因是首屏用到了几个 HSP,每次冷启动都要解析。方案是把其中只在二级页面用到的挪成按需加载,首帧成本立刻下去。
第三种:主线程大量空白但首帧迟迟不来。这种最迷惑。主线程没干活,说明它在等。等什么?可能是等 IPC 返回,也可能是等子线程结果。查法是顺着 trace 上的线程唤醒关系找,看是谁该通知主线程。我遇到过一次是页面初始化里有个同步等待,主线程在等一个还没准备好的数据源,纯粹是逻辑顺序问题。
第四种:首帧出来了但内容不对,随后又闪一下。这不是启动慢,是数据到达顺序问题。首帧渲染的是占位内容,真实数据到了之后触发第二次渲染。这种要治的是数据预取时机,让它赶在首帧之前到位,或者设计好骨架屏避免视觉跳动。
6.2 常见问题速查表
| 现象 | 可能根因 | 定位手段 | 处置方向 |
|---|---|---|---|
| 点击后长时间白屏 | AMS 排队或进程孵化慢 | AMS 日志 + trace 的 ability tag | 查系统负载、查 appspawn 侧耗时 |
| 首帧前主线程长时间占用 | 同步 IO、大对象解析 | Profiler 调用栈 | 异步化、延后到首帧之后 |
| 首帧正常但随后掉帧 | 结构变更频繁、层级过深 | trace 的 arkui tag | 用属性替代结构变更 |
| 状态改了界面不动 | 包装类型不匹配或未走代理 | 在 build 中加日志 | 检查装饰器与赋值路径 |
| 二次启动比首次快很多 | 首次有磁盘冷读和编译成本 | 对比两次 trace | 预加载、减少首屏依赖 |
| 某页面首次打开明显慢 | HSP 首次加载 | trace + 日志 | 改按需加载或调整包结构 |
6.3 读源码时我踩过的几个坑
别只看头文件就下结论。头文件里声明的东西,实现可能在另一处,而且可能有多个实现按编译配置切换。我早期读源码时吃过亏,照着接口设计了半天方案,结果发现实际用的是另一个实现分支。
版本差异比你想的大。同一个函数,在主干和两个 release 分支上签名都不一样的情况很常见,尤其是能力框架和渲染这两块,重构频率高。养成习惯:读代码前先确认分支,做结论时带上版本前提,别把某个分支的行为当成"鸿蒙的行为"。
别把调试版本的行为当正式行为。调试构建里会开一堆检查、日志、断言,耗时和内存占用都和正式版本差很远。用调试版本测出来的启动时间是没法直接用的,规律可以看,数值不能信。
镜像和预览器不能替代真机。有些性能相关的问题在预览器上完全复现不了,因为预览器的渲染路径和真机不一样。涉及渲染和启动的分析,尽量上真机。
先看数据再看代码。这是我这些年最笃定的一条。不带数据读代码,很容易陷入"这里看起来能优化"的错觉,实际上那段代码可能一年才走一次。先抓 trace 定位耗时,再针对性读那一段源码,效率和准确率都高得多。
最后分享一个我自己一直在用的小方法。每次分析完一条链路,我会画一张只有自己能看懂的草图,标上"谁在什么时刻唤醒了谁",然后用一句话概括这条链路的作用。这张图不追求完整,只追求能让我三个月后重新看的时候,三十秒内想起关键节点在哪。源码分析这件事,理解深度不是靠记住多少行代码堆出来的,而是靠能不能用一句话把一个机制说清楚——如果你能用一句话讲明白 ArkUI 为什么要攒到 VSync 再重绘,那你就是真的读懂了。