序、众所周知,每一次开始写这种框架系类的文章的时候,就是我在找工作了,有闲暇时间可以写点东西。哈哈哈哈,26年找工作,简直就是。。。。。
重要的事情说三遍
有岗位的请 CALL 我,谢谢大家了。。。。
有岗位的请 CALL 我,谢谢大家了。。。。
有岗位的请 CALL 我,谢谢大家了。。。。
上一篇我们讲了:
Application 与第三方 SDK 初始化优化。
核心原则是:
启动阶段只做真正必须做的事情。
但即使我们已经把很多 SDK 做了延迟初始化,启动依然可能不够快。
这时候通常还要继续看几个非常常见的问题:
-
线程太多
-
CPU 竞争
-
主线程等待
-
GC
-
文件 IO
-
SharedPreferences
-
数据库
-
Binder
这些问题有一个共同点:
它们不一定表现为"某个方法特别慢",但却可能让整个启动链路被拖长。
这也是为什么启动优化不能只看方法耗时。
一、线程越多,启动一定越快吗?
很多人做启动优化时,会想到一个很自然的方案:
主线程慢,那就把任务全部放到子线程。
例如原来 Application 中顺序执行:
任务 A
↓
任务 B
↓
任务 C
↓
任务 D
于是优化成:
线程 1 执行 A
线程 2 执行 B
线程 3 执行 C
线程 4 执行 D
看起来好像所有任务都并行了。
但真实情况不一定更快。
因为:
CPU 核心数量是有限的。
假设设备只有几个核心,而启动瞬间创建了十几个线程。
这些线程都会争抢 CPU。
包括:
Main Thread。
最终就可能出现:
后台线程很多,
但主线程反而拿不到 CPU。
二、Perfetto 中的 Runnable 为什么重要?
在 Perfetto 中,我们前面已经认识了几个状态:
-
Running
-
Runnable
-
Waiting
-
Sleeping
其中:
Runnable
在启动优化里非常值得关注。
Runnable 表示:
线程已经准备执行,但是暂时没有获得 CPU。
也就是说:
它不是没工作。
而是:
想干活,但是抢不到 CPU。
如果启动阶段看到 Main Thread 长时间处于 Runnable,
那就要警惕:
当前是不是存在严重的 CPU 竞争?
三、Running 和 Runnable 有什么区别?
可以这样理解。
Running
表示:
当前线程正在 CPU 上真正执行代码。
例如:
Main Thread 连续 Running 50ms。
说明:
主线程这 50ms 基本一直在计算。
可能是:
-
JSON 解析
-
View Inflate
-
类加载
-
业务计算
-
大量对象创建
Runnable
表示:
线程已经准备好了,但是还在排队等 CPU。
比如:
主线程想执行首帧绘制。
但当前其他几个后台线程正在占用 CPU。
主线程就可能:
Runnable
↓
等待调度
↓
Running
所以:
Runnable 时间长,不代表主线程代码本身复杂。
它可能只是:
CPU 竞争太严重。
四、为什么"全部异步"可能适得其反?
假设 Application 中有:
10 个初始化任务。
全部放线程池。
启动瞬间:
10 个任务一起执行。
这些任务可能同时进行:
-
JSON 解析
-
数据计算
-
Class Loading
-
数据库操作
-
SDK 初始化
CPU 很快被占满。
这时候首页 Activity 需要:
-
Inflate
-
Measure
-
Layout
-
Draw
但 Main Thread 也需要 CPU。
最终:
后台任务把前台关键任务挤慢了。
所以更合理的启动优化思路不是:
尽可能多并发。
而是:
保证启动关键路径优先。
五、线程优化应该怎么做?
一般可以从几个方向考虑。
1. 控制启动阶段线程数量
不要每个 SDK 自己创建一个线程。
尽量统一使用线程池。
2. 控制并发度
不是所有任务都需要一起执行。
例如:
高优先级任务先执行。
低优先级任务首帧之后再执行。
3. 减少线程创建
线程本身也有创建和调度成本。
大量创建短生命周期线程通常没有必要。
4. 避免主线程等待子线程
这是一个很常见的问题。
例如:
主线程:
提交一个后台任务。
然后马上:
等待后台任务完成。
这种情况虽然用了线程池,
本质上仍然是:
同步执行。
甚至还增加了线程切换成本。
六、主线程 Waiting 到底意味着什么?
Perfetto 中如果发现:
Main Thread:
Waiting
时间很长。
说明:
主线程当前没有在执行,而是在等待某个条件。
常见原因包括:
-
等锁
-
等其他线程
-
等 Binder
-
等 Condition
-
等 IO
-
等某个同步任务完成
这里最重要的问题不是:
Waiting 本身是不是慢。
而是:
主线程在等谁?
七、锁竞争为什么会拖慢启动?
假设启动时两个线程都访问一个共享对象。
线程 A:
持有锁。
↓
Main Thread:
需要这个锁。
↓
Main Thread 被阻塞。
如果线程 A 又执行了比较重的任务,
那么主线程就会一直等。
例如一些全局组件:
-
单例初始化
-
数据库
-
配置中心
-
日志组件
-
缓存系统
内部都可能存在同步锁。
所以启动阶段如果看到:
主线程长时间 Waiting,
就要检查是否存在:
锁竞争。
八、Binder 等待也是启动耗时的一部分
Android 很多系统能力不是直接在应用进程内部完成的。
而是需要通过:
Binder
调用系统服务。
例如:
-
PackageManager
-
ActivityManager
-
WindowManager
-
Location
-
设备信息
-
系统设置
如果启动阶段频繁请求这些系统服务,
可能出现:
App Main Thread
↓
Binder 请求
↓
系统进程处理
↓
返回结果
↓
Main Thread 继续执行。
如果系统服务繁忙,
或者 Binder 调用本身较多,
主线程就会等待。
因此做启动优化时,可以问一句:
这些系统服务调用真的需要在首屏之前完成吗?
如果不需要,
就应该考虑延迟。
九、GC 为什么会影响启动?
App 启动过程中通常会创建大量对象。
比如:
-
Application
-
Activity
-
Fragment
-
View
-
Drawable
-
Bitmap
-
JSON 对象
-
SDK 对象
如果启动阶段短时间创建大量对象,
堆内存压力就会上升。
当系统认为需要回收内存时,
就可能触发:
GC。
GC 会占用 CPU,
也可能让应用线程暂停一段时间。
如果 GC 恰好发生在首屏之前,
就可能增加 TTID。
十、启动阶段为什么容易产生大量对象?
常见原因包括:
大量 View Inflate
每一个 View 都是对象。
首页 View 越多,
创建对象越多。
JSON 解析
大 JSON 往往会产生大量临时对象。
大量集合
例如:
List、Map、临时 Bean。
SDK 集中初始化
每个 SDK 初始化都可能创建自己的对象体系。
Bitmap
图片解码会产生较大的内存压力。
所以启动优化不只是:
代码执行了多久。
还要考虑:
启动过程中制造了多少垃圾对象。
十一、看到 GC 就一定要优化吗?
不一定。
GC 是 Android 正常内存管理机制。
偶尔出现一次 GC:
不代表一定存在严重问题。
真正值得关注的是:
-
首屏之前频繁 GC
-
单次 GC 时间较长
-
GC 与启动卡顿时间高度重合
-
优化对象创建后启动明显改善
所以还是要结合:
Perfetto 时间线
分析。
不要简单:
Trace 里有 GC,所以启动慢就是 GC。
十二、怎么减少启动阶段 GC?
可以从几个方向考虑。
1. 减少不必要对象创建
尤其是首屏之前。
2. 避免启动时解析大数据
如果不是首屏必须的数据,
可以延迟。
3. 减少复杂布局
View 越多,
对象越多。
4. 避免大量临时集合
例如只为了初始化使用一次的大 List、Map。
5. 图片按需加载
不要 App 一启动就解码大量 Bitmap。
目标仍然是:
让启动关键路径尽可能轻。
十三、IO 为什么是启动优化重点?
磁盘 IO 和 CPU 计算不一样。
CPU 计算一般比较稳定。
而磁盘 IO 可能受到:
-
文件大小
-
文件系统
-
缓存
-
设备存储性能
-
系统负载
影响。
因此 IO 不仅可能慢,
而且:
波动也比较大。
这也是为什么同一个 App 启动多次,
某些 Iteration 会明显慢一些。
十四、启动阶段常见的 IO 有哪些?
最常见的包括:
-
文件读取
-
SharedPreferences
-
SQLite
-
数据库初始化
-
配置文件
-
缓存文件
-
日志文件
-
JSON 文件
-
资源加载
如果这些操作直接发生在 Main Thread,
就可能阻塞:
首帧绘制。
十五、SharedPreferences 为什么也要注意?
很多项目启动时会读取:
-
登录状态
-
用户配置
-
AB 实验
-
App 设置
-
上次启动信息
这些数据往往放在:
SharedPreferences。
SharedPreferences 用起来很方便,
但它底层仍然涉及:
-
文件读取
-
XML 解析
-
内存对象创建
如果配置文件越来越大,
启动阶段大量读取,
也可能增加耗时。
所以一个原则是:
不要因为 SharedPreferences API 很简单,就认为它没有性能成本。
十六、数据库为什么容易拖慢启动?
数据库操作也是启动阶段常见问题。
例如:
App 一启动就:
-
打开数据库
-
创建连接
-
查询用户数据
-
查询首页缓存
-
数据迁移
-
检查表结构
如果这些操作发生在首屏之前,
就可能影响 TTID。
尤其是:
数据库升级 / Migration
更要小心。
因为某个版本首次启动时,
可能出现一次明显的启动变慢。
十七、数据库优化的核心思路
还是先问:
这条数据是不是首屏必须?
如果不是:
延迟查询。
如果必须:
考虑:
-
减少查询量
-
优化 SQL
-
使用索引
-
减少主线程数据库访问
-
首屏只读取必要字段
-
避免启动阶段做复杂 Migration
不要一上来就把整个数据库加载到内存。
十八、文件读取也要注意"大文件"
例如启动时读取:
一个 10KB 配置文件。
和:
一个 10MB JSON 文件。
完全不是一个概念。
如果启动阶段加载:
-
大 JSON
-
大文本
-
大图片
-
大模型文件
-
大缓存
一定要非常谨慎。
通常更合理的方案是:
首屏先读取最小必要数据。
其他内容:
首帧之后再加载。
十九、IO 放后台线程就一定没问题吗?
也不一定。
假设你把 5 个大文件读取任务全部放到后台线程。
虽然 Main Thread 不直接执行 IO,
但这些任务仍然可能:
-
占用 IO 带宽
-
消耗 CPU 解析数据
-
创建大量对象
-
与主线程竞争资源
所以真正合理的方式仍然是:
减少启动阶段不必要的 IO。
而不是单纯:
把 IO 换到后台线程。
二十、CPU 问题和 IO 问题怎么区分?
Perfetto 中可以通过一些现象辅助判断。
假设一个任务:
Wall Duration:
100ms
CPU Duration:
90ms
通常说明:
主要是 CPU 计算。
可以检查:
-
JSON
-
Inflate
-
类加载
-
计算逻辑
如果:
Wall Duration:
100ms
CPU Duration:
10ms
说明:
大部分时间没有占用 CPU。
可能存在:
-
Waiting
-
IO
-
Binder
-
锁
-
调度等待
这时候分析方向就完全不同。
所以:
Wall Duration + CPU Duration 一定要结合看。
二十一、启动优化不能只看主线程代码
这是这一篇很重要的一点。
有时候你检查 Main Thread 代码:
发现并没有一个特别慢的方法。
但 TTID 还是高。
原因可能是:
后台线程太多。
↓
CPU 竞争。
或者:
主线程频繁等待 Binder。
或者:
首屏前发生 GC。
或者:
磁盘 IO 抖动。
也就是说:
启动性能是整个系统调度结果,而不仅仅是某一个 Java/Kotlin 方法的执行时间。
这也是 Perfetto 的价值。
二十二、一个典型的线程问题
假设 App 启动时初始化:
-
图片库
-
推送
-
埋点
-
广告
-
数据库
-
配置中心
为了优化启动,
全部放到线程池。
结果 Perfetto 中看到:
大量后台线程:
Running。
Main Thread:
Runnable。
此时:
主线程并不是代码慢,
而是:
一直在等 CPU。
这时候真正的优化方案应该是:
-
降低并发度
-
延迟低优先级任务
-
首帧之后再执行部分任务
而不是继续:
再多开几个线程。
二十三、一个典型的 IO 问题
假设 Perfetto 中看到:
Application 阶段 Wall Duration 很长。
但 CPU Duration 很低。
继续分析发现:
启动时读取了:
-
用户配置
-
首页缓存
-
历史记录
-
日志文件
这些操作都在 Main Thread。
那么优化方向就很明确:
首屏真正需要:
用户登录状态。
其他数据:
都可以延迟。
最终把启动路径从:
读取 10 个文件
变成:
只读取 1 个必要配置。
这种优化通常比:
微调某个方法的 2ms
更有价值。
二十四、启动阶段的问题可以分成三类
以后分析 Perfetto 时,可以先把问题分成:
第一类:CPU 重
表现:
CPU Duration 很高。
常见原因:
-
计算
-
Inflate
-
JSON
-
Class Loading
-
大量对象创建
第二类:等待重
表现:
Wall Duration 高,
CPU Duration 低。
常见原因:
-
Binder
-
Lock
-
等线程
-
IO
第三类:调度竞争
表现:
Runnable 时间高。
常见原因:
-
线程太多
-
CPU 被后台任务占用
这样一分类,
问题会清晰很多。
二十五、优化之后一定要重新测
假设我们做了这些修改:
减少启动线程。
延迟数据库查询。
减少 SharedPreferences 读取。
降低对象创建。
那么下一步不是:
看代码感觉不错。
而是重新运行:
Macrobenchmark。
例如原来:
TTID median = 340.2ms
优化以后:
TTID median = 300ms
再结合 Perfetto 看到:
Main Thread Runnable 减少。
GC 消失。
IO 延迟到首帧之后。
这时候才可以说:
这次优化有效。
二十六、到这里我们解决的还是"业务层启动问题"
目前几篇主要优化的是:
-
Application
-
SDK
-
线程
-
IO
-
GC
-
Binder
-
UI
这些都属于比较常见的:
业务层启动优化。
但是 Android 冷启动还有另外一类成本:
代码本身怎么被加载和执行。
例如:
启动需要加载哪些 Class?
这些 Class 分布在哪些 DEX?
是否发生大量 Page Fault?
启动代码是不是集中存放?
这就进入更深一层的:
Class Loading 与 DEX Layout。
二十七、小结
这一篇重点讲了三个很容易被忽略的启动性能问题:
线程
不是越多越好。
如果线程过多:
会产生 CPU 竞争,
导致 Main Thread 出现大量 Runnable。
GC
启动阶段大量对象创建,
可能触发 GC,
增加首帧之前的额外工作。
IO
文件、SharedPreferences、数据库等磁盘操作,
如果进入启动关键路径,
很容易造成启动耗时和波动。
所以 Perfetto 分析时可以重点观察:
Running
↓
CPU 是否真的很忙?
Runnable
↓
是不是在抢 CPU?
Waiting
↓
到底在等谁?
再结合:
-
GC
-
Binder
-
IO
-
Lock
定位真正的问题。
启动优化最终还是回到一个原则:
让首屏关键路径尽量少做事情、少等待、少竞争。
下一篇我们就进入启动优化里更深入的一部分:
Android 启动优化(六):Class Loading、DEX Layout 与启动类重排
这一篇会开始讲:
-
Android 启动为什么会涉及大量类加载
-
DEX 分布为什么会影响冷启动
-
Page Fault 是什么
-
为什么启动类集中可能更快
-
Startup Profile 的 DEX Layout 思路
-
ReDex InterDex 的
coldstart_classes -
类重排到底优化的是启动性能还是包体积
这也是后面把 Startup Profile 和 ReDex InterDex 真正串起来的一篇。