焦点切换 ANR,做 Android 稳定性优化的人对这个组合词应该不陌生:界面从一个输入框切到另一个输入框,或者点击列表项让焦点从一个控件转移到另一个控件,屏幕突然像冻住一样,过 5 秒弹出一个“XX 未响应”的 ANR 弹窗。最难受的就是这个触发操作看起来太轻了,不就是一次 requestFocus() 吗?系统怎么会判死?
但实际查过之后你会发现,一次焦点切换在 Android 系统里牵动的链路远比你想的长:输入事件派发、窗口焦点切换、输入法绑定、View 树的布局重排,甚至整棵视图树的焦点搜索遍历,全都挤在同一帧前后完成。任何一个环节在主线程上卡住,后面的输入事件就没人消费,InputDispatcher 的 5 秒超时一到,ANR 就来了。这篇文章想把“焦点切换导致 ANR”这件事完整拆一遍:焦点切换和输入系统到底什么关系、从 traces 里怎么定位真正的卡点、根因有哪几类、以及实践里怎么修复和预防。适合刚接手稳定性优化、正准备啃 ANR 这类疑难杂症的客户端同学,也适合想在开发阶段提前防雷的 Android 工程师。
1. 先聊清楚:焦点切换为什么是 ANR 的重灾现场
1.1 焦点切换在系统里到底触发了什么
在 Android 里,“焦点”这个词很容易把人绕晕,因为至少有三套东西都在叫焦点,而且经常同时变化。第一套是窗口焦点(Window Focus),由窗口管理服务统一管理,决定系统当前把触摸、按键这类输入事件派发给哪个窗口。第二套是视图焦点(View Focus),由 ViewRootImpl 在窗口内部维护,决定窗口内的按键事件先交给哪个 View 处理。第三套是输入法焦点,一般和拥有视图焦点的 EditText 绑定,软键盘显示不显示、输入法绑定到谁身上,都由它决定。
一次典型的焦点切换,尤其是输入框之间的切换,往往同时触发这三种状态变化:旧 View 失去视图焦点,新 View 获得视图焦点,输入法重新绑定新 EditText,窗口层面如果涉及软键盘弹出或者 Dialog 的显示关闭,重点窗口也会被重新指定。这些变化全都串在同一个时间点附近发生,任何一个环节慢下来,后续的输入事件就无法及时分发到目标窗口,最后走进 ANR 的超时路径。
更重要的是,焦点切换不是一个纯 UI 变化,它跟系统输入派发机制强绑定。系统侧的 InputDispatcher 在派发事件前必须确认当前“焦点窗口”是哪个,事件先送到焦点窗口的输入通道,再由 ViewRootImpl 交给主线程处理。如果主线程正忙到没空消费这个事件,事件就会在输入通道里积压。等到积压时间超过系统阈值,InputDispatcher 就判定应用无响应。所以说白了,“焦点切换导致 ANR”这句话更准确的解释是:焦点切换过程中,主线程因为某种原因长时间没有机会回来处理输入事件,或者处理得太慢,导致输入系统判定应用已死。
1.2 两种典型的现场表现
焦点切换 ANR 的现场表现大致可以分成两类。一类是点击焦点切换后立即卡死,手势动画结束后屏幕没有任何反应,后续点击全部无效,过几秒弹出 ANR 弹窗。这种场景一般发生在列表项里的按钮拿走焦点,或者 Dialog 消失后焦点要回到原来的输入框时。另一类是软键盘弹出或收起的过程中卡死,界面正好冻结在键盘动画的中间某一帧,看起来特别像输入法崩溃了,实际是主线程被占住,键盘动画也没有渲染线程继续推进。
除了这两种最终弹框的,还有一种“软性 ANR”很容易被忽视:还没触发系统 ANR 弹窗,但界面已经严重掉帧,键盘弹出要好几秒,列表滚动一段一卡。这种问题虽然没到系统判死的级别,但根因和焦点切换 ANR 往往一模一样,只是卡的时间还没凑够 5 秒。一句话总结,就是把平时不慌不忙的重活,全堆到了用户正在输入的这个高敏感时间窗口里。
为了快速建立怀疑方向,我习惯把现场表现和嫌疑点做个简单对照:
| 现场表现 | 常见触发场景 | 背后嫌疑 |
|---|---|---|
| 点击后立即无响应,最终弹 ANR | 列表项按钮拿走焦点,Dialog 关闭后焦点回原输入框 | 焦点回调里执行耗时任务,或触发整页 layout |
| 软键盘弹出/收起过程中卡死 | 两个输入框之间快速切换 | IME 绑定 + adjustResize 整页重新测量 |
| 高频焦点移动导致帧率暴跌 | 方向键连续快速移动焦点 | FocusFinder 大范围遍历 + 每帧重复 requestLayout |
这表不是绝对归类,但实际排查中很多焦点切换 ANR 都是多个因素叠加的结果:布局本身太重,焦点回调里又做了同步工作,IME 再来来回回重置窗口 flag,最后主线程连 5 秒的空闲窗口都挤不出来。
1.3 为什么偏偏是焦点切换容易引爆
第一个原因是时间窗口太敏感。焦点切换经常发生在用户正在输入或者马上要输入的时刻,这是输入系统最看重的场景。用户点击、打字这些动作都需要主线程快速响应。如果同一时刻焦点切换触发了一堆重量级操作,比如调整窗口避让区域、重新布局整个页面、绑定输入法,那用户的下一次输入事件就会直接撞上超时墙。普通滑动卡顿可能表现为掉帧,但是输入事件被卡住 5 秒,系统就按 ANR 处理了。
第二个原因是焦点切换天然和输入法绑定。大部分焦点切换发生在有输入框的界面上,而输入法本身是一个极复杂的系统组件:要绑定输入框、要请求显示自身、要跟窗口系统协商 insets。整个协商过程同时涉及系统服务线程和应用主线程,任何一边慢下来,另一边就得同步等待。最恶劣的情况是主线程等着某个 Binder 返回,而系统服务又在等主线程处理完前一件事,这种互相等待最容易把超时时间耗尽。
第三个原因是焦点切换会改变 View 树状态,引发连锁 re-layout。一个输入框获得焦点后,系统就要重新计算窗口避让区域,于是整个 DecorView 重新 measure、layout、draw。页面层级越深、布局越复杂,单帧耗时越高。如果焦点移动频率高,或者一次移动导致多个 View 同时 requestLayout,主线程就很容易跑出一个“永远做不完的遍历”。这三个原因叠在一起,焦点切换就成了 ANR 的高发地段。
2. 定位 ANR 的实操打法:从 traces 文件读主线程现场
2.1 复现与抓取 traces 的几种姿势
遇到焦点切换 ANR,第一件事不是改代码,而是把现场完整记录下来。系统在 ANR 发生时会把各线程的 Java 栈和 native 栈写到 traces 文件里,通常是 /data/anr/ 目录下,这就是最重要的第一手材料。
如果你的应用处于可调试状态,最稳妥的是复现前先做一次系统级信息采集,复现完成后把日志完整导出。如果是线上版本,很多团队会在进程里内置一套轻量的线程栈采集机制,在 ANR 弹窗前主动 dump 一份主线程栈上报。这比事后翻系统 traces 更准,因为系统 traces 记录的是弹窗那一瞬间的现场,而很多 ANR 真正的卡死点早在那之前就发生了。等系统正式生成 traces 文件时,主线程可能已经因为其他原因改变了位置,导致排查人员被带偏。
另外,如果问题能稳定复现,可以配合系统自带的跟踪工具录一段 trace,重点看输入事件处理和 View 布局的相关片段。录 trace 的姿势建议是:先把跟踪打开,再手动执行焦点切换复现,结束后立刻关掉。这样能拿到从事件派发、焦点变更到布局绘制的完整时间线,远比单看一个静态 traces 文件信息量大。
2.2 traces 文件里的关键信息怎么读
打开 traces 文件,第一眼先找 reason 字段,比如 “Input dispatching timed out”,这告诉你是哪一类 ANR。焦点切换引发的 ANR 绝大多数都是这种输入派发超时。接着看 CPU usage 这一段,它列了 ANR 发生时整个系统、各个进程的 CPU 占用。这个信息非常关键:如果 CPU 占用很高,优先判断主线程在做重活;如果 CPU 占用很低,主线程却卡着不动,那多半是在等锁或者在等某个 Binder 调用返回。
然后看主线程的调用栈。主线程在 traces 里一般以 main 开头。栈的落点能直接透露卡点性质:
- 如果栈停在
MessageQueue.nativePollOnce,说明主线程消息队列其实空闲,此时 ANR 可能是因为系统侧的焦点窗口迟迟没能确定,事件被 InputDispatcher 挂起,压根没送进目标窗口。 - 如果栈停在
ViewRootImpl.dispatchInputEvent或某个 View 的事件分发方法,说明事件已经进入主线程,但处理过程卡住了,重点去查栈里的业务类。 - 如果栈停在
BinderProxy.transact或Binder.transact,说明主线程在跨进程等待系统服务,典型的锁等待或循环依赖。 - 如果栈停在
requestLayout、performTraversals、measure相关方法,说明布局计算太重,整个页面在焦点变化后被强制重排了一整遍。
容易忽略的是,ANR 弹窗时的 traces 不一定包含最开始的卡点。InputDispatcher 的超时是从事件派发时刻开始算的,如果主线程在消息处理中途被卡住,dump 的时候它可能已经卡在另一个位置上。所以要结合事件时间线来对照,只看最后那一刻的调用栈很容易误判。
2.3 判断焦点切换 ANR 的 3 个关键
我在排查这类 ANR 时,会先做三个快速判断。
第一,看 reason 到底是 Input dispatching timed out,还是 Executing service,还是 Broadcast timeout。虽然名字前都有 ANR,但底层机制完全不同,修复方向也完全不同。焦点切换大多落在 input dispatching timed out,这个先确认,省得浪费时间去别的分类里找线索。
第二,看主线程栈的 native 段和 java 段分别停在哪儿,判断是不是垃圾回收或者反射调用造成的长时间停顿。这种情况很常见,尤其低内存设备上 gc 一次几百毫秒,多来几次就把超时凑够了。
第三,看系统侧是否有类似“waiting because no focused window”的标记。这表示事件的接收者根本没有焦点窗口。比如 Dialog 正在关闭、旧焦点窗口已经失效、新窗口还没完全准备好,此时 InputDispatcher 会把事件挂起,直到焦点窗口确定下来。如果这个状态一直不收敛,最终也会超时。这类问题的主线程可能一点都不忙,纯粹是系统侧焦点状态机出了问题。这三个判断能帮你快速区分“主线程太忙”和“系统焦点状态卡住”,后续排查方向差别很大。
3. 根因拆解:别只盯着输入超时
3.1 主线程耗时任务卡在焦点回调链路上
焦点切换 ANR 最常见的根因,就是主线程上某个和焦点相关的回调里执行了重量级操作。开发同学写代码时很容易忽视 onFocusChange 这个回调的执行时机:它是在输入事件处理流程中同步触发的,只要这个回调不返回,主线程就无法处理后续任何事件。
我见过一些特别典型的反模式:在 onFocusChange 里读 SharedPreferences、写数据库、做埋点上报;在焦点丢失时同步触发一次网络请求;在输入框获焦时调一下 notifyDataSetChanged 刷新整个列表;甚至在 onFocusChange 里根据状态再调用 requestFocus,反复触发焦点事件。这些代码单独看每一行都不重,问题在于它们被放进了最不该放的地方。一次 10ms 的本地 IO 平时感觉不出来,但叠加输入法绑定、整页布局和一次 GC,单条消息的处理时间就可能破秒。后续输入事件全堵在队列里,5 秒超时很容易被凑满。
这类根因的特点是:焦点切换只是导火索,真正烧起来的是主线程上无处安放的重活。所以排查时不要盯着 requestFocus 那一行看,要看主线程在焦点变化前后到底执行了哪些业务代码。
3.2 布局与绘制的连锁反应
焦点切换引发的 ANR,很多其实死在布局计算上。一个输入框获得焦点后,软键盘要出现,窗口的 insets 会变化,如果 windowSoftInputMode 配置的是 adjustResize,整个 DecorView 都会重新走一遍 measure、layout、draw。这个连锁反应对大多数页面是无感的,但对那些布局层级深、页面内容多的界面来说,一次整页 relayout 可能就要几百毫秒甚至上秒。
还有一个被低估的耗点,是焦点搜索遍历。系统在找“下一个该聚焦的 View”时,会通过 FocusFinder 在视图树里做一次遍历。正常情况下这很快,但如果页面节点非常多,或者焦点移动是通过方向键高频触发的,而大量 View 又设置了 focusable 属性,遍历成本就会被成倍放大。之前有位同学遇到过一个玄学卡顿:TV 上按方向键快速移动焦点,界面一顿一顿,后来一查,就是整个页面里塞了几百个 focusable 的节点,每按一次方向键就要全树搜索一遍。
这类根因的排查信号是 traces 里出现大量的 measure/layout 方法,或者主线程反复在焦点变化之间执行 requestLayout。需要注意的是,一次 requestLayout 并不可怕,可怕的是在焦点切换期间反复触发、互相叠加,导致主线程陷入“布局做不完”的状态。
3.3 IME 与窗口焦点纠缠的细节
另一种根因藏在输入法和窗口焦点的交互时序里。表面上看起来是焦点切换,实际上系统卡在 IME 和窗口焦点互相等待上。
举个例子,一个 Dialog 弹出时拿到窗口焦点,关闭时焦点要还给原来的 Activity 输入框。这个归还过程要同时处理旧窗口的销毁、新窗口焦点确认、输入法重新绑定三个逻辑。如果原输入框的主线程恰好被其他任务占住,系统就会先释放旧焦点,却发现新的焦点窗口还没准备好,于是输入事件被挂起。此时即使主线程想处理,也拿不到对应的窗口通道。这种问题不是主线程不够快,而是系统侧的状态机没有及时收敛。
还有一种常见情况是应用自己管理软键盘。很多即时通信类应用会在 EditText 获焦时强制调用一次显示输入法,失焦时又强制隐藏。如果这个显隐过程和系统 IME 自身的状态冲突,就会反复重置输入法窗口的可见 flag,导致主线程不断做 relayout。查这种问题时,主线程栈会多次出现和 InputMethodManager 以及窗口布局相关的调用,但真正的触发逻辑在业务代码的焦点监听里。
4. 从根源入手:修复与优化的实战方案
4.1 把重量级操作移出焦点路径
修复的第一步,也是最有效的一步,是把所有非必要的重量级操作从焦点回调链路上挪走。
onFocusChange 里只做状态记录,真正要执行的业务逻辑放到后续消息里。比如用主线程 Handler 的 post 方法,或者用 View 的 post 方法,把数据准备、埋点上报、列表刷新这些操作推迟到当前消息处理完毕之后。这样做的好处很简单:焦点回调是输入事件分发流程的同步部分,你让这条消息快速结束,主线程立刻就能回来处理后续输入事件,不用担心一个回调把整个事件队列堵死。
同时要警惕 ViewTreeObserver 的使用。有些开发者在布局监听回调里根据焦点状态再去调用 requestFocus 或者 requestLayout,这会形成“布局变化 → 回调触发 → 再次布局”的循环。如果确实需要在布局稳定后再操作焦点,建议等下一帧,或者用一个标志位做防抖。
还要特别提醒:不要在 onWindowFocusChanged 里执行跨进程调用,也不要在这个时机做重量级页面切换。这类回调天生处于窗口焦点变化的最前哨,一旦卡住,影响的不只是某个 View,而是整个窗口的事件派发。
4.2 减少焦点切换带来的布局抖动和过度遍历
第二板斧是瘦身。目标很明确:让焦点切换这件事本身变得更轻。
第一招是减少无意义的 requestLayout。请求焦点之前先判断当前焦点状态,避免给同一个 View 反复发起 requestFocus,每次请求一旦导致焦点状态变化,就可能触发 layout。第二招是缩小焦点搜索范围。检查页面里哪些 View 其实不需要参与焦点导航,把这些 View 的 focusable 设置成 false,或者通过 focusSearch 相关的属性明确指定焦点前进后退的路径。第三招是避开整页 relayout。输入框获焦导致 IME 出现时,不要被动地让整个 DecorView 重新测量,而是通过 WindowInsets 精准处理键盘区域,或者在界面布局上做局部避让,避免一弹键盘就全局重排。
这些优化看起来琐碎,但对焦点切换 ANR 的改善往往立竿见影。因为焦点切换场景里的布局抖动是纯加法,每减少一个 relayout 请求,主线程在超时窗口里就多一分喘息空间。
4.3 IME 与窗口焦点的专项优化
针对输入法纠缠的问题,需要单独处理。
先检查所有输入框的 windowSoftInputMode 配置。不要无脑统一设成 adjustResize,而是根据页面形态选择:如果页面本身不需要整体让位,用 adjustPan 或者完全不调整,反而能避开整页 relayout 的大坑。然后在代码里也要克制:不要因为输入框获焦就主动调 showSoftInput。在很多场景下,系统已经根据焦点状态自动唤起输入法,业务层再主动调用一次,等于把同一个动作做了两遍,多余的 Binder 交互和 relayout 就是这么来的。
如果应用里有自定义输入面板,要注意和系统 IME 的状态同步。不要一边在焦点变化时隐藏系统 IME,一边又让自定义面板显示,这会触发输入法窗口和应用窗口的焦点反复争夺。另外,多个 EditText 之间切换时,建议先设置好输入法选项和输入类型,再请求焦点,顺序反了会导致 IME 先弹出来一次又立刻重绑一次,白白浪费几百毫秒。
这里还有一个容易被忽略的细节:输入法绑定本身涉及跨进程交互,如果绑定过程中主线程还要同步等结果,很容易在极端场景下变成死锁。尽量让绑定动作异步完成,不要在焦点切换路径上做任何“必须等 IME 回调之后才能继续”的同步逻辑。
4.4 卡顿监控与兜底思路
即使做完了前面所有优化,也不能保证线上一次都不触发。所以我建议在焦点切换相关路径上埋点:记录焦点切换的开始时间和回调结束时间,超过阈值就上报。上报信息最好带上当前页面、焦点目标 View、以及前后 1 秒内主线程上的关键消息,方便后续复查。
还可以在应用里内置一个轻量的主线程卡顿监控组件,检测单条消息的执行耗时。不要把它做成复杂的性能分析平台,只需要能够在主线程单条消息超过 500ms 时,把线程栈和当前消息对象 dump 下来即可。这个能力解决的是“事后找不到现场”的问题,比任何优化都更让人安心。
线上灰度时也要有策略。如果一个版本的焦点切换 ANR 率明显上升,优先查看是否改了输入框相关布局、是否新增了焦点监听逻辑、是否升级了输入法相关依赖。这类问题往往由一次很小的改动引入,灰度数据能帮你快速圈定范围。
5. 排查中的经验与常见误区
5.1 看到 input dispatching timed out 就只去查输入系统
这是最常见的误判。输入超时只是结果,不是原因。主线程可能卡在任何地方,输入系统的超时判定只是一个计数器。看到 “Input dispatching timed out” 就一头扎进 InputDispatcher 源码里研究,大概率空手而归。
正确的做法是把问题分层。先看主线程卡在哪个层面:卡在业务代码,就查业务回调;卡在 Binder 调用,就查跨进程锁;卡在 native 层,就查 GC 或系统调用;卡在空闲状态,才轮到怀疑系统侧焦点窗口没有收敛。这个顺序能帮你快速缩小范围,不至于在主线程栈都还没读清楚之前就开始瞎猜。
5.2 把偶发问题都归结为低端机性能
低端机上更容易触发 ANR 是真,但低端机只是放大了问题,不是根因。如果把所有焦点切换 ANR 都归结为“设备太慢”,这个问题就永远无法真正解决。判断方法和前面说的一样:看 CPU usage。如果进程 CPU 占用很高,说明确实在猛算,设备性能不够是主因之一;如果 CPU 占用很低,主线程却卡住不动,那大概率是在等锁,跟设备快慢没有本质关系。
还有一点,偶发问题不代表没有规律。很多所谓“偶发”其实是特定时序触发,比如动画正在播放时用户恰好触发了焦点切换,或者某次 GC 和布局计算撞了车。记录时间线、多复现几次,往往能发现隐藏的触发条件。
5.3 可复现与不可复现场景怎么分别处理
如果能稳定复现,优先用系统跟踪工具录一段完整 trace,从输入事件到达、焦点状态切换、布局绘制三个阶段分别看耗时分布。这种完整时间线能直观看到事件在哪里卡住,是在 InputDispatcher 侧等待焦点窗口,还是在应用主线程排队,或者是在执行 performTraversals。
如果不能稳定复现,就要靠运行时分身术:关键焦路径打日志、主线程卡顿监控组件常驻、线上异常时把线程栈和最近消息队列内容一起上报。有些团队还会针对焦点切换做专项的 UI 自动化压测,比如让测试机自动在两个输入框之间高频切换焦点,几分钟内就能逼出大部分时序型问题。
5.4 一点个人体会
我踩过几次坑之后的体会是:排查焦点切换 ANR,心态上先不要假设它和焦点逻辑本身有直接关系。很多问题表面上叫“焦点切换 ANR”,实际背后都是主线程减负做得不够,或者系统焦点状态机在某些极端时序下没有收敛。把焦点回调里的重活清干净、把布局抖动压下去、把 IME 交互理顺,这类 ANR 的大部分场景都会被按住。如果还有零星触发,再依靠监控体系抓现场慢慢磨。焦点切换这种看似细碎的操作,恰恰是最能考验一个应用主线程是否健康的地方。