前阵子组里一位同事被线上卡顿问题折腾了快两天,日志打了几百MB,主线程行为始终对不上用户反馈的“某页面滑动掉帧”。后来大家坐在一起,用perfetto重新抓了同一场景,五分钟就锁定了问题:一个高频Binder调用在持锁状态下阻塞了主线程。那一刻会议室里的空气都变了。这次复盘让我更加确信,做卡顿分析,perfetto(以及它底层的systrace机制)不是“可选的辅助工具”,而是唯一能还原真相的入口。
这篇内容想以实战为主线,把perfetto和systrace之间的关系、抓取前的准备、打开trace后的读图顺序、卡顿形态分类、一次完整的问题定位链路,以及大项目里怎么控制开销这几个环节完整串一遍。无论你是刚开始接触性能分析的新人,还是已经在用perfetto但总感觉找不到关键证据的开发者,这篇文章都值得你按顺序看完,尤其是第四、五两章,基本是把trace从“能打开”带到“能看懂”的临界点。
1. 为什么性能工具是排查卡顿的唯一可靠入口
1.1 卡顿的本质是“帧边界被突破”,不是“日志里某一行报错”
Android渲染有一套固定的节拍机制。60Hz刷新率下,每一帧只有16.6ms预算;120Hz普及后,这个预算进一步压缩到8.3ms。系统并不会按照某个线程的“忙碌程度”判断卡顿,而是看每一帧的图像是否在VSYNC边界内完成从App处理到SurfaceFlinger合成并送显的全过程。一旦某一环超时,用户感知到的就是掉帧、滑动不跟手、点击延迟。
所以,卡顿本质上是一个“时间段内发生的、跨进程、跨线程的事件串”。它可能起因于主线程芯疼的布局计算,也可能发生在RenderThread等待GPU fence的间隙,还可能卡在Binder线程等待远端服务响应。这些事件在Logcat里往往毫无痕迹,因为日志是针对异常和错误设计的,不是针对“某个任务多花了30ms”设计的。这就解释了为什么“打日志排查卡顿”这条路绝大多数时候走不通:你根本不知道该在哪一行打日志,因为问题根本不是从某一行“报错”开始的。
1.2 systrace和perfetto:同根生,但后者是完整形态
很多老开发到现在还在直接调用Android SDK里的systrace.py脚本,输入一串命令行参数,输出一个html文件,然后拖进浏览器里看。这个流程没有错,但systrace本质上是perfetto的“简化版前端封装”。从Android 10开始,系统的trace底层数据源,包括ftrace事件、atrace标记、CPU调度信息、内核频率变化,全部由perfetto接管;systrace脚本只是负责把perfetto抓到的数据转换成了一个独立的、可被旧版浏览器查看的HTML格式。
这说明一个事实:你用了perfetto,依然拥有systrace的一切能力,但反过来不一定成立。perfetto的UI支持SQL查询、多进程轨道过滤、自定义TraceConfig、导入导出自定义数据源,这些都是旧systrace html难以做到的。我个人的判断是,新项目、新问题排查,团队应该统一用perfetto这套工具链。系统老到API 27以下才需要退回去看systrace脚本,而实际工作中绝大多数设备已经支持。
2. 抓取前的准备工作:从下载到配置
2.1 下载与安装:别用系统自带的老方案,直接走Perfetto官方渠道
perfetto的获取方式比很多人想象中简单。打开官方站点会看到一个Web UI,支持两种方式抓取:第一种是Android设备用USB连上电脑,浏览器里的Perfetto UI可以直接通过ADB发起抓取;第二种是直接在设备端跑一个命令行程序,生产一份trace文件,再拉到电脑上用UI分析。
如果你倾向命令行方式,可以在设备的串口或ADB shell中执行:
adb shell perfetto --config :test --out /data/misc/perfetto-traces/trace.perfetto-trace前提是系统里已经内置了perfetto命令。Android 10以上Pixel设备基本都有,部分厂商ROM可能会裁剪,需要确认adb shell perfetto --version有输出。没有的话,可以从perfetto官方发布页下载对应架构的二进制,push进/data/local/tmp后赋予执行权限,再通过adb shell调用。注意,如果设备没有root,Android 11以上的某些trace数据源会受到权限限制,比如内核的某些调度事件,建议优先使用ro.debuggable=1的userdebug固件或root设备抓完整数据。
2.2 TraceConfig:diy一个够用且不炸的配置
很多人第一次用perfetto的web UI,会直接点“Start Recording”默认配置,结果抓了30秒,文件2GB,拖进UI卡死。问题不在工具,在于配置里所有数据源全开了。合理的TraceConfig既能拿到关键线索,又能控制体积。
一个比较平衡的配置长这样:
// config.pbtx buffers { size_kb: 262144 // 256MB fill_policy: RING_BUFFER } data_sources { config { name: "linux.ftrace" ftrace_config { ftrace_events: "sched/sched_switch" ftrace_events: "sched/sched_wakeup" ftrace_events: "sched/sched_waking" ftrace_events: "power/cpu_frequency" ftrace_events: "power/cpu_idle" ftrace_events: "binder/binder_transaction" ftrace_events: "binder/binder_transaction_received" ftrace_events: "binder/binder_lock" ftrace_events: "binder/binder_locked" ftrace_events: "binder/binder_unlock" ftrace_events: "tracing/mark_print" ftrace_events: "mm_vmscan/mm_vmscan_direct_reclaim_begin" ftrace_events: "mm_vmscan/mm_vmscan_direct_reclaim_end" ftrace_events: "sched/sched_process_exit" } } } data_sources { config { name: "android.process_stats" } } data_sources { config { name: "android.surfaceflinger.frametimeline" } } duration_ms: 10000这里的核心思路是:只抓sched_switch(线程切换)、sched_wakeup/waking(谁唤醒了谁)、cpu_frequency(CPU频率变化)、binder_*(Binder调用)、tracing/mark_print(应用自定义Trace标记),以及多一个SurfaceFlinger的帧时间线数据源。这套组合基本覆盖了“主线程在做什么、CPU是否降频、Binder对端是谁、帧边界在哪”这四类关键证据。
2.3 复现节奏的控制:让trace命中那一次卡顿
抓取perfetto容易,难的是让trace刚好覆盖到卡顿发生的那一帧。以列表滑动为例,我常用的手法是:
- 在Perfetto UI或命令行里设置
duration_ms: 15000,也就是15秒; - 先花3秒进入目标页面但不操作,让系统把该加载的东西加载完;
- 第4秒开始进行“匀速、可重复”的滑动操作,滑到第12秒停止;
- 最后留3秒收尾,观察卡顿恢复的过程。
关键是操作必须可重复、动作恒定。如果一边抓trace一边思考“怎么滑动”,复现出来的卡顿很可能和用户场景不是同一种。更讲究一点,可以在复现前打开开发者选项里“显示Surface更新”或开启“GPU呈现模式分析”作为辅助观察,但真正定位还是以perfetto的数据为准。
另外,开发调试阶段建议把“窗口动画缩放”“过渡动画缩放”“Animator时长缩放”保持默认的1x,不要为了省事全部关掉。有些卡顿恰恰发生在动画期间,你全关掉就复现不出来了。关动画这个操作,只适合在确认“非动画路径的性能问题”时使用。
3. 打开trace后的第一件事:先读帧,再读线程
3.1 快捷键和视图布局:把工具当“放大镜”而不是“看板”
Perfetto UI打开trace文件后,默认是一大片密密麻麻的轨道。新手的第一反应往往是鼠标乱滚,试图找出“红色的位置”。这个方向完全错误。合理顺序是先看整体帧分布,再逐层放大。
关键快捷键:
w放大,s缩小;a/d左右移动时间窗口;m在当前位置打标记,多打几个标记就能测量两个标记之间的准确时长;1、2、3等数字键切换不同颜色主题的轨道高亮;Ctrl+F搜索slice名称或者进程名。
帧时间线通常显示在时间轴顶部附近,SurfaceFlinger的Display Composer或者FrameTimeline区域会有每一帧的起止时间。如果是支持FrameTimeline的设备,会直接看到jank标记或帧耗时柱状图,非常直观。
3.2 先回答三个问题:卡在哪一帧、哪条线程、什么状态
拿到任何一份trace,我先要求自己回答以下三个问题,回答不上来就继续看,不看别的:
- 用户感知的“卡顿”,具体对应哪一帧到哪一帧?帧间隔是多少?
- 卡顿发生时,目标App的主线程(通常是
Choreographer所在的UI Thread或进程名里的main)在做什么?是长时间运行,还是在等待? - 如果主线程在等待,等待什么?是等Binder返回,等情况通知,还是等锁?
这三个问题回答完,卡顿的大致区域就被框定出来了。剩下的工作才是“找具体函数”“看调用栈”,那是精细活。很多人在第一步就没做,上来就抓着主线程一个长slice问“这是什么”,就像一个人丢了钥匙,不去回忆丢钥匙的时间线,只盯着手里最后一截钥匙链看,当然找不到。
线程状态的颜色在这里很有用:绿色通常是运行态,蓝色是可运行但没被调度到,橙色/红色是陷入内核态等待(通常是锁、IO等不可中断等待)。如果主线程在卡顿时呈现蓝色或者橙红色,那么“主线程自身CPU占用高”这个推断就不成立,应该立刻把注意力转移到“谁占用了CPU”、“主线程在等谁”上。
3.3 常见误区:卡顿不等于主线程有个长slice
有一种卡顿模式极具迷惑性——主线程没有任何超过50ms的slice,但用户就是觉得掉帧。这种时候原因往往在渲染管线下游:CPU很快把UI树构建完成并提交,但GPU合成跟不上,或SurfaceFlinger合成阻塞,或因为垂直同步频率和内容刷新不匹配。此时再去主线程里找“大活”注定一无所获。
所以“先读帧,再读线程”不是说主线程不重要,而是强调顺序:先知道卡顿发生在哪一帧,再看那一帧里的所有关键线程,而不是只盯着主线程看“最长的slice”。帧是结果,线程是原因,顺序反了就会被表象带偏。
4. 三种卡顿形态的区分方法
4.1 形态A:主线程“真大活”——slice又长又深
这是最容易被新手识别的卡顿类型。主线程轨道上出现一个或几个持续时间很长的slice,颜色通常偏深,因为内部的调用栈层次很深。常见原因包括:复杂布局的measure/layout耗时过长、大量Bitmap解码、主线程直接做IO、JSON解析大数据、锁竞争导致任意代码块从“微秒级”拖到“几十毫秒级”。
判断方法很简单:把卡顿帧对齐到主线程轨道,找到覆盖帧时间段的最长slice,读它的名称。如果slice名称是一个函数名,比如performMeasure或LinearLayout.onMeasure,那方向基本就定了。进一步确认,可以在这个slice上停留查看Wall duration和Self time。Self time越大,说明不是子调用拖累,而是函数自身逻辑就有问题;Self time很小但整体slice长,说明是某个子调用最慢,继续下一层分析。
4.2 形态B:主线程在“等”——如果slice短小且伴随wait状态
这类的特征是主线程slice都不长,但线程状态长期是蓝色(runnable但没上CPU)或橙色(不可中断等待)。这里的核心问题是“谁抢占了CPU”或“主线程在等哪个锁/Binder返回”。
看这种问题,我习惯先把主线程轨道展开,看它在卡顿时间段前后的线程状态颜色变化,然后使用perfetto的Sched相关的CPU跟踪,找到同一时间段内谁在对应的CPU core上运行。如果看到CPU被一个高优先级/同优先级的渲染线程占满,那就需要考虑降优先级、减少工作量或迁移线程。如果主线程是橙色,则查看内核栈,通常能看到mutex_lock或者wait_for_completion之类的字样,再配合Binder事件轨道,就能顺藤摸瓜找到对端。
Binder的场景尤其典型:主线程发起一个transact调用,slice显示很短,但紧接着就是长时间的等待。perfetto的Binder轨道会显示binder_transaction的target进程和线程,这时候去target进程的线程栈里看它为什么迟迟不返回,往往才是问题真正的现场。
4.3 形态C:渲染侧卡顿——主线程没毛病,RenderThread和SF在忙
这类卡顿最容易被误判为“没有问题”。主线程看起来从容不迫,但FrameTimeline显示掉帧,RenderThread轨道上出现大段的等待或长task。
常见的渲染侧锅包括:GPU等待上一个帧完成(显式/隐式同步)、着色器编译引起的首帧卡顿、过度绘制导致GPU片元负载过高、SurfaceView或TextureView的转换开销、SurfaceFlinger那边的HWC合成超时。这一块的分析需要切换到RenderThread轨道看有没有sync、queue、waitForPresent之类的节点。如果能看到GPU完成的fence时间戳,对比CPU提交时间,就能判断瓶颈在CPU侧还是GPU侧。
还有一种情况值得单独提一下:当Window是SurfaceView类型时,App的绘制并不完全走常规的View渲染管线,而是App自己往一个独立的Surface上画。此时卡顿可能来自SurfaceView的缓冲排队——App画完一帧,但显示管线未及时取走,导致生产者端阻塞。这类问题在视频、相机、游戏场景特别常见,排查时记得打开android.surfaceflinger.frametimeline,看哪一个Layer的呈现一直不刷新。
4.4 形态对照表
| 卡顿形态 | 主线程特征 | RenderThread特征 | 关键证据在哪个轨道 | 优先排查方向 |
|---|---|---|---|---|
| A 主线程大活 | 长slice、深调用栈 | 跟随排队 | 主线程slice详情 | 布局、IO、算法复杂度 |
| B 主线程等待 | slice短、蓝色/橙色状态 | 不一定异常 | 调度轨道、Binder轨道 | 锁竞争、Binder对端、CPU抢占 |
| C 渲染侧卡顿 | 无明显长slice | 长task或等待fence | FrameTimeline、RenderThread | GPU瓶颈、着色器编译、合成延迟 |
这张表是我自己做排查时的速查表。遇到卡顿,先按表对照一遍,能省掉很多无头苍蝇式搜索。
5. 一个完整的卡顿定位实战链路
5.1 场景复现:列表滑动掉帧、偶现、Logcat无Error
讲一个实际发生过的案例。项目里反馈:某个二级列表页,快速上下滑动时偶现掉帧,不是每次都能复现,频率大约每五次操作出现一次。日志里没有Exception,也没有明显的ANR。一开始大家以为是数据加载的问题,在列表adapter里加了一堆日志,滑了半小时,什么也没抓到。
我用perfetto替换了排查方式。TraceConfig按上文那份配置,时长15秒,滑动了大概8次。打开trace后,第一步先在FrameTimeline里找掉帧区段,很快就发现一次连续三帧耗时都超过40ms的区间,集中在第7秒到第7.5秒之间。
5.2 逐步收紧:从帧区间到主线程到Binder对端
定位到这个区间后,我把事件窗口压缩到500ms范围,然后把主线程轨道放大。主线程在这500ms里其实非常干净:各类slice都不超过2ms,说明UI线程没有在做重活。但它的线程状态出现了连续的大段蓝色。这就触发了形态B的判断逻辑。
于是我把注意力切到CPU调度轨道。卡顿的这500ms里,好几个小核CPU频率都在最低档,而大核上有一个名为dex2oat的进程占用了接近整整300ms。这一瞬间原因其实已经很清楚了:ART的AOT编译在高负载滑动期间占满了大核,同时把CPU频率拉到一个“看似不低但对渲染线程不够友好”的状态,主线程虽然是runnable,但一直排不上队。
顺着这个方向继续查:为什么滑到第7秒才触发dex2oat?看slice里的进程名、命令行参数,确认是com.xxx.app自己的dex2oat在后台执行。原因是应用启动后第二屏里的某个动态特性触发了新Dex文件的加载和编译,后台编译器抢占了CPU。
5.3 修复与验证:限制后台编译、错峰加载
问题的修复方案并不需要在代码里“优化布局”或者“减少绘制”,而是要从调度策略上下手:一类做法是把后台编译任务的优先级调低,避免和UI抢占大核;另一类是主动预编译热点模块,避免在运行时触发dex2oat;更简单直接的,是在应用启动后、用户开始滑动前,先让系统完成一次空闲期的Dex优化,或者把相关Dex文件的编译滤波器设置成“speed-profile”。
修复后我用同样的TraceConfig重新抓了一次同样的滑动序列,FrameTimeline里的掉帧区段消失,主线程等待时间大幅缩短。这个case的结论告诉我们:卡顿根因不一定在本进程的代码里,可能是系统服务的调度策略干扰。如果不用perfetto,靠打日志很难定位到这种“跨进程、跨调度”的根因。
5.4 证据链的整理:怎么让结论可追溯
实战里还有一件常被忽略的事——把排查过程变成可追溯的证据链。我的习惯是在perfetto UI里用m打上标记,标记出掉帧起始帧号、主线程等待区间的起止时间、dex2oat进程占用的区间,然后用Perfetto的下载标记功能把这张trace保存成带注释的版本,方便回到办公室继续分析或发给同事确认。
更重要的是把结论写进缺陷单。比如在Jira描述里附上这样一段:
掉帧区间:2026-01-10 15:23:07.000 - 15:23:07.500,帧号9876。 主线程状态:runnable + uninterruptible sleep交替,无长slice。 CPU占用对象:pid 12345 (dex2oat),小核最低频,大核100%占用约300ms。 根因:运行时Dex编译抢占大核,渲染线程调度被延迟。 修复:调整Dex编译策略,验证后掉帧消失。
这种证据链的价值在于:哪怕三个月后有人重新回来问“当初这个卡顿到底是怎么解决的”,你不需要重新复盘一遍代码,只凭trace和注释就能恢复完整现场。
6. 大型项目里的抓取与排查技巧
6.1 别全开事件,按需裁剪
大型项目通常功能多、进程多、系统版本杂。如果每个开发都按默认配置抓,几台的trace一合并,存储和上传都吃不消。我建议给团队订一条规则:除非明确要排查跨进程调度或功耗相关问题,否则TraceConfig里的事件按需裁剪,能关就关。
推荐保留的最小集:
sched_switch+sched_wakeup:看调度和线程切换;cpu_frequency+cpu_idle:看是否降频锁频;tracing/mark_print:App侧自定义slice标记;android.surfaceflinger.frametimeline:看帧边界;binder_transaction+binder_transaction_received:看跨进程调用。
这组配置抓出来的trace体积小,可读性高,日常开发排查足够。有些团队还专门维护一份“面向UI线程卡顿排查”的预设配置,内部称为“性能问题第一现场包”,新人入职先学会抓这个包,再看三日内的固定案例,上手效率提升明显。
6.2 用SQL把trace当成数据库来查
perfetto UI自带的Query分析功能是非常值钱的高级能力。它允许你用SQL查询trace里的slice、线程、调度信息,把“肉眼搜索”变成“条件过滤”。
比如我想找出所有持续时间超过10ms的UI主线程slice:
SELECT ts, dur, t.name AS thread_name, s.name AS slice_name, s.depth FROM slice s LEFT JOIN thread_track t ON s.track_id = t.id WHERE t.name = 'main' AND dur > 10e6 ORDER BY dur DESC LIMIT 50;再比如,我想查看某个时间窗口内Binder调用的平均耗时和目标进程分布:
SELECT process.name AS target_process, COUNT(*) AS call_count, AVG(s.dur) / 1e6 AS avg_duration_ms FROM slice s LEFT JOIN thread_track tt ON s.track_id = tt.id LEFT JOIN thread th ON tt.utid = th.utid LEFT JOIN process ON th.upid = process.upid WHERE s.name GLOB 'binder transaction*' GROUP BY process.name ORDER BY avg_duration_ms DESC;这类SQL查询的应用场景非常广泛:查GC次数、查某个自定义标记的频率、查长时间持锁的函数等。你完全可以把perfetto当成一个性能数据仓库,而不仅仅是图形查看器。团队里如果有人对这个不熟,强烈建议花一个下午专门练一练,这是性价比极高的一笔投入。
6.3 定向抓取:同一帧多进程协同分析
大型项目跑起来后,一次交互往往涉及十几个进程。如果一次全抓,不仅文件巨大,分析时也会因信息过载而降低效率。此时更推荐定向抓取,比如配置android.process_stats和ftrace时,通过target_android_process或pid限定只抓目标App、system_server、surfaceflinger这三个进程。
但需要注意:如果怀疑卡顿和其他App争抢资源有关,比如大量后台进程占用CPU或IO,则不能只抓目标App,否则看不到“谁在抢”。这种情况我通常会用一次不带进程过滤的全量短抓取,时长控制在5秒以内,专门用于观察“那几秒内到底有哪些进程在活跃”。拿到结论后再回到定向抓取做深度分析。
6.4 分享与协作:trace文件本身就是沟通语言
我发现很多团队在线上问题协作上效率低,一个很大原因是沟通时没有共同语言。产品说“感觉卡”,QA说“复现不了”,开发说“我这边跑着没问题”。而perfetto trace天然是一个客观的、可共享的中间物。
在实际操作中,我们内部推荐的做法是:抓完trace后直接上传到perfetto UI的分享功能,或者放到公司内部的共享盘,然后给相关同事发一个链接加一段简短的“看哪一帧、哪条线程”指引。对方打开就能看到同一份证据,而不是拿着手机录屏反复看。
更进阶的玩法是自动化:在CI或测试机集群上预先部署一个抓trace的小工具,测试工程师一旦发现自己负责的模块出现掉帧,一键就会把当前30秒的trace和log一并打包上传。开发拿到后直接做后验分析。这套流程我们在实际项目中运行了近一年,挽回的排查时间非常可观。
写在最后的一点经验
用perfetto(包括systrace)分析卡顿,说到底是一个“取证”的过程,不是“猜谜”的过程。工具给到的每一个slice、每一段线程状态、每一次Binder调用,都是客观的现场物证。如果你现在还在靠感觉、靠日志、靠代码走读去猜卡顿原因,我建议你在下一个问题出现时,至少先尝试抓一份trace,按照本文的顺序:先看帧、再看线程、再区分形态,最后落成证据链。相信我,一旦习惯了这种工作方式,你很难再回到从前那种“看着代码想到底哪里慢”的日子。