做过好几个混合开发的项目之后,我对“交互”这两个字的理解是一步步被现实教育出来的。最早我以为 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),原生在代理回调里收到一个结构化对象。这是目前最推荐的主通道:单向、异步、支持结构化数据、不打断导航,还能通过WKScriptMessageHandlerWithReply(iOS 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 闭包。
第三种是callAsyncJavaScript(iOS 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会强引用你注册进去的处理器对象,如果你的处理器是页面控制器自己,而页面控制器又持有webView,webView的配置里又持有userContentController,一条闭环就成了,页面退不掉,内存只涨不降。
我见过一个现场特别典型的例子:某列表页里嵌套了一个介绍页,用的WKWebView,用户来回进出二十几次之后,内存从两百多兆一路涨到八九百兆,然后被系统杀掉。查了半天,就是处理器注册用了self,deinit从来没被打印过。
解决办法很朴素,中间加一层弱引用代理:
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注入环境变量 |
| 注入脚本时有时无 | 注入时机晚于页面执行 | 用WKUserScript+atDocumentStart |
几条我自己踩出来的经验,列在这里给后来人省点时间。一是别在didFinish里做耗时操作,那个回调在主线程,做重活会让页面卡住一段白屏时间。二是消息体里的null要小心,前端传null过来,as? [String: Any]会失败,入口判断要能容错。三是页面里window.webkit在某些极早期执行的脚本里可能还不存在,前端如果需要在最早期调桥,得先判断对象是否存在,或者用原生注入的全局函数兜一层。四是测试包和发布包的 UA 标识要区分开,线上出问题时,靠 UA 就能判断用户用的是哪个包,比翻日志快得多。
另外提一句关于桥的版本演进:方法一旦发布出去,就不要改语义了。参数想扩展就加可选字段,方法想废弃就保留至少两个版本的兼容期,页面是跟着服务端下发的,用户手里的包版本跨度可能大到你想不到。我见过一个改参数顺序的操作,导致老版本包里的页面全废,那种事故的修复成本远高于当初多写三行兼容代码。
我个人在混合页面这块的体会是,桥写得稳不稳,跟用了多花哨的框架关系不大,关键在几个朴素的地方:入口的容错、回调的超时、引用的闭环、方法的白名单。这四件事做到了,页面跟原生之间的绝大多数“玄学问题”都会消失,剩下的无非就是联调时多打几行日志的事。