一次 H5 页面卡死排查
最近处理了一个比较棘手的 H5 页面卡死问题。它只出现在部分 Android 设备上,既没有明确的 JavaScript 报错,也没有失败的接口请求,最初能看到的只有骨架屏和一句网络异常提示。
排查过程中,网络、接口、Native Bridge 和请求时机都曾经表现得像是问题所在。直到把多个正常页面和异常页面放在一起对照,才逐步找到真正的触发条件。这篇文章记录一下完整的判断过程,以及这次排查带给我的一些思考。
1. 问题现象
前段时间,远程测试人员反馈,部分 Android 设备无法打开用户自助销户问卷页。页面资源可以加载,但进入后会立即提示:
Poor network connection. Please try again later.随后页面一直停留在骨架屏。同一台设备上的个人中心可以正常打开,也能展示用户信息,所以整个 H5、网络和登录态并没有全部失效。

2. 建立本地联调环境
最开始的问题手机不在我手上,App 里的 WebView 也无法直接查看控制台。只靠测试人员描述“页面打不开”,很难知道代码执行到了哪里。
经过远程会议沟通,我先确认了问题设备的系统版本和运行环境,随后借到一台环境相近的 Android 13 测试机,在本地复现了问题。后来另一台 Android 13 设备也出现了相同现象,而测试过的 Android 14 设备均正常。
结合几台设备的复现结果,基本可以判断,这次问题集中出现在 Android 13 的 WebView 运行环境中。
为了让测试手机运行本地代码,我保留线上访问链接,再通过 Charles 把线上静态资源 Map 到本地 IP 和端口。这样 App 看到的 URL 和 Host 没有变化,实际加载的却是本地 H5 资源,可以修改代码后立即验证。
WebView 控制台仍然不可见,所以又通过 URL 参数开启临时远程日志,让手机把关键执行节点发送到本地服务,再从终端查看结果。有了这套联调方式,后面的判断才不再只依赖页面表现。

3. 排查过程
3.1 请求没有发起
线上会议中,页面有时只显示 index.html 的初始骨架,有时能进入具体业务页面,显示页面自己的 loading 骨架。问题出现的时间并不完全固定,仅凭画面还无法确定代码停在哪一层。
Charles 抓包显示,问卷接口 getSurveyOptions 没有发起。第一轮日志则停在 module.sfCheckLogin 调用返回之后,没有进入 Native callback。因为接口需要等待这个 callback 才能继续,最初很自然地怀疑 Bridge 回调通道或者 Bridge 注册时机。
3.2 第一条线索指向 Bridge
App 端检查实现后,确认 sfCheckLogin 理论上会正常回调。后续日志也证明 Token 初始化和 React 公共启动流程能够完成,Bridge 整体并没有失效。
3.3 用其他页面做对照
继续对比其他页面后,发现个人中心可以正常打开,说明公共启动流程、登录态和 Bridge 并没有整体失效。
另一个入金实验页提供了很有用的对照。这个页面同样打不开,但日志已经执行到 request.interceptor.return。对比代码后发现,入金页通过 React Router loader 提前发起请求,而销户问卷页是在组件挂载后请求。入金页只是在阻塞发生前多执行了几步,所以两个页面留下了不同的日志位置。
这也说明,请求有没有发出不是判断根因的充分条件。两个页面的请求时机不同,但最后可能被同一段前端逻辑阻塞。

3.4 缩小到导航高度 Hook
经过页面对照,异常页面的共同点逐渐集中到沉浸式导航相关逻辑。去掉导航处理后页面可以打开,恢复后再次卡住,因此可以确认问题位于导航相关的执行链路中。
接下来继续拆分这条链路,分别验证 Native 导航方法能否正常返回、requestAnimationFrame 能否执行、ImmersiveNavBarHost 能否单独运行,以及导航高度 Hook 是否会触发问题。测试结果显示,只使用 ImmersiveNavBarHost 时页面可以打开;恢复项目里的 useNavHeightRem 后,问题设备再次卡住。
继续关闭 useNavHeightRem 中对 <html style> 的 MutationObserver 后,问题页面恢复正常;重新启用后,问题再次出现。
UNO 版入金页也调用了 useNavHeightRem,使用原代码却可以打开。原因是 UNO 版本直接通过 Native Bridge 设置导航,没有渲染 ImmersiveNavBarHost,因此不会触发 SDK 测量导航高度并更新根节点变量的组合条件。
4. 最终原因
useNavHeightRem 使用 MutationObserver 监听整个 <html> 元素的 style 属性。监听回调读取 SDK 写入的 --sofi-nav-height,换算后又向同一个 <html style> 写入 --spei-nav-height-rem。
沉浸式导航 SDK 同时通过 ResizeObserver 和 requestAnimationFrame 测量导航元素,并更新 <html style> 上的 --sofi-nav-height。两套逻辑组合后,形成了下面的执行关系:
SDK 测量导航高度
↓
写入 --sofi-nav-height
↓
MutationObserver 监听到 <html style> 变化
↓
读取计算样式并写入 --spei-nav-height-rem
↓
<html style> 再次变化原代码虽然有“值相同就不再写入”的判断,但 SDK 的导航状态、滚动进度、React effect 和 ResizeObserver 还会持续影响测量。在问题设备上,这些更新形成了高频的样式监听、布局测量和根样式写入反馈,最终占用主线程,使 React 渲染、Bridge callback 和接口请求无法继续得到调度。
经过多轮对照验证,可以确定根样式 MutationObserver 是问题的触发点。启用它,Android 13 问题设备稳定卡住;关闭它,页面恢复正常。
Chromium 104 已经支持 MutationObserver 和 ResizeObserver,因此问题不是 API 缺失,而是两套观察与写入逻辑在 Android 13 问题环境中的组合行为。

