从零收拾一个hybrid容器(一)– 从选择JS通信方案开始
上一篇文章介绍了如果自己亲自开发一个hybrid容器,应该怎么去选择JS通信方案,并且重点强调了现在市面上流传最广的WebViewJavascriptBridge 他存在的致命问题,以及不应该因为所谓的看似写法统一而在api选型上做功能妥协的思想
前言
这篇文章拖到年后才开始写是有点罪过罪过,不过在写之前我想说几点,这系列文章我想表达的并不是在推广什么我自己的新Bridge轮子,也不是针对某个开源 Bridge 框架进行深度的源码分析。我们从看开源框架轮子如何设计,如何使用,源码如何工作的思维方式中跳出来,换一种模式去从目的出发,从需求出发,思考当你什么都没有的时候,你要从零思考构建一个 hybrid 框架的时候,你都要考虑哪些方面?这些方面采用怎样的设计思想能做到未来在使用中灵活自如。
单纯去做一个纯hybrid通信框架,网上有的各类 bridge 轮子,但是通信只是 hybrid 这个话题下的一个底层基础,上层的玩法还有很多,跳出通信框架看看现在各种前端+客户端的混合开发模式下,他们是否有啥共通点,然后汲取里面的设计思想,然后灵活的在自己业务中发挥出来,这样你面临的就从来不是一个选择题,我要不要用RN?我要不要用新玩具 Flutter?(这玩意槽点很多也离我这篇文章的主题有点远,我就提一嘴不打算多说)你面临的只是一个问答题:新出来个重型大轮子,我能不能看看这个思想借鉴到现有业务里的?
Hybrid
狭义Hybrid
也是现在大家普遍认知的,Hybrid就是一种给 WebView 增加一些js通信可以调用原生API的方式
广义Hybrid
我能否认为,只要是前端的开发思路与客户端原生的开发思路相结合,就认为他是一种 Hybrid?
我能否认为,通过原生的配合,把原本js or 前端开发做不到的事情做到了,用原生的方式增强了原本的前端技术能力,是否就是一种 Hybrid?
我能否认为,无论是 WebView+Bridge 也好,RN类似的原生渲染框架也好,小程序也好,某种意义上讲,他们都算 Hybrid?
因为Hybrid本是一个面向业务服务的东西,如果业务的野心足够大,WebView 容器的想象空间应该是在能力上与RN/小程序看齐的,没错,WebView 在 Hybrid 的支持下,不单纯是设计几个 Bridge 调用几个原生 API 的事,我其实在不少聊天群里深度聊过这个话题,泛前端动态化这个方向上,各种技术轮子都是一脉相承通着的,所以你看RN or 小程序是一个大厂新做的重型轮子?在我看来他们都是一回事,完全可以拆解RN中的每个环节,把RN号称比 WebView 好的原生渲染/原生组件拆解融入 WebView,我也可以学习小程序保持 Html/CSS/JS 的开发方式(当然我知道小程序是WXML/WXSS),而非RN那样统一用JSX开发。我甚至还能把RN与小程序都没有的动态bridge融入到Hybrid容器中去,What’s more? 还有更多可以开放的脑洞。
这种拆解不是说可以做到把所有框架优点塞在一个大而全的框架里就完事的,各种优化方案的选择背后一定带来的是一些取舍。谁来决定取舍,业务决定,如果自己能深度把握这里面的设计思想,就不用在乎什么新的轮子新的框架,取其设计优点(优点一定带来取舍,如果选择这个有点意味着也要选择他的取舍),融入自己的业务之中。
WebView容器 - 基本功能
前边把话题聊得有点大,有点虚,那么收回来,我后面的内容还是围绕着 WebView 来设计我们想要的 Hybrid 框架,本系列第二篇文章我会传统点,先只从传统的对 WebView 容器上的功能需求出发说一些基本的功能的设计方案与思路,但是第三篇就会从一些“黑脑洞”的功能层面,在 WebView 的基础上扩展出一些非常规的能力。本文就基于 WKWebView 说了,UIWebView 如果说真理解设计思想的话,其实没区别,同理虽然我所有的解释说明都是 iOS 的,但对于安卓来说设计思想完全适用。
选择合适的 JS 通信方案(第一篇)
实现基本的 WebView 容器能力(第二篇 本篇)
尝试拓展 WebView 容器的额外能力(第三篇 待续)
一个标准的WebView容器要具备哪些基础的功能需求,来满足常规的 hybrid-webview 开发呢?
良好的 JS 与原生通信交互能力
灵活的业务模块扩展能力
UserAgent 管理
Cookies 管理
本地加载 JS 管理
通信交互设计
上一篇文章其实介绍了好几种JS/OC通信方案,如果涉及相关的代码,我只会以WKWebView的 messageHandler + evaluatingJavaScript 的方式进行一些展示代码介绍,但其实用啥方案并无区别,思考理解设计思路,然后在任何的通信方案下迁移运用
我说的只是一种设计思路,并不是唯一设计思路
一个好的交互通信设计应该考虑哪几个方面?这几个方面的考虑都是出于什么目的?
JS主动调用原生:
最基础功能,WebView 各种想要调用原生能力都通过这个设计来通知原生,无论是打开新页面新路由,还是弹个 Tips 框,还是执行 IAP 购买,还是打开摄像头等等。
JS主动调用原生后回调:
还是在基础功能之上,如果 WebView 是想要获取一些只有原生才有的数据,比如读原生数据库,查看原生设备网络/磁盘等硬件状况,需要在上面的功能下还额外回调给 WebView
原生主动调用JS:
有什么业务场景需要原生主动调用JS呢?举个例子H5开发的时候特别想知道很多事件与时机,比如在H5界面下用户home/锁屏了,用户回到 APP 了,H5都想捕获这个时机用来开发业务需求,比如App开发的 viewWillDisappear /viewDidAppear 等前端开发也想获得当前页面进入屏幕,离开屏幕等事件需求,从而执行对应的业务逻辑。
和主动调用后回调相比,直接主动调用JS,在底层执行 API 的时候肯定一样都是 evaluatingJavaScript 但毕竟代表着2种功能形式与场景,因此在设计思想上也会带来一定差异。
通信编码:
JS与OC,在运行环境上毕竟是差异很大的2个环境,相互之间进行通信,一定是按着一定的通信协议来进行的,在协议的处理过程中一定伴随着通信编码,在一个 WebView 容器体系下,通信编码也需要注意很多细节,从而保证数据传输过程中的健壮性。
上面介绍的四块后面会有针对性的伴随着代码示例进行详解
接下来也会介绍一些有一定的设计价值,但不一定要必备,可选的两个功能,我就不深入详解了
鉴权设计(可选):
App 内打开 WebView 的时候,这么多原生能力都提供给了网页端,但网页端是否都是可信并且安全的呢,虽然鉴权设计视业务需求而定,可以做的相对简单,也可以做的复杂一些,是可选的。
简单的做那么就是广告类的页面一律用常规 WebView 打开,只有自己业务所在域名的网页,才会用带有 Hybrid 能力的WebView进行打开,并且所有 Bridge 通信在执行前先检查判断所在域名是否合法,只有自己业务域名下的网页,才允许bridge通信,其他的一律拒绝。
想要做复杂点?其实微信服务号就是一个典型的例子,所有微信服务号JSSDK提供的能力,其实都是微信 WebView 的 Hybrid 能力,但你如果想调用你必须配置 AppId 与 AppSecret ,然后在 JSSDK 内部与微信原生内部进行权限认证功能的开发,这我就不深入展开了,一般业务也用不到,只是这个环节我扩展提一下,如果想做复杂也是有必要并且有业务场景的。
批量发送(可选):
JS与OC通信,必然面临着上下文通信开销,开销试通信频繁次数而定,大部分的传统 Hybrid WebView 设计,不太需要考虑这一点,因此无需专门设计批量发送,但如果特殊的业务需求导致必须频繁通信,那么批量发送通过加大数据吞吐量减少通信次数,从而减小上下文通信开销,这一个环节也是要考虑,但是可选的。
就像我说的一般来说用不上,常规 Hybrid WebView 开发里那通信调用频率,一点压力都没有,但什么时候会用上呢?
如果还记得我上篇文章说过的 JS与OC 假跳转方式的通信会吃消息?那么如果你真的在处理 UIWebView 的时候坚持想使用假跳转,那么吃消息这个事情怎么解决呢?没错,队列批量发送,所有JS的通信在发起调用的时候都暂存进入一个数组队列,全局起 timer 心跳,以不会吃消息的频次大约300ms一次,检测消息队列是否含有内容,然后通过合并消息,一次通信发送给客户端。
RN也是有批量发送功能的,可以关注一下 RN 的 RCTBatchBridge 这个类,RN为什么需要批量消息队列呢?RN并不是假跳转的方式通信,而是最稳健功能强大的 JSContext,根本不会发生丢消息的问题,但RN依然选择了批量通信发送,是因为RN不同于WebView,RN 的渲染层依赖 JS 告知原生进行组件贴图,那么多界面元素,每次Dom变化,都可能带来的复杂高频次渲染消息的发出,因此 RN 整体设计了批量发送功能,思路也是一致,在 JS 这边每次执行通信,都暂存在队列,然后以心跳方式 flush 整个队列。 WebView的渲染都在 WebKit 内核里,其实浏览器内核也面临所谓的 Dom 环境与 JS 环境的通信,但这个已经在内核里深度优化了,我们就先不管啦,WebView的渲染涉及的高频词通信,完全不在我们的 Hybrid 框架设计的考虑范围内(前面我说过了一个脑洞,让WebView也像RN一样可以渲染原生贴图,那么批量发送就应该在考虑范围内了)
详细设计思路 — JS Call OC 无回调
声明:我会沿着思路一步步给出示例代码,会随着思路推翻或者修正前面给出过的示例代码
JS 发送消息
首先在 JS 侧我们把每一个调用原生的消息对象设计一下都需要涵盖什么内容?先只考虑 JS Call OC 无回调
var msgBody = {}; msgBody.handler = 'common'; msgBody.action = 'nativeLog'; msgBody.params = params; //任意json对象,用于传参.
非得设计这几个字段目的是啥? handler 和 action 其实主要是给每一条通信消息确定唯一的名字,params用于数据传参。
有的人说想要唯一识别每条消息,用一个 name 字段,或者干脆用个消息号数字就好了,反正发到客户端,客户端还得一一识别然后无论是注册式分发执行或是switch式分发执行。没错,所以我说了,并不是唯一设计思路,都可以灵活根据自己的业务与想法任意调整。
我为什么用2个呢?主要的原因在于想对大量的通信消息有一个整理,对于相近相似可以归类的消息,先用 handler 来命名消息所在的模块,在用 action 来命名消息的具体名字,好处是未来在进行模块化扩展,无论是在 JS 侧,还是在 OC 侧,都可以根据模块名,把大量的消息处理代码,分割到不同模块的代码类之中去,还是本着模块扩展与管理的想法,来把消息体用 handler 和 action 2个字段来进行描述
sendMessage: function (data) { if (this.isIOS) { try { window.webkit.messageHandlers.WKJSBridge.postMessage(data); } catch (error) { console.log('error native message'); } } if (this.isAndroid) { try { prompt(JSON.stringify([data])); } catch (error) { console.log('error native message'); } } },
定义完消息体我们就需要进行通信了,这个函数其实就是抽象出一层消息发送层,将我们刚刚创建的消息体,当做 data 传入 sendMessage 函数,这就是我在第一篇文章中提到的发送层来隔离平台差异以及通信 API 差异,不要为了追求所谓的前端代码统一来选择一个有天生缺陷的假跳转通信,就算选择完全不一样的通信方式,设计这样一个中间层,一样可以做到前端 JS 代码的统一
从扩展性的角度来讲,如果未来苹果可能出了更新更好的 API ,安卓也有通信 API 的调整,直接处理这个中间层就好了,别管是分平台适配,分安卓/iOS系统版本号适配,甚至同时兼容UIWebView/WKWebView,计算机领域里没有什么问题不是加一个中间层解决不了的(笑~)
如果以后打算扩展 批量消息,通信队列,那么其实也是一样的思路,设计一个 sendBatchMessage的Api
通信编码
因为本文采用的是 WKWebView 的 messageHandler 方法,在 JS Call OC 的时候会自动处理编码序列化与解码反序列化,所以你可以看到 isIOS 分支没有任何额外处理,就直接 Call 了,但咱们既然讲思路,那也得看看需要手动处理通信编码的场景。(UIWebView 的 JSContext 通信同理,可以传递对象,系统自动处理)
android 的 prompt 通信,prompt() 函数本来就是浏览器弹出一个输入框,输出一串字符串,所以只接受纯字符输入,那么编码方式就简单了,直接把 data 的 json 对象,用 JS 的 json序列化变成 json 字符串输出,等客户端收到拦截后把 json 字符串,反序列化成字典对象,(WKWebView如果也采用弹框拦截,同理)
UIWebView假跳转方式通信,因为假跳转本意是跳转到一个非法url,自然数据传递必须依靠url的参数规则去定制协议去拼接,那么就得拼接成类似 xxx:xxx/xxx?handler=xx&action=xx¶ms=”json string” 的url,然后在客户端拦截到url后,按着同样的规则反解
通信编码问题,会根据通信方案的选取/通信协议的设计,有着截然不同实现与方案,以及各自面临的坑和问题。带着一个准则,无论最终采用了什么样的数据协议设计,JS 怎么编码的,OC 怎么反向解码还原,就一定没问题,一定能解决。(就拿假跳转来说,当你跳进 encodeURIComponent 与 UrlEncode 的坑里的时候,一旦数据结构复杂起来,那就有的玩了,稍有不慎,可能编解码中间就出现歧义了就得踩坑了,不过这种事情不是啥大问题,最终一定能解决就是了)
OC接收消息
需要提前介绍一下我会在OC端设计几种对象,后面还会在灵活扩展的环节详细解释
消息体对象:包含单次消息的所有信息
bridge对象:我的 Hybrid WebView 设计理念是组合,而不是继承,因此我设计的不是一个 XXWebView / XXWebViewController 基类,使用者不需要在业务代码中使用 WebView 必须从我这里继承。我设计的是一个 NSObject 的 bridge 对象,使用者只需要跟自己业务中用的任意一种 WKWebView 的业务自己的类进行绑定,就可以拥有 Hybrid 的能力
业务web对象:业务方的 webview 对象 or webviewVC对象,业务方可以自由写自己的代码,规划自己的基类,不受任何限制,绑上bridge后,可以调用 bridge api
首先我们在OC也定义一个消息体对象
@interface msgObject : NSObject @property (nonatomic, copy, readonly) NSString * handler; @property (nonatomic, copy, readonly) NSString * action; @property (nonatomic, copy, readonly) NSDictionary * parameters; - (instancetype)initWithDictionary:(NSDictionary *)dict; @end
然后我们这里设计一套 block 注册式的消息体处理函数管理(放弃if else / switch 式的消息分发吧,哈哈),因为消息体是 handler / action 2层定义,所以 handlerMap 是个二维字典
// 这段代码属于 bridge 对象 // self.handlerMap 是 bridge对象的内部字典属性,保存着所有外部注册的各种通信的处理block代码 -(void)registerHandler:(NSString *)handlerName Action:(NSString *)actionName handler:(HandlerBlock)handler{ if (handlerName && actionName && handler) { NSMutableDictionary *handlerDic = [self.handlerMap objectForKey:handlerName]; if (!handlerDic) { handlerDic = [[NSMutableDictionary alloc]init]; } [self.handlerMap setObject:handlerDic forKey:handlerName]; [handlerDic setObject:handler forKey:actionName]; } } -(void)removeHandler:(NSString *)handlerName{ if (handlerName) { [self.handlerMap removeObjectForKey:handlerName]; } }
有了这样的注册机制,我们注册一个 OC 接受 JS 消息体的处理代码,业务在任意 webview 类 or vc 类中,可以调用我提供的注册 api 来实现业务的 消息体处理代码
// 这段代码属于 业务web 对象 // 业务在任意 webview类 or vc 类中,可以调用我提供的注册 api 来实现业务的 消息体处理代码 [self registerHandler:@"common" Action:@"nativeLog" handler:^(msgObject *msg) { NSLog(@"webview log : /n%@",msg) }];
这样 OC 这边的代码都已经准备就绪,只等 JS 通信到达的时候,进行消息体识别和分发,这是 messageHandler的系统 Api,基本思路就是,从 bridge 对象中的 handlerMap 字典中按着一级 handler 二级 action 二维字典取值去出注册好的执行代码block
-(void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{ NSDictionary *msgBody = message.body; if (msgBody) { msgObject *msg = [[msgObject alloc]initWithDictionary:msgBody]; NSDictionary *handlerDic = [self.handlerMap objectForKey:msg.handler]; HandlerBlock handler = [handlerDic objectForKey:msg.action]; handler(msg); } }
通信解码
因为 WKWebView 的 messageHandler Api 自动处理的编码解码,此处其实直接通过NSDictionary *msgBody = message.body; 一句话就直接拿到了最终消息体对象,但既然是讲编程思想,如果是安卓,如果是 iOS 但使用的不是 messageHandler 这种通信方式,自然就要在这一行代码的位置,进行手动解码,完全等同于编码的逆向操作,不做赘述了。
自此,JS CALL OC 无回调,基本流程走通
详细设计思路 — JS Call OC 回调
JS发送消息 声明含有回调
含有回调的 JS Call OC 有了回调后整个环节会更加完整,因此回调的设计得从 JS Call OC 的最初阶段就进行调整
var msgBody = {}; msgBody.handler = 'common'; msgBody.action = 'nativeLog'; msgBody.params = params; //任意json对象,用于传参. //msgBody.callbackId = ''; //msgBody.callbackFunction = '';
对JS消息体进行改造,增加用于处理回调相关的数据字段 callbackId 与 callbackFunction
callbackId:对每一次消息需要发起回调都会生成一个唯一ID,用来当回调发生时,找到最初的发起调用的 JS Callback
callbackFunction:客户端主动 Call JS 的唯一函数入口,客户端会用这个字符串来拼接回调注入的 JS 头,一般设计下,每个消息这个值都应该不变,不过也可以灵活处理(本来这个值可以不需要传递,写死在客户端,只要前端客户端约定好,但如果这个值不写死,而由前端可控操作,那么灵活性会更大,不必担心前端大规模修改 Call JS 唯一入口的时候,还得等客户端发版)
sendMessage: function (data,callback) { if (callback && typeof (callback) === 'function') { var callbackid = this.getNextCallbackID(); this.msgCallbackMap[callbackid] = callback; params.callbackId = callbackid; params.callbackFunction = 'window.callbackDispatcher'; } if (this.isIOS) { try { window.webkit.messageHandlers.WKJSBridge.postMessage(data); } catch (error) { console.log('error native message'); } } if (this.isAndroid) { try { prompt(JSON.stringify([data])); } catch (error) { console.log('error native message'); } } }, sendMessage(msgBody,function(result){ console.log('回调触发'); });
可以看到我们着手修改 sendMessage 函数,如果在调用的时候多写了一个callback函数,那么就会认为该次通信需要回调,因此对 callbackId 与 callbackFunction 进行赋值,callbackId 是一个保证每次通信都唯一的一个id值 getNextCallbackID ,大概思路可以是用时间戳+一定程度的随机小数来进行生成,思路不深入展开了。 callbackFunction 这里我们先写 window.callbackDispatcher 后面会提到这个入口是怎么操作的。
这里有一步最最重要的操作就是,this.msgCallbackMap[callbackid] = callback; 会把 JS 业务的回调函数,保存在一个全局可处理的回调字典之中,而 Key 就是这个唯一ID callbackId,这样当 OC 发起回调的时候,你才能找到对应的 JS Function
OC接受消息 识别处理回调
OC这边的消息体也得针对性进行修改,加入了 callbackID , callbackFunction, 加入了OC类的函数回调Api
typedef void (^JSResponseCallback)(NSDictionary* responseData); @interface msgObject : NSObject - (instancetype)initWithDictionary:(NSDictionary *)dict; @property (nonatomic, copy, readonly) NSString * handler; @property (nonatomic, copy, readonly) NSString * action; @property (nonatomic, copy, readonly) NSDictionary * parameters; @property (nonatomic, copy, readonly) NSString * callbackID; @property (nonatomic, copy, readonly) NSString *callbackFunction; -(void)setCallback:(JSResponseCallback)callback; //block 作为属性,保存在msgObject的.m文件里 -(void)callback:(NSDictionary *)result;//在msgObject的.m文件里 调用保存在消息体里的block @end
所以我们继续修改 OC 这边收到 JS 消息的函数体,当判断消息体含有回调信息的时候,就会生成用于回调的 OC Block,当OC业务处理完毕,准备回调回传数据的时候使用
-(void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{ NSDictionary *msgBody = message.body; if (msgBody) { msgObject *msg = [[msgObject alloc]initWithDictionary:msgBody]; NSDictionary *handlerDic = [self.handlerMap objectForKey:msg.handler]; HandlerBlock handler = [handlerDic objectForKey:msg.action]; //处理回调 if (msg.callbackID && msg.callbackID.length > 0) { //生成OC的回调block,输入参数是,任意字典对象的执行结果 JSResponseCallback callback = ^(id responseData){ //执行OC 主动 Call JS 的编码与通信 [weakSelf injectMessageFuction:callbackFunction withActionId:callbackId withParams:responseData]; }; [msg setCallback:callback]; } if (handler){ handler(msg); } } }
那业务在注册 OC 消息处理函数的时候,就可以使用这个block 进行回调
// 这段代码属于 业务web 对象 // 业务在任意 webview类 or vc 类中,可以调用我提供的注册 api 来实现业务的 消息体处理代码 [self registerHandler:@"common" Action:@"nativeLog" handler:^(msgObject *msg) { NSLog(@"webview log : /n%@",msg) NSDictionary *result = @{@"result":"result"}; //回调一个key value均为 result 字符串的字典当做数据 [msg callback:result]; }];
通信编码
之前说到 WKWebView 的 Api 自动处理的 JS CALL OC 的编码解码,但是 OC CALL JS 的编码解码并没有自动处理,所以我们得亲自做,这就是上面提到的 injectMessageFuction:withActionId:withParams 函数,介绍一下三个输入参数
Fuction:就是前边JS 传过来的 window.callbackDispatcher
ActionId:就是前边JS 传过来的 每个消息体的唯一ID
Params:就是客户端要回调的数据体,可以为空
我们会按着下面这种方式去拼接 JS 然后用 evaluateJavaScript: 来注入调用JS
[NSString stringWithFormat:@"%@('%@', '%@');", msg,actionId,paramString]
可以看到我们这么拼接出来的 JS 代码字符串其实是
window.callbackDispatcher('12345callbackid','{/'result/':/'result/'}');
但是编码过程还是需要注意的,我们如何把字典 params 转化为 paramString,确实直接用系统API NSJSONSerialization 转一下就看起来没问题了,但这里其实存在一定的隐患。
OC 主动 Call JS 的原理其实是,在客户端拼接出一段 JS 代码,但如果 params 这个数据中存在一定特殊字符比如 /r /n /f 等等,这些特殊字符会破坏 JS 的代码结构,打破原本的 JS 语法,这块要非常小心,尤其是你要传递大型嵌套字典数据的时候,简单的测试数据这个问题是无法暴露出来的,如果 JS 代码结构被破坏,那么所有通信 JS 的方法就失效了,所以编码这块大致代码思路可以是这样
// 字典JSON化 - (NSString *)_serializeMessageData:(id)message{ if (message) { return [[NSString alloc] initWithData:[NSJSONSerialization dataWithJSONObject:message options:NSJSONWritingPrettyPrinted error:nil] encoding:NSUTF8StringEncoding]; } return nil; } // JSON Javascript编码处理 - (NSString *)_transcodingJavascriptMessage:(NSString *)message { //NSLog(@"dispatchMessage = %@",message); message = [message stringByReplacingOccurrencesOfString:@"//" withString:@"////"]; message = [message stringByReplacingOccurrencesOfString:@"/"" withString:@"///""]; message = [message stringByReplacingOccurrencesOfString:@"/'" withString:@"///'"]; message = [message stringByReplacingOccurrencesOfString:@"/n" withString:@"//n"]; message = [message stringByReplacingOccurrencesOfString:@"/r" withString:@"//r"]; message = [message stringByReplacingOccurrencesOfString:@"/f" withString:@"//f"]; message = [message stringByReplacingOccurrencesOfString:@"/u2028" withString:@"//u2028"]; message = [message stringByReplacingOccurrencesOfString:@"/u2029" withString:@"//u2029"]; return message; } // 通信回调 -(void)injectMessageFuction:(NSString *)msg withActionId:(NSString *)actionId withParams:(NSDictionary *)params{ if (!params) { params = @{}; } NSString *paramsString = [self _serializeMessageData:params]; NSString *paramsJSString = [self _transcodingJavascriptMessage:paramsString]; NSString* javascriptCommand = [NSString stringWithFormat:@"%@('%@', '%@');", msg,actionId,paramsJSString]; if ([[NSThread currentThread] isMainThread]) { [self.webView evaluateJavaScript:javascriptCommand completionHandler:nil]; } else { __strong typeof(self)strongSelf = self; dispatch_sync(dispatch_get_main_queue(), ^{ [strongSelf.webView evaluateJavaScript:javascriptCommand completionHandler:nil]; }); } }
JS 接收 OC 回调
上面提到了,客户端会把 callbackId callbackFuction ResultString 拼接成如下 JS 代码,注入回 WebView
window.callbackDispatcher('12345callbackid','{/'result/':/'result/'}');
那么前端要做的就是准备好对应的函数,在window的对象上,挂上 callbackDispatcher 这个函数,这就是为啥我一开始说 callbackFunction 写死 window.callbackDispatcher 的原因,客户端用这个字符串,拼出了 JS 代码,这个 JS 代码执行的时候,就刚好window下有这么一个函数接着
window.callbackDispatcher: function (callbackId, resultjson) { var handler = this.msgCallbackMap[callbackId]; if (handler && typeof (handler) === 'function') { // JSON.parse(resultjson) console.log(resultjson); var resultObj = resultjson ? JSON.parse(resultjson) : {}; handler(resultObj); } },
当OC 已经成功回调到 JS 了,那么就用 callbackId 在刚才保存的回调字典里找到要回调的方法,然后把传过来的 resultjson 用 JS 的 JSON.parse 反序列化成字典,然后用找到的回调方法把数据传递进去
详细设计思路 — OC 主动 Call JS
彻底介绍完了 JS Call OC + 回调了,其实大致的思路已经说个七七八八了,再介绍OC 主动 Call JS 会简单许多,甚至真的自己沿着类似的设计思路思考捉摸一下,也能自行设计一个比较合理的方案了
JS 监听来自 OC 的主动消息
既然是容器框架代码层与业务解耦,提供监听的 Api 是一种比较好的方式,业务方会把监听事件用一个字符串来约定,比如锁屏事件约定为 applicationEnterBackground ,调用 API 的时候把事件字符串与事件处理函数传入,在一个全局可以管理的 eventCallMap 字典中进行存储,等待事件监听到达的时候,发起调用
//监听的API window.onListenEvent: function (eventId, handler) { var handlerArr = this.eventCallMap[eventId]; if (handlerArr === undefined) { handlerArr = []; this.eventCallMap[eventId] = handlerArr; } if (handler !== undefined) { handlerArr.push(handler); } },
那么当某个H5页面打算使用这个监听API的时候就这么使用就好了
//业务调用该API window.onListenEvent('applicationEnterBackground', function () { console.log('home press') });
刚才提到 JS Call OC 回调的时候有一个 callbackDispatcher 函数来承接,那么这种OC Call JS也得用类似的方式进行承接,于是我们准备一个 eventDispatcher ,思路是类似的,我不多介绍了
//接收OC事件的API window.eventDispatcher: function (eventId, resultjson) { var handlerArr = this.eventCallMap[eventId]; for (var key in handlerArr) { if (handlerArr.hasOwnProperty(key)) { var handler = handlerArr[key]; if (handler && typeof (handler) === 'function') { var resultObj = resultjson ? JSON.parse(resultjson) : {}; handler(resultObj); } } } },
OC 主动调用 JS
既然是OC主动发起,那么我们就拿锁屏这个事件来举例
-(void)addLifeCycleListenerCommon{ // app从后台进入前台都会调用这个方法 [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationEnterForeground) name:UIApplicationWillEnterForegroundNotification object:nil]; // 添加检测app进入后台的观察者 [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationEnterBackground) name: UIApplicationDidEnterBackgroundNotification object:nil]; } -(void)removeLifeCycleListenerCommon{ [[NSNotificationCenter defaultCenter]removeObserver:self]; } -(void)applicationEnterForeground{ //最关键的主动通信JS的函数 [self sendEventName:@"applicationEnterForeground" withParams:nil]; } -(void)applicationEnterBackground{ //最关键的主动通信JS的函数 [self sendEventName:@"applicationEnterBackground" withParams:nil]; }
刚刚 JS Call OC 回调的时候使用的是injectMessageFuction:withActionId:withParams:函数,那么这里介绍的 sendEventName:withParams:则是类似的用于拼接JS进行回调的函数
-(void)sendEventName:(NSString *)event withParams:(NSDictionary *)params{ NSString *jsFunction = 'window.eventDispatcher'; //还是走`injectMessageFuction:withActionId:withParams:` 这个函数,统一进行通信编码处理 [self injectMessageFuction:jsFunction withActionId:event withParams:params]; }
TIPS 思路扩展:
此处window.eventDispatcher 是客户端写死的,但我们之前提到过 callbackDispatcher就设计成 JS 通过主动通信传给客户端而不是写死,这样便于扩展。那么eventDispatcher 能不能也这样呢?其实很简单,我们设计一个 JS Call OC 的消息,传过来让客户端保存住就好了嘛。
业务模块扩展
上面把基本的 JS Call OC / 回调 / OC Call JS 的基本通信流程设计思路串了一遍,但我们提一下代码模块设计思路,因为通信是底层通用逻辑,但在这之上,业务会发展出各种专为业务服务的消息体,这些消息是堆积在一个代码里越积越多,毫无管理,还是设计成模块式划分,横向灵活扩展可插拔式的代码结构?这里只提一些我的个人的想法,代码的整洁之道有很多,每个人都有自己的体会,并不是说怎样就是最好的,我这里也仅仅是很粗略的提一下。
JS 代码模块设计
jsbridge-core.js
所有底层通信的相关代码能力,都会放到core这个js代码里,也就是上面我们介绍的各种核心通信框架代码
jsbridge-common.js
假如有一些通用的bridge消息需求,比如日志/获取设备信息/屏幕锁屏监听/屏幕,各种Common相关的需求代码,都放到这里
jsbridge-haha.js
假如有一些业务独有的需求,比如加入购物车,比如购买兑换积分等等,可以统一归类到 haha 模块,所有跟 haha 模块相关的代码,都放在这里
var Core = function () { this.ua = navigator.userAgent; this.isAndroid = (/(Android);?[/s//]+([/d.]+)?/.test(this.ua)); this.isIOS = !!this.ua.match(//(i[^;]+;( U;)? CPU.+Mac OS X/); this.msgCallbackMap = {}; this.eventCallMap = {}; this.sendMessage = function(xxxx){xxxx}; this.onListenEvent = function(xxxx){xxxx}; this.eventDispatcher = function(xxxx){xxxx}; this.callbackDispatcher = function(xxxx){xxxx}; }; window.bridge.Core = Core;
那么我们就把 JS 的通信底层 Core 模块设置好了,刚才提到的各种代码都在里面,这里这么写有点low,只为展示思路,有了思路,前端怎么整理代码优化代码都可以。另外注意此时我们已经是 window.bridge.core.callbackDispatcher了,所以传递 callbackFunction 的时候要注意
var Common = function () { this.webviewAppearEvent = 'webviewAppear'; this.webviewDisappearEvent = 'webviewDisappear'; this.applicationEnterBackgroundEvent = 'applicationEnterBackground'; this.applicationEnterForegroundEvent = 'applicationEnterForeground'; }; // dataDic为Object对象 Common.nativeLog = function (dataDic) { var params = {}; params.dataDic = dataDic; this.sendCommonMessage('nativeLog', params); }, // traceData为字符串 Common.crashTrace = function (traceData) { var params = {}; params.data = traceData; this.sendCommonMessage('crashTrace', params); }, // 复制剪切板 Common.copyContent = function (content) { var params = {}; params.str = content; this.sendCommonMessage('copyContent', params); }, //获取设备一些通用信息 Common.getCommonParams = function (callback) { this.sendCommonMessage('commonParams', {}, callback); }, // common模块的基础类,选用同样的 handler name => Common Common.sendCommonMessage: function (action, params, callback) { var msgBody = {}; msgBody.handler = 'Common'; msgBody.action = action; msgBody.params = params; window.bridge.Core.sendMessage(msgBody, callback); } window.bridge.Common = Common;
那么业务页面中使用就这样咯
//具体的某个h5页面 //页面有复制进入剪切板的需求 window.bridge.Common.copyContent('哈哈哈哈,我复制进剪切板啦') //页面有读取客户端信息的需求 window.bridge.Common.getCommonParams(function (params) { console.log(params); }); //页面有监听锁屏的需求 window.bridge.Core.onListenEvent(window.bridge.Common.applicationEnterBackgroundEvent, function () { console.log('home press') });
所有代码都有点low,只是介绍思路,我们业务代码也不会这样简单粗暴的写,只是为了简单说明意图,并且我也不深入扩展 haha 模块了就是举例。以后如果有新的一类业务需求,可以扩展新的模块,如果某个模块有新消息需求,可以单独新增消息
OC 代码模块设计
前文:
我的 Hybrid WebView 设计理念是组合,而不是继承,因此我设计的不是一个 XXWebView / XXWebViewController 基类,使用者不需要在业务代码中使用 WebView 必须从我这里继承。我设计的是一个 NSObject 的 bridge 对象,使用者只需要跟自己业务中用的任意一种 WKWebView 的业务自己的类进行绑定,就可以拥有 Hybrid 的能力
我也会准备一个类做核心通信类,比如就叫 XXBridge ,集成自 NSObject,用户在ViewController里可以创建各自业务自己封装的任意WebView对象,然后执行绑定操作,把 XXBridge 对象与 WebView 绑定起来,类似这样
//在任意业务VC的viewDidLoad里 //创建WKWebView WKWebViewConfiguration *config = [WKWebViewConfiguration new]; config.preferences = [WKPreferences new]; config.preferences.minimumFontSize = 10; config.preferences.javaScriptEnabled = YES; config.preferences.javaScriptCanOpenWindowsAutomatically = YES; WKWebView *webView = [[WKWebView alloc]initWithFrame:CGRectZero configuration:config]; self.webView = webView; //创建并绑定Bridge self.jsBridge = [[WKJSBridge alloc]init]; self.jsBridge.delegate = self; [self.jsBridge bindBridgeWithWebView:webView];
所谓的绑定过程大概思路其实只是,把WebView的一些 navigationDelegate/UIDelegate、configuration.userContentController 设置指向 XXBridge 内的处理函数,再把所有 WKWebView 的navigationDelegate UIDelegate通过 XXBridge 的delegate 转发给原VC,其实就是一层简单的代理拦截,我就不详解了,剩下的都是上面的提到过的具体通信代码了。
当然你完全可以使用继承,强调过很多次,只说设计思路,并且并不是唯一思路,也不代表是最优思路。
XXBridge Class
XXBridge+Common Category
XXBridge+haha Category
提到模块化可插拔式扩展,在OC里面最快想到的当然是Category,既然 JS 代码都划分为 Core/Common/haha,那么OC也这么做呗(不用category当然也行,只要把代码按着模块简洁合理的分割开来易于扩展和管理就好)
@implementation XXBridge (Common) -(void)registCommonHandler{ [self addLifeCycleListenerCommon]; __weak typeof(self) weakSelf = self; [self registerHandler:@"Common" Action:@"commonParams" handler:^(WKJSBridgeMessage *msg) { NSDictionary *result = [weakSelf getCommonParams]; [msg callback:result]; }]; [self registerHandler:@"Common" Action:@"copyContent" handler:^(WKJSBridgeMessage *msg) { NSDictionary *params = msg.parameters; NSString *content = [params objectForKey:@"str"]; UIPasteboard *pasteboard = [UIPasteboard generalPasteboard]; pasteboard.string = content; }]; [self registerHandler:@"Common" Action:@"nativeLog" handler:^(WKJSBridgeMessage *msg) { [weakSelf nativeLog:msg.parameters]; }]; [self registerHandler:@"Common" Action:@"crashTrace" handler:^(WKJSBridgeMessage *msg) { [weakSelf crashTrace:msg.parameters]; }]; } -(void)applicationEnterForeground{ [self sendEventName:WKJSBridgeAppEnterForegroundEvent withParams:nil]; } -(void)applicationEnterBackground{ [self sendEventName:WKJSBridgeAppEnterBackgroundEvent withParams:nil]; } -(void)addLifeCycleListenerCommon{ // app从后台进入前台都会调用这个方法 [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationEnterForeground) name:UIApplicationWillEnterForegroundNotification object:nil]; // 添加检测app进入后台的观察者 [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationEnterBackground) name: UIApplicationDidEnterBackgroundNotification object:nil]; } -(void)removeLifeCycleListenerCommon{ [[NSNotificationCenter defaultCenter]removeObserver:self]; } @end
UserAgent管理
WebView容器其实有一个很重要的需求,就是修改WebView UA,作为区别巨有容器能力的WebView识别方式,一般情况下拿到的UA会长这样
Mozilla/5.0 (Linux; Android 6.0.1; XT1650-05 Build/MCC24.246-37; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/51.0.2704.81 Mobile Safari/537.36
但我们在APP内无论是创建有 Hybrid 能力的 WebView,还是常规 WebView ,他们的UA都是系统默认UA,无法做到从 UA 上区别,客户端使用的 WebView 容器了,因此其实还有一个很实用的需求就是,针对 Hybrid WebView 扩充 UA
Mozilla/5.0 (Linux; Android 6.0.1; XT1650-05 Build/MCC24.246-37; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/51.0.2704.81 Mobile Safari/537.36 NBBRIDGE_屏幕宽_屏幕高_操作系统版本号_App版本号_设备类型
如果我扩充这样的UA有什么好处呢?前端可以很方便的通过对UA进行正则处理,快速便捷的取到
屏幕尺寸
操作系统尺寸
APP版本号
设备类型
有人会问,这些数据也可以通过设计一个Bridge消息,直接从客户端拿,为啥非得走UA呢?因为对于网页来说,分网页的 Client Side 和网页的 Server Side ,Client就说明这段 JS 已经运行在客户端的浏览器里,但 Server Side 会发生在 WebView 向 URL 发起请求,打到服务器端,此时可能是 PHP/Node/JAVA/GO等各种语言写的服务器,但他们的共同特点是在 Server Side 是没有bridge,是不可能建立通信的,此时UA就有意义了。
全局UA
iOS 8及 8 以下只能进行全局 UA 修改,可以通过 NSUserDefaults 的方式修改,一次修改每个WebView都有效(无论是常规 WebView 还是被你改造过的 Hybrid WebView)
NSDictionary *dictionary = [NSDictionary dictionaryWithObjectsAndKeys:UAStringXXX, @"UserAgent", nil]; [[NSUserDefaults standardUserDefaults] registerDefaults:dictionary];
独立UA
iOS 9 有了独立UA,可以针对每一个 WKWebView 对象实例,设置专属的UA
if (@available(iOS 9.0, *)) { self.webView.customUserAgent = self.fullUserAgent; }
Cookie管理
前边介绍UA的时候说过,在 Server Side 的时候是无法建立 Hybrid 通信的,因此传递数据的方式就只有 UA/Cookie/URL Query
UA适合传递跟设备相关的,描述设备固定不变的信息
Cookie适合传递任何你想要的数据,但Cookie有失效与域名限制
URL Query 适合传递任何你想要的数据,不过最好这个数据没什么安全敏感,因为GET请求是明文的(POST请求也可以,类比一下不多说了)
提到 WKWebView 就不得不把Cookie管理单独说一下。因为,WKWebView在Cookie上有太多的坑了,所以非常有必要把 Cookie 进行专门的手动代码管理。
传统的NSHTTPCookieStorage
通过 NSHTTPCookieStorage 设置的 Cookie ,这样设置的Cookie 无论是 UIWebView 页面请求还是 NSURLSession 网络请求,都会带上 Cookie,所以十分方便
WKWebView 的 Cookie 大坑
这篇文章里介绍了很多WKWebView的坑,其中会详细说好多Cookie的问题,简单说就是
WKWebView 发起的请求并不会带上 NSHTTPCookieStorage 里面的 Cookie
而比如用户登陆状态token等,最基础的设计就是把 token 写到 cookie 里,如果 WebView 获取不到 Cookie 的登陆状态应该怎么办
WKWebView ServerSide Cookie 设置
简单的说就是把 WKWebView 发起的 NSURLRequest 拦截,MutableCopy 一个,然后手动在RequestHeader里从NSHTTPCookieStorage读取Cookie进行添加
-(void)syncRequestCookie:(NSMutableURLRequest *)request { if (!request.URL) { return; } NSArray *availableCookie = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookiesForURL:request.URL]; NSMutableArray *filterCookie = [[NSMutableArray alloc]init]; if (filterCookie.count > 0) { NSDictionary *reqheader = [NSHTTPCookie requestHeaderFieldsWithCookies:filterCookie]; NSString *cookieStr = [reqheader objectForKey:@"Cookie"]; [request setValue:cookieStr forHTTPHeaderField:@"Cookie"]; } return; }
TIPS:
当服务器发生重定向的时候,此时第一次在 RequestHeader 中写入的 Cookie 会丢失,还需要重新对重定向的 NSURLRequest 进行 RequestHeader 的 Cookie 处理 ,简单的说就是在 webView:decidePolicyForNavigationAction:decisionHandler: 的时候,判断此时 Request 是否有你要的 Cookie 没有就Cancel掉,修改Request 重新发起
WKWebView ClientSide Cookie 设置
上面这么写完了,当页面加载的时候,后端无论是啥语言,都能从请求里看到 Cookie 了,但是后端渲染返回页面后,在 Client Side 浏览器里运行的时候,JS 在执行的时候用 document.cookie API 是读取不到的。所以还得针对 Client Side Cookie 进行处理
-(void)syncClientCookieScripts:(NSMutableURLRequest *)request{ if (!request.URL) { return; } NSArray *availableCookie = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookiesForURL:request.URL]; NSMutableArray *filterCookie = [[NSMutableArray alloc]init]; for (NSHTTPCookie * cookie in availableCookie) { if (self.syncCookieMode) { //httponly需求不得写入js cookie if (!cookie.HTTPOnly) { [filterCookie addObject:cookie]; } } } // 拼接 JS 代码 对 Client Side 注入Cookie NSDictionary *reqheader = [NSHTTPCookie requestHeaderFieldsWithCookies:filterCookie]; NSString *cookieStr = [reqheader objectForKey:@"Cookie"]; if (filterCookie.count > 0) { for (NSHTTPCookie *cookie in filterCookie) { NSTimeInterval expiretime = [cookie.expiresDate timeIntervalSince1970]; NSString *js = [NSString stringWithFormat:@"document.cookie ='%@=%@;expires=%f';",cookie.name,cookie.value,expiretime]; WKUserScript *jsscript = [[WKUserScript alloc]initWithSource:js injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:NO]; [self.userContentController addUserScript:jsscript]; } } return; }
Client Side Cookie 注入依靠的是创建一个 JS 脚本,让 WebView 去执行,介绍通信的时候我们用的是 evaluateScript 去进行主动注入,好处是随时随地调用都可以执行,但眼下这个场景用 WKUserScript 更好,并且推荐使用 WKUserScriptInjectionTimeAtDocumentStart 这个时机。
本地脚本管理
UIWebView代码注入时机与姿势
我的另一篇文章就提到了一个问题,注入时机。
在页面加载前主动注入,由于页面加载的时候,JS 会启用全新的 JSContext 因此之前注入全都无效,在页面加载完毕的时候注入,JS 会注入的比较晚,导致在 JS代码开始执行 -> 页面完全加载完毕 期间,Client Side 是没有 JS 注入的效果的
我的Link中提到的CSS注入
我的上文提到的Cookie注入
因此 WKWebView 的 WKUserScript 就提供了对于这个问题的解决办法,他的使用方式不是主动注入,而是提前准备好要注入的 JS 代码,在 WKWebView 加载页面的系统处理期间,由系统帮你选择在固定的时机,注入你准备好的 JS 代码,目前只有2个时机,DocumentStart/DocumentEnd
WKUserScriptInjectionTimeAtDocumentStart
WKUserScriptInjectionTimeAtDocumentEnd
因此这种提前预置静态 JS 注入的需求,也是一个 Hybrid WebView 容器应该考虑到的
WebView容器 - 常规扩展
一个 Hybrid WebView 容器,如果设计好了基础通信流程,设计好了模块扩展模式,其实还可以做一些比较通用的功能组件,这思路其实也和 RN 很类似的,好的架构设计好了,就应该可以横向自由灵活的自己扩展任意业务组件,但 RN 不也内置了很多 FB 提前帮你写好了的通用组件么?
Common 组件:我们的示例代码就是 Common 组件的一些基础操作
复制剪切板
获取设备信息
打客户端Log,上报日志
打客户端Crash追踪Log,随Crash上报
……打开你的想象力
CommonUI 组件:也有一些 Common 并且与 UI 相关的基础操作
showTips 展示客户端文字浮层
showDialog 展示客户端确认弹框,回调用户选择按钮
pullRefresh 采用客户端的下拉刷新,但配合 H5 进行数据加载
Router 跳转任意 App 内路由页面
NaviBarControl 可以让前端来定制客户端顶部 NaviBar
share Button 增加原生分享按钮,点击后出发原生分享
Other Button 增加任意原生按钮,点击后跳转任意 App 内路由页面
……打开你的想象力
NetWork 组件:判断浏览器调试环境下走 AJAX 网络请求,判断是客户端就通过客户端发起原生网络请求,请求结果回调 JS (为什么做?一般原生会封装网络请求,有更精细粒度的cache控制 ,和通用无痕日志埋点)
Get 不解释了
Post 不解释了
……打开你的想象力
Storage 组件:前端的存储只能使用 LocalStorage 和 Cookie 这二者都有很大的缺陷
Key - Value Plist Storage : 可以让前端把 Key Value 发给客户端,让客户端通过本地Plist 存储/读取/删除
File Storage:可以让前端把大段需要存储的字符串,发给客户端,让客户端在App沙盒内开辟文件路径, saveToFile存储成文件,并且提供目录操作能力,创建目录/删除目录/创建文件/删除文件/读取文件
……打开你的想象力
Push组件:可以让前端有能力写本地Push闹铃到App 或者上报远程Push Token
本地 Push 设置
远程 Push 获取 Token
其实如果基础功能扩充的足够强大,Hybrid WebView 可以有很强的能力,可以充分打开你的想象力,Hybrid 的宗旨就是,如果 WebView 原本做不到,或者做起来有很大限制或者性能不佳,那么就让原生配合,一起做到
WebView容器 - 脑洞预告
其实这部分内容本来应该是第三篇的内容,但也可以提前简单说说吊吊胃口╮(╯_╰)╭
深度调试能力
在客户端下调试 WebView 只能 safari 调试?能不能更方便一些? 能不能在QA黑盒测试不重新打包运行连电脑的情况下进行 JS 调试?
动态调用能力
所有的消息都必须提前在 OC 客户端写好处理模块,前端才能调用,能不能不发版就调用新原生逻辑?
能不能直接在JS里面写OC?
原生渲染能力
RN被很多人拿来说 RN 做出来的是原生 App ,界面层级都是原生的,WebView就做不到么?
小程序底层就是 WebView ,但小程序有些组件官方文档会写,此组件为原生,无法控制与其他 Dom 的 Z轴层级,他是怎么做的?(视频/地图/Canvas组件)
我们自己写的 WebView 容器能不能做到?
异步线程能力
都说 JS 是单线程,而且慢,能不能给 JS 增加多线程能力?(没错 WebKit 在2017年有个WebKit新标准提案就是这个,但我们暂时先不需要浏览器内核支持,客户端Hybrid能不能先简单支持一下?)
离线秒开能力
小程序会打包,然后下发给微信客户端,这样小程序的界面框架加载完全无网络请求,极大程度的加快前端散碎静态资源的加载速度,秒开能力(注意这和腾讯的 VasSonic 的秒开并不是一类方案)
系列相关文章
从零收拾一个hybrid框架(一)– 从选择JS通信方案开始
从零收拾一个hybrid框架(二)– WebView容器基础功能设计思路(本篇)
从零收拾一个Hybrid框架(三)– WebView容器的一些脑洞方案思路探讨 (挖坑ing)