WKWebView 做 OC 与 JS 交互,这套东西我前前后后在好几个项目里啃过,从最早的 UIWebView 时代一路踩到 WKWebView 全面落地,踩过的坑加起来能写满一屏。今天就把这些年攒下来的经验一次性倒出来,不只是贴代码,更重要的是把为什么这么做、坑在哪里、怎么排查讲清楚,让后来的人少走点弯路。
先交代一下背景:现在的 App 里,WebView 基本是标配。不管是内嵌 H5 活动页、电商 Banner、富文本展示,还是整套 Hybrid 混合开发框架,都绕不开原生 OC 代码和网页里的 JavaScript 互相调用。WKWebView 从 iOS 8 开始取代 UIWebView,到了 iOS 12 以后 UIWebView 直接被标记为废弃,所以新项目必须要用 WKWebView。而这个交互过程,网上资料参差不齐,很多只讲个大概流程,真正关键的细节全靠在实战里趟出来。
这篇文章覆盖的内容:OC 怎么调用 JS 方法、JS 怎么把数据回传给 OC、中间涉及的内存管理、线程调度、时序问题,还有安全侧的校验思路,以及我在实际开发里总结的排查技巧。无论你是刚接触 Hybrid 开发的新手,还是被线上 bug 折磨得焦头烂额的老手,这篇都值得看下去。
1. WKWebView 对比 UIWebView:为什么交互方案必须换血
1.1 从 UIWebView 到 WKWebView 的必然迁移
在 WKWebView 出现之前,iOS 开发里做 WebView 只有 UIWebView 一个选择。UIWebView 运行在独立进程里,性能还算凑合,但它有个致命问题:内存占用居高不下。网页加载越多、JS 执行越复杂,App 整体内存就被拖得越厉害,在旧设备上经常直接闪退。另一个问题是 UIWebView 的 JS 交互完全依赖 UIWebViewDelegate 的webView:shouldStartLoadWithRequest:navigationType:方法拦截 URL,本质上是一种“伪装成请求”的通信,笨重且容易出错。
WKWebView 的最大改进是采用了多进程架构,网页渲染在独立的 WebContent 进程里完成,内存占用大幅下降,页面崩溃了也不会直接拖垮主 App。同时,WKWebView 提供了 JIT 加速机制,JavaScript 执行性能比 UIWebView 提升了好几倍。最关键的是,苹果为它专门设计了WKScriptMessageHandler、WKUserContentController这样的原生通信接口,让 OC 和 JS 的交互从“绕弯子”变成了“直连通道”。
注意:苹果在 iOS 12 以后已经将 UIWebView 正式标记为废弃(Deprecated),App Store 审核时也会对还在用 UIWebView 的 App 加大审查力度。任何新项目都不该再考虑 UIWebView 的兼容方案。
1.2 WKWebView 交互核心组件全景图
先把 WKWebView 交互相关的几个关键类拎出来,很多人上来就写代码,却对这几个组件之间的关系一头雾水。我用最直白的方式解释一下:
- WKWebView:网页展示和原生交互的入口,相当于整个通信机制里的“宿主”。
- WKUserContentController:负责管理注入到网页里的脚本、以及注册 JS 消息处理器。可以理解成“交换机”,一边连着网页,一边连着原生代码。
- WKScriptMessageHandler:协议。当网页执行
window.webkit.messageHandlers.xxx.postMessage(data)时,就会在原生侧触发对应的回调方法。这个协议是 JS 主动找 OC 说话的唯一官方通道。 - WKUserScript:用来在网页加载前或加载后向页面注入 JavaScript 代码,实现“原生在网页里放一段代码”的效果。
- WKScriptMessage:消息载体。JS 调用原生时,携带的数据、发送消息的 WebView、消息名称都封装在它里面。
- WKWebViewConfiguration:WebView 的配置中心,上面说的 WKUserContentController 就挂在这里。
这六个组件的关系一句话总结:配置中心(Configuration)里挂交换机(UserContentController),交换机里注册协议(ScriptMessageHandler),协议触发后的数据封装成消息(ScriptMessage),页面里的 JS 通过注入脚本(UserScript)或系统自带的消息桥接来发起通信。理清楚这张关系图,后面所有代码写起来都是水到渠成。
2. OC 调用 JS:从 evaluateJavaScript 到异步进阶方案
2.1 基础方案:evaluateJavaScript 的用法与注意点
OC 调 JS 最基础的方式就是evaluateJavaScript:completionHandler:,这个方法直接在当前 WebView 的页面上下文里执行一段 JS 字符串。
// 简单调用,在 page 里弹窗提示 [self.webView evaluateJavaScript:@"alert('hello from OC')" completionHandler:^(id result, NSError *error) { if (error) { NSLog(@"JS 执行失败: %@", error.localizedDescription); } else { NSLog(@"JS 执行结果: %@", result); } }];这是我刚开始用 WKWebView 时最频繁的写法。但在实际业务中,单纯跑一句无返回值的 JS 很少,更常见的是把参数传进去、把结果拿回来。参数传递一定要做字符串拼接处理。
// 传参示例:调用 JS 里的方法并传入一个字典 NSString *paramString = [self jsonStringWithDict:@{ @"userId": @"1029384756", @"userName": @"小明" }]; NSString *jsCode = [NSString stringWithFormat:@"handleNativeDataFromApp(%@)", paramString]; [self.webView evaluateJavaScript:jsCode completionHandler:^(id result, NSError *error) { if (!error) { NSLog(@"JS 返回的值是: %@", result); } }];这里的jsonStringWithDict:需要一个将字典转成 JSON 字符串的辅助方法,注意要把字符串里的单引号、换行符等都做好转义,否则一旦数据里带了特殊字符,JS 端解析必然出错。
2.2 返回值类型与同步阻塞的误区
很多刚接触的开发者会误以为completionHandler里拿到的result就是 JS 函数的返回值,这个理解不够准确。准确地说,result是 JS 代码执行完成后产生的“最终表达式”的值,遵循 JS 的表达式求值规则。如果最后执行的是赋值语句或函数调用而没有 return,拿到的就是 undefined。
此外,evaluateJavaScript是异步执行的,但如果你在 JS 函数里写的是一个耗时同步任务,它在 JS 引擎里的执行依然是阻塞式的,只是这个阻塞不卡原生主线程罢了。这个特性在后续讲时序问题时很关键——不要在 JS 里放太重的同步任务,否则你从 OC 侧调用 JS 时,回调迟迟不来,体验会变得很糟糕。
还有一个常见的坑:WKWebView 的evaluateJavaScript必须在主线程调用。我在一次跨线程调试时,把这段代码放到了子线程里,结果直接崩溃,报错信息指向“API violation”。所以非主线程环境下,一定要包装回主线程:
dispatch_async(dispatch_get_main_queue(), ^{ [self.webView evaluateJavaScript:jsCode completionHandler:completion]; });2.3 进阶方案:callAsyncJavaScript 处理 Promise 返回
iOS 15 开始,WKWebView 新增了callAsyncJavaScript:arguments:inFrame:inContentWorld:completionHandler:方法,这才是处理异步 JS 回调的正解。
业务场景中,JS 方法往往不是同步返回结果,而是内部发起网络请求,或者等待用户操作,通过 Promise 来异步返回数据。用传统的evaluateJavaScript去调用这种异步方法,你拿到的 result 永远是个 Promise 对象,而不是最终数据。
// 需要 iOS 15+ 才能使用 NSString *jsCode = @ "async function fetchOrderInfo(orderId) {" " const resp = await window.httpClient.get('/api/order/' + orderId);" " return resp.data;" "}"; [self.webView callAsyncJavaScript:jsCode arguments:@{@"orderId": @"ORD20240115"} inFrame:nil inContentWorld:WKContentWorld.pageWorld completionHandler:^(id result, NSError *error) { if (!error) { NSLog(@"异步返回值: %@", result); } }];你看,callAsyncJavaScript会把 Promise 自动展开,等 Promise resolve 之后把真正的值回传给原生层,这在混编项目里处理异步数据流时太好用了。但要注意,这个 API 只在 iOS 15 以上系统可用,如果项目最低支持版本低于 iOS 15,就必须自己封装一层 Promise 结果监听机制。
2.4 关于 WKContentWorld 的理解
刚才代码里出现了WKContentWorld.pageWorld,这个参数很多开发者第一次接触会懵。简单说,WKContentWorld 是 iOS 14 引入的 JS 运行环境隔离机制,它可以让你往 WebView 里注入的 JavaScript 与页面本身的 JavaScript 隔离运行。我推荐使用WKContentWorld.pageWorld来执行业务代码,它和页面本身共享环境,适合调用页面里的业务函数。而WKContentWorld.defaultClientWorld是独立的客户端世界,适合放一些不想污染页面环境的工具脚本。
这个隔离机制在安全层面也很有价值:如果只是往页面里注入 UUID 标识符这类数据,可以放进独立的 client world,避免页面脚本误访问或篡改。
3. JS 调用 OC:WKScriptMessageHandler 的全链路实践
3.1 注册消息处理器与桥接协议实现
JS 主动调 OC,核心是WKUserContentController注册一个消息处理器。我把完整步骤拆开:
第一步,创建配置对象的时候,注册消息处理器:
WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init]; WKUserContentController *userContentController = [[WKUserContentController alloc] init]; // 注册 message handler,名称是 payResult // 这里要注意,must know: self 会被 userContentController 强引用 [userContentController addScriptMessageHandler:self name:@"payResult"]; configuration.userContentController = userContentController; self.webView = [[WKWebView alloc] initWithFrame:CGRectZero configuration:configuration];第二步,实现WKScriptMessageHandler协议方法:
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { // 根据 message.name 区分不同的业务功能 if ([message.name isEqualToString:@"payResult"]) { NSDictionary *body = message.body; NSString *orderId = body[@"orderId"]; BOOL success = [body[@"success"] boolValue]; // 在这里处理支付结果回调 [self handlePayResultWithOrderId:orderId success:success]; } }第三步,H5 页面端对应的调用代码,只要放在页面里的按钮点击事件里就行:
// 在 H5 代码中调用原生的 payResult 处理器 window.webkit.messageHandlers.payResult.postMessage({ orderId: "ORD20240115", success: true, message: "支付成功" });整套流程跑通后,你会发现 JS 侧的数据通过postMessage封装成一个WKScriptMessage对象,message.body就是 JS 传过来的数据。可以传字符串、字典、数组等任意能被 JSON 序列化的对象。
3.2 消息处理器与 WebView 生命周期绑定
一个容易忽略的细节:didReceiveScriptMessage:方法触发时,message.webView是指向当前消息的 WebView 实例。如果在同一个 ViewController 里注册了多个 WebView 的同一个处理器名称,通过message.webView可以区分出是哪个 WebView 发出的消息,避免串台。
另一个容易踩的坑是注册时机。如果消息处理器注册在 WebView 加载页面之前,但 WebView 被销毁后处理器没有移除,下一次再创建 WebView 时再次注册同名处理器,就会出现重复注册的日志。正确的做法是在dealloc或页面销毁前手动移除:
- (void)dealloc { [_webView.configuration.userContentController removeScriptMessageHandlerForName:@"payResult"]; _webView = nil; }3.3 前端 JS 调用原生前的前置安全校验
这里必须要提醒一句:H5 页面里调用window.webkit.messageHandlers.xxx.postMessage()不是只有你的业务页面才能执行。如果 WebView 加载了不受信任的第三方页面,或者页面被注入了恶意脚本,任何人都能往你的原生代码里发送伪造消息。因此,原生侧必须对消息来源做校验。
一个好的方案是消息里带上一个页面里注入的 token,这个 token 由原生在WKUserScript里动态注入到页面全局变量,页面调用 JS 时把 token 一起传给原生,原生侧校验 token 是否匹配。
// H5 调用时带上 token window.webkit.messageHandlers.getUserInfo.postMessage({ requestId: "xxx", token: window.NATIVE_TOKEN });// OC 侧校验 - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { if (![message.name isEqualToString:@"getUserInfo"]) { return; } NSDictionary *body = (NSDictionary *)message.body; NSString *token = body[@"token"]; if (![token isEqualToString:self.injectedToken] || !token) { NSLog(@"非法消息,拒绝处理"); return; } // 正常处理 }这样即便第三方页面伪造了消息,没有正确的 token 也过不了校验关。注意 token 不要在页面 JS 里硬编码,要由原生每次启动 WebView 时随机生成并注入,增加伪造难度。
3.4 另一条备选路径:URL 拦截机制
虽然 WKScriptMessageHandler 是官方推荐的正道,但有些老项目还保留着另一种通信方式:通过约定 URL scheme 将 JSON 参数编码在 URL 里,再由WKNavigationDelegate的decidePolicyForNavigationAction拦截。
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { NSURL *url = navigationAction.request.URL; if ([url.scheme isEqualToString:@"myapp"]) { // myapp://actionName?key1=value1&key2=value2 [self handleCustomSchemeWithHost:url.host params:url.query]; decisionHandler(WKNavigationActionPolicyCancel); return; } decisionHandler(WKNavigationActionPolicyAllow); }这个方案早期在 UIWebView 时代就已经被广泛使用,但到了 WKWebView 时代,它最大的问题在于:这种“伪请求”会让 WebView 连续多次发起无效的导航回调,如果处理不当,页面状态的判断会被干扰。我个人的建议是,新项目直接走 WKScriptMessageHandler,不要再用 URL 拦截做业务通信,只在需要特殊处理某些外部链接跳转时才用这种办法。
4. 逃不掉的大坑:内存循环引用、线程错乱与时序冲突
4.1 循环引用问题:为什么你的控制器永远不被释放
WKWebView 交互里最经典的坑就是循环引用。WKUserContentController会强引用你注册的WKScriptMessageHandler,如果你直接传了self,那么self -> WebView -> configuration -> userContentController -> self,这串引用链直接锁死,self(比如某个 ViewController)永远无法释放。
我早期排查过一个诡异 bug:一个支付页面每次都点返回按钮,但仪器监控显示控制器对象一直存活,内存不断上涨。最后定位就是这里——注册了self作为消息处理器,却没有在dealloc里移除,整个页面永远停留在内存里。
解决方案有两个,第一种最简单也最常用:
// 在页面销毁时移除 - (void)dealloc { [self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"payResult"]; }但这种方法有个致命缺陷:如果 ViewController 的dealloc因为循环引用一直不被调用,这个移除代码就成了摆设。真正的根治之法是用一个中间代理对象来打断引用链:
// WeakScriptMessageDelegate.h #import <WebKit/WebKit.h> @interface WeakScriptMessageDelegate : NSObject <WKScriptMessageHandler> @property (nonatomic, weak) id<WKScriptMessageHandler> scriptDelegate; - (instancetype)initWithDelegate:(id<WKScriptMessageHandler>)scriptDelegate; @end // WeakScriptMessageDelegate.m @implementation WeakScriptMessageDelegate - (instancetype)initWithDelegate:(id<WKScriptMessageHandler>)scriptDelegate { self = [super init]; if (self) { _scriptDelegate = scriptDelegate; } return self; } - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { if ([self.scriptDelegate respondsToSelector:@selector(userContentController:didReceiveScriptMessage:)]) { [self.scriptDelegate userContentController:userContentController didReceiveScriptMessage:message]; } } @end注册时改成:
[self.userContentController addScriptMessageHandler:[[WeakScriptMessageDelegate alloc] initWithDelegate:self] name:@"payResult"];这样userContentController只是强引用代理对象,而代理对象对self是弱引用。即使你忘了手动 remove,控制器也能正常释放,不会再泄漏。我个人是把这种代理封装成了公共组件,所有项目统一使用。
4.2 线程错乱:代理方法到底跑在哪个线程
很多人以为 WKWebView 的所有回调都在主线程,这个认知是错的。didReceiveScriptMessage:这个代理方法是在 WebContent 进程对应的子线程里触发的,而不是主线程。如果在子线程里直接刷新 UI,轻则界面卡顿,重则直接崩溃。
注意:WKWebView 的某些 delegate 回调可能不在主线程,处理 UI 相关逻辑前必须确认线程。我习惯在每个涉及 UI 操作的代理回调开头做一次线程检查并强制切换:
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { // 处理数据逻辑可以留在当前线程 NSDictionary *body = message.body; NSString *orderId = body[@"orderId"]; // 更新 UI 必须回主线程 dispatch_async(dispatch_get_main_queue(), ^{ [self updatePayResultUIWithOrderId:orderId]; }); }另外,evaluateJavaScript的 completionHandler 在某些情况下也可能被子线程调用,但绝大多数实际运行场景里,在主线程调用方法时回调就在主线程。为了稳妥,我还是会在回调里加上主线程切换。
线程这块的经验总结:不依赖系统给你“保证”,而是自己在关键位置主动调度线程,这样才能确保跨版本系统的行为一致性。
4.3 时序问题:页面未加载完就调 JS 的坑
OC 调用 JS 的另一个高频坑是时机问题。WebView 刚创建、页面还在加载中,这时你执行evaluateJavaScript,大概率会因为 JS 环境里还没有你需要的函数而拿到 undefined 或报错。
我踩过的具体场景是这样的:App 首屏需要从原生拿用户登录态,然后传给 H5 的全局初始化函数。代码逻辑上确实是页面创建后就去调用,但没考虑网络加载耗时,本地调试时页面加载飞快不会出问题,线上一有网络延迟,H5 里的window.initUserInfo还不存在,调用直接失败。
正确的思路是在WKNavigationDelegate的didFinishNavigation:回调里再做 JS 调用:
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { if (self.needPushUserInfo) { NSString *jsCode = [NSString stringWithFormat:@"window.initUserInfo && window.initUserInfo(%@)", [self jsonStringForUserInfo]]; [webView evaluateJavaScript:jsCode completionHandler:nil]; } }还有一种情况是页面执行的是动态加载的异步逻辑,didFinishNavigation时可能还没注册全局函数。这时候可以在 JS 侧设计成事件订阅模式:原生只是标记“用户信息可用”,H5 内部在适当时候主动拉取。或者原生侧加一个轮询重试机制,隔 200ms 检查一次函数是否存在,最多重试 5 次。我在早期项目里用过重试方案,代码不复杂,但扛过了很多线上偶发问题。
4.4 页面跳转导致的回调丢失
H5 页面内进行 SPA 路由切换(比如 Vue Router 或 React Router)时,WebView 并没有触发重新加载,didFinishNavigation只会触发一次。但页面内部的路由切换会让一些全局函数被重新初始化。
这个坑藏得很深:某次在单页应用里,用户从 A 路由切到 B 路由,原生在 A 路由时注入的 JS 数据在 B 路由已经失效,但原生无感知,仍然用旧 WebView 调用新路由下的 JS 函数,结果拿到的是旧数据。
我的解法是:和前端约定一个路由变化后的原生通知协议。H5 在前端路由切换完成后,显式调用window.webkit.messageHandlers.routeChanged.postMessage({path: 'B'}),原生收到后重新进行对应的 JS 注入。这种“由 H5 主动同步生命周期给原生”的模式,在 Hybrid 项目里非常实用。
5. 数据格式约定:抽屉式接口设计让两端对齐
5.1 设计一个稳定的 JS-Bridge 消息格式
交互代码写多了,你会发现真正复杂的往往不是技术实现,而是通信双方的消息格式约定。H5 和原生是两个团队(或者至少是两个技术栈),如果没有一个稳定的桥接协议,后面联调就是灾难现场。
我推荐的桥接协议格式是一个包含 action、data、callbackId 的 JSON 结构:
// H5 调原生 window.webkit.messageHandlers.hybridBridge.postMessage({ action: "openCamera", // 动作类型,枚举定义好 data: { source: "avatar" }, // 业务数据 callbackId: "10001", // 回调 ID,原生处理完后通过这个 ID 回调 JS });// 原生侧解析 - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { NSDictionary *payload = message.body; NSString *action = payload[@"action"]; NSDictionary *data = payload[@"data"]; NSString *callbackId = payload[@"callbackId"]; if ([action isEqualToString:@"openCamera"]) { // 调起相机,完成后回调 JS } }关键在 callbackId 的设计,这让“原生消费完再反馈给 JS”有了入口。原生处理完业务后,构造一个 JS 调用:
NSString *callbackJS = [NSString stringWithFormat:@"window.HybridBridge.triggerCallback('%@', %@)", callbackId, resultJson]; [self.webView evaluateJavaScript:callbackJS completionHandler:nil];前端提前在全局注册好triggerCallback方法,通过 callbackId 匹配是哪个调用触发的回调,然后 resolve 对应的业务 Promise。这个模式用熟了之后,两端联调最少能减少一半的沟通成本。
5.2 序列化与反序列化的边界处理
在前面的代码里多次出现jsonStringWithDict:这类方法,这是整个桥接协议里最容易出 bug 的地方。OC 的字典转 JSON 字符串,如果里面有中文、换行、特殊字符,直接塞进 JS 字符串里很容易破坏 JS 语法。
我的做法是借助NSJSONSerialization生成 JSON 数据,再转成字符串,但对字符串做了额外处理:
- (NSString *)jsonStringWithDict:(NSDictionary *)dict { if (!dict) return @"{}"; NSError *error = nil; NSData *jsonData = [NSJSONSerialization dataWithJSONObject:dict options:0 error:&error]; if (error) { return @"{}"; } NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; if (!jsonString) return @"{}"; // 对特殊字符做转义,防止 JS 解析错误 jsonString = [jsonString stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"]; jsonString = [jsonString stringByReplacingOccurrencesOfString:@"\r" withString:@"\\r"]; jsonString = [jsonString stringByReplacingOccurrencesOfString:@"'" withString:@"\\'"]; return jsonString; }反过来,message.body已经是 OC 对象了,不需要额外序列化,直接取就行。JS 传给 OC 的字符串、数字、布尔值会自动转换成 OC 对应的对象类型,但要注意 JS 里的特殊结构——比如undefined和NaN——在传参时会被转换成null或者直接丢失,这些边界要提前和前端约定好。
6. 安全加固:接口防暴露、文件访问限制与注入防护
6.1 非业务请求的拦截策略
WebView 加载页面时,页面里的资源请求(图片、CSS、JS 文件、接口请求)都会走 WebView 的网络栈。但如果你的业务只是加载固定域名的 H5 页面,建议在decidePolicyForNavigationAction里限制只允许访问特定的域名或特定 scheme:
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { NSURL *url = navigationAction.request.URL; if ([url.scheme isEqualToString:@"http"] || [url.scheme isEqualToString:@"https"]) { if (navigationAction.navigationType == WKNavigationTypeLinkActivated) { // 外部链接在新 WebView 打开,不要直接在当前页面跳转 decisionHandler(WKNavigationActionPolicyCancel); return; } } decisionHandler(WKNavigationActionPolicyAllow); }这种策略还有个附带好处:防止页面被恶意跳转到钓鱼站点,减少用户信息被窃取的风险。
6.2 禁用非 HTTPS 加载与 unsafe 配置
苹果对 ATS(App Transport Security)有明确要求,默认禁止 HTTP 明文传输。很多 H5 页面为了开发方便会临时用 HTTP 地址,但线上如果你还开放 HTTP 域名,用户数据在网络传输中是明文暴露的。
我的做法是:debug 环境通过Info.plist的NSAllowsLocalNetworking或NSExceptionDomains开临时白名单,release 环境全部走 HTTPS,并且不设置NSAllowsArbitraryLoads。这块如果配置不对,页面在正式环境会直接加载失败,排查起来又隐蔽又费时间。
6.3 防止页面脚本篡改原生消息
又回到刚才提到的 token 机制。我把它再做一次升级:不只是原生侧校验 token,还可以让 JS 侧每次调用都生成一个自增的序列号,原生侧校验序列号连续性。如果序列号跳变或者重复,视为异常调用,直接丢弃并上报。
这个机制主要防止的是页面被注入恶意脚本后,攻击者通过反复调用合法接口探测数据。序列号校验能把这种探测行为挡在数据返回之前。
6.4 JavaScript 注入的时机与白名单页面判定
不是所有页面都需要注入原生桥接脚本。我的线上项目有个策略:只有页面 URL 命中业务白名单域名时,才在didFinishNavigation或navigationDelegate的didCommitNavigation阶段注入业务需要的 JS 桥接工具。这里有个经验细节:didCommitNavigation注入比didFinishNavigation注入要更早,适合那些页面加载过程中就要用到原生能力的场景;而didFinishNavigation注入适合页面加载完再初始化的场景。
三者对比如下:
| 注入时机 | 触发点 | 适用场景 | 注意点 |
|---|---|---|---|
| didCommitNavigation | 内容提交时 | 页面加载早期就需要 JS 资源 | 页面 DOM 可能未完全构建 |
| didFinishNavigation | 加载完成时 | 常规的业务初始化 | 页面可能已经发起了请求 |
| WKUserScript 注入 | 页面加载前 | 全局工具脚本、token 注入 | 不依赖页面加载状态,最稳定 |
实际项目里我是三层并用:全局 token 和桥接初始化函数用 WKUserScript 在页面加载前注入;业务数据处理函数在 didFinishNavigation 注入;而字符串替换类的临时补丁脚本在 didCommitNavigation 注入。职责分明后,代码维护起来轻松很多。
6.5 图片上传与本地文件访问的安全考量
如果你的 WebView 里需要让 H5 调起原生相册选图,会涉及WKOpenPanelParameters相关的权限弹窗。我见过不少项目在这块做得不规范:直接把相册权限开到了 Info.plist 里的NSPhotoLibraryUsageDescription,却没有对 WebView 的 file input 选择行为做限制,导致页面里任何第三方元素都能触发选图窗口,体验和安全都不好。
推荐的做法是:原生侧收到openCamera或openPhotoLibrary的桥接消息后,由原生自己拉起系统选择器,选完图之后把图片数据或上传后的 URL 通过回调传回 JS 侧,而不是让 WebView 内部的 file input 直接弹出系统 UI。这样既能统一交互样式,也能把权限边界控制在原生代码里,避免不可信的网页内容随意访问系统资源。
7. 调试技巧实录:从 WKWebView 日志到线上问题定位
7.1 加载 Safari Web Inspector 远程调试
WKWebView 调试的第一利器是 Safari 的“开发”菜单里的 Web Inspector。只要在 Mac 上连接真机,打开 Safari 的开发菜单,就能看到真机 App 里所有 WKWebView 的页面,点击可以打开完整的调试器面板——Console、Elements、Network、Sources 一应俱全。这个面板和浏览器开发者工具几乎一模一样,排查 JS 错误和网络请求状态极其高效。
有几个常用姿势值得分享:
- Console 里直接手动调用
window.webkit.messageHandlers.xxx.postMessage(),用来验证原生桥接是否正常,甚至可以在 Console 里伪造各种异常参数来测试原生的容错处理。 - Network 面板里查看 H5 接口请求,确认 H5 在加载时发起的请求是否符合预期,帮助区分是原生问题还是前端接口问题。
- Sources 面板打断点,如果 H5 代码是压缩混淆过的,这一招可能不好用,但如果是开发环境没压缩,断点调试直接看函数参数非常直观。
7.2 原生侧的日志埋点与统一格式化
原生侧的桥接日志也建议统一埋点。我在项目里封装了一个WKLog宏,统一打印消息名、方向(JS to OC 还是 OC to JS)、参数体、耗时。日志格式统一后,线上排查直接捞日志关键字就能快速定位问题。
#define WKBridgeLog(direction, name, body) \ NSLog(@"[WK Bridge] %@ | %@ | %@", direction, name, body)关键路径上的日志一定要输出,比如注册处理器、收到消息、执行 evaluateJavaScript、回调 JS。不要嫌日志多,线上出 bug 时这几行日志可能救你一命。我常常遇到过这种情况:线上反馈网页按钮点了没反应,靠日志一查,发现是 H5 调起的消息名,和原生注册的名称差了一个字母,这种问题没有日志查起来如同大海捞针。
7.3 常见崩溃日志对照速查表
WKWebView 交互相关的崩溃日志,有几个固定模式已经形成特征,这里整理成速查表:
| 崩溃特征 | 报错关键信息 | 可能原因 | 解决方案 |
|---|---|---|---|
| 子线程刷新 UI | API violation | 在非主线程触发了 UI 更新或 evaluateJavaScript | 统一做线程切换封装 |
| 内存持续上涨不释放 | 无直接崩溃但 Memory 曲线上升 | message handler 循环引用,控制器不释放 | 用 WeakScriptMessageDelegate 替代 self |
| JS 里对象未定义崩溃 | ReferenceError | 页面加载未完成就调用 JS | 等 didFinishNavigation 再消费,或用事件订阅 |
| 重复注册 handler | "already has a handler" | 重复创建 WebView 配置且未移除 handler | 页面销毁时移除,或复用同一个 handler |
| H5 加载空白 | 页面请求被 ATS 拦截 | HTTP 明文请求未配置白名单 | 在 Info.plist 里按域名配置 ATS 例外 |
这张表是我在多个项目排障过程中积累出来的,遇到类似症状直接对着查,大部分能原地解决。
7.4 一个实战排查案例:回调条数不一致
有一次我做 Hybrid 项目联调,发现一个数据上报接口在 H5 端触发了 10 次,但原生只收到了 8 次。排查过程很有意思,分享一下思路。
第一步,先确认是不是消息丢失。我在原生的didReceiveScriptMessage:里打了日志,又在前端 JS 调用前打了日志。对比下来发现前端确实调了 10 次,后端日志显示收到 8 次。这说明通信链路中有东西把消息吞了。
第二步,去查 WKUserContentController 的消息机制。发现前端代码在调用postMessage时没有检查 WebView 是否已经置空,而且页面切换时 WebView 被提前销毁,那时发出的消息自然就没了。这个问题本质不是链路断了,而是时序竞争——页面销毁过程中消息被丢弃了。
第三步,和前端约定:调用原生桥接方法时,增加一个返回值确认机制。前端发完消息后,原生处理完必须回调一个确认指令,前端只有收到确认后才认为消息发送成功,否则进入重试逻辑。这之后再也没有出现过这种“前端以为发出去,后端以为没收到”的扯皮场景。
8. 实战心得:从理论到上线,WKWebView 交互的踩坑记录
8.1 Why JavaScriptCore 不适合走业务桥接
有些开发者可能会问:iOS 里有现成的 JavaScriptCore 框架,为什么不直接用 JSContext 来做 JS 与 OC 的交互?这个问题我深入研究过,JavaScriptCore 确实能直接反射调用 JS 函数、获取 JS 对象,但它和 WKWebView 存在一个根本性的断层:WKWebView 使用的是独立于 App 进程的 JS 引擎,它内部的 JS 环境和 JavaScriptCore 框架的 JSContext 是彼此隔离的,无法互通。
也就是说,你用 JavaScriptCore 在 App 进程里创建 JSCContext,然后在 WKWebView 的页面 JS 里直接调用这个 Context 里的 OC 方法,这条路是走不通的。JavaScriptCore 更适用于不依赖 WebView 的纯 JS 引擎场景,比如一些跨平台框架需要执行 JS 逻辑时。业务桥接老老实实用 WKScriptMessageHandler,这才是正确姿势。
8.2 大文件与二进制数据的传输优化
H5 和原生之间传输大文件(图片、音频)时,JSON 序列化直接传 base64 字符串会让内存暴涨、传递延迟增加。我推荐的做法是:H5 要传大量数据时,只传一个文件 ID 或本地 URL,原生侧根据文件 ID 从本地沙盒或网络下载数据,而不是把整个内容塞进postMessage。
反过来,原生要向 H5 传图片,也是先下载到本地沙盒,再用file://或自定义 scheme 生成一个可以在 WebView 里访问的 URL,把它传给页面,页面通过<img src>来加载,而不是把图片 base64 拼进 JS 调用。两种方式对比下来,传输耗时和内存占用至少差一个量级。
8.3 Hybrid 框架下的统一封装思路
如果你在公司里维护的是一个 Hybrid 基础框架,WebView 交互能力会被很多业务模块复用。这时候千万别在业务代码里散落各处直接调用userContentController addScriptMessageHandler,一定要做一层统封装。
我习惯的做法是这样的:
- 封装一个
HybridBridgeManager单例,内部维护注册列表和消息路由表。 - 所有的消息处理器注册统一走管理器的
registerHandler:forAction:接口。 didReceiveScriptMessage:统一收口到管理器,然后根据 action 分发到对应的业务模块。- 所有交互统一走
HybridBridgeManager,它内部处理线程切换、token 校验、序列号校验、日志埋点、异常上报。
这套封装落地之后,新增一个交互协议只需要两步:注册处理方法、在路由表里加一个 action 映射。排查问题时也不再需要每个页面打日志,直接在管理器里看统一的日志输出即可。我在某公司做基础框架时就是这么设计的,稳定性提升很明显,线上问题率降了八成。
8.4 一些零散但关键的细节补充
最后补几个零散但实际有用的细节,每一条都是我在真实项目中花过时间踩出来的:
关于页面尺寸与视口适配:WKWebView 默认的视口宽度是 980px,如果你的 H5 页面没有正确设置 viewport meta,会出现页面内容显示过小、两边留白之类的布局问题。原生侧可以通过注入 viewport meta 或者配置WKWebViewConfiguration来处理,但最根本的还是让前端在页面里把 viewport 写对。
关于页面上下拉回弹:webView.bounces属性可以控制页面回弹效果。很多业务页面不希望出现回弹感,可以设置self.webView.scrollView.bounces = NO,但要注意如果 H5 页面内部有滚动容器,这个设置会影响内部滚动体验,需要根据业务具体场景来权衡。
关于 cookie 同步:WKWebView 的 cookie 管理和 UIWebView 不完全一样。如果 H5 页面依赖登录态 cookie,而用户登录状态在原生层更新了,需要手动把新 cookie 同步到 WebView 的 cookie store。iOS 11 之后可以通过WKWebsiteDataStore管理 cookie,但跨进程同步仍然存在诡异的时间差,建议原生登录成功后主动刷新 WebView 或给页面发一个“登录态已更新”的事件,让页面主动重新获取数据。
关于视频播放:H5 里嵌入视频时,WKWebView 默认是全屏播放,如果你希望内联播放,需要配置allowsInlineMediaPlayback。这个属性只读,必须在initWithFrame:configuration:时通过 WKWebViewConfiguration 创建才能设置。
写在最后的实战心得
这些内容我是在好几个实际项目的摸爬滚打里一点点趟出来的。如果你问我对 WKWebView OC 与 JS 交互最大的体会是什么,我会说:这套机制本身的 API 设计并不难,难的是藏在 API 背后的工程细节——线程、内存、时序、安全、协议约定,每一个单拎出来都可能让线上出大问题。
我习惯在做 WebView 交互功能的第一次迭代时,就把桥接协议文档写好,把消息格式和回调机制定死,然后让前端在联调前就把 Mock 数据准备好,两边各自开发,最后联调阶段几乎不会出现推诿扯皮的情况。同时,所有桥接代码必须走统一的封装组件,不能各自为政,这是我在当初被线上问题教育过一次之后强制推行的规矩。
如果你正在写一个新项目,或者正准备从 UIWebView 迁移到 WKWebView,强烈建议先把这篇文章里讲的线程、循环引用、时序这三个问题在代码层面做好防护,然后再铺开业务逻辑。这三点做好了,后面至少省下三分之二的排查时间。遇到具体的坑,也欢迎带着问题来交流,我踩过的坑大概率你也正在踩。