Published on

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

Authors
  • avatar
    Name
    Jack Qin
    Twitter

很多交互逻辑都要回答同一个问题:"这次状态变化,是用户干的,还是系统自己干的?"地图的"跟随模式"是个干净的样本:地图自动跟着目标移动,但用户一旦手动拖动就应脱离跟随。难点不在跟随,而在辨别——react-native-mapsonRegionChange 对用户拖动和程序化 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 tick800ms 窗口内所有帧归类为程序化 → 跟随保持
站点变化触发 useAnimateToSite(跟随中)程序化——跟随保持(切换站点不该丢跟随)
慢设备上动画超过 800ms最后几帧被当成用户手势 → 跟随过早解除。缓解:按最慢的现实设备定窗口,或若能拦到动画回调就每帧 bump

注意最后一行——窗口给小了会悄悄破坏 follow,加了更长的动画就要相应调大窗口。这是时间窗口方案的诚实边界:它把"必然失效的布尔"换成了"一个需要按真实设备校准的阈值",确定性更高,但要求你知道自己最慢的目标设备。


测试与可迁移的那一层

测试是纯逻辑——把决策抽成一个函数,入参是当前时间 + 上次程序化时间戳 + isProgrammatic 布尔,Vitest 不靠 React 就能覆盖(本项目 renderHook 不可用):

用例断言
now < programmaticUntildecideOnPan(state, true).next === state——不变,跟随保持
now >= programmaticUntilisFollowing.next.isFollowing === false——解除跟随
now >= programmaticUntil 且非 isFollowing.next === state——no-op

多帧陷阱还需要一次手动复现(引入新地图功能时做):release build 上开 follow → 在 zoom > 14 时点 FAB(让动画是纯镜头移动而非先缩放再居中)→ 确认 settle 后 FAB 仍是"跟随中"。若它自己翻成"未跟随",说明窗口太短或漏了 markProgrammaticMove()

抛开地图的具体 API,真正可迁移的认知是:当一个操作在时间上是一段区间、却被你用一个只覆盖一个时刻的标志去标记时,区间里其余的事件必然漏网。 任何"程序化触发会发出一串而非一个事件"的场景——动画、批量 DOM 变更触发的 observer 回调、防抖期内的多次 input——都共享这个陷阱。建模时先问一句:我要抑制/归类的这个操作,在时间轴上是一个点还是一段区间? 是区间,就别用布尔,用窗口;而且窗口长度要按你最慢的真实环境来定价。