2026-09-14 定位并修复。一个「兜底路径被删 → 只在未测试环境里静默退化」的完整案例:微信/浏览器全程正常,只有学校壳坏,且坏了近三周无人发现,因为首屏定位仍然正常。

发生条件 (When It Happens)

同时满足这几条时才会踩到,缺一条都看不见:

  1. 页面运行在能力受限的 WebView(学校壳/自研壳)里,该 WebView 不提供 navigator.geolocation(未实现 onGeolocationPermissionsShowPrompt、未授权、或安全上下文不满足);
  2. 同一页面在普通浏览器/微信里能拿到 geolocation——所以测试者看到的一切都是正常的;
  3. 该场景曾经有一条更宽的兜底路径(走原生桥的周期性单次定位),后来被"更优雅的替代方案"(连续定位流)换掉了;
  4. 首屏位置另有来源(原生桥)保持正常,把连续流的失效掩盖住。

症状 (Symptoms)

  • 进入导航后光标钉在原地,剩余路线不推进;
  • 但地图会跟着 setCenter 动——这一条最迷惑人:看起来"定位在跑",只是标记不动;
  • 首屏定位正常,页面无报错、不白屏;
  • 换到微信/浏览器打开同一页面,完全正常。

根因 (Root Cause)

一、替换兜底时把"环境覆盖面"一起换窄了

原路径是周期性走完整三级链路(原生桥优先 → SDK → 浏览器 geolocation),环境覆盖面最宽;新路径换成单条 navigator.geolocation.watchPosition,环境覆盖面骤然收窄到"只有浏览器 geolocation 可用的地方"。

关键认知:navigator.geolocation 的可用性不是一个布尔值,它按宿主环境分裂。 同一份代码在 Chrome / 微信 WebView / 自研 WebView 里是三套行为。任何"从多源兜底改成单一源"的重构,都必须先回答:被砍掉的那些源,各自覆盖了哪个环境?

二、被删掉的那条路,恰好是唯一不用过"坐标转换"的路

三级链路里,原生桥返回 BD09(与地图同坐标系),直接可用;浏览器 geolocation 返回 WGS84,必须过一道联网的坐标转换(BMapGL.Convertor.translate(..., 1, 5, cb),带超时)。

于是新路径额外多了一个网络依赖 + 超时才能产出样本。转换一失败,样本被整条丢弃,只在调试日志里留一句。

可迁移判断:判断"两条定位路径是否等价",不能只看它们都能给出 lat/lng,要看它们各自的坐标系、精度、延迟、以及产出样本需要几步。

三、样本"到了"却"不生效"——引擎的单调时间戳闸

导航引擎要求样本时间戳严格单调:

if (lastSample && timestamp <= lastSample.timestamp) {
    return { valid: false, reason: "out-of-order" };
}

而 watchPosition 回调传的是 position.timestamp——GPS 定位时刻,不是回调时刻,在 WebView 里不保证递增。

更隐蔽的是渲染口径与状态口径分叉:

  • 状态:state.current = ... 无条件更新;
  • 相机:map.setCenter(state.current.point) 无条件执行;
  • 光标:读的是 state.navigationRenderPoint,而它只在引擎接受样本时才被写。

所以样本被拒时,光标是唯一不动的东西。

可迁移判断:一条数据流被下游拒绝时,如果上游有多个消费者且各自读不同的状态字段,就会出现"部分更新"的假象。要么让拒绝显式冒泡到所有消费者,要么让消费者读同一个真值。

四、吸附修复后的同类冻结——不要把来源时间戳当作接收顺序

2026-09-15 复核发现,即使吸附半径已限制在 10–25 米,光标仍可能表现为固定不动:watchPosition 的 position.timestamp 是 GPS fix 时刻,学校桥/兜底定位使用的是回调时刻,而 WGS84→BD09 转换还是异步的。多源混流时,来源时间戳可能倒退,导航引擎会把样本判为 out-of-order;由于光标只读已接受样本产生的 navigationRenderPoint,最终显示上一个吸附点。

正确分工是:

  • sourceTimestamp 只做诊断,保留原始 GPS fix 时刻;
  • 导航进度、速度和渲染排序统一使用单调的 receivedAt(收到/进入处理链的时刻);
  • 同一毫秒到达的样本补 1ms,明确更早到达的旧回调仍拒绝;
  • 吸附只改变显示点,不得改变“样本是否新鲜”和“样本是否被引擎接受”的判定。

这类问题的识别信号是:日志出现 忽略定位样本:out-of-order,但 state.current 或地图中心仍在更新。此时应先查时间戳语义和异步转换顺序,不要继续放大吸附半径。

