
在Android开发里“焦点”这个概念做应用层的同学可能一年都碰不上几次一旦碰上就是玄学问题输入框点不了、返回键没反应、软键盘弹不出来、电视遥控器焦点乱跑。我当年第一次被窗口焦点坑是在做一个自定义Launcher的时候明明把addView的LayoutParams都配好了结果按键事件死活不进我写的ViewGroup后来查了整整一天才发现是FLAG_NOT_FOCUSABLE这个flag在作祟。从那时起我就意识到Android的焦点体系远不止view.isFocused()那么简单它横跨WindowManager、ViewRootImpl、InputDispatcher三层每一层都有自己独立的“焦点账本”任何一层对不上账表现出来就是各种灵异事件。这篇文章我想把窗口焦点这套机制从头到尾捋一遍不绕弯子直接从代码和日志出发讲清楚焦点是什么、焦点在哪分配、焦点变了会触发什么以及你在实际开发中怎么监听、怎么设置、怎么排查。适合刚接触Framework的初级开发者也适合被焦点问题折磨过但没时间深挖的应用层老手。1. 先搞清楚窗口焦点到底是什么1.1 焦点不是“选中的控件”而是“事件分发的目标”很多初学者会把Focus和点击、选中混为一谈这其实是被UI交互习惯带偏了。在Android里Touch事件的派发靠的是坐标命中——手指按在哪系统就把MotionEvent发给那个位置的View。而焦点Focus负责的是另一条线按键事件KeyEvent、轨迹球、以及IME输入法的交互目标它不看坐标只看“当前系统认定的那个焦点窗口是谁”。打个比方Touch事件就像你在会议室里指名道姓叫某个人发言而焦点的分发更像是主持人手里那支话筒只有拿到话筒的人说的话才被全场听见。键盘按键、遥控器方向键、手柄事件这些东西没有坐标它们发出去就必须有一个唯一的接收者这个接收者就是焦点窗口。所以你会发现一个很有意思的现象一个窗口可以完全不可见但仍然持有焦点一个窗口可以完全可见但如果没有焦点它连返回键都收不到。这个特性在系统UI、通知栏、输入法窗口这些特殊窗口上体现得淋漓尽致。1.2 窗口级焦点与应用内焦点是两套体系窗口焦点和View焦点虽然在逻辑上有关联但它们是两套独立的机制分布在不同的进程和不同的类里。窗口级别的焦点在SystemServer进程里管理核心是WindowManagerServiceWMS的mFocusedWindow它决定的是InputDispatcher把按键事件发给哪个窗口。而应用级别的焦点在App进程里管理核心是每个Window对应的ViewRootImpl内部的mViewFocus状态它决定的是窗口内部哪个View持有焦点、onWindowFocusChanged会不会被调用、dispatchKeyEvent会从View树的哪个节点开始分发。这两套体系靠什么联系靠ViewRootImpl在窗口焦点变化时向WindowManagerService上报也靠WindowManagerService在系统焦点切换时向应用进程发送WINDOW_FOCUS_CHANGED消息。这个双向通信一旦延迟或者丢失就会出现“系统觉得A窗口有焦点但A窗口自己觉得没焦点”的状态错乱我后面会专门讲这种情况。2. 系统侧的焦点分配WMS和InputDispatcher如何协同2.1 WindowManagerService中的mFocusedWindowWindowManagerService里维护着一个非常重要的成员变量mFocusedWindow这个变量表示系统当前认定的焦点窗口。任何窗口要成为焦点窗口必须满足几个条件窗口处于ADDED状态即已经成功添加到WMS的窗口列表里。窗口没有FLAG_NOT_FOCUSABLE标志。窗口没有处于paused状态。窗口是可见的在部分场景下系统会允许不可见窗口持焦点但普通应用窗口必须可见。如果窗口带有FLAG_ALT_FOCUSABLE_IM它会和FLAG_NOT_FOCUSABLE组合使用表达“我可以接收按键但不接收输入法焦点”这个在游戏和特殊输入场景里很常见。WMS在哪些时机更新mFocusedWindow窗口添加addWindow、窗口移除removeWindow、窗口焦点显式请求requestWindowFocus、窗口可见性变化setViewVisibility、启动窗口完成relayout的时候都会重新计算。重计算的逻辑核心在WMS.updateFocusedWindowLocked方法里这个方法会遍历窗口列表找出满足条件的第一个窗口然后把它赋值给mFocusedWindow。如果焦点窗口发生了变化WMS会调用mInputManager.setFocusedWindow通知InputDispatcher同时向旧的焦点窗口和新的焦点窗口发送WINDOW_FOCUS_CHANGED消息。这也是为什么你可以通过重写onWindowFocusChanged来感知焦点变化的原因。2.2 InputDispatcher的焦点窗口句柄InputDispatcher是Native层的输入事件分发中枢它内部有一个mFocusedWindowHandle指向当前焦点窗口的InputChannel句柄。按键事件KeyEvent进入InputDispatcher后会直接跳过坐标命中逻辑发给mFocusedWindowHandle对应的输入通道。这里有一个很多人不知道的细节mFocusedWindowHandle不仅决定了按键事件的去处还参与触摸事件的touchMode判断。在触摸模式下如果触摸命中的窗口不是焦点窗口InputDispatcher会把焦点切换到触摸命中的窗口除非触摸窗口设置了FLAG_NOT_TOUCHABLE或者系统处于touch mode且View树不允许焦点转移。什么场景下窗口有焦点但收不到按键最常见的是输入法窗口IME。IME窗口通常是FLAG_NOT_FOCUSABLE的它不参与按键分发但它会通过InputMethodService的onKeyEvent在InputDispatcher之前拦截按键用于字母键直接上屏的需求。这就导致了“焦点在应用但某些按键先被输入法吃掉了”的现象。2.3 窗口动画和焦点的关系还有一个冷门知识窗口动画期间焦点不会立即转移。系统为了保证动画平滑在窗口切换动画过程中会暂时把焦点保留在旧窗口上等新窗口的动画完成后再转移焦点。这个行为是由WindowStateAnimator和WMS的mFocusMayChange标志位控制的。如果你在窗口切换动画期间调用requestFocus你会发现焦点变化有一定的延迟。这不是Bug是系统有意设计的。这也是为什么在Activity的onResume里拿焦点的时机要谨慎——onResume的时机可能早于窗口动画结束导致你拿到的还是旧窗口的焦点状态。3. 应用侧的焦点生命线从ViewRootImpl到View树3.1 ViewRootImpl如何接收和分发窗口焦点每个Window在应用进程侧都由一个ViewRootImpl管理它是连接应用视图和WMS的桥梁。当窗口焦点变化时WMS会向ViewRootImpl的W一个Binder Stub发送MSG_WINDOW_FOCUS_CHANGED消息。在ViewRootImpl内部这个消息的处理逻辑大致如下更新mWindowFocusChanged标志记录是否有hasWindowFocus的回调待处理。调用mView.dispatchWindowFocusChanged(hasFocus)这里的mView就是Window的根ViewDecorView。如果焦点是获得状态会检查View树上是否有View请求了焦点mRealFocusedView如果存在但不可聚焦会重新寻找可聚焦的View。如果窗口失去焦点会把View树里的焦点清除View的onWindowFocusChanged(false)递归调用并且重置mRealFocusedView。这个流程决定了应用内焦点状态和窗口焦点状态是严格同步的窗口获得焦点View树才能有焦点View窗口失去焦点View树里的焦点全部清空。3.2 View树的焦点分发策略View树的焦点分发核心在ViewRootImpl的dispatchViewFocusEvent和View类的dispatchWindowFocusChanged方法。简单说窗口焦点变化会触发一次从根View到整棵View树的深度遍历每个View都会收到onWindowFocusChanged回调。但注意onWindowFocusChanged和onFocusChanged是两回事onWindowFocusChanged(boolean hasFocus)窗口焦点变化回调所有子View都会收到。onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect)View内部焦点变化回调只有焦点View本身收到。一个常见的坑是软的键盘弹出或者requestFocus失败时onWindowFocusChanged可能会被触发多次如果不加防抖很容易在回调里重复初始化状态机。我在做TvLauncher的时候就因为onWindowFocusChanged(true)被连续触发两次导致焦点默认位置被重复设置用户按一下右键跳了两个格子。3.3 requestFocus的时序陷阱View.requestFocus()是应用开发里最常用的焦点操作但它并不是立刻生效的。requestFocus只是在View树内部设置焦点标记真正的窗口焦点协商发生在ViewRootImpl的windowFocusChanged流程结束后。具体来说requestFocus会经历以下步骤检查当前窗口是否有窗口焦点如果没有requestFocus会失败返回false除非你设置View.FOCUSABLE同时窗口焦点在途中。如果窗口有焦点requestFocus会调用FocusFinder查找最佳焦点View然后清除旧焦点设置新焦点。最后ViewRootImpl会发起一次异步的performTraversals把新的焦点状态同步给WMS。这就是为什么你经常在onResume里调用requestFocus没反应因为onResume时窗口焦点可能还没建立mView的窗口焦点状态还是falserequestFocus直接被拒了。正确做法是在onWindowFocusChanged(true)里做或者用post延迟到下一个消息循环。4. 开发中最常用的窗口焦点设置与监听手段4.1 通过WindowManager.LayoutParams控制焦点特性日常开发中我们直接跟窗口焦点特性打交道最多的地方是WindowManager.LayoutParams里的几个flag。FLAG_NOT_FOCUSABLE是最常用的设置了它窗口不会成为焦点窗口同时输入法也不会为它弹出。这个flag经常用在悬浮窗、Tooltip、非交互式窗口上。但很多人不知道FLAG_NOT_FOCUSABLE还有配套的FLAG_ALT_FOCUSABLE_IM两者组合有四种状态flags组合能否获取焦点输入法交互无是正常FLAG_NOT_FOCUSABLE否不弹出输入法FLAG_ALT_FOCUSABLE_IM是不弹出输入法FLAG_NOT_FOCUSABLE FLAG_ALT_FOCUSABLE_IM否输入法照常弹出这个组合非常实用。举例来说你做一个悬浮歌词窗口希望用户点它时它能收到点击但又不希望它抢走主界面的焦点同时你又希望用户可以输入内容——这种需求单靠FLAG_NOT_FOCUSABLE是做不到的必须组合FLAG_ALT_FOCUSABLE_IM才能实现。4.2 监听窗口焦点变化的几种姿势窗口焦点变化的监听主要有三个入口Activity.onWindowFocusChanged(boolean hasFocus)Activity级别实现简单但要注意它和onResume/onPause不一定同步焦点变化更贴近用户可见性。View.onWindowFocusChanged(boolean hasFocus)View级别可以做更细粒度的逻辑比如焦点View上的高亮切换。ViewTreeObserver.OnWindowFocusChangeListener不侵入View代码的监听方式适合工具类或者 BaseActivity 里统一处理。如果你在自定义View里需要精确感知键盘事件推荐在onWindowFocusChanged里做post延迟处理因为键盘的attach/detach有异步过程焦点回调的时候输入框架可能还没准备好。Override public void onWindowFocusChanged(boolean hasWindowFocus) { super.onWindowFocusChanged(hasWindowFocus); if (hasWindowFocus) { post(() - { if (isAttachedToWindow() isFocusable()) { requestFocus(); } }); } }4.3 在Dialog、PopupWindow、Toast里维护窗口焦点Dialog、PopupWindow这类窗口的焦点管理最容易被忽视它们经常和Activity的焦点状态打架。Dialog默认是具备焦点获取能力的它出现时会抢走Activity的焦点所以在Dialog显示期间Activity的onWindowFocusChanged(false)会被回调。PopupWindow则根据构造参数focusable来决定是否抢焦点setFocusable(true)时PopupWindow会盖住并抢走焦点用户点外部区域消失setFocusable(false)时PopupWindow不抢焦点但这样它内部的输入框也无法获得键盘输入。这里有一个非常隐蔽的坑当PopupWindow.setFocusable(true)时它在API 26以上如果同时设置了setOutsideTouchable(true)点击外部区域会触发dismiss但dismiss的动画会导致焦点回传给Activity的时序晚于onResume。如果你在onResume里依赖焦点做初始化很可能遇到“界面已经恢复了但键盘弹不出来”的问题。5. 焦点问题排查我只靠这几招5.1 用dumpsys window查系统侧焦点遇到焦点问题第一件事就是看系统当前认为的焦点窗口是谁。在终端执行adb shell dumpsys window | grep -A3 mFocusedWindow输出类似mFocusedWindowWindow{... u0 com.example.myapp/com.example.MainActivity} mFocusedAppAppWindowToken{... token...}mFocusedWindow表示当前窗口焦点归属的WindowmFocusedApp表示当前焦点App的ActivityToken。两者不一致时就是系统侧和应用侧状态错乱这通常会引发KeyEvent事件丢失。如果想看更细的信息可以加dumpsys window windows在输出的每个Window条目里看isOnScreen、isVisible、mHasSurface、mAttrs.flags这些字段判断合法焦点窗口为什么没被选中。我自己的排查习惯是先看mFocusedWindow是不是我预期的窗口如果不是就查看窗口列表里排在焦点窗口前面的窗口是不是被错误地设置了FLAG_NOT_FOCUSABLE或者处于不可见状态。90%以上的系统侧焦点异常都能在这个步骤里定位。5.2 用InputDispatcher的日志定位事件分发窗口焦点虽然系统侧正常但按键仍然没反应时就要查InputDispatcher。在Android 10及以上版本可以开Input的trace日志adb shell dumpsys input重点看输出里的FocusedWindow字段和FocusedApplications字段FocusedWindow: nameWindow{...}, displayId0如果FocusedWindow是null说明InputDispatcher没有拿到系统侧的焦点窗口句柄这时即使dumpsys window显示有焦点窗口也没用事件就是发不出去。另一种常见情况是FocusedWindow指向了输入法或系统UI窗口导致应用收不到任何按键。这种一般发生在输入法弹出后焦点窗口被IME抢走且没有正确恢复。解决办法是检查焦点窗口的FLAG_ALT_FOCUSABLE_IM配置是否正确。5.3 定位应用内View焦点状态应用内的焦点状态可以在Activity的onWindowFocusChanged里打印当前焦点Viewoverride fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) Log.d(FocusDebug, windowFocus: $hasFocus, currentFocus: $currentFocus, focusedViewId: ${currentFocus?.id}) }或者用adb shell dumpsys activity top查看当前Activity的View层级和焦点状态。正常情况下currentFocus应该指向你期望的焦点View。如果它是null说明View树里没有任何View持有焦点。6. 那些年被窗口焦点坑过的经典场景6.1 软键盘弹不出来但View明明能点击这是最高频的问题。一个EditText在界面上正常显示点击它能弹出光标和输入法但某些场景下点击却只有光标、没有输入法弹出。这时候第一反应应该查这个EditText所属的窗口是否持有输入法焦点。排查思路是这样如果EditText位于PopupWindow里而PopupWindow没有设置setFocusable(true)那么它的窗口不是焦点窗口输入法不会为它弹出。用dumpsys window查看焦点窗口会发现焦点还在Activity的主窗口上EditText虽然被点击了但IME认为没有需要输入的焦点窗口。解决方案是在PopupWindow弹出前设置setFocusable(true)或者用Dialog代替PopupWindow承载输入场景。另一个坑是Popup弹出来的时候Activity触发onWindowFocusChanged(false)如果Activity在失去焦点的回调里做了hideSoftInput什么的那输入法就更难弹出来了。6.2 键盘事件进不了应用应用层写了onKeyDown或dispatchKeyEvent但按键就是不回调按键疑似被系统吃掉了。这类问题的排查路径和我前面说的一致先查系统焦点在不在你的窗口上。以下这些场景特别容易丢掉按键事件窗口设置了FLAG_NOT_FOCUSABLE无论View树里焦点怎么设置按键都不会进来。输入法窗口拦截了按键比如中文输入法的“候选上屏键”这类键在IME里直接消化了。Dialog虽然可见但因为动画或者快速切换尚未完成窗口焦点转移按键被上一个窗口处理了。无障碍服务或者录屏等系统服务劫持了按键流。有段时间我做机顶盒应用遥控器的“OK键”总是偶发地触发两次事件后来发现是onKeyDown和onKeyUp之间窗口焦点发生了切换导致同一枚按键在两个不同窗口里各触发了一次。解决的方式是在事件分发里加event.getDownTime()去重。6.3 多窗口和分屏下的焦点交叉Android的多窗口Multi-Window、分屏、画中画都是典型的焦点交叉场景。在分屏模式下两个应用会并存显示但只有用户最近触摸的那个窗口持有焦点。这就带来一个问题onWindowFocusChanged(false)不一定代表应用不可见。在分屏场景下应用完全可见但失去了焦点很多开发者在这里踩坑——把失去焦点当成不可见处理结果在分屏模式里两个应用互相误杀逻辑。正确的语义是onWindowFocusChanged(false)表示用户不再与你的窗口交互但你的界面可能仍然完全可见。如果你要根据可见性做资源释放应该结合onStop/onStart或者onConfigurationChanged来判断。举个例子分屏下视频播放器失去焦点时不应停止播放但可以暂停UI动画来省电而真正进入后台时才应该考虑暂停播放释放资源。6.4 双屏/折叠屏下的焦点分布Android 12开始原生的双屏和折叠屏支持逐渐成熟窗口焦点在多显示区域的分布也开始变得复杂。每个Display有自己独立的焦点窗口也就是说主屏可以有一个焦点窗口副屏也可以有一个焦点窗口它们互不干扰。如果你的应用只支持单窗口模式但用户把它拖到了扩展屏上同时主屏又有另一个应用在交互那么你的应用依然可以收到触摸事件但不会收到焦点变化。这里有一个特别迷惑的体验在折叠屏上内屏展开时Activity会重新布局走onConfigurationChanged窗口焦点会经历失去→重获的完整过程。如果你在onWindowFocusChanged(false)里做了数据保存又在onWindowFocusChanged(true)里做了恢复稍有不慎就会造成短暂的白屏或焦点丢失。我处理这类问题的经验是不要在焦点回调里做重量级操作。焦点回调应该只做轻量级的状态标记数据保存用onStop数据恢复用onStart或onResume这样能被多窗口和折叠屏的各种组合场景安全覆盖。7. 总结我在焦点调试上的一些土办法最后分享几个我在外部设备、车机、电视、折叠屏等项目里被焦点问题毒打后留下的土办法。第一个监控焦点不能只盯一个平台。做一个全局的焦点日志开关把Activity.onWindowFocusChanged、View.onWindowFocusChanged、ViewRootImpl的mViewFocus状态、dumpsys window的输出四个维度的信息同时打出来。这样才能判断是系统侧分配出错还是应用侧消费出错。只盯一层永远是盲人摸象。第二个请求焦点不要硬来。如果一个输入框必须要获取焦点不要在onCreate、onResume、onStart里都写一遍requestFocus那会导致焦点在多个位置竞争。正确姿势是统一在onWindowFocusChanged(true)里请求而且用post延迟。实在不行再考虑强制加window.setSoftInputMode(SOFT_INPUT_STATE_ALWAYS_VISIBLE)来隐性提示系统。第三个遇到焦点问题先关机重启。这不是玩笑WindowManagerService和InputDispatcher里的焦点状态出现死锁式的错乱时日志是查不出来的因为数据本身没问题只是时序错了。这种情况下重启一次设备往往比你在代码里优三小时效率还高。第四个对焦点的理解和调试要有“双账本”意识。系统侧有一本账告诉你mFocusedWindow是谁应用侧有一本账告诉你当前View树里谁持有焦点。两本账永远不直接相等它们之间靠异步消息同步。排查问题时先问“哪本账错了”再问“为什么错”最后才是“怎么改”。这个思维模式帮我解决掉了至少一半以上的疑难焦点问题。窗口焦点这块内容说深可以很深说浅其实也就那么多东西。应用层开发者不需要去改WMS的代码但理解了FLAG_NOT_FOCUSABLE和mFocusedWindow的协同方式看懂dumpsys window的输出知道焦点回调为什么有时候滞后就足以应付绝大多数日常开发问题了。以后遇到输入框弹不出键盘、按键没反应、焦点乱跳这些问题希望你能想起这篇文章里说的那几层关系别一上来就改业务代码——先看看系统账本上焦点到底记在谁的名下。