上周帮一个团队处理线上卡顿,打开ANR trace 第一眼看到的又是主线程停在 Choreographer.doFrame。这个位置在性能优化里算是典型疑难杂症了:从堆栈看,问题似乎很明确,主线程就是在绘制流程里卡住了;但真正的原因往往藏在 doFrame 的前前后后,有可能是布局问题,有可能是动画问题,也有可能是一个看似无关的业务 Handler 占住了主线程。这篇文章就围绕“应用主线程在 doFrame 的 ANR”这类问题,把我平时怎么排查、怎么定位、怎么治理的完整思路过一遍。内容偏实战,适合正在和线上 ANR 率、卡顿率缠斗的同学参考,也适合刚接触性能优化、想理解 Choreographer 工作机制的朋友,耐心看完应该会有收获。
1. doFrame与ANR的关系:为什么它们总是一起出现
1.1 Choreographer.doFrame在UI周期里到底扮演什么角色
先说一个基础但很容易被忽略的点:doFrame 并不是 Android 系统 UI 线程的全部,它只是被 Choreographer 调用的一次帧回调。Choreographer 会等待硬件 VSync 信号,等信号来了,才把当前帧里注册好的任务按顺序执行掉。这个顺序是固定的:先处理 input 相关的回调,再处理 animation 回调,最后是 traversal 回调,也就是 ViewRootImpl 的 performTraversals。
可以把它类比成一个流水线,VSync 就是流水线的启动铃。铃一响,doFrame 开始运转,把输入、动画、布局、绘制这几道工序依次推完。正常情况下,这一整套流程要在 16ms 左右完成,这样屏幕刷新到下一帧时恰好有新的画面可以展示。一旦 doFrame 内部任意一道工序超时,比如 measure 或者 draw 消耗了 100ms,那这一帧就出不来,用户就会感到卡顿、掉帧。
关键在于,如果掉帧只是偶尔一两次,系统最多是统计一下 jank,并不会弹窗。但如果是持续性的、严重的主线程阻塞,输入事件超过 5 秒没有被主线程处理完,系统就会认定是 “Input dispatching timed out”,于是弹 ANR。所以 doFrame 和 ANR 的联系,本质上是一条链路:doFrame 负责在每帧内完成 UI 工作,doFrame 一旦长期被占用,输入响应超时,系统就动手了。
1.2 系统是如何把慢帧一步步升级成ANR弹窗的
很多人会把掉帧和 ANR 混为一谈,其实它们是两个层级的故障。掉帧是性能劣化,ANR 是红线事故。就拿 doFrame 相关的场景来说,最典型的触发路径是:用户的手指在屏幕上滑动,系统派发了一个 MotionEvent 到主线程;如果主线程正忙着执行 doFrame 里的 performTraversals,暂时没空处理这个事件,事件就会等在队列里;等到 doFrame 执行完,发现已经超过了 5 秒,那就不好意思,ANR 弹窗出现。
除了输入超时,还有两种情况也容易和 doFrame 扯上关系。一种是 BroadcastReceiver 超时,某些广播的 onReceive 方法跑在主线程,如果恰好广播是在 doFrame 链路里某个回调触发的,而 onReceive 里又有耗时逻辑,整个主线程被占住,超过前台 10 秒的后台 60 秒,也会 ANR。另一种是 Service 超时,情况类似。所以看到 ANR 类型不是 “Input dispatching timed out” 时,也别急着排除 doFrame 的嫌疑,还得看主线程当时是不是正卡在绘制链路上。
理解这一层后,排查思路就能打开:不要只盯着 doFrame 函数本身,而要把它当作一个时间窗口,窗口内任何一段主线程的高负载,最终都可能表现为 doFrame 相关的 ANR。
1.3 “卡在doFrame”这个结论,经常会误导排查方向
实际处理问题的时候,我发现很多人一看到 trace 里主线程栈在 Choreographer.doFrame,就以为“绘制代码写得太重了”,然后一头扎进自定义 View 的 onDraw 里找问题。这个方向有时候对,但经常会被打脸。
原因是 doFrame 的调用方式比较特殊。它不是进程内部想调就能调的,必须等 VSync。如果主线程之前已经被某个耗时任务占用了几秒钟,等 VSync 来了之后,doFrame 根本排不上队,主线程还在执行那个耗时任务。这时抓到的 trace 停在哪里?大概率不在 doFrame 里,而是在那个耗时函数的堆栈上。只有当主线程其实已经空闲,并且真的开始执行 doFrame 了,但 doFrame 内部代码太重,trace 才会停在 performTraversals、measure、draw 这些函数里面。
所以看到“主线程在 doFrame”这个堆栈,第一件事不是去改 doFrame 相关代码,而是要先判断:这是 doFrame 执行得慢,还是 doFrame 压根没机会执行。前者是绘制链路问题,后者通常是消息队列拥堵问题。这两种情况的修复方式完全不同,判断错了,后面全是白忙。
2. doFrame ANR的根因分类型排查
2.1 类型一:主线程消息队列拥堵,doFrame被硬生生饿死
先说我见过最多的一类:doFrame 没做任何事,纯粹是被其他消息挤到后面去了。主线程的 Looper 本质上是一个死循环,不断从 MessageQueue 里取消息并执行。如果某个消息执行了 2 秒,那后面排队的消息全部等待,包括 Choreographer 投递的帧回调。
举一个实际例子:有一次线上 ANR,主线程 trace 停在了一个业务 Handler 的 handleMessage 上,里面是一段复杂的 JSON 解析,加上同步的 SharedPreferences 写入,执行了将近 6 秒。那期间用户滑动列表,VSync 信号一直在触发,Choreographer 一次又一次尝试投递 doFrame,但每次都被挡在 MessageQueue 外面。最终系统判定输入事件超时,ANR 弹窗。
这种场景下,你去看 ANR trace,主线程停在业务代码上,根本不在 doFrame 函数内部。但搜索相关日志时,又确实能看到掉帧统计在短时间内爆表,很多同学就迷茫了。其实机制非常简单:doFrame 被饿死了,不是它自己不干活,是没机会干活。
排查时需要关注两点:一是 trace 里主线程具体停在哪一个消息处理上,二是看这个耗时消息是哪里 post 进来的。通常可以用自定义的主线程消息监控,把每条消息的执行时间打出来,找那条超过几百毫秒的消息,顺藤摸瓜抓到 handler 源头。
2.2 类型二:performTraversals内部超时,布局和绘制是重灾区
第二种情况才是真正意义上的“doFrame 内部卡住”。trace 会明确停在 ViewRootImpl.performTraversals 里,再往下可能是 View.measure、View.layout、View.draw 这些调用链中的任意一环。
布局阶段超时的原因很直白:View 树太深、节点太多、measure 写得太复杂。比如有些老项目还留着五六层嵌套的 LinearLayout,一个页面大几百个 View 节点,每个节点 measure 一次,整个树就要跑几百上千次测量。低端机上单次 measure 就可能超过 20ms,一帧里再来点自定义 View 的复杂计算,50ms 就出去了。
绘制阶段超时的原因更常见:onDraw 里做了不该做的事。我最常遇到的是在 onDraw 里创建 Paint、创建 Path、做文字宽高测量、做字符串拼接、甚至解析 JSON。这些操作单次看着不重,架不住每帧都执行,滑动时一次 draw 几十毫秒,指数级恶化。
doFrame 里还有一段容易被忽略的时间是 draw 之后的 sync 阶段,也就是 RenderThread 和 UI 线程同步渲染命令的过程。如果 view 层 android 设置了复杂的效果,比如阴影、模糊、大尺寸的硬件层,sync 耗时会显著上升。这属于绘制链路的另一种重负载,排查时也不要漏掉。
2.3 类型三:高频requestLayout导致的测量风暴与无效帧
这类问题表面看是 doFrame 卡了,骨子里是布局请求太频繁。View 的 invalidate 和 requestLayout 是有区别的。invalidate 只在当前 View 的绘制区域打一个标记,下一帧只需要重绘它;requestLayout 则要求整棵 View 树重新 measure、layout、draw。一次两次无所谓,但如果一个页面在短时间内被反复 requestLayout,每帧都在执行完整遍历,那就非常致命。
最常见的触发场景是:某个自定义 View 在 onDraw 里根据测量结果调整自身大小,调用了 requestLayout;requestLayout 又导致下一帧重新测量,测量完又触发 onDraw,onDraw 里再次 requestLayout。这个循环会让每帧的 performTraversals 都变得异常繁重,轻则掉帧,重则整个主线程被拖到 ANR。
另一个高频场景是使用 RecyclerView 时,数据更新方式不合理。比如在一个列表里频繁调用 notifyDataSetChanged,而不是使用差异化更新 DiffUtil,每次都会触发大量 item 重新布局,在列表中间再嵌套一个自身会发 requestLayout 的组件,整个 doFrame 的耗时直接起飞。
这种根因的 trace 往往停在 performMeasure 或者 performLayout 上,而且多次抓到的堆栈高度相似。排查时可以重点看是否在循环、回调里触发了 requestLayout,以及布局层级有没有不必要的复杂结构。
2.4 类型四:输入事件处理与doFrame互相争抢主线程
还有一种情况比较隐蔽:问题不出在 doFrame 内部,而是出在输入事件的预处理阶段。前面说过 Choreographer 在每一帧阶段会先处理 input 回调,但如果 onTouchEvent、onInterceptTouchEvent、dispatchTouchEvent 里做了耗时操作,那 input 阶段就会卡住,后面的 animation 和 traversal 全部都得等。
有个经典案例:一个滑动的 ViewPager 页面,onPageScrolled 回调里做了大量数据刷新和 Bitmap 解码,每次滑动都触发一次耗时操作。从时间窗口看,主线程大部分时间都在和滑动相关的事件处理纠缠,Choreographer 的动画和绘制任务反复被推迟,最终输入事件处理的超时直接变成 ANR。
这种场景的难点在于 trace 可能看到的是 Activity.dispatchTouchEvent、View.onTouchEvent 这类堆栈,而不是 doFrame,但整条帧链路确实是在 doFrame 的时间片内被打断的。排查时不能只盯着绘制,要把每一帧里从输入到动画再到遍历的完整时间分布都打出来,才能看清是谁抢了谁的时间。
2.5 四类根因的特征对照
| 根因类型 | trace特征 | 发生时机 | 直观表现 | 修复方向 |
|---|---|---|---|---|
| 主线程消息队列拥堵 | 主线程停在业务Handler/Looper.loop | 任意操作 | 点击无响应,操作延迟 | 消息拆分、耗时任务异步化、消息去重 |
| performTraversals内部超时 | 停在measure/layout/draw相关调用 | 滑动、页面切换 | 明显掉帧、卡顿 | 布局扁平化、自定义View瘦身、绘制缓存 |
| 高频requestLayout | 停在performMeasure/performLayout | 数据刷新、动画回调 | 帧率骤降 | 合并刷新、避免在draw里调用requestLayout |
| 输入事件与doFrame互抢 | 停在dispatchTouchEvent/onTouchEvent | 触摸交互中 | 掉帧+输入延迟 | 事件回调轻量化、耗时逻辑移出主线程 |
这张表可以当排查的快速索引,不过实际案例经常是几种根因叠加,比如既有布局重,又有消息队列拥堵。遇到混合型问题,就需要逐个拆开验证。
3. 从拿到Trace到揪出真凶的完整排查链路
3.1 第一步:读懂ANR trace里的主线程堆栈
拿到一份 ANR trace,很多人的习惯是直接搜 “main”,然后把主线程的调用栈贴给同事。这个动作没问题,但读栈不能只看一个函数名,要按层拆。
比如下面这段模拟的 trace 片段:
"main" prio=5 tid=1 Runnable at com.example.widget.InfoCardView.onDraw(InfoCardView.java:87) at android.view.View.draw(View.java:20100) at android.view.ViewGroup.drawChild(ViewGroup.java:4300) at android.view.ViewGroup.dispatchDraw(ViewGroup.java:4100) at android.view.View.draw(View.java:20400) at android.view.ViewRootImpl.performDraw(ViewRootImpl.java:3500) at android.view.ViewRootImpl.performTraversals(ViewRootImpl.java:3100) at android.view.ViewRootImpl.doTraversal(ViewRootImpl.java:2600) at android.view.ViewRootImpl$TraversalRunnable.run(ViewRootImpl.java:7900) at android.view.Choreographer.doFrame(Choreographer.java:700) at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:880) at android.os.Handler.handleCallback(Handler.java:890)读到这段栈,不要只盯着 Choreographer.doFrame,那是结果不是原因。真正的信息在栈顶,也就是 com.example.widget.InfoCardView.onDraw。这个函数就是当前正在执行的耗时点,重点检查它。再看调用链,它是被 performDraw 调起来的,说明问题确实在绘制链路内部。这时再结合前文的根因分类,基本可以把方向锁定在 performTraversals 内部超时这一档。
如果 trace 停在 Looper.loop 或者某个业务 Handler,那就赶紧跳出 doFrame 的思路,转去查消息队列拥堵。一句话:读栈永远从栈顶读到栈底,先确认现在到底在执行什么代码,再判断责任方是谁。
3.2 第二步:复现并量化doFrame内各阶段的耗时
静态读完栈,下一步要复现问题并量化耗时分布。这里有两个很实用的工具。
第一个是 Choreographer 的 FrameCallback,可以用来统计相邻两帧间的真实间隔,判断掉帧有没有出现、出现在什么操作之后。
public class FrameMonitor { private long lastFrameTimeNanos = 0L; public void start() { Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() { @Override public void doFrame(long frameTimeNanos) { if (lastFrameTimeNanos != 0L) { long costMs = (frameTimeNanos - lastFrameTimeNanos) / 1_000_000; if (costMs > 50L) { Log.w("FrameMonitor", "jank, this frame cost " + costMs + " ms"); } } lastFrameTimeNanos = frameTimeNanos; Choreographer.getInstance().postFrameCallback(this); } }); } }注意这个监控不能常开,每帧都会回调,自身有一点开销,但定位问题时临时开一下非常值。如果发现掉帧集中在某个业务操作之后,比如点击某个按钮,那后面的 trace 采集就会有的放矢。
第二个是 FrameMetrics API,它能直接拿到系统记录的整帧信息,包括输入处理耗时、动画耗时、measure/layout 耗时、draw 耗时、同步耗时、交换缓冲耗时等。
Window window = getWindow(); window.addOnFrameMetricsAvailableListener((window2, frameMetrics, dropCount) -> { long total = frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) / 1_000_000; long measureLayout = frameMetrics.getMetric(FrameMetrics.MEASURE_LAYOUT_DURATION) / 1_000_000; long draw = frameMetrics.getMetric(FrameMetrics.DRAW_DURATION) / 1_000_000; long input = frameMetrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION) / 1_000_000; if (total > 50L) { Log.w("FrameMetrics", "total=" + total + "ms, input=" + input + "ms, measureLayout=" + measureLayout + "ms, draw=" + draw + "ms"); } }, new Handler(Looper.getMainLooper()));这个 API 的好处是不用侵入业务代码,系统已经把每一帧拆成了几个阶段,直接看哪段时间最长就能定位责任阶段。input 长是事件处理问题,measureLayout 长是布局问题,draw 长是绘制问题,非常清晰。
3.3 第三步:实战复盘一个横向列表引发的doFrame ANR
讲一个我实际跟过的案例,帮助理解上面这套方法怎么串起来。
线上反馈某版本在低端机上滑动一个横向卡片列表特别卡,使用几分钟后直接 ANR。拿到 dropbox 里的 trace,主线程栈停在了一个自定义 CardView 的 onDraw 方法里,栈底部同样挂着 Choreographer.doFrame。按 3.1 的读法,问题应该出在绘制本身。
接下来复现问题时,我在目标机型的 Debug 包上打开了 FrameMetrics,复现滑动操作,日志打出来:单帧 total 稳定在 300ms 到 800ms 之间,其中 draw 阶段占了 250ms 以上。再往下查 onDraw 代码,发现每次 draw 都要做三件事:创建 Path、做文字的 measure 计算、new 一个 Paint 对象并设置复杂 shader。这些对象的创建和计算在低端机上非常昂贵,滑动时一秒钟 60 帧,等于每帧都在重复做同样的事情,彻底把主线程拖垮。
修复方法不复杂:把 Paint、Path 挪到 View 构造时初始化,文字测量结果做缓存,shader 复用,onDraw 里只做 canvas.draw 操作。改完后在同样的机型上滑了十分钟,单帧 draw 时间基本稳定在 5ms 左右,不再出现卡顿和 ANR。
这个案例的启发点是:onDraw 里的代码量有时看着不大,但每帧重复执行之后,消耗会被放大到一个你想不到的量级。性能问题不能只看单次执行耗时,还要看执行频率。
4. 修复手段与线上防线:让doFrame不再成为事故现场
4.1 在Debug包落地一个无侵入的doFrame耗时监控
排查类的工具再顺手,也只是事后诸葛亮。真正想把 doFrame 的 ANR 压下去,要在开发阶段就建立一条监控防线。我现在的习惯是在 Debug 包和内部体验包里挂一个帧耗时监控,最简单的方式就是 Choreographer FrameCallback 方案,设定一个阈值,比如单帧耗时超过 100ms 时,立刻把主线程堆栈打到日志里。
代码可以参考前面那一段 FrameMonitor,加一点改动:在采集到慢帧的时候,用 Log.getStackTraceString(Thread.currentThread()) 抓一下当前主线程栈。这样等同事反馈“开发环境也卡了”的时候,调试日志里已经躺着现成的堆栈,不用再让人复现一遍。
有一点要提醒:这种监控不要直接带到线上包,更不要长期开着采集所有堆栈,否则监控本身会制造掉帧。可以做成配置开关,灰度期间或特定用户群里临时打开,采集一段时间后关掉。
4.2 从源头消灭主线程重活:消息治理与布局绘制瘦身
治标之外还得治本。先说消息治理。统计一下主线程消息队列里到底跑了哪些重任务:有没有大型 JSON 解析、数据库查询、Bitmap 解码、加密操作,这些能挪到子线程的一律异步化。挪不走的,比如 View 操作,要尽量合并。两个 Handler 消息如果都可以延迟一点,就用一个消息合并处理,减少消息队列的总排队长度。对于重复发起的延时任务,比如频繁 postDelayed 的轮询,要记得在页面不可见时移除,避免主线程被无意义地占用。
布局和绘制层面的优化可以参照几个常见原则:能用一个 View 画出来的效果就不要用多层 ViewGroup 叠;能用 ConstraintLayout 一层结构就不要用四层 LinearLayout;onDraw 里不要做任何对象创建、资源解码、文件读写操作。列表里的 item 优先用 RecyclerView,数据刷新优先用 DiffUtil 这类增量更新,避免整页重绘。
这些优化不是一次能做完的,建议在平时需求迭代里顺手做,或者单独立项专项治理。哪块掉帧最明显就先改哪块,逐步积累。
4.3 用FrameMetrics和系统Trace搭起线上兜底
线上环境比较复杂,真机分布广、系统版本多,很多卡顿只会在特定机型触发。要做线上兜底,可以在几个核心 Activity 的 Window 上挂 FrameMetrics 监听,把慢帧的关键指标周期上报到监控平台。平时不需要全量采集,只上报 total 超过 200ms 的帧,就能拿到最具参考价值的性能数据。
如果有条件,可以在慢帧发生时结合系统 Trace 能力,把主线程当时的调度情况、CPU 占用、锁等待一起采集下来。用 perfetto 抓到的数据能看清一个问题:卡顿时是 CPU 本身繁忙,还是线程在等待锁,或者是在低功耗模式下运行。这些信息对定位混合型问题非常关键。
需要注意的是采集逻辑本身不能拖慢主线程,建议采样率控制在 1% 以内,上报采用异步批量。线上监控要做到“平时不动声色,出事时有据可查”,而不是反过来变成新的性能负担。
4.4 发版前的卡顿回归与灰度监控习惯
最后说点习惯层面的东西。我后来发现,doFrame 类的 ANR 很容易在发版初期集中爆发,因为回归测试很少会在低端机上长时间滑动列表。所以现在每次版本发布前,我会安排一轮低端机的重点场景回归,覆盖三个地方:列表页快速滑动、列表进入二级详情页再返回、页面切换动画。这三个场景最容易触发 doFrame 相关问题。
灰度期间再把 4.1 的帧耗时监控开关悄悄打开,针对新用户群里出现异常的情况做实时告警。只要慢帧被及时抓到,ANR 大概率能在演变成事故之前被拦下来。这套流程跑顺之后,doFrame 类的 ANR 基本不会再成为线上顽疾。
我自己习惯在项目的 Debug 包里长期放一个 FrameMonitor,同时把线上监控阈值、采样率、白名单做成服务端可配置的参数,出问题随时调整无需发版。这样一套组合下来,再遇到“应用主线程在 doFrame 的 ANR”时,第一步已经不是去线上日志里找堆栈,而是先去监控面板确认影响范围,再用 trace 和 FrameMetrics 定位耗时阶段。排查得快,治理得早,比事后补丁省力得多。