Android 主线程性能优化实战:从 90% 降至 13%

Android 主线程性能优化实战:从 90% 降至 13%

📊 优化成果

指标 优化前 优化后 提升
doFrame 主线程占用 94.13% 13.16% ↓ 86%
UI 渲染耗时 10,794,664 μs 1,377,894 μs ↓ 87%
帧率表现 频繁掉帧 稳定 60fps 显著提升

🎯 问题现象

在 GPS 测速应用主界面运行时:

  • 主线程持续高负载:CPU 使用率居高不下!

CPU Timeline 表现

从 CPU 使用率图可以看到:

  • 主线程(main thread)持续处于高负载状态(橙色区域密集)
  • 有规律的性能尖刺,说明某个操作在周期性执行
  • Threads 数量达到 67 个,系统资源紧张

🔍 性能分析方法论

第一步:使用 Android Profiler 录制性能数据

工具路径View → Tool Windows → Profiler

操作步骤

  1. 连接设备,选择目标应用进程
  2. 点击 CPU Profiler,选择录制模式:"Java/Kotlin Method Recording"
  3. 点击 Record 开始录制
  4. 操作应用,重现卡顿场景(如:倒计时运行 10 秒,页面滑动)
  5. 点击 Stop 停止录制(建议录制 10 秒左右
📌 录制模式选择

Android Profiler 提供两种主要的录制模式:

模式 特点 适用场景 性能开销
Sample Java Methods 周期性采样(默认 1ms) 长时间录制、整体性能评估
Java/Kotlin Method Recording 记录每个方法的调用 精确分析方法耗时、本次使用

本次优化使用Java/Kotlin Method Recording

  • ✅ 能精确记录每个方法的调用次数和耗时
  • ✅ 可以看到完整的调用栈和层级关系
  • ✅ 适合定位具体的性能瓶颈
  • ⚠️ 性能开销较大,录制时间不宜过长(建议 10 秒左右

录制时长建议

  • ⏱️ 10 秒:足够捕获倒计时的多次更新(10 次刷新)
  • ⏱️ 太短(< 5 秒):数据样本不足,可能不具代表性
  • ⏱️ 太长(> 30 秒):数据量过大,影响分析性能,且录制本身会影响应用运行

第二步:Top Down 分析 - 发现性能瓶颈

Top Down 视图的作用:从调用入口开始,查看哪些方法占用了最多的主线程时间。

分析结果

从 Profiler 的 Top Down 视图可以清楚看到:

复制代码
📊 Call Chart - Top Down 视图:

main()                          11,462,265 μs    100.00%
└─ dispatchMessage()            11,234,487 μs     97.96%
   └─ handleCallback()          11,224,206 μs     97.88%
      └─ run()                  10,794,703 μs     94.13%  ⚠️ 异常高!
         └─ doFrame()           10,794,664 μs     94.13%
            └─ doCallbacks()    10,793,370 μs     94.12%

关键数据

  • run(Choreographer$FrameDisplayEventReceiver) 占用 94.13%
  • doFrame() 耗时 10,794,664 μs(约 10.8 秒)
  • doCallbacks() 占用 94.12%
🔴 关键发现

1. doFrame 占用主线程 94.13%

  • doFrame 是 Choreographer 的帧渲染回调
  • 包含 measurelayoutdraw 三个阶段
  • 正常情况:doFrame 应该占用 < 20%,每帧 < 16ms
  • 当前情况:占用 94%,说明渲染流程严重耗时

2. 持续的高占用

  • 不是偶发的卡顿,而是持续的性能问题
  • CPU Timeline 显示橙色区域密集,说明主线程一直在忙碌
  • 推测:有某个操作在不断地触发渲染流程

3. 初步判断

  • 问题出在 UI 渲染流程
  • 很可能是频繁触发了 measurelayout
  • 需要找出是谁在不断调用 requestLayout()

第三步:展开 doFrame 调用链 - 定位具体问题

继续在 Top Down 视图中展开 doFrame()

复制代码
doFrame()
└─ doCallbacks()
   └─ ViewRootImpl$TraversalRunnable.run()
      └─ performTraversals()           ⚠️ 视图遍历
         └─ performMeasure()           ⚠️ 测量阶段耗时
            └─ measure()
               └─ onMeasure()
🔴 核心发现:measure 阶段耗时严重

measure 阶段的作用

  • 计算每个 View 的宽高
  • 递归遍历整个 View 树
  • 是渲染流程中最耗时的部分

异常点

  • performMeasure 占用了大量时间
  • 说明频繁触发了 View 的重新测量
  • Android 机制 :只有调用 requestLayout() 才会触发 measure

结论

某个或某些 View 在频繁调用 requestLayout(),导致整个 View 树不断重新测量。


第四步:Bottom Up 分析 - 反向追踪调用源

Bottom Up 视图的作用:从耗时方法出发,查看是谁调用了它。

操作步骤
  1. 切换到 Bottom Up 标签页
  2. 在搜索框输入:requestLayout
  3. 展开调用栈,查看所有调用 requestLayout 的位置
分析结果

通过 Bottom Up 视图搜索 requestLayout,可以看到:

复制代码
📊 Bottom Up 视图:

requestLayout()                         ← 搜索目标
├─ DurationDigitalView.updateView()    ⚠️ 高频调用!
│  └─ MainSpeedDistanceView.updateTime()
│     └─ MainBaseFragmentV20$observe$3.invokeSuspend()
│
├─ DistanceDigitalView.updateView()    ⚠️ 频繁调用
│  └─ MainSpeedDistanceView.updateSpeedAndDistanceView()
│
├─ TextView.setText()                   ⚠️ 不必要的调用
│  └─ GpsStatusView.updateView()
│
└─ ... 其他调用
🔴 找到三个优化点

问题 1:DurationDigitalView 每秒调用 requestLayout

  • 倒计时每秒更新一次
  • 每次更新都调用 requestLayout()
  • 实际上宽度 99% 的时候并未改变

问题 2:DistanceDigitalView 频繁 requestLayout

  • 距离数据更新时触发
  • 同样存在不必要的 layout 操作

问题 3:TextView.setText() 导致的隐式 requestLayout

  • GPS 信号视图频繁更新(每 200ms)
  • 即使文字内容相同,也重新 setText()
  • Android 机制 :TextView 的 setText() 内部会判断,如果长度变化会调用 requestLayout()

🔧 优化策略

核心原则

只在必要时 requestLayout,其他时候用 invalidate

方法 触发流程 耗时 使用场景
requestLayout() measure → layout → draw View 尺寸/位置变化
invalidate() draw 只有内容变化(颜色、数字)

优化点 1:DurationDigitalView - 智能判断是否需要 requestLayout

问题分析

倒计时显示格式:HH:MM:SS(如:01:23:45

关键发现

  • 小时数字的第一位如果是 1,宽度较窄(特殊处理)
  • 只有在 09 → 1019 → 20 时,宽度才会变化
  • 其他时候(如 00 → 0110 → 11)宽度不变

优化思路

  1. 记录上一次的小时值
  2. 判断宽度是否真的会改变
  3. 只在宽度变化时调用 requestLayout()
  4. 其他时候只调用 invalidate()
实现要点
复制代码
updateView(duration) {
    计算新的时分秒
    
    判断小时的第一位是否在 0 和 1 之间切换?
    if (是) {
        requestLayout()  ← 宽度会变,需要重新布局
    } else {
        invalidate()     ← 宽度不变,只重绘内容
    }
}

额外优化:缓存 measure 结果

复制代码
onMeasure() {
    if (宽度没有变化 && 有缓存) {
        直接使用缓存的宽度  ← 跳过计算
        return
    }
    
    正常计算宽度
    缓存结果
}
优化效果
时间段 优化前 requestLayout 次数 优化后 减少
00:00 → 00:09 10 次 0 次 ↓ 100%
00:09 → 00:10 1 次 1 次 -
00:10 → 00:19 10 次 0 次 ↓ 100%
1 小时总计 3,600 次 6 次 ↓ 99.8%

优化点 2:DistanceDigitalView - 同样的优化逻辑

问题分析

距离显示:12.34 km

宽度变化情况

  • 整数部分位数变化:9.99 → 10.00(1位变2位)
  • 小数点前的数字 1 特殊处理

优化思路

  1. 判断整数部分位数是否变化
  2. 判断是否涉及数字 1 的宽度切换
  3. 只在必要时 requestLayout()
优化效果

距离更新频率取决于 GPS 数据,假设每秒更新 1 次:

  • 优化前:每次更新都 requestLayout
  • 优化后:只有位数变化时才 requestLayout
  • 减少约 90% 的 requestLayout 调用

优化点 3:TextView 相同值不重新赋值

问题分析

GPS 信号数据频繁更新:

复制代码
gpsStatusView.updateView(gpsData) {
    textView.text = "${gpsData.usedCount}/${gpsData.totalCount}"
    // ⚠️ 即使内容相同(如 "3/10" → "3/10"),也会调用 setText
}

Android TextView 的行为

  • setText() 内部会比较新旧文本
  • 如果文本长度变化,会调用 requestLayout()
  • 即使内容相同,也会触发 invalidate()(重绘)

优化思路

复制代码
在调用 setText 之前,先判断值是否真的变化了

if (newText != currentText) {
    textView.text = newText  ← 只在内容变化时设置
}
优化效果

GPS 数据更新频率:每 200ms(优化前)

  • 10 秒内调用 setText:50 次
  • 假设信号稳定,数据相同:避免 50 次不必要的操作
  • 实际减少 70-80% 的文本赋值操作

优化点 4:GpsCountModel 数据节流

问题分析

GPS 信号数据流特点:

  • 更新频率高(200ms)
  • 数据变化慢(信号稳定时数据相同)
  • UI 更新频率不需要这么高(人眼感知限制)

优化策略 1:降低更新频率

  • 使用节流(throttle):2 秒内最多更新一次
  • 用户体验无影响(GPS 信号不需要实时显示)

优化策略 2:过滤相同数据

  • 将普通 class 改为 data class
  • 利用结构相等性,自动过滤相同数据
  • 数据相同时不触发 UI 更新
优化效果
场景 优化前(10秒) 优化后(10秒) 减少
GPS 信号稳定 50 次更新 0 次 ↓ 100%
GPS 信号波动 50 次更新 5 次 ↓ 90%

📈 验证优化效果

再次录制 Profiler

优化完成后,再次使用 Android Profiler 录制相同场景:

Top Down 对比

优化前

复制代码
doFrame()  10,794,664 μs  (94.13%)  ⚠️

优化后

复制代码
doFrame()   1,377,894 μs  (13.16%)  ✅

分析

  • doFrame 耗时减少 87%(10,794,664 μs → 1,377,894 μs)
  • 主线程负载从 94.13% 降至 13.16%
  • 达到了流畅应用的标准(< 20%)
  • doCallbacks 占比从 94.12% 降至 13.09%
Bottom Up 验证

再次搜索 requestLayout

  • DurationDigitalView.requestLayout:调用次数显著减少
  • DistanceDigitalView.requestLayout:调用次数显著减少
  • TextView 相关的隐式 requestLayout:大幅减少
CPU Timeline 对比

优化前

  • 橙色区域密集(主线程繁忙)
  • 有明显的性能尖刺
  • 帧率不稳定
  • Threads 数量:67 个

优化后

!

  • 主线程大部分时间为绿色(空闲)
  • 尖刺消失,CPU 占用平缓
  • 帧率稳定在 60fps
  • Threads 数量:22 个(减少 67%)

🎓 性能优化方法论总结

标准流程

复制代码
1. 发现问题
   ↓ 用户反馈卡顿 / 自己测试发现性能问题
   
2. 使用 Profiler 录制
   ↓ Android Profiler - CPU - Sample Java Methods
   
3. Top Down 分析
   ↓ 找出耗时最多的方法(doFrame、measure 等)
   
4. Bottom Up 追踪
   ↓ 反向查找是谁调用了耗时方法
   
5. 定位根因
   ↓ 分析为什么会频繁调用(业务逻辑问题)
   
6. 制定优化方案
   ↓ 减少不必要的调用 / 优化算法 / 延迟执行
   
7. 实施优化
   ↓ 修改代码
   
8. 验证效果
   ↓ 再次 Profiler 录制,对比数据

关键技巧

技巧 1:Top Down 看整体,Bottom Up 找细节
  • Top Down:从整体入手,找出最耗时的大块
  • Bottom Up:从细节入手,找出具体的调用位置
  • 结合使用:先 Top Down 定位问题域,再 Bottom Up 找具体代码
技巧 2:关注百分比,而不只是绝对时间
  • 10ms 在 100ms 总时间中占 10%(可能有问题)
  • 10ms 在 10s 总时间中占 0.1%(可以忽略)
  • 经验值:单个方法占用 > 5% 就值得关注
技巧 3:寻找重复模式
  • CPU Timeline 中的规律性尖刺说明有周期性任务
  • Call Chart 中宽度很宽的色块说明该方法执行时间长
  • 重复出现的调用栈往往是优化重点
技巧 4:善用搜索功能

在 Profiler 中搜索关键方法名:

  • requestLayout - View 布局相关
  • onMeasure - 测量相关
  • onDraw - 绘制相关
  • setText - TextView 相关
  • notifyDataSetChanged - RecyclerView 相关

🎯 本次优化总结

优化成果

维度 改善
主线程占用 94.13% → 13.16%(↓ 86%)
帧率稳定性 频繁掉帧 → 稳定 60fps
用户体验 卡顿严重 → 流畅运行

优化要点

  1. DurationDigitalView:智能判断是否需要 requestLayout

    • 1 小时减少 3,594 次 requestLayout 调用
  2. DistanceDigitalView:同样的优化逻辑

    • 减少约 90% 的不必要布局操作
  3. TextView 优化:相同值不重新赋值

    • 减少 70-80% 的文本赋值操作
  4. GpsCountModel:数据节流 + 去重

    • 减少 90% 的 UI 更新频率

核心经验

性能优化的本质:在保证功能的前提下,减少不必要的计算和操作。

  • 不是所有的更新都需要 requestLayout
  • 不是所有的赋值都需要执行
  • 不是所有的数据变化都需要立即反映到 UI
相关推荐
Kapaseker33 分钟前
Compose 进阶—巧用 GraphicsLayer
android·kotlin
黄林晴1 小时前
Android17 为什么重写 MessageQueue
android
阿巴斯甜1 天前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker1 天前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq95271 天前
Andorid Google 登录接入文档
android
黄林晴1 天前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab2 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿2 天前
Android MediaPlayer 笔记
android
Jony_2 天前
Android 启动优化方案
android
阿巴斯甜2 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android