- Published on
事件流里区分"谁动的手":单标志位为什么注定输给多帧动画
- Authors

- Name
- Jack Qin
很多交互逻辑都要回答同一个问题:"这次状态变化,是用户干的,还是系统自己干的?"地图的"跟随模式"是个干净的样本:地图自动跟着目标移动,但用户一旦手动拖动就应脱离跟随。难点不在跟随,而在辨别——react-native-maps 的 onRegionChange 对用户拖动和程序化 animateCamera 的触发方式完全一致,事件 payload 上没有任何标志告诉你"这是用户,不是我"。
本文不讲跟随怎么写,而是想拆一个更普遍的建模错误:当辨别信号本身缺失时,开发者会本能地用一个布尔标志位去"标记"程序化操作;而这个布尔注定失效,因为它对操作的时间结构做了一个错误假设——它假设"一次程序化操作 = 一个事件"。理解这个假设为什么在多帧动画的现实里必然破裂,比记住"用时间窗口"这个修法更有迁移价值。
现象:跟随刚开就自己脱离了
follow 模式开启后,镜头移动到目标,然后立刻自己脱离了跟随。它在 release build 上 100% 复现,却有时在慢速 dev build 上藏起来——这个"快环境必现、慢环境偶尔躲过"的特征本身就是线索,下文会解释它为什么指向帧数。
最自然的写法是:动画前置一个布尔 ref,在下一个 onRegionChange 里清掉。
// 错误 —— 单事件标志位,只抑制了多帧动画的第一帧。
const suppressNextRef = useRef(false);
function animateToDriver(coord: Coord): void {
suppressNextRef.current = true; // ← 只抑制第一帧
mapRef.current?.animateCamera({ center: coord, ... }, { duration: 300 });
}
function handleRegionChange(): void {
if (suppressNextRef.current) {
suppressNextRef.current = false; // ← 第一帧后就被清掉了
return;
}
onUserPan(); // 第 2..N 帧被当成用户拖动 → bug
}
机制一:辨别信号根本不存在
先认清这个问题为什么没有"正路"。onRegionChange / onRegionChangeComplete 对用户手势和程序化 animateCamera / animateToRegion 的触发方式完全同构——事件对象里没有 origin 字段。onPanDrag 看似能补这个缺口,但它 iOS 上有、Android 上没有,且即便在 iOS 上也会漏掉"捏合缩放后继续拖"这类连续手势。
也就是说,平台没有给你一个可靠的"是不是用户"信号。既然信号缺失,你只能自己制造一个:在程序化触发前主动记一笔状态,事件来时拿它做反查。问题不在"要不要自己记状态",而在"记成什么形状"。
机制二:一次动画发出多个事件——单标志位的致命假设
布尔标志位的隐含假设是:一次 animateCamera 调用对应一个 onRegionChange,所以"抑制下一个事件"就够了。这个假设是错的。
单次 animateCamera 调用会发出多个 onRegionChange——iOS 上每帧一个,Android 上几个一批。一个 300ms 的动画在 60fps 下就是十几个事件。布尔标志位只在第一个事件上被清掉,剩下的十几帧全部漏网,被 onRegionChange 当成用户拖动登记下来,于是每次程序化重新居中都悄悄解除 follow。
这也解释了开头那个"快环境必现、慢环境偶尔躲过"的特征:动画发出的事件数和帧率正相关。release build 帧率高、每次动画 fire 一长串事件,漏网帧几乎必然命中清理后的窗口;慢速 dev build 偶尔一次动画只 fire 一个事件,恰好被单标志位完整盖住,于是 bug 隐身。性能越好,bug 越稳定复现——这是单标志位对事件时间结构假设错误的直接症状。
错误的根在于:程序化移动不是一个点(单个事件),而是一段区间(一串事件)。用一个只能盖住一个点的布尔去盖一段区间,必然漏。
修复:把程序化移动建模成时间窗口
正确的抽象是匹配它真实的时间结构——把程序化移动当成覆盖一个时间窗口。任何驱动 animate* 的内部调用都把窗口末端时间戳往后推;onRegionChange 在把事件归类为"用户发起"之前,先检查现在是否还在窗口内。
// MapScreen.tsx(或你持有 MapView ref 的地方)
const programmaticMoveUntilRef = useRef<number>(0);
// 窗口必须比可能 fire 的最长内部动画还长。
// useAnimateToSite 默认 600ms;再加 settle 帧的余量。
const PROGRAMMATIC_MOVE_SUPPRESS_MS = 800;
/** 在任何对 MapView 的内部 animate*() 调用之前调它。 */
function markProgrammaticMove(): void {
programmaticMoveUntilRef.current = Date.now() + PROGRAMMATIC_MOVE_SUPPRESS_MS;
}
function handleRegionChange(): void {
if (Date.now() < programmaticMoveUntilRef.current) return; // 程序化
onUserPan?.(); // 用户手势
}
function animateToDriver(c: Coord): void {
markProgrammaticMove();
mapRef.current?.animateCamera({ center: c, ... }, { duration: 300 });
}
窗口模型对多帧动画天然健壮:一段动画的全部帧都落在同一个窗口内,无论它发出 1 个还是 50 个事件。它对重叠的内部移动也健壮——后来的 markProgrammaticMove() 调用只是把窗口末端往后延长,而不像两个布尔标志位那样互相打架。
副作用触发的动画也要 bump 窗口
有些镜头移动不是用户碰地图触发的(切换站点、deep-link 路由变化、从 sheet 程序化缩放)。这些动画同样会 fire 一串 onRegionChange,同样要 bump 窗口,否则它们产生的帧会被误判为用户拖动:
// 切换站点会通过下游 hook(useAnimateToSite)重新对准镜头。
// bump 窗口,免得产生的帧被当成用户拖动而悄悄解除 follow。
useEffect(() => {
if (siteId) markProgrammaticMove()
}, [siteId])
| 触发 | 动画前必需的调用 |
|---|---|
mapRef.animateCamera({ ... }) 做 follow 重新居中 | markProgrammaticMove() |
mapRef.animateToRegion(...) 做放大/缩小按钮 | markProgrammaticMove() |
站点切换动画(useAnimateToSite 之类) | markProgrammaticMove() |
| 用户拖动 / 捏合 / 双指旋转 | 无——onRegionChange 必须 fire onUserPan |
窗口长度:一个显式的、按最慢设备定价的旋钮
时间窗口把一个隐患从"代码结构"变成了"一个数值参数"——这既是优点也是它的边界。窗口至少要和屏幕能 fire 的最长内部动画一样长。撰写时 useAnimateToSite 默认动画 600ms,800ms 给了 200ms settle 缓冲。
它的失效模式是显式且可推理的:
| 场景 | 应有行为 |
|---|---|
| 跟随中,用户单指拖动 | 抑制窗口过期后 onRegionChange fire → onUserPan() → 解除跟随 |
| 跟随中,用户捏合缩放 | 同上——解除 |
App 调 animateCamera 做一次 follow tick | 800ms 窗口内所有帧归类为程序化 → 跟随保持 |
站点变化触发 useAnimateToSite(跟随中) | 程序化——跟随保持(切换站点不该丢跟随) |
| 慢设备上动画超过 800ms | 最后几帧被当成用户手势 → 跟随过早解除。缓解:按最慢的现实设备定窗口,或若能拦到动画回调就每帧 bump |
注意最后一行——窗口给小了会悄悄破坏 follow,加了更长的动画就要相应调大窗口。这是时间窗口方案的诚实边界:它把"必然失效的布尔"换成了"一个需要按真实设备校准的阈值",确定性更高,但要求你知道自己最慢的目标设备。
测试与可迁移的那一层
测试是纯逻辑——把决策抽成一个函数,入参是当前时间 + 上次程序化时间戳 + isProgrammatic 布尔,Vitest 不靠 React 就能覆盖(本项目 renderHook 不可用):
| 用例 | 断言 |
|---|---|
now < programmaticUntil | decideOnPan(state, true).next === state——不变,跟随保持 |
now >= programmaticUntil 且 isFollowing | .next.isFollowing === false——解除跟随 |
now >= programmaticUntil 且非 isFollowing | .next === state——no-op |
多帧陷阱还需要一次手动复现(引入新地图功能时做):release build 上开 follow → 在 zoom > 14 时点 FAB(让动画是纯镜头移动而非先缩放再居中)→ 确认 settle 后 FAB 仍是"跟随中"。若它自己翻成"未跟随",说明窗口太短或漏了 markProgrammaticMove()。
抛开地图的具体 API,真正可迁移的认知是:当一个操作在时间上是一段区间、却被你用一个只覆盖一个时刻的标志去标记时,区间里其余的事件必然漏网。 任何"程序化触发会发出一串而非一个事件"的场景——动画、批量 DOM 变更触发的 observer 回调、防抖期内的多次 input——都共享这个陷阱。建模时先问一句:我要抑制/归类的这个操作,在时间轴上是一个点还是一段区间? 是区间,就别用布尔,用窗口;而且窗口长度要按你最慢的真实环境来定价。