5. 最终解决方案
项目增加 useNavHeightRem,是为了把 SDK 输出的 px 导航高度转换为页面使用的 rem。实际上,CSS 的 calc() 可以混合计算 px 和 rem,没有必要再通过 JavaScript 监听和复制变量。
最终在 Android 上取消 MutationObserver 和 resize 事件,让现有变量直接引用 SDK 变量:
--spei-nav-height-rem: var(--sofi-nav-height, 8.8rem);页面仍然使用 --spei-nav-height-rem,原有样式不需要批量修改。SDK 更新 --sofi-nav-height 后,浏览器会自动重新计算依赖它的 CSS。
iOS 保留原来的 MutationObserver + resize 和 px 转 rem 逻辑。本次问题只在 Android 设备复现,iOS 已经稳定运行,保留原逻辑可以减少修改范围和回归风险。
6. 一些思考
6.1 最后一条日志不一定是根因
日志停在 sfCheckLogin 调用后,最初确实很像 Native callback 没有执行。后面的对照结果证明,它只是主线程停下来之前留下的最后一条记录。
Bridge callback、Promise、定时器、请求拦截器和 React 更新最终都需要主线程继续调度。主线程被其他逻辑占住时,它们可能一起表现为“没有后续”。日志能确定的是代码已经执行到哪里,不能单独证明下一行就是问题所在。
6.2 先让问题可以被观察
这次真正推动排查继续进行的,是先借到同类设备复现,再通过 Charles 和远程日志建立本地联调环境。
当设备不在手上、控制台又不可见时,继续修改业务代码很容易变成猜测。先让每次修改都有可重复的结果,后面才能判断某个现象到底来自代码、环境还是执行时机。
6.3 正常页面也是排查依据
个人中心能打开,说明公共启动链路没有整体失效;入金页能够进入请求拦截器,说明请求日志不同可能只是执行时机不同;UNO 页面能打开,则把差异缩小到导航 Host 和测高逻辑。
排查失败页面时,正常页面不是无关信息。把两类页面放在一起比较,找出共同条件和不同条件,往往比一直盯着失败日志更容易缩小范围。
6.4 每次只验证一个变量
排查过程中出现过多个看起来合理的方向:Bridge 注册太晚、callback 丢失、loader 请求时机不同、requestAnimationFrame 不兼容、导航 SDK 存在问题。
这些判断都能解释一部分现象,却无法解释所有页面的表现。逐步移除和恢复功能,每次只改变一个条件,最后才把范围缩小到 useNavHeightRem 中的 MutationObserver。
6.5 用实验结果确认结论
单条日志只能提供方向,能够稳定控制问题出现和消失的变量才更接近根因。这次在多个失败页面和 Android 13 设备上反复验证后,MutationObserver 的启用状态始终决定页面是否卡住,最终结论也由此确定。
相比继续推测某个 callback 为什么没有执行,这种可重复的对照结果更有说服力。
7. 总结
这次问题与接口、网络、Token、React Router loader 或 Bridge 方法本身没有直接关系。触发点是项目对根样式的 MutationObserver 与沉浸式导航 SDK 的动态测高逻辑发生高频反馈。Android 取消根样式监听,改为直接引用 SDK CSS 变量后,页面恢复正常。
整个排查过程也提醒了我,最后一条日志只能提供边界,不能直接代替根因。遇到无法稳定复现的问题时,先建立观测能力,再用正常页面做对照,每次只改变一个条件,结果通常会比继续猜测更接近答案。
喜欢这篇文章嘛,觉得文章不错的话,奖励奖励我!


评论