焦点切换 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 的大部分场景都会被按住。如果还有零星触发再依靠监控体系抓现场慢慢磨。焦点切换这种看似细碎的操作恰恰是最能考验一个应用主线程是否健康的地方。
阅读完成 · 觉得有帮助?