我收集的性能优化资料和工具链
我收集的性能优化资料和工具链
Android 性能优化这个领域有个特点:官方文档永远滞后于实际需求,社区里的经验贴又往往停留在"打开 Profile GPU Rendering 看看红条"这种层面。过去五年里我陆陆续续攒了一些真正在生产环境验证过的工具、文档和调试思路,这篇把其中我觉得最实用的部分整理出来,顺便说说每个东西的边界在哪里,免得有人踩我踩过的坑。
Systrace 到 Perfetto 的迁移,比想象中麻烦
Google 从 Android 10 开始力推 Perfetto 取代 Systrace,但这件事的执行做得很差。Systrace 的 Python 脚本在 2020 年之后基本不再更新,而 Perfetto 的 UI 工具 trace processor 直到 2022 年的某个版本才稳定支持自定义数据源的 SQL 查询。我当时的项目需要追踪自定义的 binder 调用耗时,用 Systrace 的 atrace 标签只能输出到 /sys/kernel/debug/tracing/trace_marker,在 Android 12 的设备上频繁遇到 buffer 不够丢事件的问题。切换到 Perfetto 之后,buffer 配置改到了 data_source_config 的 tracing_config 里,格式从简单的 key-value 变成了一套 protobuf 定义,学习成本陡增。
Perfetto 的查询语言基于 SQL,但和 SQLite 不完全兼容。它内置了 slice 表、thread_track 表、process 表等概念化的视图,实际写查询的时候经常要 JOIN 三四层才能拿到想要的线程名和耗时。我常用的一个查询模板是找主线程上超过 16ms 的 Choreographer#doFrame 切片,这个在 Systrace 里一眼就能看到,在 Perfetto 里要写成:
SELECT slice.ts, slice.dur, thread.name, process.name
FROM slice
JOIN thread_track ON slice.track_id = thread_track.id
JOIN thread USING(utid)
JOIN process USING(upid)
WHERE slice.name = 'Choreographer#doFrame'
AND slice.dur > 16000000
AND thread.name = 'ui_thread_name_here';注意 dur 的单位是纳秒,这个文档里写得不明显,我第一次用的时候按微秒查,结果永远为空。Perfetto UI 的地址是 ui.perfetto.dev,可以加载本地 trace 文件,但加载 500MB 以上的 trace 时 Chrome 会频繁触发内存回收,建议用命令行的 trace_processor_shell 做批量分析。trace_processor_shell 在 GitHub 的 google/perfetto 仓库里 release 页面下载,Linux 和 macOS 都有预编译二进制,Windows 的支持直到 2023 年才勉强可用。
Perfetto 的真正优势在于可以抓系统级别的 ftrace 事件,包括 sched_switch、binder_transaction 这些 Systrace 看不到的内核细节。但开启这些事件需要 root 或者 adb shell perfetto -c - --txt 配合特定的 config 文件,非 root 设备只能抓到应用自身的 atrace 标签。这一点在官方文档里被埋得很深,我花了两个下午才搞清楚为什么同样的 config 在两台设备上表现不同。
Macrobenchmark 库:Google 的"官方答案"有多靠谱
Jetpack Macrobenchmark 是 Google 2021 年推出的测量冷启动、滚动帧率的库,底层封装了 Perfetto 的抓取逻辑,但暴露的 API 非常克制。它的设计假设是你有一个可以稳定复现的测试场景,这在实际项目中往往是最大的难点。我接入 Macrobenchmark 的第一个项目是某个电商 App 的首页,结果发现每次冷启动的耗时波动超过 30%,因为首页的推荐接口有 AB 实验,返回的数据量差异很大。Macrobenchmark 的 measureRepeated 默认跑 10 次取中位数,对于这种方差大的场景,10 次根本不够,但跑更多次又会触发后端的风控。
Macrobenchmark 需要把测试代码放到一个独立的 benchmark module 里,这个 module 必须签名和主包一致,或者直接用 debuggable true 的构建变体。我在一个内部工具链比较重的项目里遇到的问题是:公司的打包脚本会给 release 包做资源混淆和字符串加密,benchmark module 跑的是 benchmark 构建类型,默认不带这些处理,测出来的启动时间和线上真实用户差了 15% 以上。后来不得不把 benchmark 的构建流程也接入同样的后处理,但 Macrobenchmark 的文档明确说"不要在优化后的构建上跑 benchmark",这就形成了一个悖论。
FrameMetrics 的 API 也有坑。MacrobenchmarkScope.measureFrameMetrics() 返回的是 List<<FrameMetrics>,每个 FrameMetrics 对象包含 frameOverrunMs,这个字段在 Android 12(API 31)以下永远返回 FRAME_STATS_INVALID,因为底层依赖 SurfaceFlinger 的新接口。如果你的 benchmark 要覆盖 Android 10-11 的设备,只能退回到 expectedDuration 和 frameDurationCpu 的组合,但这两个字段的语义和 frameOverrunMs 不完全等价,跨版本对比数据时要特别小心。
Macrobenchmark 目前是免费的,但依赖的 androidx.benchmark:benchmark-macro-junit4 版本迭代很快,1.1.x 到 1.2.x 之间改了 CompilationMode 的默认值,从 Partial 变成 Full,这个改动没有放在 release note 的显眼位置,我升级后测出来的冷启动时间突然变好,排查了半天才发现是代码被完全 AOT 编译了,和线上用户的 verify-profile 模式不一致。
Simpleperf:被低估的 Native 性能分析入口
Android 的 CPU profiler 在 Android Studio 里集成得很好,但那个 profiler 基于 simpleperf 的封装,丢失了很多底层能力。simpleperf 本身是 AOSP 源码树里的工具,也在 NDK 里提供了预编译版本,路径在 $NDK/simpleperf。我推荐直接命令行用,而不是依赖 Android Studio 的图形界面。
simpleperf 最核心的优势是支持内核级别的 perf_event_open,可以拿到 L1 cache miss、branch misprediction 这些硬件计数器。Android Studio 的 CPU profiler 默认只采样 CPU 时间,不开启这些硬件事件。开启的方式是在 simpleperf record 的时候加 -e 参数,比如 -e L1-dcache-load-misses。但这里有个设备兼容性问题:不同 SoC 的 PMU(Performance Monitoring Unit)实现不同,高通的 Kryo 核心和 ARM 的 Cortex 核心支持的硬件事件集合有差异。simpleperf list 可以列出当前设备支持的所有事件,但有些低端芯片会返回空列表,因为厂商在内核配置里禁用了 perf 子系统。
我在一个视频编解码相关的项目里用 simpleperf 定位过热点函数。那个项目用到了 FFmpeg 的软解路径,在 Android Studio profiler 里只能看到 libavcodec.so 占用了大量 CPU,但展开调用栈全是地址,因为 release 构建的 so 没有符号表。simpleperf 支持 --symfs 参数指定符号文件搜索路径,我把 CI 构建时生成的 libavcodec.so 带符号版本放到指定目录,就能正确解析函数名。Android Studio 的 profiler 理论上也能配置符号路径,但那个界面藏得很深,而且每次重新抓取都要重新配置。
simpleperf 的 flamegraph 生成需要借助 Brendan Gregg 的 FlameGraph 脚本,或者 simpleperf report-sample 输出到 perf.data,再用 inferno 这类工具可视化。这个流程比 Android Studio 的一键生成 flamegraph 麻烦很多,但可控性更强。我遇到过 Android Studio 生成的 flamegraph 把递归调用合并错误的情况,导致热点函数的高度被低估,simpleperf 原始数据用 inferno 渲染就没有这个问题。
JankStats:运行时帧率监控的工程化实践
Google 在 2022 年的 I/O 上发布了 JankStats 库,属于 AndroidX 的一部分,artifact 是 androidx.metrics:metrics-performance。它的定位是解决"线上用户到底卡不卡"这个问题,而不是实验室里的 benchmark。这个库的设计很务实:它监听 Choreographer 的帧回调,计算每一帧的实际绘制时间和目标时间的差值,超过某个阈值就记为一次 jank。
JankStats 的 API 简单到几乎没什么学习成本,初始化只需要一行 JankStats.createAndTrack(window, jankListener)。但接入生产环境后会发现几个实际问题。第一个是阈值怎么定:默认的 jankHeuristicMultiplier 是 2.0,意思是实际耗时超过目标帧时间的两倍算 jank,对于 60Hz 设备就是超过 33.3ms。这个阈值对游戏类 App 可能太宽松,对信息流滚动可能又太严格。我倾向于按设备刷新率动态计算,比如 120Hz 设备用 16.67ms 的 1.5 倍(25ms)作为阈值,但 JankStats 的 JankFrameMetrics 只暴露 frameDurationUiNanos,不直接告诉你设备的刷新率,需要自己从 Display.getRefreshRate() 拿。
第二个问题是采样策略。如果每一帧都上报,数据量太大,后端存储和查询成本受不了。JankStats 没有内置采样,需要自己在 OnJankListener 里做过滤。我的做法是在用户会话级别做分层采样:新用户 100% 采样,老用户按设备性能分桶,高端机 10% 采样,低端机 50% 采样。但这个策略和 JankStats 本身无关,属于工程基础设施的建设。
第三个问题是和系统帧率统计的偏差。JankStats 基于 Choreographer 的回调时间,如果某帧的绘制被 SurfaceFlinger 延迟了,JankStats 感知不到。Android 13 引入了 FrameMetrics 的 FRAME_DEADLINE 字段,可以拿到更准确的 deadline 信息,但 JankStats 库目前(1.0.0-beta01)还没有适配这个 API。我在 Pixel 7 上对比过,重度负载场景下 JankStats 报告的 jank 次数比 dumpsys gfxinfo 少了 15% 左右,因为后者包含了合成阶段的延迟。
JankStats 目前是免费开源的,但属于 beta 状态已经超过一年,API 稳定性不敢保证。我现在的用法是把它作为"快速感知"层,具体的帧时间分布还是靠后台捞 gfxinfo 的聚合数据做校准。
ReDex 和 R8 的博弈:字节码优化的最后一公里
Facebook 开源的 ReDex 是一套字节码优化工具链,地址在 github.com/facebook/redex。它的定位和 Android 构建流程里的 R8 有重叠,但优化方向不同。R8 主要做 shrink、obfuscate、optimize,侧重包体积和基础的内联、死代码消除。ReDex 更激进,有 interdex(按启动路径重排 dex 顺序)、remove_unreachable(比 R8 更激进的可达性分析)、strip_debug_info 等 Pass。
我在一个包体积 80MB+ 的项目里接入过 ReDex,冷启动时间从 1.8s 降到 1.4s,主要收益来自 interdex。interdex 的原理是把启动路径上需要的类按引用顺序排列到同一个 dex 里,减少 DexFile::FindClass 时的磁盘随机读取。这个优化在 Android 5.0-7.0 的 Dalvik/ART 上效果最明显,因为那个时期的 dex2oat 对跨 dex 引用的处理比较 naive。Android 8.0 之后 ART 改进了 dex cache 机制,interdex 的收益会衰减,但在低端机上仍然有 5-10% 的提升。
ReDex 的接入成本不低。它需要你把构建流程从 AGP 的默认流程里拆出来,先打出未优化的 APK,再用 ReDex 处理,最后重新签名。Facebook 内部用 Buck 构建,ReDex 的文档也默认你熟悉 Buck 的产出格式。AGP 用户需要写额外的 Gradle task 来桥接,我当时的做法是在 packageRelease 之后加一个 redexRelease task,用 Python 脚本调用 ReDex 的二进制。这个二进制需要单独编译,依赖 LLVM 和 Boost,我在 M1 Mac 上编译时遇到过很多 ABI 不匹配的问题,最后改在 Linux CI 容器里跑。
ReDex 和 R8 不能简单叠加。R8 的 optimize 阶段会做方法内联,这会破坏 interdex 的类引用顺序分析,因为内联之后有些类引用从显式变成隐式。我的实践是:R8 只开 shrink 和 obfuscate,optimize 关掉,把优化空间留给 ReDex。但这样 R8 的某些安全优化(比如 null check 消除)也会丢失,需要权衡。Facebook 自己的做法是内部 fork 了 R8,但那个 fork 没有开源。
ReDex 的维护状态需要关注。2023 年 Facebook 有过一次组织架构调整,ReDex 的提交频率明显下降,有些 Android 14 的 dex 格式变更还没有适配。我现在的项目已经逐步把 ReDex 的优化迁移到 R8 的自定义 Proguard rules 里,虽然效果差一些,但维护成本可控。
Profilo:Facebook 的另一套性能追踪系统
Profilo 是 Facebook 开源的另一套东西,地址 github.com/facebookincubator/profilo,定位是"低开销的持续性能追踪"。它和 Perfetto/Systrace 的思路完全不同:Perfetto 是"需要时抓取全量 trace",Profilo 是"一直开着,按需回环读取"。
Profilo 的核心设计是一个用户态的环形 buffer,多个数据源(atrace、stack sampling、memory stats)并行写入,buffer 满后覆盖最老的数据。当某个触发条件满足时(比如主线程卡顿超过阈值、ANR、自定义的 marker),系统把 buffer 里的快照 dump 出来上传。这个设计的好处是"不漏现场":传统抓 trace 的方式是发现问题后再去复现,但很多问题复现不了;Profilo 相当于一直开着黑匣子,出问题时回读。
我研究过 Profilo 的源码,但没有在生产环境完整接入,因为接入成本比 JankStats 高一个数量级。Profilo 需要 JNI 层初始化一个 TraceOrchestrator,配置 buffer 大小、数据源组合、触发策略,这些配置没有 XML 或 JSON 的便捷方式,要在 Java/Kotlin 代码里写。更麻烦的是 stack sampling 的实现:Profilo 用到了 unwind 库来做轻量级栈回溯,这个库在不同 ABI(arm64-v8a、armeabi-v7a)上的行为有细微差异,x86_64 的模拟器支持一直不太完整。
Profilo 的一个独特数据源是 memory_stats,可以追踪 malloc 的分配热点,这个在排查 native 内存泄漏时很有用。但 memory_stats 依赖 malloc_hooks,这在 Android 10 之后被 Bionic 的 Scudo allocator 部分限制,某些分配路径 hook 不到。Facebook 内部用的是自己 fork 的 Bionic,开源版本没有这个 fork,所以 memory_stats 的准确性要打折扣。
Profilo 的 GitHub 仓库最后一次有意义的提交是 2022 年底,Issue 区的回复也很稀疏。我不建议新项目直接接入,但它的环形 buffer 设计思路值得借鉴。我现在的做法是用 JankStats 做触发器,配合 Debug.startMethodTracingSampling 做轻量采样,虽然功能弱很多,但稳定性有保障。
我自己维护的一个资料聚合:perfetto-dev-docs
最后说一个非工具的东西。Google 的 Perfetto 文档分散在三个地方:perfetto.dev 的参考文档、AOSP 源码里的 README、以及 chromium.googlesource.com 上的设计文档。我整理了一个 Notion 页面,把这三处的关键信息按"快速开始"、"SQL 查询参考"、"自定义数据源开发"、"Android 抓取实战"四个板块重新组织,地址不公开但结构可以描述。
这个页面里我花最多时间整理的是 Perfetto 的 SQL schema 演进。Perfetto 的 trace_processor 每个版本都会加表或改字段名,比如 thread 表在 v25 之前叫 threads,单复数不统一。slice 表的 category 字段在某些版本是 cat,某些版本是 category。这些 breaking change 没有集中的 changelog,我只能通过对比不同版本的 trace_processor 输出来维护。
我还记录了各个手机厂商对 Perfetto 的支持情况。华为 EMUI 12 之后的某些版本禁用了 /sys/kernel/debug/tracing 的节点,导致 atrace 标签抓不到;小米 MIUI 的 perfetto 二进制被替换成了定制版本,config 格式不兼容标准 Perfetto;OPPO 的 ColorOS 在 Android 13 上开启了 SELinux 限制,adb shell perfetto 需要额外的权限声明。这些信息来自我实际测试和 GitHub issue 的交叉验证,没有官方汇总。
工具选择的个人偏好
如果让我现在从零开始搭建一个性能监控体系,我的选择会是:
冷启动实验室测量用 Macrobenchmark,但只作为回归测试的看门狗,不追求绝对数值的准确性。线上帧率感知用 JankStats,采样策略自己控制,数据只作为"有没有恶化"的相对指标。深度问题定位用 Perfetto 命令行抓 trace,本地用 trace_processor_shell 分析,不依赖 Chrome UI。Native 热点用 simpleperf 直接采样,符号表从 CI 产物里匹配。字节码优化如果团队有专人维护,可以尝试 ReDex 的 interdex,否则用 R8 的保守配置更稳妥。
这个组合里没有 Profilo 的位置,不是因为它不好,是因为维护成本超出了大多数团队的承受范围。性能优化的工具链选择和团队规模强相关,三个人的团队和三十个人的团队,最优解完全不同。
这些工具和资料我都在实际项目里用过至少一个完整迭代周期,有些已经弃用,有些还在演进。写出来不是作为"最佳实践"推销,只是作为一份经过验证的参考,减少后来者的试错时间。性能优化这个领域,最昂贵的成本从来不是工具本身,而是"不知道某个工具存在"或者"高估了某个工具的适用范围"。