性能优化:布局、内存与启动速度的系统治理
性能优化先看用户感受
Android 性能问题最终都会落到用户体验上:页面滑动不流畅、点击后迟迟没有响应、应用打开太慢、使用一段时间后越来越卡,甚至直接崩溃。很多团队一提到性能优化就想到“换框架”“重构架构”“加缓存”,但真正有效的优化通常更朴素:先定位瓶颈,再针对关键链路做小而确定的改动。
性能优化最怕凭感觉。比如“这个页面好像卡”,如果没有数据,就很难判断是布局层级太深、主线程做了耗时任务、图片太大,还是列表刷新方式不合理。正确的思路是:
- 先定义现象:卡顿、内存上涨、启动慢还是耗电异常。
- 再采集证据:帧率、耗时、内存曲线、方法调用栈、启动 Trace。
- 最后做验证:优化前后使用同一套场景和指标对比。
本文重点看三个最常见、也最容易产生收益的方向:布局渲染、内存占用、启动速度。
建立基础指标
优化之前要先知道自己在优化什么。建议至少关注这些指标:
- 帧渲染耗时:主线程是否频繁超过 16.6ms。
- 卡顿率:用户滑动、切页、弹窗时是否出现明显掉帧。
- 内存峰值:复杂页面或长时间使用后的 Java Heap、Native Heap、Graphics 内存。
- 冷启动耗时:从点击图标到首屏可交互的时间。
- 崩溃与 OOM:内存压力下是否更容易触发崩溃。
常用工具包括 Android Studio Profiler、Perfetto、Layout Inspector、Memory Profiler、Macrobenchmark、LeakCanary、Firebase Performance 或自研埋点。工具本身不是目的,它们的价值是把“感觉慢”变成“哪段代码慢”。
布局优化:减少无效测量和绘制
Android 页面渲染大致经历 measure、layout、draw。布局越复杂,嵌套越深,View 树越大,系统要做的测量和绘制工作就越多。布局优化的核心不是把 XML 写得更短,而是减少不必要的层级、重复测量和过度绘制。
控制布局层级
早期项目里常见 LinearLayout 套 LinearLayout,再配合多个 RelativeLayout。简单页面问题不大,一旦列表项、复杂表单、卡片流里大量重复使用,就会放大成本。
ConstraintLayout 的优势是可以用较少层级表达复杂约束。例如一个头像、标题、副标题、右侧按钮的列表项,如果用多层 LinearLayout 包裹,可能需要三四层容器;用 ConstraintLayout 往往一层就够。
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="72dp"
android:padding="16dp">
<ImageView
android:id="@+id/avatar"
android:layout_width="40dp"
android:layout_height="40dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintEnd_toStartOf="@id/action"
app:layout_constraintTop_toTopOf="@id/avatar" />
<Button
android:id="@+id/action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
使用 Layout Inspector 可以直接查看 View 层级。优化时优先处理列表项、首页、复杂弹窗这些高频页面,因为它们会被反复创建和绘制。
避免过度绘制
过度绘制指同一个像素被重复绘制多次。比如 Activity 背景是白色,根布局又设置白色,卡片容器也设置白色,最后 TextView 还叠一层背景。用户只看到最终结果,GPU 却做了多余工作。
可以在开发者选项里开启 “Debug GPU overdraw” 观察页面。如果大片区域显示过度绘制严重,可以检查:
- 根布局和主题背景是否重复。
- 列表项是否设置了不必要的背景。
- shape、阴影、半透明遮罩是否能减少层级。
- 自定义 View 是否只绘制可见区域。
不是所有 overdraw 都必须清零。半透明蒙层、圆角卡片、复杂动效本身就有成本,关键是避免无意义的重复背景。
列表渲染要稳定
RecyclerView 是性能敏感区域。一个页面如果有大量列表项,单个 item 的小问题会被放大很多倍。
建议遵守几条原则:
- 使用 DiffUtil 或 ListAdapter,避免
notifyDataSetChanged()全量刷新。 - 在
onBindViewHolder()中只做绑定,不做网络请求、数据库查询或复杂计算。 - 图片加载设置合适尺寸,避免把超大图直接塞进 ImageView。
- 对多类型列表控制 ViewHolder 复杂度,必要时拆分模块。
- 对稳定 ID、局部刷新 payload 做合理使用。
例如局部刷新可以避免整个 item 重新绑定:
class MessageAdapter : ListAdapter<Message, MessageVH>(DIFF) {
override fun onBindViewHolder(holder: MessageVH, position: Int, payloads: MutableList<Any>) {
if (payloads.contains("readState")) {
holder.updateReadState(getItem(position).read)
return
}
super.onBindViewHolder(holder, position, payloads)
}
}
如果只是已读状态变化,却重新加载头像、重算富文本、刷新整个 item,就会浪费大量主线程时间。
内存优化:控制对象生命周期和资源体积
内存优化不是简单地“少 new 对象”。现代 Android 设备上,正常业务创建对象并不可怕,可怕的是对象生命周期失控、图片资源过大、缓存没有上限,以及页面关闭后仍被持有。
区分内存上涨和内存泄漏
内存上涨不一定是泄漏。比如打开图片列表,内存上升是正常的;退出页面后经过 GC 仍然降不下来,才值得怀疑泄漏。
排查时可以这样做:
- 使用 Memory Profiler 观察页面进入、操作、退出后的内存曲线。
- 手动触发 GC,看对象是否能释放。
- 用 LeakCanary 检查 Activity、Fragment、View 是否泄漏。
- 对大对象进行归因,比如 Bitmap、byte array、长列表缓存。
如果是缓存导致的上涨,需要看缓存策略;如果是生命周期对象无法释放,需要看引用链。
图片是内存大户
一张 1080 × 1920 的 ARGB_8888 图片,解码后大约占 8MB 内存。列表里同时加载多张大图,很容易造成内存压力。图片优化通常收益很明显:
- 服务端提供合适尺寸的缩略图,不要用原图撑列表。
- 使用 Glide、Coil 等成熟图片库,让它们处理采样、缓存和生命周期。
- ImageView 尺寸明确时,请求接近显示尺寸的图片。
- 对大图预览、长图、动图做单独策略。
- 避免把 Bitmap 放进静态变量或无上限集合。
使用 Coil 时可以指定尺寸和生命周期:
imageView.load(url) {
size(imageView.width, imageView.height)
crossfade(true)
placeholder(R.drawable.placeholder)
error(R.drawable.image_error)
}
图片库不是魔法。如果传入的是超大原图地址,并且页面里同时显示很多张,仍然会产生明显内存和解码成本。
缓存必须有边界
缓存可以提升速度,但无边界缓存会把性能问题变成内存问题。常见风险包括:
- 用
mutableMapOf()长期缓存接口结果,从不清理。 - 单例保存页面数据,用户切换账号后仍然存在。
- 内存缓存和磁盘缓存职责混乱。
- 为了“避免重复请求”把大量对象常驻内存。
更稳妥的做法是为缓存设置容量、过期时间和清理时机。比如 LruCache 会按最近使用情况淘汰旧数据:
class AvatarMemoryCache(maxSize: Int) {
private val cache = object : LruCache<String, Bitmap>(maxSize) {
override fun sizeOf(key: String, value: Bitmap): Int {
return value.byteCount / 1024
}
}
fun get(url: String): Bitmap? = cache.get(url)
fun put(url: String, bitmap: Bitmap) {
cache.put(url, bitmap)
}
}
缓存策略要和业务一致:头像、配置、短期列表、离线内容的生命周期都不一样,不应该用同一种全局缓存处理所有数据。
启动优化:让首屏路径更短
启动速度是用户最直观感受到的性能指标之一。冷启动时,系统要创建进程、初始化 Application、启动入口 Activity、加载布局、执行首屏请求和渲染。任何一个环节阻塞主线程,都会拉长等待时间。
Application 不要变成杂物间
很多项目把各种 SDK 初始化都放在 Application.onCreate():统计、推送、地图、支付、图片库、数据库、日志、配置中心。随着业务增长,启动自然越来越慢。
优化思路是给初始化任务分级:
- 首屏必需:没有它就无法显示首页,保留在启动链路。
- 首屏非必需:可以延迟到首页渲染后。
- 按需使用:用户进入相关功能时再初始化。
- 后台任务:放到合适的调度器中,不阻塞主线程。
示例:
class App : Application() {
override fun onCreate() {
super.onCreate()
initCrashReporter()
initCoreConfig()
Handler(Looper.getMainLooper()).post {
initAnalytics()
initImagePreload()
}
}
}
这不是鼓励把所有任务都 post 到后面,而是提醒:启动链路只保留真正必要的工作。延迟任务也要注意总量,避免首页刚显示就立刻卡顿。
首屏数据先轻后重
首页经常同时承担很多职责:用户信息、Banner、推荐流、弹窗、红点、配置、广告、AB 实验。如果全部串行完成后才显示页面,冷启动必然慢。
可以把首屏拆成不同优先级:
- 骨架屏或基础布局先展示。
- 必要数据优先请求。
- 次要模块异步加载。
- 弹窗、广告、推荐模块等首帧后再处理。
在协程里可以并发请求互不依赖的数据:
viewModelScope.launch {
val profile = async { repository.loadProfile() }
val tabs = async { repository.loadTabs() }
renderBasicPage(
user = profile.await(),
tabs = tabs.await()
)
launch { loadRecommendFeed() }
launch { loadMarketingCard() }
}
注意并发不是越多越好。网络请求过多会抢占带宽,也会增加服务端压力。关键是缩短关键路径,而不是把所有事情同时扔出去。
用基准测试守住成果
启动优化很容易反弹。今天移走一个 SDK 初始化,明天另一个需求又把耗时任务加回 Application。因此建议用 Macrobenchmark 或线上性能监控持续观察。
Macrobenchmark 可以从外部进程启动应用,更接近真实用户路径:
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun startup() = benchmarkRule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(StartupTimingMetric()),
iterations = 10,
startupMode = StartupMode.COLD
) {
pressHome()
startActivityAndWait()
}
}
当启动耗时超过阈值时,让 CI 或监控报警,比上线后等用户反馈更可靠。
主线程治理:别让 UI 线程背锅
布局、内存、启动之外,还有一个贯穿所有性能问题的原则:主线程只做 UI 相关的轻量工作。常见的主线程陷阱包括:
- 在点击事件里读写大文件。
- 在
onCreate()中同步查询数据库。 - 在
onBindViewHolder()中解析大 JSON。 - 在广播回调里做复杂计算。
- 在自定义 View 的
onDraw()中创建对象或加载资源。
可以用 StrictMode 在开发阶段暴露问题:
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build()
)
}
StrictMode 不一定适合直接开启所有检测并阻断流程,但它非常适合帮助团队发现“本不该在主线程发生”的操作。
优化要可回归
一次性能优化完成后,建议留下三类东西:
- 指标:优化前后的帧耗时、启动耗时、内存峰值。
- 结论:瓶颈原因和对应改动。
- 防线:基准测试、代码规范、监控报警或 Review 清单。
否则优化成果很容易随着需求迭代流失。比如列表已经改成 DiffUtil,但后来有人为了省事又加回 notifyDataSetChanged();启动已经拆掉无关 SDK 初始化,但新功能又把同步 I/O 放进 Application。没有防线,性能会慢慢退化。
一份实用检查清单
布局渲染:
- 高频页面是否存在过深嵌套。
- 列表 item 是否足够轻量。
- 是否存在明显过度绘制。
- 自定义 View 是否在
onDraw()中创建对象。 - 列表刷新是否使用 DiffUtil 或局部刷新。
内存占用:
- Activity、Fragment、ViewBinding 是否能正确释放。
- 图片是否按显示尺寸加载。
- 缓存是否有容量和过期策略。
- 大对象是否被静态变量或单例持有。
- 长时间使用后内存是否持续上涨。
启动速度:
Application.onCreate()是否只做必要初始化。- 首屏请求是否存在不必要串行等待。
- 首页是否先展示基础内容,再加载次要模块。
- 是否有启动基准测试或线上监控。
- 新增 SDK 是否评估启动成本。
小结
Android 性能优化不是追求某个神奇技巧,而是一套工程化方法:用数据定位问题,用小步改动解决瓶颈,再用监控和测试防止退化。
布局优化关注 View 树、过度绘制和列表绑定;内存优化关注对象生命周期、图片体积和缓存边界;启动优化关注关键路径、初始化分级和首屏优先级。把这些基础工作做好,大多数应用的流畅度和稳定性都会有明显提升。