以WKWebView为媒介,解决一个实际项目中的硬需求。这个标题几乎是所有iOS开发者的必修课,但它背后隐藏的细节,比大多数人想象的多得多。今天这篇东西,我不讲API怎么查文档,只讲在项目里真正跑通、踩平坑之后,留下的那套最稳的写法,以及每一步背后的逻辑。
1. 项目背景与交互方案选型思路
先说清楚这个场景到底在解决什么问题。现在做原生App,动不动就要嵌H5页面,无论是运营活动、电商详情还是复杂表单,WebView都是绕不开的。原生代码和网页里的JavaScript互相通信,是实现混合开发的基础能力。而WKWebView从iOS 8开始取代了老旧的UIWebView,内存占用小、加载速度快、自带JS高性能引擎,说白了就是官方亲儿子,到现在已经完全看不到UIWebView的影子了。
OC与JS交互,本质上是两种语言运行在两个完全隔离的进程空间里。OC语言跑在App的native层,拥有系统能力、私有数据、完整API权限;JS跑在WebContent进程里,受沙箱限制,只能操作DOM和浏览器提供的API。交互的核心,就是在两者之间建立一道受控的桥梁,让数据以安全的方式跨过这道边界。
在我处理过的项目里,交互需求大致分三种:
- 网页需要获取用户原生信息,比如设备型号、版本号、登录令牌,这时候OC主动“塞”给JS;
- 网页触发了按钮点击,需要原生响应,比如打开相机、调起支付、跳转新页面,这时候JS主动“喊”OC;
- 双方互传数据,比如H5带着业务参数来,OC处理完又带回结果去。
方案选型上,业界主流就两条路:一是用JSBridge框架,比如某著名开源桥接库,成熟但重,适合大型项目;二是自己手写一套轻量桥接,代码量不大、完全可控,适合大多数中小型项目。我偏向后者,理由很简单:依赖越少,出问题时排查越容易,而且苹果官方API已经提供了相当完善的基础能力,没必要把几十KB的库硬塞进包体里。
这个方案的核心能力点在于:利用WKScriptMessageHandler接收JS发来的消息,利用evaluateJavaScript(_:completionHandler:)执行JS代码。看懂这两个API的底层实现,所有交互业务都能往里面套。
2. 基础配置与环境搭建
2.1 从UIWebView迁移到WKWebView的必经之路
如果你是从老项目翻新过来的,第一步就是把UIWebView全部替换成WKWebView。别小看这一步,两者虽然用起来都差不多,但底层完全不同。UIWebView的JS执行速度慢、内存管理一塌糊涂,而且UIWebView的JS上下文,也就是JSContext,没法稳定监听JS异常和异步回调。WKWebView把这些坑基本都填平了。
创建一个基础WKWebView,代码非常直白:
#import <WebKit/WebKit.h> WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init]; // 允许内联播放视频,但禁止自动播放 config.allowsInlineMediaPlayback = YES; if (@available(iOS 10.0, *)) { config.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeAll; } // 偏好设置:JS开关 WKPreferences *preferences = [[WKPreferences alloc] init]; preferences.javaScriptEnabled = YES; preferences.javaScriptCanOpenWindowsAutomatically = YES; config.preferences = preferences; WKWebView *webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:config]; webView.navigationDelegate = self; webView.UIDelegate = self; [self.view addSubview:webView];这里有个容易忽略的点,preferences.javaScriptCanOpenWindowsAutomatically如果设成NO,那么JS里调用window.open就无声无息地失败,排查起来特别隐蔽。
加载远端HTML没问题,但如果涉及到本地HTML资源,要注意WKWebView不允许直接读file://协议,必须用loadFileURL:allowingReadAccessToURL:方法加载,否则页面空白。
2.2 一定要做的UserAgent定制
很多H5端为了区分App环境,会校验UserAgent里的自定义标识。WKWebView默认的UA只是Safari的,完全看不出是原生壳。定制UA的方法比较隐秘,正常情况下在WKWebView初始化之前设置:
// 获取默认UA NSString *originalUA = [[WKWebView new] valueForKey:@"userAgent"]; NSString *customUA = [originalUA stringByAppendingString:@" AppName/1.0.0"]; [[NSUserDefaults standardUserDefaults] registerDefaults:@{@"UserAgent": customUA}];注意,这个设置必须在创建webView之前,而且[[WKWebView new] valueForKey:@"userAgent"]这段代码会在主线程动态生成一个webView,如果没设置成功,后面H5识别不了环境就会走错分支。
3. JS调用OC的实现机制与实操
3.1 WKScriptMessageHandler的工作原理
这是整个交互链路里最有技术含量的一环。JS调用OC,不是直接调用,而是借助一个叫WKUserContentController的中介。OC先注册一个“命名通道”,然后把通道名注入到JS的window.webkit.messageHandlers对象里。JS那边发起调用时,消息会通过IPC机制传给原生层,由代理方法接收。
注册代码:
WKUserContentController *userContentController = [[WKUserContentController alloc] init]; [userContentController addScriptMessageHandler:self name:@"nativeBridge"]; config.userContentController = userContentController;这行代码一执行,JS环境里就自动出现了window.webkit.messageHandlers.nativeBridge这个对象。JS发消息的时候,只需要这样写:
window.webkit.messageHandlers.nativeBridge.postMessage({ action: "openCamera", params: { source: "profile" } });执行完postMessage之后,OC这边的代理方法就跟闹钟一样响:
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { if ([message.name isEqualToString:@"nativeBridge"]) { // message.body 就是上面传下来的对象 NSDictionary *payload = (NSDictionary *)message.body; NSString *action = payload[@"action"]; // 分发到对应处理逻辑 } }这里要提醒一句:message.body可以接收任意类型,数字、字符串、字典、数组都可以,但强烈建议统一用字典格式,并且固定为{action: "xxx", params: {...}}这种结构。别问为什么,我接手过一个项目,JS那边传参格式五花八门,一个规范不统一,OC这边解析逻辑能写成屎山。交互接口的报文格式,跟服务端API一样,必须一视同仁地设计。
3.2 处理完JS消息后的响应回调
很多场景,JS那边不只是“通知”OC,还需要OC给个返回值。比如JS要查询APP登录状态,处理完之后要告诉JS“是”或“否”。
最简单直接的方案,是OC拿到回执后,直接再执行一段JS回调代码:
NSString *jsCallback = [NSString stringWithFormat:@"window.onNativeResponse && window.onNativeResponse(%@)", resultJSONString]; [webView evaluateJavaScript:jsCallback completionHandler:nil];但这种方式有个致命伤:回调函数名写死了。如果页面同时有多个交互并发,比如用户快速点击了好几个按钮,每个回调都往window.onNativeResponse塞值,H5端根本分不清哪个回执对应哪个请求。
更稳的方案是采用回调ID机制,JS发消息时自带一个随机生成的callbackId:
var requestId = Date.now().toString(36) + Math.random().toString(36).slice(2); window.webkit.messageHandlers.nativeBridge.postMessage({ action: "checkLoginStatus", params: {}, callbackId: requestId }); window.bridgeCallbacks = window.bridgeCallbacks || {}; window.bridgeCallbacks[requestId] = function(result) { /* 处理结果 */ };OC处理完后,把callbackId原样返回:
NSString *jsCallback = [NSString stringWithFormat:@"window.bridgeCallbacks['%@'] && window.bridgeCallbacks['%@'](%@)", callbackId, callbackId, resultJSONString];这样每个请求都有唯一ID,H5端就能精确匹配到对应的回调闭包,彻底解决并发混乱问题。
3.3 常见参数传递的类型陷阱
JS传递多层嵌套的字典时,有个极其容易踩的坑:如果参数里包含NSNull类型,OC端做字典转JSON字符串时会直接崩溃。JS的null到了OC里变成NSNull,而NSJSONSerialization只支持NSString、NSNumber、NSArray、NSDictionary、NSNull这几种类型,如果传了其他对象,或者字典里有null,序列化成JSON时没问题,但反序列化回字典后取值的逻辑容易忽略空值判断。
我的习惯是在所有入口做一层“净化”:
// 递归去除NSNull - (id)removeNullObjects:(id)data { if ([data isKindOfClass:[NSDictionary class]]) { NSMutableDictionary *mutableDict = [NSMutableDictionary dictionary]; for (NSString *key in data) { id value = [self removeNullObjects:data[key]]; if (value) { mutableDict[key] = value; } } return mutableDict; } else if ([data isKindOfClass:[NSArray class]]) { NSMutableArray *mutableArray = [NSMutableArray array]; for (id item in data) { id cleanItem = [self removeNullObjects:item]; if (cleanItem) { [mutableArray addObject:cleanItem]; } } return mutableArray; } return data; }这个removeNullObjects:方法我会放在所有JS消息处理的入口先跑一遍,脏数据在起点就拦掉,后面业务逻辑就干净得多。
4. OC调用JS的实现与细节控制
4.1 evaluateJavaScript的正确打开方式
OC调用JS,苹果给的标准方法是evaluateJavaScript。这个方法可以执行任意JS语句,并且通过执行结果拿到返回值。最简单的用法:
[webView evaluateJavaScript:@"document.title" completionHandler:^(id _Nullable result, NSError * _Nullable error) { // result 就是页面标题 }];要注意的是,这个方法的回调不在主线程,UI操作必须手动切回主线程。刻意写成这样来演示一下:
[webView evaluateJavaScript:@"getUserInfo()" completionHandler:^(id result, NSError *error) { dispatch_async(dispatch_get_main_queue(), ^{ // 在这里处理UI更新 }); }];JS里的函数返回值,无论是字符串、数字、布尔值还是JSON对象,只要JSON.stringify能出来的类型,evaluateJavaScript的result都能原样拿到,这个衔接得很顺手。
但有个大坑,如果JS函数返回的是undefined,result就是nil,error反而是nil,别把error当成功依据。要判断执行成功,得同时看result是否为nil。
4.2 构造JS代码字符串时的转义细节
拼接JS字符串,是OC调用JS时最容易翻车的地方。好多新人在这一步被整疯,因为字符串里该转义的地方没转义,不该转义的却画蛇添足。原则是:在OC里拼接JS代码,JS字符串字面量内的引号必须转义,但OC的字符串包裹符要避免冲突。
举个例子,要传一个用户名字符串给JS函数:
NSString *userName = @"张三"; NSString *jsCode = [NSString stringWithFormat:@"updateUserName('%@')", userName]; [webView evaluateJavaScript:jsCode completionHandler:nil];这段代码初看没问题,但万一userName本身包含单引号或换行符,JS代码就被炸了。更稳妥的方式是用JSON字符串作为中间载体:因为NSJSONSerialization生成的JSON字符串天然就是合法的JS字面量。
NSDictionary *userInfo = @{@"name": userName, @"age": @18}; NSError *jsonError; NSData *jsonData = [NSJSONSerialization dataWithJSONObject:userInfo options:0 error:&jsonError]; NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; // jsonString 形如 {"name":"张三","age":18} NSString *jsCode = [NSString stringWithFormat:@"updateUserInfo(%@)", jsonString]; [webView evaluateJavaScript:jsCode completionHandler:nil];把JSON数据直接作为JS的对象字面量传入,这招是从实际项目中打出来的,省掉了所有转义神坑。
4.3 页面加载完成的时机配合
OC调用JS有个天然的时序问题:如果页面还没加载完就要执行JS,脚本会找不到函数定义。所以通用做法是在webView:didFinishNavigation:代理回调后,再去跟webView沟通。
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { // 只有在这里执行evaluateJavaScript才稳妥 [webView evaluateJavaScript:@"onAppReady()" completionHandler:nil]; }但有些场景,比如JS报错后需要降级处理,或者页面内自己管理生命周期,那就需要JS侧配合,主动暴露一个“ready”信号,OC这边等信号来了再发数据。这更像双方握手协议,而不是单方面套时机。
5. 关键场景与完整代码示例
5.1 一个通用JSBridge的抽象
为了把上面的所有逻辑收拢到一起,我在项目里会封装一个JSBridgeManager类。它负责消息注册、分发、回调管理,以及OC向JS发送数据的功能。这种封装思路适合所有中等规模的项目,避免把交互代码散落在控制器里。
先看头文件接口:
#import <Foundation/Foundation.h> #import <WebKit/WebKit.h> NS_ASSUME_NONNULL_BEGIN typedef void(^JSBridgeHandler)(NSDictionary *payload, void(^callback)(NSDictionary *response)); @interface JSBridgeManager : NSObject <WKScriptMessageHandler> - (instancetype)initWithWebView:(WKWebView *)webView; - (void)registerHandler:(NSString *)action handler:(JSBridgeHandler)handler; - (void)callJS:(NSString *)functionName data:(NSDictionary *)data; - (void)removeAllHandlers; @end NS_ASSUME_NONNULL_END这个接口把“通道名”和“handler字典”解耦开,调用方只需关心业务action,不需要理会底层消息机制。这样写还有一个好处,如果以后要换桥接库,换掉manager内部实现即可,业务层纹丝不动。
5.2 实现里的细节要点
再看实现要点:
@interface JSBridgeManager () @property (nonatomic, weak) WKWebView *webView; @property (nonatomic, strong) NSMutableDictionary<NSString *, JSBridgeHandler> *handlers; @end @implementation JSBridgeManager - (instancetype)initWithWebView:(WKWebView *)webView { self = [super init]; if (self) { _webView = webView; _handlers = [NSMutableDictionary dictionary]; // 注册消息通道 [webView.configuration.userContentController addScriptMessageHandler:self name:@"nativeBridge"]; } return self; } - (void)registerHandler:(NSString *)action handler:(JSBridgeHandler)handler { self.handlers[action] = handler; } - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { if (![message.name isEqualToString:@"nativeBridge"]) return; NSDictionary *body = (NSDictionary *)message.body; if (![body isKindOfClass:[NSDictionary class]]) return; NSString *action = body[@"action"]; NSString *callbackId = body[@"callbackId"]; NSDictionary *params = body[@"params"]; JSBridgeHandler handler = self.handlers[action]; if (!handler) { // 未注册的action,可记录日志 return; } handler(params, ^(NSDictionary *response) { // 根据回调ID,回传结果给JS if (callbackId && self.webView) { NSError *jsonError; NSData *jsonData = [NSJSONSerialization dataWithJSONObject:response options:0 error:&jsonError]; if (jsonError) return; NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; NSString *jsCode = [NSString stringWithFormat:@"window.bridgeCallbacks && window.bridgeCallbacks['%@'] && window.bridgeCallbacks['%@'](%@)", callbackId, callbackId, jsonString]; dispatch_async(dispatch_get_main_queue(), ^{ [self.webView evaluateJavaScript:jsCode completionHandler:nil]; }); } }); } - (void)callJS:(NSString *)functionName data:(NSDictionary *)data { NSError *jsonError; NSData *jsonData = [NSJSONSerialization dataWithJSONObject:data options:0 error:&jsonError]; if (jsonError) return; NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; NSString *jsCode = [NSString stringWithFormat:@"%@(%@)", functionName, jsonString]; dispatch_async(dispatch_get_main_queue(), ^{ [self.webView evaluateJavaScript:jsCode completionHandler:nil]; }); } - (void)removeAllHandlers { [self.handlers removeAllObjects]; [self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"nativeBridge"]; } @end这段代码有几个用心的地方:webView用weak修饰,避免双向循环引用;回调通过callbackId精确匹配;所有JS执行都切回主线程。如果你的项目交互风格跟这个一致,可以整段抄走,再根据业务扩展action即可。
5.3 外部接入示意
接入的地方也非常轻:
self.bridgeManager = [[JSBridgeManager alloc] initWithWebView:self.webView]; [self.bridgeManager registerHandler:@"openCamera" handler:^(NSDictionary *payload, void (^callback)(NSDictionary *)) { // 打开相机逻辑 callback(@{@"code": @0, @"message": @"ok"}); }]; [self.bridgeManager registerHandler:@"share" handler:^(NSDictionary *payload, void (^callback)(NSDictionary *)) { // 分享逻辑 callback(@{@"code": @0, @"message": @"分享成功"}); }];一句话总结:所有JS可以调用的原生功能,都在这个manager里集中注册,不让控制器直接参与,这样页面销毁时清理监听关系才干净利落。
6. 内存管理与循环引用的大坑
6.1 解决ScriptMessageHandler的retain cycle
交互模块最隐蔽的炸弹,就是内存泄漏。addScriptMessageHandler方法中的handler对象会被WKWebView强引用,如果这个对象又强引用了webView,形成webView -> handler -> webView的环,页面dismiss后谁也不会释放。
实际开发中,很多人就直接把控制器self传进去做handler了,结果控制器永远不释放,严重一点的,每打开一个H5页面,内存就涨一截,多开几个页面直接闪退警告。
解决办法就是:在页面销毁前必须移除handler,而且移除时机一定要卡准。
- (void)dealloc { [webView.configuration.userContentController removeScriptMessageHandlerForName:@"nativeBridge"]; }但dealloc本身不可靠,更好的做法是在页面可见生命周期专门处理:
- (void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; if (self.isBeingDismissed || self.navigationController.isBeingDismissed) { [self.bridgeManager removeAllHandlers]; } }如果handler本来就放在bridgeManager对象里,那就在manager里的removeAllHandlers统一移除,外部只管调方法,不用记太多细节。
6.2 JS回调闭包里引用外部对象的注意点
注册handler时,回调闭包如果引用了self,而闭包又长期保存在manager里,也会形成保留环。比如:
__weak typeof(self) weakSelf = self; [self.bridgeManager registerHandler:@"openCamera" handler:^(NSDictionary *payload, void (^callback)(NSDictionary *response)) { __strong typeof(weakSelf) strongSelf = weakSelf; [strongSelf openCameraWithParams:payload]; }];对于使用block的地方,这种weak-strong模式是最稳妥的。养成习惯,别省,真出了问题查内存泄漏图查到怀疑人生。
7. 问题排查与避坑指南
7.1 JS执行报错的常见原因清单
交互开发中,最常见的当属“JS可以调OC,但OC调JS没反应”。排查路径无非这么几条:
- JS函数名写错。这看起来是低级错误,但在动态拼接函数名的实现里极易发生,比如参数透传来透传去,函数名被多套了一层引号。通过
evaluateJavaScript的completionHandler里的error信息能迅速定位。 - 调用时机太早,页面还没创建函数。这个就必须等
didFinishNavigation后再调用,或者JS主动告知就绪信号。 - WKWebView正在加载新页面时执行脚本,新的navigation会把旧页面内容替换掉,脚本执行对象已经是新页面了。所以如果要跳转,得在跳转前先把数据发完。
看error信息很重要,evaluateJavaScript的NSError里会包含一条非常详细的JavaScript执行异常描述,包括异常发生位置和堆栈,学会读这个堆栈,排查效率能提高一半以上。
7.2 应用后台时的JS调用陷阱
还有一类问题特别折磨人:App切到后台,或webView进入无响应状态时,evaluateJavaScript的回调永远不执行。这其实不是bug,而是系统在某种状态下暂停了JavaScript的执行。我在一个视频播放页面就碰到过,页面推入后台,JS定时器全停了,等回到前台才继续跑。
对策很简单,重要数据交互尽量App回到前台后再发,或者至少不要依赖回调做关键链路的节点判断。
7.3 拦截链接与解决新窗口无法打开
WKWebView默认不允许window.open方法弹新页面,因为缺少UIdelegate的实现。很多H5页面里点“查看帮助”按钮,跳了个新窗口,结果原生端一片空白。解决办法是实现UIDelegate代理:
- (WKWebView *)webView:(WKWebView *)webView createWebViewWithConfiguration:(WKWebViewConfiguration *)configuration forNavigationAction:(WKNavigationAction *)navigationAction windowFeatures:(WKWindowFeatures *)windowFeatures { // 打开新页面样式由自己控制 if (!navigationAction.targetFrame.isMainFrame) { [webView loadRequest:navigationAction.request]; } return nil; }这段代码的处理逻辑类似“拦截所有非主框架的跳转,统一在当前页面加载”,实践中很有效。
7.4 常用排查工具与标志
推荐一个硬核技巧:在WKWebView里动态注入Log函数,让H5的日志串回原生控制台。在WKUserScript注入时机为WKUserScriptInjectionTimeAtDocumentStart时,加入如下代码:
window.console.log = function(...args) { window.webkit.messageHandlers.nativeBridge.postMessage({ action: "consoleLog", params: { log: args.map(String).join(" ") } }); };OC端注册一个consoleLog的handler,把日志直接打出来。以后H5跑了什么逻辑,原生这边一眼就能看到,不用每次都开Safari开发者工具远程调试。
另外,WKWebView还自带一个网页内容检查器,在debug环境下,可以通过webView.setValue:forKey:开启一些调试开关,不过在项目发布前一定要确认关掉了。这个用起来比较深度,日常开发用前面那套方案基本就够了。
8. 性能优化与安全加固扩展
8.1 拦截与禁用危险方法
只要暴露了桥接通道,理论上面向公众的H5都可以调用。就算不是恶意,页面里不小心写了个postMessage传了超大对象,OC这边也会很不爽。所以桥接层必须做安全校验。
第一层校验,是域名校验:只允许当前正在加载可信域名页面时,才响应对应消息。
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { if (![message.name isEqualToString:@"nativeBridge"]) return; NSURL *pageURL = self.webView.URL; NSString *host = pageURL.host; if (![host isEqualToString:@"trusted.example.com"]) { return; } // 其余处理 }第二层校验,是参数校验:看看action是否在白名单内,参数里有没有非法的URL scheme等。这里提到的“可信域名”是个示例,实际项目替换成自己的可信域名即可。
8.2 阻止自动播放与弹窗的体验问题
还有很多交互体验细节容易被忽略。有些H5页面一进来,视频就开始带声音地播放,或者弹窗不停。这通常需要靠配置和UIDelegate双管齐下:
- 在
WKWebViewConfiguration里,设置mediaTypesRequiringUserActionForPlayback为所有类型,禁止任何媒体自动播放; - 实现
webView:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler:时,自己要处理alert弹窗。如果忽略这个方法,JS的alert()会直接无效,H5的开发人员会找你兴师问罪。
- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler { UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"提示" message:message preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { completionHandler(); }]]; [self presentViewController:alert animated:YES completion:nil]; }这个代理不实现,alert弹窗就不出来,很多新人会以为是自己调用姿势不对。
9. 交互规范的工程化落地经验
9.1 报文协议尽量向服务端API靠拢
这套桥接交互,本质就是一个私有API。既然是API,就得有协议。我建议在项目工程文档里固定一张表,把action名称、方向、参数、返回值全部列清楚。像下面这样:
| action | 方向 | 参数 | 返回值 |
|---|---|---|---|
| openCamera | JS -> OC | source: String | {code: Int, imageUrl: String} |
| getLoginToken | JS -> OC | 无 | {code: Int, token: String} |
| updateTitle | OC -> JS | title: String | 无 |
| onPageReady | OC -> JS | appVersion: String | 无 |
这张表不是摆设,而是双方联调的契约。谁改了参数,表格必须同步更新。我见过不少项目,表格躺在文档里吃灰,结果接口变了,前端后知后觉,线上问题一批批出。
9.2 按版本号做兼容
H5页面在App内嵌容器里,版本不随App发版。换句话说,App已经上线了1.0,但H5页面在1.2甚至2.0的接口已经被服务端偷偷切了。这就必须做版本兼容。
我习惯在构造桥接环境时,把一个nativeVersion字段注入到JS环境里:
NSDictionary *envInfo = @{@"appVersion": @"1.0.0", @"platform": @"iOS"}; NSString *jsonString = [self jsonStringFromDict:envInfo]; NSString *injectJS = [NSString stringWithFormat:@"window.NativeEnv = %@;", jsonString]; WKUserScript *script = [[WKUserScript alloc] initWithSource:injectJS injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]; [userContentController addUserScript:script];H5端就可以根据自己的需求,判断是否使用新接口或走旧逻辑回退。
9.3 日志链路贯通
交互问题排查,难的不是技术,而是双方信息不对称。你的代码里弹了个错误,但H5那边完全不知道。我强烈建议交互模块里统一加日志上报,OC侧记录所有进出的动作名、时间戳、参数摘要,JS侧也埋点一起上报。全网日志一对,谁的问题清清楚楚,再也不用互相甩锅。
10. 项目上线后的复盘与心得
这个交互方案在我手上跑过多个版本,期间的坑和优化,攒了一些肺腑之言。
第一,桥接协议一定得从业务中来,到业务中去。市面上那些万能桥接框架,虽然功能齐全,但带来的心智负担不小,配置项动辄几十个。自己封装一个只满足业务需求的桥,代码量控制在两三百行内,后维护的人半小时能看懂全貌,这比任何“优雅的架构”都实在。
第二,JS端的所有回调,必须有超时兜底逻辑。OC这边处理一个原生能力,比如请求相册权限,用户可能半天不点击弹窗,JS那边就永远等不到回调。这时候H5端一般会有个loading转圈,用户等到天荒地老。JS侧对每个桥接请求设置10秒超时,超时就显示失败状态,交互体验会稳很多。
第三,多留接口,别急着精简。开发过程中,常发现H5需要一个当前定位的接口,或者一个分享缩略图的接口。这些接口按业务场景登记到协议表里,反正复用成本低,一次做对,后面改起来就顺了。
第四,别迷信内存泄漏检测工具。桥接循环引用问题,工具能检测到的往往晚一步,等波及页面性能时已经晚了。最好的办法是形成代码习惯:凡是addScriptMessageHandler,就必须在对应生命周期移除,就像打开文件必须关闭一样。写代码的时候就把规则钉进去,别等野指针来教育你。
第五,WKWebView的后台执行能力是有限的,不要期待它像浏览器一样完整。很多H5页面依赖setInterval做轮询,切到后台后会被系统自动暂停。如果你的业务强依赖后台轮询,比如订单状态查询,优先走原生后台请求,而不是让页面扛着WebView存活。
回头看看这套实现,其实没有一个技术点是崭新的。WKScriptMessageHandler、evaluateJavaScript、WKUserScript,都是苹果文档里写明的能力。真正的价值在于把它们拼装成一个可维护、可扩展、能落地的工程方案。过程中我最大的体会是:交互方案设计得再好,也不如双方约定好一个简单直接的协议来得有效。把通信格式固定下来,把回调机制设计清楚,把内存隐患从源头堵住,剩下的业务只是往框架里填代码而已。
如果你正打算在新项目里搞OC和JS交互,建议从这篇文章里的基础封装开始,不要一上来就引第三方库。自己先跑通一次,踩过那几个经典的坑,你就明白整个链路里哪些地方容易出问题、为什么时机这么重要。这对后续排查线上问题,帮助非常大。