一次 OHOS PlatformView 输入焦点问题的修复复盘
这次问题最初看起来并不复杂:在 OHOS 上,Flutter TextField 弹起键盘后,点击 native PlatformView 里的输入框,再收起键盘,随后重新点击 Flutter 输入框时,键盘可能无法再次弹起。
从现象看,核心矛盾是输入 owner 没有切干净:Flutter 侧仍然认为自己持有输入焦点和键盘状态,但 native 输入框已经接管过 IME;当 native 侧收起键盘后,Flutter 再次 show 时可能误判键盘仍处于可复用状态,最终导致键盘起不来。
这本来应该是一个非常窄的修复:处理 Flutter TextField -> physical PlatformView TextInput -> hide -> Flutter TextField 这一条链路。但真正做起来以后,我们经历了一轮比较典型的“状态机补丁膨胀”。
第一阶段:先修现象,但打坏了原本好的链路
最初的方向是围绕 TextInputPlugin 做修复:在 Flutter 切到 physical PlatformView 时,记录 PlatformView owner;在 native blur 或 teardown 时,释放这个 owner;同时抑制 Flutter framework 随后发来的 stale hide,避免它在 handoff 过程中把键盘提前收掉。
这确实能修复最初的问题。日志里可以看到:
- Flutter
TextField先成为 framework client - 点击 native 输入框后进入
setPlatformViewClient - framework 紧接着发来
hide - 我们识别这是 handoff 过程中的 stale hide,于是 suppress
- native blur 后再清理 PlatformView owner
- 回到 Flutter 输入框时,键盘可以重新弹起
但问题是,我们很快发现它引入了新的边界问题。比如:
- Flutter 键盘起来后点击 native,键盘被收起
Flutter -> native -> hide -> background -> foreground后出现无主键盘- native focused 页面 pop / teardown 后可能留下旧 owner 状态
- 有些 cleanup 在 blur、detach、dispose 多个入口重复出现
这些问题说明:虽然单点 case 被修了,但我们开始把 TextInputPlugin 变成一个更复杂的状态机。每修一个新表现,就补一个新 flag、一个新 cleanup、一个新异步兜底。代码逐渐从一个窄修复扩展到多个文件、多个状态变量、多个生命周期入口,修改量越来越大,风险也越来越难解释。
这也是中间 review 反复指出的风险:hide suppress 可能吞掉真实 teardown hide、detach 后晚到 focus 回调可能回灌、前后台恢复没有覆盖 native owner、测试只覆盖 helper predicate 而没有覆盖跨类异步状态机。这些评论不一定每一条都是当前可复现 bug,但它们反映了同一个问题:修复已经开始膨胀成“跨 TextInputChannel -> TextInputPlugin -> PlatformViewsController 的状态机治理”。
第二阶段:尝试用 ArkUI focus 对齐,但发现时序不可靠
我们也尝试过另一个方向:不要在 ACTION_DOWN 阶段提前切 focus,而是等 ArkUI 内部真正的 native editor focus 回调到了,再通知 Flutter invokeViewFocused。
这个方向从架构上看更“正确”:只有真实 native 输入框获得焦点,才切换输入 owner。它可以避免点到 PlatformView 非输入区域时误切焦点。
但实际问题是,OHOS ArkUI 的 focus / touch 时序和 Flutter framework 的 hide 消息不是严格同步的。native child focus 往往已经晚于 framework 发出的 clear client / hide 链路。也就是说,如果完全等 ArkUI focus,可能已经错过了最初想规避的 race window。
这和 Android 的模型并不能简单对齐。Android 那边的 PlatformView focus / input connection 生命周期有更成熟的通路,很多 owner 切换语义可以借助系统输入连接自然完成;但 OHOS 这里,PlatformView 是 ArkUI 组件,Flutter engine 外层看到的是 PlatformView 容器,内部 child 的 focus 事件并不天然等价于 Flutter text input owner 切换。硬把 Android 的思路搬过来,会导致状态机越来越绕。
这个阶段给我们的结论是:不能只追求“理论上更正确”的 focus 事件;必须尊重当前 OHOS 的事件时序。如果时序不够早,单靠 ArkUI focus 无法稳定修复 first-tap handoff race。
第三阶段:退回来,重新收窄问题边界
后面我们把思路重新收窄:这次 PR 不应该重写完整输入法状态机,只修最初明确的问题。
核心边界重新定义为:
- 只处理 Flutter framework text client 切到 physical PlatformView text input 的 handoff
- 只 suppress 一次 handoff 过程中紧跟而来的 stale framework hide
- native blur 时只做 owner bookkeeping reset,不主动扩大成 IME teardown
- teardown cleanup 只是兜底,不参与正常 steady-state 链路
- virtual-display PlatformView 保持原路径
- 非文本 PlatformView 不参与 text input handoff
这样以后,TextInputPlugin 里的修改重新变得能解释:
setPlatformViewClient记录 physical PlatformView owner- 一次性 suppress stale framework hide
clearPlatformViewClient处理 native blur 后的 owner releaseclearPlatformViewClientForTeardown只作为 view 消失时的 terminal fallbackisFrameworkTextInputActive用来约束 eager handoff 只发生在 Flutter -> native 这条路径
这个阶段的目标不是让所有边界都完美,而是确保这次 PR 的行为范围可控,不把已经验证好的链路再次打坏。
第四阶段:发现 composite PlatformView 的新问题
后来我们又发现一个很关键的现象:Flutter TextField 弹起键盘后,点击 Top Native Input 这个 PlatformView 的非输入区域,Flutter 光标会消失,但键盘还在。
这个现象一开始看起来像 TextInputPlugin 又出问题了,但继续看 demo 实现后发现,根因其实在 PlatformView 粒度。
demo 里的 Top Native Input 并不是“一个 PlatformView 等于一个 native TextInput”。它是一个复合 ArkUI view:
- 标题
- 内部
TextInput - footer 文案
- padding / background / border
这一整张 card 都是同一个 PlatformView。engine 在 ACTION_DOWN 只能看到“viewId 被点到了”,不知道点的是内部输入框,还是标题、空白、padding。
原来的 eager handoff 逻辑是:只要这个 PlatformView 声明自己是 text input handoff participant,就在 ACTION_DOWN 直接 invokeViewFocused(viewId)。这会导致点到 card 非输入区时,Flutter 先把焦点让出去,但 native editor 并没有接手,于是进入半切换状态。
这个问题的正确修复位置不是 TextInputPlugin,而是 PlatformViewsController / PlatformView 的 handoff 触发条件。
最终我们新增了一个更细粒度的扩展点:
shouldParticipateInTextInputHandoff()表示这个 PlatformView 是否整体参与 text input handoffshouldTriggerTextInputHandoffOnTouch()表示这一次 touch 是否真的应该触发 eager handoff
PlatformViewsController 改成:
- 先把
ACTION_DOWN分发给 ArkUI child - 再询问具体 PlatformView:这次 touch 是否命中了真实 editor child
- 只有返回 true 时,才调用
invokeViewFocused(viewId)
demo 侧配套实现为:
- 内部真实
TextInput收到 touch 时,按viewId记录 editor touch signal shouldTriggerTextInputHandoffOnTouch()消费这个 signal- 点非输入区时返回 false
- 点真实输入框时返回 true
这个方案验证后行为符合预期:
- 点 native card 非输入区,Flutter 光标不会消失
- 点真实 native 输入框,仍然能触发 first-tap handoff
- Flutter -> native -> hide -> Flutter 正常
- native -> native、route pop、前后台恢复链路正常
更重要的是,这个方案没有继续扩大 TextInputPlugin 状态机,而是把问题放回了正确的语义层:PlatformView 是否应该在本次 touch 上触发 handoff。
这次最大的教训
这次修复最大的教训不是某一行代码怎么写,而是边界意识。
一开始的问题很具体,但我们很容易被后续现象带着走,把每一个新表现都塞进同一个状态机里修。这样会让修复越来越像“补洞”,代码量不断膨胀,review 也会越来越难回答:每个 flag 在什么生命周期生效?是否会吞掉真实 hide?detach 后晚到 focus 怎么办?前后台 native owner 怎么恢复?测试到底覆盖了什么?
真正让问题收回来的是重新问:
- 这次 PR 到底修哪条链路?
- 哪些行为是原本就存在的,不属于本次修复?
- 哪些风险需要日志证明后再动?
- 哪些问题应该在
TextInputPlugin,哪些应该在PlatformViewsController? - 这个 PlatformView 是单输入框,还是复合 ArkUI 容器?
最终比较稳的方向是:
TextInputPlugin只处理输入 owner 和 IME bookkeepingPlatformViewsController处理什么时候需要 eager focus handoff- 具体 PlatformView 实现负责说明“这次 touch 是否真的命中了 native editor”
- demo 只作为验证配套,不把 demo 特化逻辑反推成 engine 通用状态机
总结
这次 PR 从一个“Flutter -> native 后键盘回不来”的具体 bug 开始,中间走过一次状态机膨胀,也尝试过等待 ArkUI focus 的更理想路径,但最终还是回到一个更朴素的原则:
不要在错误的层修问题。
IME owner 的问题放在 TextInputPlugin,touch 命中语义放在 PlatformView / PlatformViewsController,复合 view 的 child 判断交给具体 PlatformView 自己实现。这样修复才有边界,review 才能讲清楚,后续风险也才有办法用日志继续验证。
