现在绝大多数做 Hybrid 的团队都撞上了同一堵墙:H5 那一坨资产好好的,原生壳却突然装不上了。纯血鸿蒙把 AOSP 兼容层拿掉之后,只认自家的 HAP 包,以前那种"套个 WebView 壳、桥接几个原生能力就能上架"的做法直接断档。我前后帮三个团队做过这类迁移,最慢的一个卡了六周,最快的那个两周出了可演示版本,差距不在技术难度,而在于有没有先把"到底断在哪一环"想清楚。这篇就把 Hybrid 应用迁移到纯血鸿蒙的完整路径拆开讲:哪些层必须重写、JSBridge 协议怎么重新设计、原生插件怎么逐个替换、首屏白屏和软键盘这些老问题在新容器上又会以什么形式冒出来。适合手里有一份现成 H5 资产、需要在鸿蒙上快速跑通并上架的前端和客户端同学参考,也适合技术负责人用来评估排期。
1. 纯血鸿蒙把"壳"打碎了:Hybrid 应用到底断在哪一环
先把结论摆在前头:Hybrid 应用迁移到鸿蒙,真正要重写的是壳和桥,不是 H5。这个判断如果一开始就搞反,团队会把大量时间浪费在改页面样式上,结果壳跑不起来,一切都是白搭。我见过一个团队上来就组织前端重构 H5,改了两周 CSS,最后发现容器层根本没打通,连首页都加载不出来。
1.1 从"能跑 APK"到"只认 HAP"到底变了什么
旧版本的系统里保留了一套 AOSP 兼容层,安卓的 APK 包可以正常安装、正常调用系统服务,所以老的 Hybrid 壳——不管是用开源容器封装的,还是自研的——都能原样跑。纯血鸿蒙把这层兼容拿掉了,应用必须是 HAP 包,Ability 是基本运行单元,UI 用 ArkUI 的声明式语法描述,逻辑用 ArkTS 写。这意味着原来那个"用 Java/Kotlin 写壳、里面塞一个 WebView"的结构,代码层面一行都用不上。
但这里有个容易误解的地方:很多人以为"Web 页面是跨平台的,随便找个浏览器就能跑",所以觉得 H5 天然兼容。这个想法只对了一半。H5 页面本身确实能跑,只要有 Web 渲染引擎。问题在于 Hybrid 应用的 H5 从来不是独立运行的,它依赖壳提供的一整套原生能力:登录态注入、相机扫码、定位、文件上传、支付回调、推送唤起、返回键拦截、状态栏控制。这些东西在新系统上全都没有现成实现,必须重新对接。
更麻烦的是通信机制变了。旧方案里常见的同步桥接方式,在新容器上是异步的,这个差异会在后面第 3 章详细展开,但你现在就要知道:凡是"同步取值"的桥接 API,全部要改成 Promise。这是工作量最大的一块隐性成本。
1.2 Hybrid 的五层结构,逐层拆开看改造量
我习惯把 Hybrid 应用拆成五层来看,这样评估工作量会清晰很多。下面这张表是我在几个项目里实际统计出来的改造比例,个体差异会有,但量级差不多:
| 层级 | 职责 | 旧实现 | 新系统对应实现 | 改造量 |
|---|---|---|---|---|
| 展示层 | H5 页面与静态资源 | 打包进 assets | 放进 rawfile 或沙箱目录 | 低,改路径即可 |
| 容器层 | Web 渲染与页面加载 | 系统 WebView | ArkWeb 的 Web 组件 | 中,配置项要重配 |
| 桥接层 | JS 与原生双向通信 | 注入对象 + 同步返回 | 注入代理对象 + 异步回调 | 高,协议要重设计 |
| 能力层 | 相机、定位、文件、推送、支付 | 各平台插件 | 系统 Kit 逐个对接 | 高,取决于插件数量 |
| 宿主层 | 生命周期、路由、权限、包管理 | Activity/Application | Ability + Navigation | 中高,需要重新设计 |
这张表里最容易低估的是最后一行"宿主层"。很多团队觉得壳都重写了,宿主层能有多难?实际上路由栈、返回键处理、权限申请时序、前后台切换时的 Web 保活,这些琐碎逻辑加起来的调试时间往往超过桥接层。特别是返回键:用户按一下系统返回,你是让 H5 的 history 回退,还是让原生页面栈回退?这个判断逻辑在混合场景下非常容易出错,后面第 4 章有专门一节讲。
1.3 先做一次迁移体检:把风险分成三档
我强烈建议在动手写代码之前,先花一天时间做一次"迁移体检"。方法很简单:把现有 Hybrid 应用的所有原生能力调用列成一张清单,逐个标注它在鸿蒙上有没有对应的系统能力。
具体怎么标?我一般分三档。P0 是壳本身跑不起来的问题,包括容器搭建、桥接骨架、离线包加载,这三件事不通,其他都别谈。P1 是核心业务链路依赖的能力,比如登录、支付、下单、拍照上传,这些缺失会直接导致核心流程断掉。P2 是体验和边缘能力,比如分享到第三方、扫码历史、推送角标、埋点上报。
分档的意义在于排期。P0 通常一到两周能打通,P1 取决于插件数量,P2 可以放到灰度之后补。我见过一个项目把所有能力一视同仁地排在同一个迭代里,结果 P2 的某个第三方分享组件没有对应实现,硬生生拖住了整个发版节奏,这种损失完全可以通过分档避免。
提示:体检阶段就要把所有"同步取值"的桥接 API 标红。这类 API 在迁移后必须改成异步,前端调用点要跟着改,是隐性工作量的大头。
2. 环境与工程骨架:把 WebView 容器重新搭起来
环境这一步没什么花活,但有个坑我要提前说:不要用模拟器验证 Web 容器的渲染效果。ArkWeb 在模拟器上的表现和真机差异比想象中大,尤其在字体渲染、软键盘弹出时机、视频硬解这几块。我在某个项目里模拟器上一切正常,真机上一进页面就白屏,查了半天是缓存目录权限的问题。所以从第一天起就接真机调试。
2.1 工具链版本怎么选:稳定优先于新
开发工具用官方的 IDE,SDK 版本我建议选当前主流的稳定版,不要追最新的预览版。原因很实在:Web 组件属于迭代比较快的模块,预览版里某些回调的触发时机可能还在调整,你按预览版的行为写完代码,升到正式版又要改一遍。
工程配置上有几个点必须提前确认。第一是编译 SDK 版本,它决定了你能用哪些 API,也决定了上架时审核的基础要求,这个要和发布计划对齐。第二是签名配置,调试签名和发布签名要分开管理,别把调试证书打进发布包。第三是设备类型声明,如果你的 Hybrid 应用要同时覆盖手机和平板,deviceTypes里要都写上,否则在平板设备上搜不到。
ArkTS 的严格模式也是个需要适应的东西。它禁用了不少动态特性,比如运行时给对象随意加属性、any类型到处飞。这套限制对大型项目是好事,但对于从 JS 转过来的同学,前两天写起来会比较别扭。我的建议是桥接层的数据结构全部显式定义接口,宁可多写几行类型声明,也别用as any糊过去,否则后面调试时类型报错会把你淹没。
2.2 模块怎么划分:把容器和业务隔开
工程结构我建议一开始就拆开,别全部堆在入口模块里。一个比较实用的划分是三层:
entry:应用入口,只放 Ability、页面路由表和全局初始化webcontainer:Web 组件封装、桥接协议、生命周期管理,打成 HAR 包plugins:相机、定位、文件、推送等能力插件,每个插件一个目录,也打成 HAR
这么拆的好处是可复用。后面如果要做多入口(比如一个应用里有商城模块和工具模块),容器层直接引就行,不用复制粘贴。而且插件独立成包之后,某个插件有问题可以单独降级或者延迟初始化,不会拖累整个壳的启动速度。
权限声明要注意,鸿蒙的权限分两类:system_grant类型的在配置文件里声明就生效,user_grant类型的除了声明,还必须在运行时向用户弹窗申请。相机、麦克风、位置、通讯录都属于后者。配置文件里写在模块的requestPermissions字段下,每个权限要带上申请理由,这个理由会展示给用户,别写得太敷衍,审核的时候是会被挑的。
2.3 让 rawfile 里的 H5 跑起来的最小闭环
容器层的第一行代码,我建议先做到"能把本地 H5 加载出来",这一步通了再谈桥接。核心配置项看起来多,但每一个都有明确用途,我把最关键的几个列出来:
import { webview } from '@kit.ArkWeb'; @Entry @Component struct HybridPage { controller: webview.WebviewController = new webview.WebviewController(); build() { Column() { Web({ src: $rawfile('www/index.html'), controller: this.controller }) .javaScriptAccess(true) // 必须开,否则 H5 逻辑全废 .domStorageAccess(true) // localStorage / sessionStorage 依赖它 .fileAccess(true) // 允许访问文件协议,rawfile 加载需要 .imageAccess(true) .onlineImageAccess(true) .mixedMode(MixedMode.All) // 允许 https 页面加载 http 资源 .zoomAccess(false) // 移动端禁用双指缩放,体验更接近原生 .cacheMode(CacheMode.Default) } .width('100%') .height('100%') } }fileAccess这一项特别容易漏。它默认是关闭的,关着的时候用$rawfile加载的页面里,相对路径引用的 JS 和 CSS 会加载失败,表现就是页面结构出来了但样式和交互全没有。我第一次踩这个坑时以为是打包路径错了,查了两个小时才发现是这个开关。
mixedMode也要提一句。很多老项目的 H5 里还混着 http 的图片和接口,如果容器不允许混合加载,这些资源会静默失败。短期内为了平滑迁移,先放开,但要在迁移清单里标记成待整改项,后面统一换成 https。
还有一个必须提前想清楚的问题:rawfile 是只读的。打进包里的 H5 想更新,只能发新版本应用。这对高频迭代的业务是致命的。标准做法是"内置兜底包 + 沙箱离线包":rawfile 里放一个基础版本保证首次启动能用,同时从服务端拉取离线包解压到应用沙箱目录,容器优先加载沙箱里的版本,加载失败再回退到 rawfile。这个降级链路一定要写,否则离线包损坏的时候用户看到的就是永久白屏。
3. JSBridge 重写:双向通信的协议设计与落地
桥接层是整个迁移里最需要动脑子的部分。它不难,但细节多,而且一旦设计得不好,后面每加一个能力都要在三个地方改代码,维护成本会指数上升。
3.1 为什么不能照搬旧桥:注入机制的三个差异
旧方案里,安卓侧通常是往 WebView 注入一个 Java 对象,JS 调用它的方法时能拿到同步返回值;iOS 侧走消息回调,是异步的。很多团队为了抹平差异,在 JS 侧包了一层,但底层还是默认"能同步取值"。
新容器上的注入机制统一成了异步:原生对象暴露的方法被调用时,拿不到返回值,只能通过再执行一段 JS 把结果传回去。这个变化带来三个连锁反应。
第一,所有同步 API 必须 Promise 化。像getUserInfo()、getToken()、getDeviceId()这类在旧代码里直接const token = bridge.getToken()的写法,全部要改成await bridge.call('getToken')。调用点的改动是机械的,但量可能很大,得提前扫一遍。
第二,注入时机有讲究。代理对象需要在页面加载过程中完成注册,如果注册得晚,页面里的脚本执行时找不到对象,直接报undefined。稳妥的做法是在 Web 组件初始化时就配置好代理,同时配合页面加载完成的回调做一次"就绪"通知。
第三,方法名要显式声明。注入时得把允许 JS 调用的方法名列成清单,没在清单里的调不通。这是好事,等于白名单,但对习惯反射式调用的团队来说需要适应。
3.2 一套能直接抄的 Promise 化通信协议
我在几个项目里用的是同一套协议,结构简单、扩展方便。核心思路是:JS 侧统一走一个入口函数,内部生成自增的回调 ID,把待返回的 Promise 存进 Map,原生处理完按 ID 回填。
JS 侧大概长这样:
(function () { const pending = new Map(); let seq = 0; function call(method, params, timeout) { const wait = timeout || 15000; return new Promise(function (resolve, reject) { const id = ++seq; const timer = setTimeout(function () { pending.delete(id); reject({ code: 'TIMEOUT', message: 'native call timeout: ' + method }); }, wait); pending.set(id, { resolve: resolve, reject: reject, timer: timer }); window.__nativeBridge.postMessage( JSON.stringify({ id: id, method: method, params: params || {} }) ); }); } window.__bridge = { call: call, onCallback: function (payload) { const res = JSON.parse(payload); const item = pending.get(res.id); if (!item) return; clearTimeout(item.timer); pending.delete(res.id); if (res.code === 0) { item.resolve(res.data); } else { item.reject({ code: res.code, message: res.message }); } } }; })();原生侧的骨架:
import { webview } from '@kit.ArkWeb'; interface BridgeRequest { id: number; method: string; params: Record<string, Object>; } class NativeBridge { private controller: webview.WebviewController; private handlers: Map<string, (p: Record<string, Object>) => Promise<Object>> = new Map(); constructor(controller: webview.WebviewController) { this.controller = controller; this.registerAll(); } postMessage(raw: string): void { const req = JSON.parse(raw) as BridgeRequest; const handler = this.handlers.get(req.method); if (!handler) { this.callback(req.id, 404, null, 'handler not found: ' + req.method); return; } handler(req.params) .then((data) => this.callback(req.id, 0, data, '')) .catch((e: Error) => this.callback(req.id, 500, null, e.message)); } private callback(id: number, code: number, data: Object | null, msg: string): void { const payload = JSON.stringify({ id: id, code: code, data: data, message: msg }); // 注意:payload 里可能含单引号,直接拼进 JS 字符串会语法错误 const script = 'window.__bridge.onCallback(' + JSON.stringify(payload) + ')'; this.controller.runJavaScript(script); } }最后那行JSON.stringify(payload)是重点。很多人写成onCallback('${payload}'),结果 data 里一出现单引号或者换行,整个 JS 语句就语法错误,表现为"某些接口偶尔回调不回来",非常难查。用两次序列化把字符串安全地传进去,这个问题就没了。
3.3 原生主动调 JS:时机与内存两件事
除了 JS 发起的调用,还有反方向的:原生要通知 H5,比如推送到达、支付结果、扫描完成。这类调用有两个约束。
时机约束:runJavaScript必须在页面加载完成之后执行,在加载过程中调用会失败或者丢消息。我的做法是在页面加载结束的回调里置一个pageReady标记,把待发的消息先入队,就绪后统一冲刷一次。这个队列在页面刷新时要清空重来,因为刷新后 JS 侧的上下文全没了,老的会话已经失效。
内存约束:原生对象持有 Web 控制器引用时,如果页面已经销毁而对象还在,就会造成泄漏。解决办法是把控制器的生命周期和页面绑定,在页面销毁时清空持有的引用和回调表。ArkUI 里组件提供的aboutToDisappear就是个合适的清理时机。别小看这一点,一个频繁进出的页面,泄漏积累下来会导致内存持续增长,最终被系统查杀。
3.4 错误码和超时:别让前端"石沉大海"
桥接最让人抓狂的故障是"调了没反应"。为了避免这种情况,协议里必须内置三样东西:统一的错误码、明确的超时、可读的错误信息。
我定的错误码很简单,就几类:0成功;404方法未实现;403权限被拒;408超时;500内部异常。前端拿到非 0 的码就走降级逻辑——比如相机权限被拒,就引导用户手动上传图片。
超时时间按场景设,不要一刀切。读取本地数据这类操作 3 秒足够,涉及网络和用户交互的(比如拍照、支付)给 30 秒甚至不设超时,改为由原生主动回调。一刀切 10 秒的后果是用户在拍照界面停留久一点,Promise 就被 reject 了,然后相机的回调又回来了,前后状态对不上,页面直接错乱。
提示:桥接层一定要有一个"能力探测"接口。H5 启动时先问一次当前壳支持哪些方法,拿到列表后按需启用功能。老壳配新页面、新壳配老页面的情况在灰度期一定会出现,有探测机制就不会崩。
4. 能力插件逐个替换:相机、定位、存储、支付回调
能力层是体力活,但有几个地方容易踩坑,值得单独说。
4.1 权限申请的两段式流程与常见时序错误
鸿蒙的敏感权限必须在配置文件里声明,同时在运行时动态申请。很多团队只做了第一步,代码跑起来直接抛权限异常。正确的做法是在调用能力之前先查权限状态,未授权就发起申请,申请结果回来再决定是否继续。
时序错误是这里最常见的 bug。典型场景:用户点了"拍照",代码先申请权限,弹窗还没消失就急着打开相机,结果相机启动失败。正确逻辑必须写在申请的回调里,是个明确的链式结构:查状态 → 未授权则申请 → 授权成功才启动能力 → 授权失败则提示并降级。拍照、录音、定位这三类插件都要按这个模式来。
还有个细节:用户拒绝过一次之后,再次申请可能不弹窗了,系统会直接返回拒绝。这时候你要引导用户去设置页手动开权限,而不是傻乎乎地反复弹。判断方法是在拒绝结果里看是否还能再次申请,或者简单点,第二次拒绝后直接给提示。
4.2 localStorage 到底存在哪,哪些数据不能指望它
domStorageAccess打开之后,localStorage 和 sessionStorage 是可以用的,数据存在应用沙箱里。但有三点必须清楚:清理应用缓存会清掉它;卸载重装一定丢;不同 Web 组件实例之间不共享。
所以我的原则是:localStorage 只放临时状态和缓存,登录态、设备指纹、用户偏好这类数据一律走原生存储。原生存储用偏好设置那套 API,读写都在沙箱里,稳定性高得多。H5 启动时通过桥接同步一次,之后由原生负责刷新。
文件上传也是同类问题。H5 的<input type="file">在新容器里能唤起选择器,但如果业务要求"拍照上传",就需要原生相机拍完把文件路径回传。这里要处理的是沙箱路径和文件协议白名单,如果容器不允许访问某类路径,图片会加载不出来,表现是"上传成功但缩略图空白"。
4.3 返回键与混合路由栈:最容易混乱的一环
H5 有 history,原生有页面栈,两者要协调。我见过的最混乱的实现是:用户在某 H5 页面按返回,原生直接把整个 Web 容器关了,跳出应用。
合理的策略需要 H5 侧配合。做法是容器在收到返回事件时,先执行一段 JS 查询window.history.length或者一个自定义的"可回退"标记,如果 H5 内部还有历史,就执行history.back();如果没有,才交给原生处理页面栈。
页面内的跳转也要统一。H5 里的链接跳转如果在容器内打开,返回逻辑就归 H5 管;如果是新开原生页面(比如商品详情走原生实现),返回就归原生管。这种混合栈最容易出的问题是循环:A 页面跳 H5,H5 又调原生打开 A,返回一次回到 H5,再返回又回到 A,用户会被绕晕。解决方案是给每次跳转打上来源标记,返回时按来源类型决定退到哪。
4.4 下载、分享与系统能力的对接细节
下载文件这块,H5 常用的a标签下载在新容器里不一定生效。稳妥的做法是拦截下载动作,把 URL 通过桥接传给原生,由原生负责下载到沙箱目录,再调用系统的文件保存能力把文件落到用户可见的位置。
分享也是同理。分享要调系统分享面板,参数里带标题、描述、缩略图和链接。缩略图这块容易出问题,网络图要先下载到本地再传,直接传 URL 有些场景不生效。
支付回调的处理要特别小心。支付流程通常是:H5 发起 → 原生拉起支付 → 支付结果通过原生回传到 H5。问题在于支付过程中用户可能切后台、可能杀进程,回来的时候 Web 容器可能已经重建了。所以支付结果不能只靠一次回调传递,必须在原生侧持久化一份结果,H5 启动时主动查一次"有没有未处理的支付结果"。这套补偿机制做不做,直接决定了会不会出现"用户付了钱但订单没更新"的客诉。
5. 首屏与体验:Hybrid 在鸿蒙上最容易翻车的几个点
容器跑通、桥接打通之后,剩下的都是体验问题。这些问题不影响功能,但影响评分和用户留存。
5.1 白屏与首帧:把容器预热起来
白屏是 Hybrid 的通病,在新容器上同样存在,甚至更明显,因为首次初始化 Web 引擎的开销不小。
我的三板斧是:预创建、本地优先、骨架屏。预创建是指在应用启动或者上一个页面时就提前实例化 Web 控制器,等真正进入页面时直接复用,省掉初始化时间。本地优先是指首屏资源全部走本地离线包,不依赖网络,把首屏耗时从"网络 RTT + 渲染"压到"渲染"这一项。骨架屏是在容器就绪之前先显示一个静态的页面骨架,视觉上给用户反馈。
有一点值得提:ArkWeb 提供了预加载和预连接的能力,可以在合适的时机提前加载资源。具体怎么用要结合业务,别滥用,预加载太多会拖慢冷启动。
5.2 软键盘、沉浸式与安全区:移动端的经典老三样
软键盘顶起页面这个问题,在新容器里默认行为可能和你的预期不一样。有的场景输入框被键盘遮住,有的场景整个页面被顶上去之后回不来。解决思路是让容器接管键盘避让,同时在 H5 里做好视口适配:视口设置里加上安全区相关的属性,然后用环境变量给底部留出空间。
沉浸式状态栏和刘海屏适配也是同理。容器铺满全屏之后,H5 的顶部内容会被状态栏盖住。做法是原生侧算出状态栏高度,通过桥接或者注入全局变量的方式传给 H5,H5 在根节点上补 padding。别用固定的像素值写死,不同机型的高度不一样。
还有一个容易被忽略的:页面滚动到底部时的橡皮筋效果。H5 滚动和原生容器滚动叠加在一起,会出现"滚两下才动一下"的粘滞感。这个要把外层容器的滚动关掉,只让 H5 内部滚动,同时注意overscroll相关的行为配置。
5.3 Cookie、UA 与登录态同步
登录态在混合应用里是个老难题。H5 的登录态通常靠 Cookie,原生的靠 token。两套体系要同步,否则会出现"H5 显示已登录,原生接口报未授权"。
我的做法是以原生 token 为准,H5 每次发起请求前从桥接拿一次当前 token 放进请求头,不依赖 Cookie。这样做的代价是每个请求多一次桥接调用,但桥接是内存操作,开销可以忽略。
UA 也要加标识。在容器初始化时往 UA 里追加一个自定义标记,服务端和前端都能据此判断当前运行环境,做差异化逻辑。这个标记在灰度期特别有用,可以精确区分"老壳版本"和"新壳版本"。
Cookie 同步方面,如果确实有依赖 Cookie 的老接口,要确保容器配置里允许 Cookie 写入,并且在页面加载过程中做同步。这块的坑在于时序:Cookie 设置得太晚,页面里的首次请求就带不上。
6. 上线前的联调清单与灰度策略
写到这里,功能层面基本齐了。但从"能跑"到"能上",中间还差一轮系统性的自测和一个稳妥的灰度方案。
6.1 一份可以直接抄的自测表
下面这张表是我每个项目上线前必过一遍的,按优先级排的,前几项不过就不要提测。
| 检查项 | 关注点 | 常见失败表现 |
|---|---|---|
| 冷启动首屏 | 从点击图标到首屏可交互 | 白屏超过 2 秒 |
| 页面跳转返回 | H5 与原生混合栈 | 返回跳出应用或循环 |
| 相机与相册 | 权限、路径、图片方向 | 拍完不显示或图片旋转 |
| 定位 | 权限、精度、超时 | 一直加载不出结果 |
| 文件上传下载 | 沙箱路径、进度、失败重试 | 大文件卡死无反馈 |
| 登录态 | 前后台切换、超时续期 | 切后台回来自动退出 |
| 软键盘 | 输入框遮挡、页面位移 | 输入框被挡住看不见 |
| 弱网与断网 | 离线包加载、请求重试 | 断网后白屏不恢复 |
| 深色模式 | 背景色、图片、状态栏 | 文字看不见或闪白 |
| 桥接超时 | 超时后的降级与提示 | 按钮点了永远转圈 |
深色模式这一项很多人不重视,但新系统上系统级的深色模式切换很频繁。如果 H5 没有做适配,用户切一次系统主题,页面就变成黑底黑字。最省事的做法是容器层统一指定配色倾向,H5 侧先不做完整适配,至少保证不会出现不可读的对比度。
6.2 灰度怎么做才不会翻车
新壳上线,我建议分三步走。第一步是小范围灰度,比如 5% 的用户,重点看崩溃率和白屏率这两个指标。第二步扩到 30%,这时候关注业务转化数据有没有异常波动。第三步全量。
灰度期最需要保证的是壳和 H5 的版本兼容。因为 H5 可以独立更新,而壳的更新要走应用商店,两者的版本节奏对不上是常态。所以桥接协议必须向后兼容:新增方法不影响老版本,删除方法要先走一轮"标记废弃但保留实现"的过渡。H5 侧靠第 3 章提到的那套能力探测机制来做降级,探测到当前壳不支持某个方法就走备用路径。
回滚方案也要提前准备。壳的回滚只能靠发版,周期长,所以更现实的做法是:把出问题的功能通过服务端开关关掉,用 H5 远程配置控制,让用户降级到旧交互。这套开关机制在迁移项目里几乎是必备的。
最后再分享一个我在实际项目里摸出来的小技巧:把桥接层的每一次调用都打一条轻量日志,只在调试包和灰度包里开启,记录方法名、耗时和结果码。上线后如果用户反馈某个功能异常,翻日志能直接定位是哪个方法超时或者报错,比让用户复现快得多。这套日志在迁移期救过我三次,其中一次是某个机型上定位回调永远不返回,靠日志才发现是权限申请被系统静默拒绝了。