1. 凌晨三点的那条ANR告警:焦点切换问题的现场画像
先说实话,每年我们团队处理的各种ANR里,真正由“焦点切换”四个字直接背锅的,其实只占很小一部分。但这部分问题有一个共同特点:极其隐蔽,平时怎么点都不出,一旦线上触发就是批量上报,而且崩溃栈往往不带任何业务代码,看起来就像系统在闹鬼。
我第一次遇到这个典型场景是在一个电商类App的搜索结果页面。用户从搜索框点进结果列表,再切到筛选栏,整个流程一气呵成。看起来就是一次普通的焦点移交,可偏偏在某些机型上,触发了主线程长时间阻塞。告警平台给出的摘要很简单:Input dispatching timed out,等待的窗口是主Activity,但没有任何业务方法出现在主线程栈里。
先说结论我再拆细节:焦点切换ANR,绝大多数时候不是“焦点切换”这个动作本身有多耗时,而是焦点变化触发了某一连串的后续工作——回调、布局、输入法交互、焦点搜索——其中某一个环节被人为拖慢,然后整条主线程被拽死。你光看“焦点切换ANR”这个标签去查,基本查不到东西;你得看明白焦点切换发生时,系统层到底在主线程上排了多少任务。
1.1 告警日志长什么样,先学会读
几乎所有Android ANR都能在/data/anr/traces.txt或者云平台的产物里看到这几类标志性内容。和焦点切换相关的,最常见的入口是InputDispatching,典型写法类似:
ANR in com.example.app (com.example.app/.MainActivity) Reason: Input dispatching timed out (Waiting because the touched window has not finished handling the input event that was previously delivered to it.)如果你只看到这么一行,其实什么都说明不了。因为任何主线程卡顿都会导致后续的输入事件派发超时,哪怕焦点根本没动过,只是某个列表加载卡了3秒,也一样是这个Reason。
要判断是否和焦点切换有关,得往后翻日志,重点找几个关键字:
Focus change request相关:说明那次输入前有焦点变化请求;InputMethodManager相关:焦点变化通常会伴随输入法显示/隐藏;ViewRootImpl里的Relayout returned:焦点变化可能触发布局重新执行;Choreographer帧回调大量排队:说明视觉刷新已经跟不上。
我自己的习惯是,先把ANR发生前500ms内落盘的logcat片段拉出来看。如果这台设备开了persist.log.tag.*的详细日志,通常能看到焦点事件和输入事件的先后顺序。假如你发现“焦点从EditText切到FilterView”之后,紧接着的输入事件一直派发不出去,那才算真正和焦点切换建立起了时间上的关联。
1.2 确认是不是“焦点切换”造成的:关键字筛选与现场重建
只靠traces.txt是不够的,因为线上ANR的trace是进程“临终前”的抽样,主线程栈停在哪儿,不代表焦点切换时栈就在哪儿。更靠谱的办法是现场重建,尽可能还原用户操作序列。
我当时拿到这批告警后做的第一件事,不是看代码,而是把上报里的机型、系统版本、页面路径拉出来聚类。结果很有意思:集中在某一款输入法版本 + 某类带EditText的搜索页 + 快速点击筛选栏的场景。一旦用户慢慢点,或者不调出输入法,问题就不出现。
这个规律本身就指向焦点切换路径。接着按这个路径去复现:进入搜索页,等输入法弹起来,然后快速点旁边的筛选按钮。果然,在测试机上偶尔会卡3到5秒,随后弹ANR对话框。
注意一个细节:并不是每次快速切换都会卡,而是输入法刚弹起、软键盘窗口还在动画过程中,此刻焦点发生二次切换,出问题的概率最高。这个特征后面在源码分析阶段起到了关键作用。
1.3 这类ANR为什么容易背锅:被其他业务牵连的假象
分享一个反直觉的经验:很多被归到“焦点切换ANR”的工单,你往下追几层会发现,焦点只是那个“倒霉的触发器”。真正的主线程卡顿源头可能是:
- 某个
OnFocusChangeListener里做了SharedPreferences的同步读取; - 某个自定义View在
onFocusChanged里触发了重量级计算; - 焦点切换触发了一次全树
requestLayout,而树里嵌套了深度极大的布局; - 输入法显示/隐藏时,
imePadding变化导致整个页面重新测量,配合上低端机直接卡死。
所以,接到“焦点切换ANR”的第一反应不要是“去看看谁的焦点代码写错了”,而是先建立完整的事件链路:输入法动作、焦点迁移、布局计算、业务回调,四者串起来看。谁在哪个环节消耗了主线程,谁才是真凶。
2. 焦点切换为什么会拖垮主线程:我都翻了哪些源码
定位这类问题,没法绕开Android的焦点机制。我建议你花一个下午认真读一读ViewGroup和ViewRootImpl里和焦点相关的代码,读完之后再看ANR,视角完全不一样。
2.1 从触摸到焦点:Android焦点机制的路由过程
Android里有两套焦点体系。一套是传统方向键/遥控器时代的焦点,由focusSearch()通过FocusFinder按方向查找下一个可聚焦节点;另一套是触摸交互下的焦点,谁被点到谁就获得焦点,同时还会关联输入法窗口。
触摸触发焦点变化时,事件流大致是:
InputDispatcher把触摸事件派发给焦点窗口;ViewRootImpl把事件交给ViewGroup分发;ViewGroup在dispatchTouchEvent中命中某个可聚焦的子View;- 调用
View.requestFocus(),内部触发焦点搜索与旧焦点清理; - 新旧焦点View各自的
onFocusChanged回调被执行,同时通知ViewTreeObserver里的焦点监听器; - 如果需要输入法,
ViewRootImpl会走InputMethodManager的Binder调用,让系统输入法窗口显示。
这里第一步到第四步基本是轻量的,问题主要出在第五步和第六步。因为你永远不知道业务代码在onFocusChanged回调里塞了什么。
2.2 一个焦点变化引发的连锁反应:焦点搜索、监听回调与布局刷新
大多数人对焦点变化的理解就是“旧View失去焦点,新View获得焦点”,但系统实际做的工作远不止这两行回调。
当requestFocus被调用时,ViewGroup会从根布局开始做焦点搜索。默认情况下FocusFinder按“深度优先”的方式遍历View树去找下一个可聚焦节点。如果你的布局树很浅,这段遍历可以忽略不计;但如果页面里嵌了多层RecyclerView、ViewPager2、嵌套滚动容器,树的规模很容易达到几百上千个节点。某些自定义容器为了做特殊的高亮效果,还会重写focusSearch,在里面套一层业务判定逻辑,一次焦点搜索直接变成一次全树重度遍历。
更麻烦的是,焦点变化会触发旧的焦点节点发出onFocusChange事件。很多开发习惯在自定义View的onFocusChanged里做状态切换,比如高亮、展开、加载数据。如果布局里几十个可聚焦View都注册了监听,并且每个监听器里都做了不必要的刷新,那一次点击就可能引发几十次局部刷新。叠加起来,主线程很快就不够用了。
布局刷新这块,requestFocus本身不强制requestLayout,但是焦点变化常见的配套操作会。例如改变了background、padding、选中状态、显示隐藏,这些都会调用invalidate甚至requestLayout。当焦点切到输入框时,软键盘弹出导致windowSoftInputMode调整,整个页面高度变化,所有依赖onMeasure的节点都会重测一遍。
2.3 输入法与窗口焦点:两把隐藏的锁
在焦点切换ANR里,输入法这条链路特别容易翻车。现代Android的输入法交互大量依赖Binder,任何一步跨进程调用都有可能在系统服务繁忙时变成阻塞点。
当焦点从普通View切到EditText时,ViewRootImpl会通知InputMethodManager去连接输入法。这一步涉及:
- 应用进程向SystemServer发起
startInput请求; - SystemServer向输入法进程发起
bindInput、showSoftInput等事务; - 输入法进程回调应用进程,告知输入法启动完成;
- 应用主线程需要处理
onStartInput等回调。
如果输入法进程本身卡了,或者系统服务负载过高,这次交互就可能让应用主线程等待很长时间。更隐蔽的是,某些定制ROM的输入法实现有问题,在showSoftInput时会反向等待应用窗口的焦点确认,两边互相等,最后主线程被Binder调用牢牢占住。
我之前在这个案子里验证过:把默认输入法换成轻量输入法后,同样操作流畅无卡顿;换回原输入法,复现概率立刻回来。这基本上就锁定了输入法进程和应用进程之间的交互存在病灶。
2.4 常见误解:焦点ANR不等于“焦点代码卡顿”
这里必须纠正一个很常见的误判——很多人看到ANR栈顶停在InputMethodManager或Binder相关方法上,就觉得是系统问题,然后找人去“查系统”。实际上,Binder调用等的是“另一端”,另一端往往就是我们自己的业务代码。
举个例子,主线程停在Binder的某个transact()等待返回,这个Binder对端可能是SystemServer,也可能是我们的进程自己起的Binder服务。如果对端在处理请求时需要主线程配合,就很容易造成全局死等。
所以说,看到栈和焦点相关,不要急着下结论。你要做的,是把“焦点切换”理解成一个多阶段事务:既有本地View树的焦点迁移,又有跨进程的输入法交互,还有可能伴随布局刷新。任何一个阶段恶化,最终都会指向同一个结果:主线程的MessageQueue被一条等待或耗时的任务占住,后续输入事件全部超时,ANR诞生。
记住这句话:焦点切换只是导火索,炸药可能是你的业务代码、你的布局结构、甚至是你接入的第三方输入法。
3. 逐步定位:从trace到代码行,我的一整套排查链路
光讲机制不落地等于白讲。下面把排查过程全过程拆开,你照着这个链路走,大多数焦点切换ANR都能被挖出来。
3.1 复现问题:给它一个能稳定出问题的场景
排查ANR最重要的一步是先让问题在本地可复现。可复现意味着你之后所有修改都能用“复现率”来验证。
我的复现工程通常这么搭:
- 单独建一个测试页面,把线上页面的布局骨架拷过来,不去加载真实业务数据,先把布局结构复刻出来;
- 在目标控件上注册焦点监听,日志打印每个焦点变化的时机和耗时;
- 开启
Settings.Global.ANR_SHOW_BACKGROUND这类系统调试开关,让ANR对话框在后台也弹出来; - 连着USB,打开
adb logcat,重点过滤ViewRootImpl、InputMethodManager、Choreographer三个tag; - 有条件就开
systrace,抓取这段时间CPU和主线程调度情况。
这里有个容易被忽略的点:一定要在低端机或者开启了“不保留活动”的测试机上跑。很多焦点ANR在高端机上根本复现不出来,因为高端机主线程即使被多消耗几百毫秒也能在输入事件超时前处理完,只有低端机才会把延迟放大到肉眼可见的程度。
我当时把测试机换成一款入门级设备,用自动点击脚本连续执行“点搜索框 -> 点筛选栏 -> 再点搜索框”这个循环,大约执行几十次就能稳定复现。复现成功后,接下来每一步验证都有了依据。
3.2 拿到ANR trace后怎么找焦点相关栈
本地复现之后,adb shell am hang或者触发系统ANR后,把traces.txt拉下来重点看主线程。别急着看栈顶,先看栈底和中间阶段。
一次典型的由焦点切换触发的ANR,主线程栈大概会有两类长相:
第一类,栈顶停在InputMethodManager相关Binder调用上。这说明主线程在等待输入法事务返回。看到这种栈,你需要继续确认Binder对端是谁,以及为什么对端没有及时返回。很多时候,问题不在输入法进程,而在于输入法的showSoftInput回调又反过来期望应用主线程先完成某个动作。
第二类,栈里看不到任何“焦点”方法,但能看到Choreographer.doFrame或View.measure相关方法被大量调用。这说明焦点切换触发了布局风暴,主线程在拼命做测量和绘制,导致后续输入事件派发推迟。
我还会顺手看下CPU占用。如果ANR发生时CPU占用很高,那大概率是应用自己把主线程烧满了;如果CPU很低,可能是锁等待或者Binder超时。这俩的排查方向截然不同,前者查布局和计算,后者查同步等待和跨进程交互。
3.3 利用布局渲染分析确认罪魁祸首
如果栈里出现大量measure调用,就需要拿出第二个工具:Layout Inspector或者systrace里的View Hierarchy段。
我遇到的具体案例里,焦点从搜索框切走后,输入法隐藏,于是AdjustResize触发了页面整体relayout。这时候页面最底部的某个自定义统计容器在onMeasure里做了一次全量数据遍历,而那个容器又挂在一个深层LinearLayout里。一次relayout可以接受,问题是输入法动画期间,relayout被连续触发了十几次,每次都要全量重测一遍那个数据容器。最终,一个本来几毫秒的测量操作被放大成了秒级卡顿。
用systrace能很清楚地看到,InputMethodManager相关调用的上下文中,Choreographer持续收到帧回调,performTraversals反复执行,measure时间呈山峰状。配合着看,触发源是哪个控件就锁定了。
3.4 把问题代码从几百行缩到十几行的二分法
锁定了方向之后,下一步是缩小可疑代码范围。纯靠读代码去猜往往效率很低,我习惯用“开关法”做二分:
- 先给候选View的
onFocusChange回调打个总开关,屏蔽掉里面所有业务逻辑,跑复现脚本,验证复现率是否下降; - 如果下降,再逐个放开回调里的子模块,每放开一个就复现验证一轮,很快就能找出是哪一个业务调用导致了卡顿;
- 如果屏蔽所有回调后仍在卡,说明问题不在回调,而在布局结构或输入法交互上,需要回到
systrace看触发源。
这个流程看起来笨,但胜在准确。因为焦点切换链路牵扯的模块多,单凭直观经验很容易被误导。我曾以为问题一定在某个自定义focusSearch里,结果屏蔽后发现复现率一点没变,最后定位到的是输入法动画期间另一个View在不停请求布局。
4. 最常踩雷的几种焦点切换写法(含修复示范)
把排查方法讲完,再分享几类我真实遇到过的经典翻车代码。这些写法几乎遍布在各种大型App里,每次出现都以“焦点切换ANR”告警收场。
4.1 onFocusChange里做重量级操作:典型案例与修复
这是最普遍的一类。很多人把焦点变化当成了页面状态切换的“万能入口”,于是写了不少类似这样的代码:
// 错误示范:主线程里同步做耗时操作 editText.setOnFocusChangeListener((v, hasFocus) -> { if (hasFocus) { List<Item> data = database.queryAllSync(); // 可能耗时几百毫秒 adapter.setData(data); adapter.notifyDataSetChanged(); updateUi(); } });这种写法的问题在于,焦点变化发生在输入事件派发过程中,主线程正在处理触摸事件。此时任何超过5秒的阻塞都会直接导致ANR;即便只有一两百毫秒,在焦点频繁切换的场景下也足以引发明显掉帧和连锁超时。
修复思路很简单:回调里只做轻量状态更新,数据准备放异步或延迟到下一个空闲时机。
// 修正思路:轻量回调,异步数据 editText.setOnFocusChangeListener((v, hasFocus) -> { if (hasFocus) { v.post(() -> { // 延迟到事件处理完再刷新UI startLoadingDataAsync(); }); } });这里不要误会,不是所有异步都能解决问题。如果你post到主线程,依然可能在下一次Looper循环时占用主线程;但至少它不会卡在当前输入事件的派发路径上,ANR风险大幅降低。想更稳的话,数据加载放到子线程,回来再切主线程刷新。
4.2 自定义View里重写focusSearch导致的搜索风暴
第二类高发问题出在自定义容器上。做电视端或支持方向键操作的应用时,为了控制焦点移动顺序,我们经常会重写focusSearch。一旦重写逻辑写得粗糙,焦点搜索会变成一场灾难。
举个例子,某个自定义ViewGroup里存了一份“候选焦点列表”,每次focusSearch进来都去遍历整棵子树,遍历过程中还可能调用业务方法统计可见性。这段逻辑在页面只有几十个View时完全无感,但当列表膨胀到上千个节点时,一次方向键的焦点切换就可能消耗几百毫秒,连按方向键直接就ANR。
修复办法有三个方向:
- 不重写
focusSearch,改用focusability属性和布局顺序来控制焦点顺序; - 必须重写时,尽量用
FocusFinder默认逻辑,只在结果基础上做微调; - 对上一步的微调逻辑做缓存,避免每次搜索都全量遍历。
我当时接手的一个项目里,就是把重写后的focusSearch改为先调用super.focusSearch拿到默认候选,再通过一个轻量哈希判断是否替换结果,耗时从全树遍历降到了常数级。
4.3 焦点切换与RecyclerView/动画叠加的时序陷阱
焦点变化经常和列表滚动、入场动画叠加在一起,形成最难排查的时序问题。
典型场景是这样的:页面里有一个RecyclerView,item内包含可聚焦控件。用户滚动列表时,焦点还在旧item上,滚动结束后系统尝试重新聚焦到新item上。此时如果列表刚做完一次notifyDataSetChanged,item被回收重建,焦点搜索就可能反复在“失效的item”和“新item”之间切换。
更隐蔽的是动画叠加。很多页面会在焦点变化时对目标View播放放大动画,同时对旧View做缩小动画。两个动画叠加期间,如果又触发一次焦点切换,就会反复invalidate和requestLayout,动画系统每一帧都在重算,主线程卡顿指数上升。
针对这个,我的实践是:
- 列表里尽量不跨item做焦点传递,改用
RecyclerView的FocusSearch机制配合布局管理器去处理; - 动画开始前屏蔽焦点监听,动画结束后再恢复;
- 处理焦点变化时,先判断目标View是否仍附着在窗口上,避免对已回收View做无意义操作。
@Override public void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) { if (!isAttachedToWindow()) { // 已脱离窗口的View不要再触发任何动画和回调 return; } super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); }这一段在列表场景里能救回来很多诡异卡顿。很多人只查回调、不查View是否还挂在窗口上,结果焦点给了一个即将被回收的item,白白浪费大量计算。
4.4 多窗口/分屏场景下的焦点竞争
最后这类比较新,但越来越常见:分屏、多窗口、平板横屏下,两个窗口同时都在请求焦点。
当用户在分屏模式下操作两个页面时,系统焦点可能在两个窗口之间来回跳。如果两个窗口里的控件都在用OnGlobalFocusChangeListener做全局状态同步,就可能出现A窗口焦点变化通知B窗口刷新,B窗口刷新又导致B窗口焦点再变,循环往复。
我在一个Pad应用上遇到过类似卡死:某个全局弹窗监听器对所有页面里的输入框做高亮状态同步,分屏时两个页面互相触发焦点回调,形成死循环式的相互重绘。最终只能靠加防御条件:仅当焦点事件来自自己窗口时才处理。
// 只处理自己窗口内的焦点变化 if (v.getRootView() != myRootView) { return; }这类问题在日志上往往看不出明确原因,因为主线程栈会停在普通的刷新方法上,但只要摸到“焦点竞争”这个模式,修复思路就很直接:切断跨窗口的焦点联动。
5. 上线之后的收尾:监控、回捞与团队规则
问题修复完不等于这件事结束。真正要命的是,焦点切换ANR往往在特定机型、特定输入法、特定操作节奏下才会出现,你这次修掉了根因,下次换一个控件组合,问题可能换张脸又回来了。所以一定要有机制兜底。
5.1 如何用现有工具给焦点ANR加一层监控
如果App已经接入了APM平台,直接用平台自带的ANR捕获能力就行。重点不是“捕获”,而是“聚类”。我建议给OnGlobalFocusChangeListener包一层统一的监控包装,核心做三件事:
- 记录焦点变化事件的时间戳和控件路径;
- 记录焦点变化前后主线程
Looper中任务的执行耗时; - 将耗时的焦点变化单独上报,并和ANR日志做关联。
这样即便下次焦点切换再次出问题,你至少能精准回答“哪个控件、哪次切换、耗时多少”,不用再靠猜。
我通常还会顺手在灰度环境跑一轮“焦点压力测试”:写一个自动化脚本,在主要页面上随机快速切换焦点,持续十分钟,观察主线程耗时曲线。这一步成本不高,但效果很好,能提前把很多潜在的焦点卡顿砸出来。
5.2 把这次排障沉淀成可复用的检查清单
这次的排查过程走完后,我建议把经验整理成一份检查清单,下次再遇到“焦点切换ANR”时按表走:
| 排查步骤 | 关键动作 | 常见结论 |
|---|---|---|
| 1. 看现场 | 拉取ANR trace与logcat,确认焦点变化时机 | 分清输入超时/布局耗尽/输入法Binder等待 |
| 2. 聚类 | 按机型、输入法、页面、操作路径聚类 | 找到触发条件规律 |
| 3. 复现 | 低端机+脚本连续焦点切换 | 稳定复现问题 |
| 4. 定位 | systrace确认布局/Binder/回调耗时 | 锁定重点模块 |
| 5. 缩圈 | 用开关法二分业务回调 | 找出具体耗时调用 |
| 6. 修复 | 异步化/延迟化/加窗口判断 | 消除阻塞 |
| 7. 回归 | 原场景+压力脚本验证 | 复现率归零,主线程耗时恢复 |
表格里的每一步都可以继续细化为团队内部wiki里的模板,让新来的同学也能直接上手。
5.3 避免再次出现:代码评审里要盯的几个点
最后说团队规则。哪怕你这次把所有线上问题都修完了,不立规矩,同类问题大概率会在三个月后的新页面上重演。我在代码评审里固定会盯这几点:
OnFocusChangeListener回调体内有没有数据库查询、网络请求、文件读写、大列表遍历;- 自定义View里有没有重写
focusSearch,重写逻辑的复杂度和调用频次是多少; - 焦点变化是否触发了
requestLayout,以及该操作是否在动画期间被频繁重复触发; - 输入法和焦点切换绑定的页面,是否做过输入法显示/隐藏的耗时监控。
这几点看着基础,但绝大多数“焦点切换ANR”都死在这里。把它们写进评审规范,比任何事后排查都更有价值。
在我实际使用中还有一个非常管用的小技巧:新页面开发时,主动在焦点切换路径上加一条耗时埋点,打印到日志里。只要这条日志成为标准动作,焦点相关的性能问题通常活不过线上灰度期,早早就被开发自己发现了。这比每天盯告警群舒服得多。