首页 / 资讯中心 / 文章详情

iOS原生与H5交互全链路:通道选型、JSBridge封装与避坑

iOS原生与H5交互全链路:通道选型、JSBridge封装与避坑 ★ FEATURED ARTICLE
做过好几个混合开发的项目之后我对“交互”这两个字的理解是一步步被现实教育出来的。最早我以为 iOS 原生与 H5 交互就是给 WebView 塞一个方法、页面里调一下回调就完事后来发现真正在线上出问题的从来不是“能不能调通”而是调用超时了怎么办、参数大了被截断怎么办、页面刷新之后桥断了怎么办、内存一路涨到系统把进程干掉怎么办。所以这篇我想把 iOS 原生与 H5 交互方法这件事从头到尾捋一遍通道有哪几条、每条适合什么场景、容器怎么配、桥怎么封、坑在哪里、线上怎么排查。适合刚接手混合页面联调的同学也适合已经写过几版 JSBridge、但总觉得哪里不踏实的同学我会尽量把“为什么这么设计”讲清楚而不是只丢一段能跑的代码。1. 先看清通道原生跟 H5 到底有哪几种说话方式1.1 三条主通道与一条必须澄清的误区在现在的系统 WebView 组件里原生与页面之间能走的路其实就三条理解这三条基本就不会被各种“桥”的框架绕晕。第一条是URL Scheme 拦截。页面里改location.href或者插一个隐藏 iframe 指向一个自定义协议的地址原生在导航代理里拦下来解析 query 拿到参数。这条路最老、最通用缺点是它是单向的、有长度限制而且会打断页面正常的导航栈安卓那边还能用iOS 上频繁触发容易出现“跳一下”的闪烁或者被当成真实导航处理。第二条是脚本消息处理器也就是WKScriptMessageHandler那一套。页面调用window.webkit.messageHandlers.xxx.postMessage(payload)原生在代理回调里收到一个结构化对象。这是目前最推荐的主通道单向、异步、支持结构化数据、不打断导航还能通过WKScriptMessageHandlerWithReplyiOS 14 起让页面直接拿到返回值。第三条是原生执行脚本evaluateJavaScript或callAsyncJavaScript负责把结果送回页面。它跟第二条是配对关系一个负责上行、一个负责下行合起来才是完整的双向通道。这里必须澄清一个流传很广的误区很多人还惦记着用JavaScriptCore的JSContext去跟WKWebView里的页面做对象互调觉得那样能像老组件一样直接拿到 JS 对象。行不通。WKWebView的页面跑在独立进程里原生拿不到它的 JS 执行上下文JSContext那套只能在原生自己创建的 JS 虚拟机里用跟页面里的window不是同一个世界。这个认知如果一开始没纠正后面所有设计都会走偏。1.2 选型判断什么场景走哪条路把场景摊开看选择其实很清晰。我一般按三个维度判断方向、数据量、是否需要同步返回。场景推荐通道理由页面通知原生打开相册、扫码、支付消息处理器单向、参数结构化、无长度焦虑页面需要原生返回设备信息、登录态消息处理器 Reply 或回调配对需要返回值走异步回调最稳原生主动通知页面刷新、登录失效执行脚本下行唯一选择老项目页面已上线只加一个简单埋点URL Scheme改动最小页面侧不用引入桥传输图片、文件消息处理器 分片或临时文件路径走 URL 必被截断每秒多次的滚动位置同步消息处理器 节流合并频繁执行脚本会拖慢主线程需要特别说一句URL Scheme 那条路不要当成主力。它的参数要过 URL 编码中文、特殊符号、长 JSON 都容易出事URL 长度在实践里超过两千字符就有被截断的风险而且页面侧一旦写错协议名原生那边完全无感知联调时最难查。我现在的做法是新项目一律只留消息处理器这一条主通道URL Scheme 仅作为极少数历史页面的兼容兜底并且统一在一个路由解析器里收口方便打日志。2. 容器打底WebView 的初始化与注入时机2.1 配置对象里那几个必须改的默认值容器初始化这一步决定了后面八成的稳定性。很多人直接把WKWebView用默认配置创建出来跑起来发现页面里的localStorage不持久、调试看不到日志、注入的脚本时有时无其实都是配置没做。let config WKWebViewConfiguration() // 1) 消息处理器注册最好在容器创建时就一次性注册完 let controller WKUserContentController() controller.add(WeakScriptMessageProxy(target: self), name: appBridge) config.userContentController controller // 2) 页面偏好 let prefs WKPreferences() prefs.javaScriptEnabled true prefs.javaScriptCanOpenWindowsAutomatically false config.preferences prefs // 3) 内联播放避免视频全屏强制接管 config.allowsInlineMediaPlayback true // 4) UA 追加标识方便前端判断容器环境 config.applicationNameForUserAgent MyApp/1.0 let webView WKWebView(frame: .zero, configuration: config)javaScriptCanOpenWindowsAutomatically建议关掉不然页面里的window.open会莫名其妙地在你不知情的时候触发新窗口逻辑混合页面里这类弹窗十有八九是广告或第三方 SDK 带来的关掉能省不少事。applicationNameForUserAgent这个字段我强烈建议加上前端用它做环境判断比用 UA 字符串匹配稳得多改天容器版本升级、要下线老接口靠这个标识就能平滑过渡。还有一点消息处理器要一次性注册完不要在页面加载过程中动态增删。我见过有代码在didFinish里removeScriptMessageHandler再add结果页面里正在飞的消息全部丢失表现就是“偶尔不回调”查半天以为网络问题。2.2 原生脚本注入的三种写法与时机差异往页面里注入脚本写法有三种时机不一样用途也不一样。第一种是WKUserScript通过userContentController.addUserScript加进去可以在atDocumentStart或atDocumentEnd执行。这条路适合注入桥的初始化代码——页面自己的 JS 还没跑起来之前window.appBridge就已经存在了前端同学不用等 DOM 就绪就能调。这是我目前唯一推荐的桥初始化方式。let source window.appBridge { call: function(method, params, callbackId) { window.webkit.messageHandlers.appBridge.postMessage({ method: method, params: params || {}, callbackId: callbackId }); } }; let script WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: true) config.userContentController.addUserScript(script)forMainFrameOnly参数很关键。如果页面里有 iframe设成false意味着每个子框架都会注入一份某些第三方广告框架会因此拿到你的桥对象理论上能被用来探测能力。除了明确需要 iframe 通信的场景我一般一律设true。第二种是evaluateJavaScript运行时随用随调主要用来做下行回调和临时执行。它必须在主线程调用回调也在主线程。注意它没有返回值同步获取的能力所有结果都得走 completion 闭包。第三种是callAsyncJavaScriptiOS 14 起优势是能直接传参数对象不用自己拼字符串做 JSON 转义写起来安全得多webView.callAsyncJavaScript( return window.appBridge.receive(callbackId, payload), arguments: [callbackId: cid, payload: dict], in: nil, in: .page ) { result in switch result { case .success(let value): print(结果, value ?? nil) case .failure(let error): print(执行失败, error) } }如果你的最低支持版本能到 14下行回调优先用callAsyncJavaScript省掉一大堆手工拼接字符串带来的转义 bug。至于更老的版本老老实实JSONSerialization把字典序列化成字符串、再拼进模板里拼接前记得做一次转义处理别把引号里的内容直接塞进去。2.3 生命周期与白屏兜底混合页面最让人心跳加速的一刻是用户从后台切回来页面白了一大片。原因通常是页面进程被系统回收了。WKWebView有一个webViewWebContentProcessDidTerminate代理回调专门通知这件事。func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { // 记录现场再决定重载 webView.reload() }我不建议一上来就无脑reload因为用户可能正在填表单重载等于把输入全丢了。比较稳的做法是在这个回调里先把“页面已崩”标记打到日志里然后根据业务重要性决定——关键页面支付、签约保留快照后重载普通页面直接重载并在顶部弹一个轻提示。另外这个回调在真机上出现的频率比模拟器高得多内存吃紧的时候特别容易触发这也反过来提醒我们别在原生侧攒太多大对象。3. 把双向通道跑通从页面调原生到原生回页面3.1 上行通道消息处理器的注册与参数约定上行通道的注册分两步往WKUserContentController上挂处理器然后实现WKScriptMessageHandler的didReceive方法。关键在于回调一定在主线程所以任何耗时操作都得自己派发到后台队列做完再回主线程回包。参数约定这块我踩过的最大的坑是页面传过来的body类型是不确定的。可能是一个字符串、可能是一个字典、甚至可能是一个数字。如果直接当成字典强转一旦前端传错类型原生侧就崩了。所以入口处必须做一次类型收敛func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name appBridge else { return } guard let body message.body as? [String: Any] else { // 前端传错格式记日志直接忽略千万别崩 return } let method body[method] as? String ?? let params body[params] as? [String: Any] ?? [:] let callbackId body[callbackId] as? String ?? DispatchQueue.global(qos: .userInitiated).async { let result BridgeRouter.handle(method: method, params: params) DispatchQueue.main.async { self.reply(callbackId: callbackId, payload: result) } } }method的命名我建议直接用点分层级比如device.camera.open、user.info.get比一个个扁平的名字好维护也方便按前缀做权限白名单。回调 ID 用时间戳加随机串生成不要用递增数字——页面刷新之后计数器重置容易跟上一轮的 ID 撞车回调打到错误的地方去。3.2 下行通道回调配对与超时保护下行这一步最容易埋雷。页面调用之后原生做了事、回了包但页面那边接收函数如果没挂上或者回调 ID 对不上这次调用就凭空消失了前端那边只能靠setTimeout猜。我的做法是在页面侧维护一个回调表约好固定入口名原生只管往里塞(function () { const pending new Map(); window.appBridgeReady true; window.nativeCallback function (callbackId, ok, payload) { const item pending.get(callbackId); if (!item) return; pending.delete(callbackId); clearTimeout(item.timer); ok ? item.resolve(payload) : item.reject(payload); }; function call(method, params, timeout) { return new Promise((resolve, reject) { const callbackId Date.now() _ Math.random().toString(36).slice(2); const timer setTimeout(() { pending.delete(callbackId); reject(new Error(bridge_timeout: method)); }, timeout || 8000); pending.set(callbackId, { resolve, reject, timer }); window.webkit.messageHandlers.appBridge.postMessage({ method, params, callbackId }); }); } window.appBridge { call: call }; })();这段代码里有三个细节值得强调。第一超时必须有不然原生侧某个分支漏了回包页面会永远挂着等待状态用户看到的是点了没反应。第二pending表要清理回调回来就删、超时就删否则页面开着一整天这张表会越来越长。第三入口函数挂载要幂等页面路由切换时如果重复执行注入代码容易把已有回调表覆盖掉这时候可以加一层判断已经存在就跳过。3.3 一套能落地的桥骨架代码把上面两半合起来就是一套能直接用的结构。原生侧的职责其实就三件事收消息、路由分发、回包。final class BridgeRouter { static func handle(method: String, params: [String: Any]) - (Bool, [String: Any]) { guard method.hasPrefix(device.) || method.hasPrefix(user.) else { return (false, [code: 403, message: method_not_allowed]) } switch method { case device.info.get: let info: [String: Any] [ os: ios, appVersion: Bundle.main.infoDictionary?[CFBundleShortVersionString] as? String ?? - ] return (true, [code: 0, data: info]) case user.login.state: return (true, [code: 0, data: [loggedIn: TokenStore.isValid]]) default: return (false, [code: 404, message: method_not_found]) } } }回包格式我强烈建议统一成{code, message, data}三件套跟常见的接口风格对齐前端处理起来不用写两套解包逻辑。code别偷懒用true/false表示后面要区分“未登录”和“权限不足”这种不同错误码时布尔值就不够用了。还有一条我踩过的教训方法名列表要有一份单一来源。原生和前端各维护一份对照表迟早会对不上。我现在的做法是原生侧维护一份枚举或者一个 switch同时导出一份清单放进前端文档里每次加方法两边同时改联调的时候对着清单一个个打勾比口头对接口可靠得多。3.4 大参数与二进制数据的传输取舍postMessage虽然能传结构化数据但它本质上是跨进程序列化遇到大对象性能会明显下降。我实测传一个几百 KB 的 JSON页面侧会有可见的卡顿。传输图片、文件这类内容我的经验是别走消息体。正确姿势是原生先把文件写到沙盒或者缓存目录把路径作为参数传过去页面要展示就自己发请求拉或者原生直接 base64 之后放进img的src。如果一定要走消息体注意 base64 会让体积膨胀大概三分之一并且大字符串在跨进程传递时会占用双份内存低端机很容易触发内存警告。分片传输是另一种选择把大数据切成若干块、每块带一个序号发过去、页面侧拼接。这套方案我一般只在确有必要时才用因为它会显著增加两边的状态复杂度——分片丢了要重传、顺序乱了要缓冲、中途页面刷新要清理残余状态。能用临时文件路径解决的事情尽量别上分片。4. 踩坑最多的地方内存、Cookie、存储与兼容4.1 强引用循环那个让人半夜爬起来的内存增长只要写WKScriptMessageHandler就绕不开循环引用。WKUserContentController会强引用你注册进去的处理器对象如果你的处理器是页面控制器自己而页面控制器又持有webViewwebView的配置里又持有userContentController一条闭环就成了页面退不掉内存只涨不降。我见过一个现场特别典型的例子某列表页里嵌套了一个介绍页用的WKWebView用户来回进出二十几次之后内存从两百多兆一路涨到八九百兆然后被系统杀掉。查了半天就是处理器注册用了selfdeinit从来没被打印过。解决办法很朴素中间加一层弱引用代理final class WeakScriptMessageProxy: NSObject, WKScriptMessageHandler { private weak var target: WKScriptMessageHandler? init(target: WKScriptMessageHandler) { self.target target } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { target?.userContentController(userContentController, didReceive: message) } }注册时用WeakScriptMessageProxy(target: self)同时记得在页面销毁时把处理器移除掉removeScriptMessageHandler(forName:)该调就调。另外WKUserScript里注入的代码如果引用了页面对象也可能造成页面侧的泄漏注入脚本尽量别闭包捕获大对象。一条自查方法给页面控制器加一个deinit打日志业务跑通之后反复进出同一个页面二十次日志不打印就说明有泄漏比用工具抓半天都直接。4.2 Cookie 与登录态同步登录态是混合页面第二高频的坑。原生登录完之后页面里还是未登录状态用户点什么都跳登录页。原因在于 cookie 的存储是分开的。原生侧写的 cookie 默认进的是系统共享存储页面侧读的是WKWebsiteDataStore里的存储两边不同步。iOS 11 之后有WKHTTPCookieStore可以操作let store webView.configuration.websiteDataStore.httpCookieStore let cookie HTTPCookie(properties: [ .domain: your.domain, .path: /, .name: token, .value: tokenValue, .secure: TRUE, .expires: Date().addingTimeInterval(7 * 24 * 3600) ])! store.setCookie(cookie) { // 设置完成后再刷新或继续导航否则可能读到旧状态 }注意这个setCookie是异步的回调里再触发页面加载不然页面先跑起来、cookie 后写进去读到的还是旧的。还有一个容易忽略的点如果你的域名有多个子域domain字段要写成带前导点的形式才能覆盖子域写错了就在部分页面生效、部分页面不生效非常难查。4.3 存储、UA 与跨域那些事localStorage的坑在于清理时机。页面数据存储跟着WKWebsiteDataStore走如果原生侧在退出登录时清空了所有站点数据用户下次进来发现页面自己的缓存、草稿全没了体验上就是“这个页面有 bug”。所以清理要精确只清 cookie 和需要清的域别图省事一把梭。UA 这块除了前面说的applicationNameForUserAgent还要注意页面里的navigator.userAgent在不同系统版本上格式会有细微差别前端如果用字符串切片匹配版本号很容易在某个系统版本上失效。我建议约好一个自定义标识比如 UA 末尾带上MyApp/1.0前端用正则抓这一段比抓系统版本稳。跨域问题在混合页面里表现得更隐蔽因为file://加载和http(s)://加载的规则不一样。如果页面是本地包fetch到远程接口会受同源策略限制。这块我一般让页面统一走远程域名加载本地包只作为离线兜底减少一类问题。5. 性能与安全让通道跑得快也跑得稳5.1 通信频次、批量与节流消息通道不是免费的。每次postMessage都是一次跨进程序列化每次evaluateJavaScript都是一次跨进程调用。滚动监听里每秒调几十次主线程就开始掉帧了。我的处理原则是三条上行合并页面侧把短时间内的多次调用攒起来比如滚动位置每 100 毫秒只上报一次用节流包一层。下行批量原生要通知页面多个状态变更时别一条条执行脚本拼成一个对象一次性发过去页面侧再分发。首屏预置页面加载就需要的环境信息版本号、是否登录、主题色在atDocumentStart阶段就注入到全局变量里别等页面渲染完再来一轮往返。function throttle(fn, wait) { let last 0, timer null; return function (...args) { const now Date.now(); const remain wait - (now - last); if (remain 0) { clearTimeout(timer); timer null; last now; fn.apply(this, args); } else if (!timer) { timer setTimeout(() { last Date.now(); timer null; fn.apply(this, args); }, remain); } }; }还有一点evaluateJavaScript的回调如果里面对页面做了大范围 DOM 操作会阻塞渲染反过来影响调用它的原生侧体验。所以下行执行尽量做小粒度操作别一次执行几百行的脚本字符串。5.2 白名单与参数校验桥一旦开放出去就等于给页面开了一扇通往原生能力的门。页面里如果引了第三方脚本理论上也能调你的桥。所以方法白名单这件事不能省。我在路由分发那一层做三件事方法名前缀校验只放行登记过的模块、参数类型校验每个方法声明自己的参数 schema进来先过一遍、敏感操作二次确认支付、通讯录这类必须原生弹确认不能页面说调就调。struct BridgeParamRule { let name: String let type: Any.Type let required: Bool } static func validate(params: [String: Any], rules: [BridgeParamRule]) - Bool { for rule in rules { let value params[rule.name] if rule.required value nil { return false } if let v value, !(type(of: v) rule.type) { return false } } return true }校验失败就直接回错误码别走默认值兜底。默认值兜底看起来友好实际上会让前端的错误传参一直藏着等到某个必填参数缺失导致业务异常时才发现。6. 排查手册真机调试与常见问题速查6.1 调试链路怎么搭混合页面的调试最有效的方式还是真机连开发工具走系统的网页检查器。这一步能省掉八成的瞎猜。需要注意的是调试开关在发布包里必须关掉只在测试包打开不然线上包也能被检查存在风险。如果检查器连不上我一般按这个顺序排查数据线是不是只能充电的线、设备有没有信任这台机器、页面是不是在独立进程里正在崩溃、以及配置里有没有禁用检查。页面侧的日志我习惯在atDocumentStart阶段注入一段重写console.log的脚本把日志同时往消息通道发一份这样原生控制台也能看到页面日志排查时序问题时特别有用——原生日志和页面日志在同一个时间轴上谁先谁后一目了然。(function () { const raw console.log.bind(console); console.log function (...args) { raw(...args); try { window.webkit.messageHandlers.appBridge.postMessage({ method: debug.log, params: { text: args.map(String).join( ) } }); } catch (e) {} }; })();6.2 问题速查表与我自己踩过的坑现象大概率原因处理方式页面调了没反应处理器没注册、名字拼错检查add(_:name:)与页面messageHandlers名字是否一致偶发不回调处理器被动态移除、页面刷新后桥丢失注册一次性完成刷新后重新注入内存只涨不降处理器强引用闭环用弱引用代理销毁时移除处理器页面登录态丢失cookie 未同步或异步写入时序错用WKHTTPCookieStore写入回调后再加载页面切后台回来白屏页面进程被回收在终止回调里记录并重载大参数传不过去走 URL 通道被截断改用消息通道或传临时文件路径页面加载慢首屏信息靠往返获取用atDocumentStart注入环境变量注入脚本时有时无注入时机晚于页面执行用WKUserScriptatDocumentStart几条我自己踩出来的经验列在这里给后来人省点时间。一是别在didFinish里做耗时操作那个回调在主线程做重活会让页面卡住一段白屏时间。二是消息体里的null要小心前端传null过来as? [String: Any]会失败入口判断要能容错。三是页面里window.webkit在某些极早期执行的脚本里可能还不存在前端如果需要在最早期调桥得先判断对象是否存在或者用原生注入的全局函数兜一层。四是测试包和发布包的 UA 标识要区分开线上出问题时靠 UA 就能判断用户用的是哪个包比翻日志快得多。另外提一句关于桥的版本演进方法一旦发布出去就不要改语义了。参数想扩展就加可选字段方法想废弃就保留至少两个版本的兼容期页面是跟着服务端下发的用户手里的包版本跨度可能大到你想不到。我见过一个改参数顺序的操作导致老版本包里的页面全废那种事故的修复成本远高于当初多写三行兼容代码。我个人在混合页面这块的体会是桥写得稳不稳跟用了多花哨的框架关系不大关键在几个朴素的地方入口的容错、回调的超时、引用的闭环、方法的白名单。这四件事做到了页面跟原生之间的绝大多数“玄学问题”都会消失剩下的无非就是联调时多打几行日志的事。
阅读完成 · 觉得有帮助?
咨询建站