最终修法 (Fix):看门狗,而不是"再加一条定时样本"

直觉修法是"把原来那个 10s 定时定位加回来"。直接加回会打架:

  • 两条源交错 → 时间戳非单调 → 互相顶成 out-of-order,反而把本来能用的那条腿踢瘸;
  • 速度推算会把两源之间的位置跳变除以几百毫秒,算出上百 m/s 的假速度 → 样本被判不可信 → 降级;
  • 光标还会在两个源之间来回横跳。

正确形状是看门狗(watchdog)——定时器留着,但只在"主管道静默"时才动手:

var WATCHDOG_TICK_MS  = 10000;   // 每 10s 醒一次
var WATCHDOG_STALE_MS = 8000;    // 静默超过 8s 才认为主管道死了

function runWatchdogTick() {
    if (!navigating || tickActive) return;
    if (!isStale()) return;          // ← 主管道正常时全程空转,不插队
    tickActive = true;
    fallbackLocateChain().finally(() => { tickActive = false; });
}

看门狗模式的三个要点(都可迁移):

  1. 判活阈值要明显大于主管道的正常间隔。连续定位正常是秒级,取 8s 静默才算死——不会因为一次抖动误触发。
  2. 只在静默时动作,正常时零开销、零副作用。这解决了"两条源混流"的根问题。
  3. 记账口径必须是"下游接受了",不是"上游调用了"。这是个通用陷阱:

    // ✗ 错:按回调到达记账 —— 样本一直被拒时,看门狗误判健康,永不兜底
    onSampleReceived(() => { lastHealthyAt = now; });
    // ✓ 对:按下游接受记账
    const r = ingest(sample);
    if (r && r.accepted) lastHealthyAt = now;

    健康判据必须落在"效果"上,不能落在"动作"上。 这是本案例里最容易二次踩坑的地方——第一版修复就写错了这一处。

顺带堵掉的一个破坏性失败路径

兜底定位失败时,原代码无条件执行 clearCurrentPosition(),而它内部会把当前路线快照一起清空:

state.current = null;  state.routeReady = false;
state.routePath = [];  state.routeSnapshot = null;   // ← 路线当场消失

在导航进行中这比"保留上一个点"糟糕得多——一次兜底失败会摧毁整个会话。

可迁移判断:失败处理要分"会话是否进行中"。会话进行中时,过期数据通常比没有数据有用;清空状态的操作必须显式说明"为什么此刻清空是安全的"。

下次如何识别 (How to Recognize Next Time)

环境矩阵先行。遇到"某人能用某人不能用"的定位问题,第一件事是列矩阵,而不是读代码:

浏览器微信 WebView自研 WebView
navigator.geolocation✅✅❓
原生桥——✅(通常仅一次性)

分歧点出现在哪一列,根因就在那个环境的哪一项能力上,不在业务逻辑里。

调试日志要能区分"哪一层的哪一步失败"。本案例里三句话字面不同,一眼分开:

日志失败在哪
连续定位坐标转换失败样本产出阶段(坐标系转换)
忽略定位样本:out-of-order样本消费阶段(引擎时间戳单调)
静默超过 8s,看门狗发起兜底定位 + 原生桥成功主管道死了、兜底在工作

判断原生桥本身是否完好:只看首屏能否定位。 能 → 桥是好的,问题在连续流的宿主承载上;不能 → 才是桥坏了。

通用教训 (Transferable Lessons)

  1. "更优雅的替代方案"常常是"环境覆盖面更窄的方案"。 用连续定位流替代周期性单次定位,技术上更先进,但它把"原生桥能兜底"的整个环境类别悄悄排除掉了。重构定位/传感器/权限这类受宿主环境影响的能力时,必须显式写出环境覆盖矩阵的 before/after 对照。
  2. 静默退化比崩溃危险得多。 崩溃会被发现;"首屏正常、只有某个子功能不灵"可以存活数周。凡是保留兜底路径的地方,都应有一条能证明兜底真的被走到的日志。
  3. 删除兜底路径时必须留下锚点。 本案例的删除发生在一个标题为 feat: connect Volcengine TTS narration 的提交里——没有任何测试或注释记着"这是某环境下唯一可用的来源"。实践:删除兜底分支时,在代码里留一行注释说明它覆盖的环境,或补一条契约测试锁住。
  4. watchPosition 与单次 getCurrentPosition 不可互相替代,它们的时间戳语义、精度、以及是否受安全上下文限制都不同。

关联


本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Frontend