性能问题的三类表现
用户感知到的性能问题基本只有三种:启动慢、用着卡、越用越费内存(最终被系统杀掉)。它们的技术原因不同,排查路径也不同。
- 启动慢:Application 里做了太多初始化,或首屏需要等待网络数据。
- 卡顿:主线程被耗时操作占用,或者界面层级过深导致绘制负担重。
- 内存增长:泄漏(对象被意外持有)或资源未释放(图片、监听、连接)。
性能优化的第一步永远是复现与量化。没有基线数据,所有“优化完成”都只是主观感觉。
因此本文的顺序是:先讲测量工具与基线建立,再分别讲三类问题的定位与治理,最后给出可持续的流程。
启动优化:从 Application 到首帧
冷启动可分为三个阶段:进程创建与 Application 初始化、Activity 创建与布局加载、首帧绘制。优化目标是让首帧尽早出现,并在首帧之后再做非必要的初始化。
- 延迟初始化把非首屏必需的 SDK 初始化挪到首帧之后,用懒加载或启动任务框架调度。
- 并行初始化互相独立的初始化放到不同线程,缩短总时长。
- 布局精简减少层级、避免嵌套过深,能显著缩短首帧前的测量与布局时间。
- 启动窗口用主题背景与占位骨架保持一致,避免白屏带来的“更慢”观感。
一个实用的做法是给初始化加上约束:只有首屏确实需要的才允许在主线程、在首帧之前执行。其余全部改为懒加载,这条规则能解决大部分启动慢的问题。
内存泄漏:来源与检测
安卓的内存泄漏几乎都源于“生命周期更长的对象持有了生命周期更短的对象”,常见来源有四个。
- 静态变量持有 Activity 或 View 引用。
- 未注销的监听器与回调(广播接收器、事件总线、传感器、数据库观察者)。
- 非静态内部类 / 匿名 Handler 隐式持有外部 Activity。
- 未取消的协程或线程,其闭包持有界面引用。
检测方法很直接:在 Profiler 里抓两次堆快照,执行一段会创建再销毁界面的操作,比较对象数量;如果某个 Activity 或 Fragment 实例持续增长,就说明有泄漏。再按引用链回溯,找出是谁持有它。
预防手段比事后排查更划算:在 onDestroy 里统一注销监听、协程一律绑定生命周期作用域、Handler 使用静态内部类加弱引用、单例不持有 Context(需要时使用 applicationContext)。把这几条写进团队规范,泄漏会少很多。
卡顿分析:主线程的归因方法
卡顿的本质是主线程无法在 16.6 毫秒(60Hz 下)内完成一帧的工作。归因方法分三步。
- 确认现象:用掉帧统计或帧率工具,确认是持续掉帧还是偶发卡顿。
- 定位时段:用 CPU Profiler 录制卡顿发生的那几秒,看主线程上哪个方法占用了最多时间。
- 修正实现:把耗时操作移出主线程,或做缓存、预计算、批量处理。
常见的元凶包括:在主线程做文件或数据库读写、JSON 解析、图片解码、复杂的正则匹配、以及在 onDraw 里创建对象。这些问题在开发机上往往看不出(性能足够好),一到低端机就集中爆发——所以在目标机型上测试非常必要。
网络与图片:容易被忽略的耗时项
很多“卡顿”其实来自网络等待与图片处理,只是表现为界面不响应。
- 请求合并与并发控制:首屏需要的多个接口并行发起,不要串行等待。
- 数据预取与本地缓存:列表页滚动时提前加载下一页;重复数据先读本地缓存再后台刷新。
- 图片按需解码:按控件实际尺寸采样,避免把 4000 像素的图解码成 200 像素的缩略图。
- 列表图片复用:滚动中取消已离开屏幕的加载任务,避免无效解码与内存占用。
- 启动阶段的网络请求要延后或做超时降级,绝不能让首屏卡在网络返回上。
首屏不要等网络。先渲染本地缓存或骨架屏,数据回来再刷新——用户感知的速度取决于“多快看到内容”,而不是“多快拿到数据”。
工具链:Profiler 与基准测试
| 工具 | 用途 | 输出 |
|---|---|---|
| CPU Profiler | 查看主线程调用耗时 | 调用火焰图与采样记录 |
| Memory Profiler | 找内存增长与泄漏 | 堆快照与引用链 |
| Perfetto / 系统跟踪 | 看整机时序(渲染、IO、线程) | 时间线视图 |
| Macrobenchmark | 量化启动与滚动性能 | 可比较的数值指标 |
Profiler 适合“找问题”,Macrobenchmark 适合“验证改善”。两者配合使用:先用 Profiler 定位瓶颈,改动后跑基准测试确认数值真的下降了,而不是凭感觉宣布优化成功。
实战:用 Macrobenchmark 量化启动耗时
下面这段基准测试会在真机上反复冷启动应用,输出带统计区间的耗时指标,可以直接作为回归判断依据。
// benchmark 模块中的启动基准测试
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@get:Rule
val rule = MacrobenchmarkRule()
@Test
fun coldStartup() = rule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(StartupTimingMetric(), FrameTimingMetric()),
iterations = 5,
startupMode = StartupMode.COLD,
compilationMode = CompilationMode.Partial(),
) {
pressHome()
startActivityAndWait()
device.wait(Until.hasObject(By.res("home_root")), 3_000)
}
}
// Application 中把非必要初始化延后到首帧之后
class App : Application() {
override fun onCreate() {
super.onCreate()
// 首屏必需:只保留最轻量的初始化
initCrashReporter()
// 非必需:等主线程空闲后再执行
Handler(Looper.getMainLooper()).post {
Looper.myQueue().addIdleHandler {
initAnalytics()
initImageLoader()
false // 只执行一次,返回 false 表示移除该 IdleHandler
}
}
}
private fun initCrashReporter() { /* 轻量初始化 */ }
private fun initAnalytics() { /* 较重的第三方 SDK */ }
private fun initImageLoader() { /* 图片库配置 */ }
}
两点说明:基准测试必须跑在真机上,模拟器的数值不可信;IdleHandler 适合“必须执行但不紧急”的初始化,真正的懒加载(用到时才初始化)效果更好。用这套工具建立基线后,每次发版前跑一遍,就能及时发现性能回归。
性能治理的流程与红线
把性能当作一次性任务做完就完了,往往几个月后又会回到原点。可持续的做法是建立流程与红线。
- 定义指标:冷启动耗时、首屏可见耗时、掉帧率、崩溃率、内存峰值,各选一个可自动采集的指标。
- 建立基线:在当前版本上采集数据,写入文档,作为后续对比依据。
- 接入监控:线上采集真实用户的启动与卡顿数据,按机型与系统版本分组看。
- 设置红线:例如“冷启动 P90 不得超过 2 秒”“掉帧率不得高于 5%”,超出即在发版前拦截。
- 持续回归:把基准测试接入 CI,每次合并都跑一次趋势检查。
性能不是某个版本的任务,而是一条长期水位线。没有红线的团队,一定会在某次迭代里悄悄退化。
做到这一步,性能优化就从“救火”变成了“工程纪律”。再配合构建提速与代码规范,整个研发流程的效率会有可见的提升。