性能优化:内存泄漏检测、ANR分析、布局优化、协程优化
性能优化这个话题,说实话,很多开发者觉得它「虚」。
代码能跑,功能正常,为什么要优化?
嗯,我以前也这么想。直到有一次,我负责的一个社交App在用户手机后台运行半小时后,直接OOM崩溃了。用户反馈炸了锅,我连夜排查,发现罪魁祸首就是一个没被释放的Activity引用——说白了,就是内存泄漏。
从那以后,我把性能优化列入了每个版本的必修课。今天我们就聊聊四个最核心的方向:内存泄漏、ANR、布局和协程。
核心观点:性能优化不是锦上添花,而是雪中送炭。一个流畅、不崩溃、不卡顿的App,才是用户愿意留下来的基础。
一、内存泄漏检测:LeakCanary 实战
内存泄漏,说白了就是「该回收的对象没被回收」。Java/Kotlin 有 GC,但 GC 只回收「不可达」的对象。如果你的代码里某个对象被一个长生命周期的对象引用着,GC 就永远动不了它。
我个人的习惯是:在开发阶段就集成 LeakCanary,而不是等到线上出问题再查。
1.1 集成方式
// build.gradle.kts (app 模块)
dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
}
就这么一行。LeakCanary 2.x 不需要手动初始化,它会自动注册。你只需要在 debug 包中运行 App,正常操作,如果有泄漏,通知栏会弹出提示。
1.2 常见泄漏场景
- 静态变量持有 Activity 引用:比如一个静态的 View 或 Context。
- 内部类/匿名类:非静态内部类隐式持有外部类引用。Handler、Runnable 最容易中招。
- 未取消的注册:比如 BroadcastReceiver、EventBus 注册后没解绑。
- 单例持有 Context:单例里如果传了 Activity 的 Context,Activity 退出后单例还活着,就泄漏了。
我的经验:我曾经在一个 Fragment 里写了一个匿名 Handler,postDelayed 了一个 10 秒的任务。结果用户快速切换页面,Fragment 被销毁了,但 Handler 还在,导致 Fragment 泄漏。后来我改用 Lifecycle 配合协程,彻底解决了这个问题。
1.3 如何分析 LeakCanary 的堆栈
LeakCanary 会给出一个引用链,比如:
┬───
│ GC Root: Local variable in native code
│
├─ android.app.ActivityThread instance
│ ↓ ActivityThread.mActivities
├─ android.util.ArrayMap
│ ↓ ArrayMap.valueAt
├─ com.example.MainActivity instance
│ ↓ MainActivity.mLeakedView
├─ android.widget.TextView
│ ↓ TextView.mContext
╰─ com.example.MainActivity (leaked)
看到这个链,你就知道:MainActivity 被 ActivityThread 的 mActivities 引用着,而 mActivities 里有一个 TextView 的 mContext 指向了 MainActivity。说白了,就是 Activity 没被销毁,或者销毁后还被其他地方引用着。
注意:LeakCanary 只在 debug 包中生效。线上包不要集成,否则会影响性能。如果你需要线上监控,可以考虑接入 Matrix 或自己封装一个轻量版。
二、ANR 分析:从日志到定位
ANR(Application Not Responding)是用户最直观的「卡死」体验。系统会弹出一个对话框,问用户「继续等待还是关闭」。说实话,用户大概率选关闭。
ANR 的触发条件很简单:
- 主线程阻塞超过 5 秒(输入事件)
- BroadcastReceiver 未在 10 秒内返回
- Service 未在 20 秒内执行完
2.1 如何获取 ANR 日志
ANR 发生时,系统会在 /data/anr/traces.txt 中记录堆栈信息。你可以通过 adb 拉取:
adb pull /data/anr/traces.txt
或者,在 Android 8.0 以上,系统会在 /data/anr/ 下生成多个文件,文件名包含时间戳。
2.2 分析 traces.txt 的关键点
- 找到
main线程(通常叫"main" prio=5 tid=1) - 看它的堆栈,停在哪个方法上
- 如果是
Thread.sleep()或wait(),说明是主动阻塞 - 如果是
BinderProxy.transact(),说明是跨进程调用卡住了 - 如果是
Handler.dispatchMessage()里执行了耗时操作,那就是代码问题
避坑指南:我曾经遇到一个 ANR,堆栈显示停在 View.onDraw() 里。我以为是绘制太复杂,结果发现是 onDraw() 里调用了 BitmapFactory.decodeResource()——这玩意儿在主线程里做 IO,不卡才怪。
2.3 预防 ANR 的手段
- 主线程不要做任何 IO 操作(网络、文件、数据库)
- 使用
StrictMode在开发阶段检测主线程违规 - 复杂计算放到协程或线程池
- 使用
AsyncTask或HandlerThread处理后台任务
三、布局优化:从渲染树到性能
布局优化,说白了就是让 View 树更「扁」、更「轻」。Android 的渲染流程是:Measure → Layout → Draw。每一层嵌套都会增加计算量。
3.1 减少层级
我见过最夸张的布局,一个简单的列表项嵌套了 7 层 LinearLayout。你想想看,每个 item 都要 measure 7 次,滑起来能不卡吗?
优化手段:
- 使用
ConstraintLayout替代多层嵌套 - 使用
Merge标签合并根布局 - 使用
ViewStub延迟加载不常用的布局
3.2 使用 include 和 ViewStub
<!-- 主布局 -->
<LinearLayout ...>
<include layout="@layout/common_header" />
<ViewStub
android:id="@+id/stub_extra"
android:layout="@layout/extra_panel"
android:inflatedId="@+id/extra_panel" />
</LinearLayout>
在代码中按需加载:
val stub = findViewById<ViewStub>(R.id.stub_extra)
stub.inflate() // 只有调用时才加载
3.3 使用 RecyclerView 替代 ListView
这个其实不用多说。RecyclerView 的 ViewHolder 复用机制比 ListView 高效得多。而且它支持动画、多布局、拖拽,功能也更强大。
我的建议:如果你还在用 ListView,赶紧迁移到 RecyclerView。迁移成本不高,但性能提升是立竿见影的。
四、协程优化:从线程到协程的思维转变
Kotlin 协程不是线程,它只是一个「可挂起的计算」。说白了,协程让异步代码写起来像同步代码,而且不会阻塞线程。
4.1 协程的常见陷阱
- 全局作用域:使用
GlobalScope.launch会导致协程无法被取消,容易泄漏。 - 未处理异常:协程中的异常如果不处理,会直接崩溃。
- 线程切换过多:频繁
withContext(Dispatchers.IO)反而增加开销。
4.2 正确的协程使用方式
// 在 ViewModel 中使用 viewModelScope
viewModelScope.launch {
val data = withContext(Dispatchers.IO) {
repository.fetchData()
}
// 回到主线程更新 UI
updateUI(data)
}
为什么用 viewModelScope?因为它会在 ViewModel 被清除时自动取消所有协程,避免泄漏。
4.3 协程的并发控制
如果你需要并发执行多个任务,可以用 async/await:
viewModelScope.launch {
val result1 = async { fetchData1() }
val result2 = async { fetchData2() }
val combined = result1.await() + result2.await()
}
但要注意:async 默认使用 Dispatchers.Default,如果任务中有 IO 操作,记得指定 Dispatchers.IO。
注意:不要滥用 async。如果两个任务之间有依赖关系,用 launch 顺序执行反而更清晰。我见过有人把三个串行任务写成三个 async,然后 await 三次——代码又长又难读,性能也没提升。
知识体系总览
下面这张图,是我自己总结的「性能优化四象限」。你可以把它当作一个检查清单:
这四个方向,每一个都值得深入。但说实话,你不需要一次性全部做完。我的建议是:先从内存泄漏和 ANR 入手,这两个问题对用户体验影响最大。布局优化和协程优化,可以在日常开发中逐步养成习惯。
最后说一句:性能优化不是一次性的工作,而是一种思维习惯。每次写代码前,问自己一句:「这段代码会不会在主线程卡住?会不会导致泄漏?」久而久之,你写出来的代码自然就「抗造」了。