一份简单的故障报告:某个频道卡片上,长按方向键中心按钮应该触发收藏/取消收藏的动作,但它却时不时失灵。有的设备上只触发一次就停了,有的设备干脆对长按完全没反应。

最后定位到的修复方案更简单——把 Modifier.onKeyEvent 换成 Modifier.onPreviewKeyEvent,只改了一个 Composable 里的修饰符。为什么这么一颗小螺丝钉,却能彻底扭转局面?那得从 Compose 对按键事件的传递方式说起。

看上去一样的两个钩子,站位完全不同
Jetpack Compose 提供了两个与按键相关的修饰符钩子:Modifier.onKeyEventModifier.onPreviewKeyEvent。光看名字,一个叫“按键事件”,一个叫“预览按键事件”,好像预览版只是提前看了看而已。实际上,它们在事件分发链上处在两个截然不同的阶段。

当用户按下遥控器上的按键,Android 系统会沿着一个分发树把事件传下去:Activity → ViewGroup(根视图)→ 获得焦点的 Composable → 子 Composable。整个过程分两轮——先走捕获阶段,从最外层一路传到最里层获得焦点的节点;再走冒泡阶段,从最里层的节点沿原路返回。

捕获阶段 就是 Modifier.onPreviewKeyEvent 的舞台,事件从外层向内层依次触发。而 冒泡阶段 留给 Modifier.onKeyEvent,事件从最内层的节点向外冒。所以,如果同一个 Composable 上同时挂了点击处理和长按处理,它们在两套不同时间线上争抢事件,冲突就这么埋下了。

长按为什么会“打架”?
Android TV 在框架层负责识别长按。用户按住方向键中心按钮不放时:
· 首先会立即触发一个 KeyEvent.ACTION_DOWN
· 如果按键持续按下,系统会按重复速率不断产生新的 ACTION_DOWN;
· 直到松开,才产生 ACTION_UP

Compose 的焦点系统为了不让“按住”动作意外触发“点击”,会在冒泡阶段把作为“确认”用途(比如选择、激活)的 ACTION_DOWN 消费掉。问题就出在这里:ChannelCard 组件上,单击动作通过点击处理器挂载,长按切换收藏却通过 onKeyEvent 监听。冒泡阶段,点击处理器抢在前面把 ACTION_DOWN 消费了,长按处理的逻辑往往等不到事件——有些设备上点击处理器先拿下事件,长按代码没机会执行;有的设备上长按勉强触发一次,重复的 ACTION_DOWN 又被吞掉,导致后续长按失灵。

用一个比喻:点击处理器在冒泡阶段的“内圈入口”堵着,长按处理器还在外面排队,根本进不来。

换到捕获阶段,一切清净了
改动前的代码大致是这样的:

Modifier.onKeyEvent { keyEvent ->    if (keyEvent.key == Key.DirectionCenter &&        keyEvent.type == KeyEventType.KeyDown &&        keyEvent.isLongPress) {        onToggleFavorite()        true    } else false}

改成 Modifier.onPreviewKeyEvent 后,同一套逻辑移到了捕获阶段。预览版修饰符在最外层拦截事件,在任何子组件或同级处理器之前就拿到了 ACTION_DOWN。长按判定一成立,onToggleFavorite() 干净利落地执行,然后返回 true 阻止事件继续传播,单击处理器压根没机会触发。这样一来,长按和单击各走各的道,不再打架。

什么时候该用哪个?
简单粗暴的规则:如果一个 Composable 上同时存在单击处理器和按键处理器,并且按键处理的目标是长按或次要动作,那就果断用 onPreviewKeyEvent,赶在单击机制前把事件拦下来。普通按键(比如方向键导航)一般用 onKeyEvent 处理就行,因为那些操作本来也不应该阻止其他冒泡行为。

关于 isLongPress 的小彩蛋
还要提一句坑点:Compose 里 KeyEvent 的 isLongPress 属性在某些 Android 电视设备上并不总是可靠。实际的框架长按判定与按键重复速率、设备固件都有关系,所以即便换成了预览版修饰符,也最好加上自己的长按计时或状态锁定,避免重入。在这篇文章的案例里,isLongPress 仅作为第一道判据,但修复的核心从来不在于这个属性的准确性,而在于事件到底在捕获阶段还是冒泡阶段被拿到。

这个 bug 最终只改了一个修饰符,却要理解一整套 Compose 事件传播机制。下一次再遇到“长按偶尔失灵”的灵异现象,不妨先问问自己:到底是点击处理器把事件吞掉了,还是你还在用 onKeyEvent 等冒泡?换成预览版,也许是整条链路里最简单也最容易被忽视的解法。