开篇叨叨

这一系列博客写到这里基本上是快完成第一阶段的内容了,在规划这一系列博客之前对该篇博客的定位是:综述现有的动态化热更新方案,然后介绍下JavaScriptCore,但是后面思考了下,因为这部分内容更新换代非常快,并且对这部分的选型往往会依照现有的项目实际情况,以及团队的技术栈和个人喜好,所以后续想弄成一个可更新不断迭代的方案对比列表,这里重点介绍JavaScriptCore以及动态化的核心内容。

我理解的动态化包括:应用内容的动态化,比如一些活动页面等经常更换内容的页面,还有一部分是包括热更新在内的通过发布补丁来修复线上bug。当然如果从宽泛角度来讲应用内可配置的内容也属于动态化。在技术选型的时候我们会将跨平台和动态化综合考虑,因为一个项目中不可能跨平台选用Flutter,动态化选用Weex。其实现有的比较流行的跨平台方案主要有ReactNative, Weex, 最基本的WebView + JsBridge,以及目前比较流行的Flutter. 如果动态化内容只是简单的活动展示页的化其实WebView + JsBridge就可以满足需求了。没必要引入三方框架,引入框架会带来整个包的体积增大。但是如果你的动态化页面包含许多交互,以及动态效果,往往这些动态效果需要比较高的性能。这时候就需要通过引入RN,Weex或者Flutter了,但是Flutter的动态化目前比较捉襟见肘,所以个人比较偏向RN 或者 Weex,两者比较还是比较偏向RN,但是Weex比较吸引我的是使用Vue来写前端页面。因为个人只会Vue 不会 React。RN 在后续的专题中会另起一个系列。目前打算是在个人对前端技术进行总结的时候来写这部分博客,哈哈,如果按照老规矩那又得一两年之后。但是说不准,可能会提前。

最后说个大家比较容易犯的误区就是:大家会认为动态化这部分只要配备一个Web开发就可以了,但是实际上项目中往往会遇到各种平台适配问题,所以这部分一般会以一个Web开发为主,然后iOS端和Android端各派出一个人手来进行接入以及支援。

好了叨叨结束就要开始我们的正题了

这篇博客主要从下面三个方面进行介绍:

1. WKWebView
2. JavaScriptCore
3. Hybird && JSBridge
  1. WKWebView

WKWebView是在iOS8的时候推出的,在这之前UIWebview是iOS平台的唯一选择。由于UIWebview存在系统级的内存泄露、极高内存峰值、较差的稳定性、以及Javascript的运行性能及通信限制等问题,反观WKWebView号称拥有60fps滚动刷新率,丰富的手势、高效的Web和Native通信机制,默认进度条采用了和safari相同的Nitro引擎极大提升了Javascript的运行速度,WKWebView独立的进程管理,也降低了内存占用及Crash对主App的影响。目前很多项目已经逐渐弃用UIWebview转向使用WKWebView,但是如果你的项目需要支持iOS7那么就必须要UIWebview和WKWebview混合使用,下面是二者的简单对比:

1.1 UIWebview

在看WKWebView之前我们先看下UIWebview。UIWebview接口比较简单,它主要有如下几个部分构成:

页面加载

页面加载主要有三种方式一种是NSURLRequest,一种是http String格式,还有一种是NSData格式。

- (void)loadRequest:(NSURLRequest *)request;
- (void)loadHTMLString:(NSString *)string baseURL:(nullable NSURL *)baseURL;
- (void)loadData:(NSData *)data MIMEType:(NSString *)MIMEType textEncodingName:(NSString *)textEncodingName baseURL:(NSURL *)baseURL;

比较重要的属性

// UIWebView代理
@property (nullable, nonatomic, assign) id <UIWebViewDelegate> delegate;

//只读的scrollView 以及对应的request
@property (nonatomic, readonly, strong) UIScrollView *scrollView API_AVAILABLE(ios(5.0));
@property (nullable, nonatomic, readonly, strong) NSURLRequest *request;

//一些状态属性
@property (nonatomic, readonly, getter=canGoBack) BOOL canGoBack;
@property (nonatomic, readonly, getter=canGoForward) BOOL canGoForward;
@property (nonatomic, readonly, getter=isLoading) BOOL loading;

//其他控制UIWebView行为的属性

UIWebViewDelegate

我们可以看到iOS 13 上这些代理已经被标记为不支持了,所以一般情况下除非你的应用需要支持iOS 7,否则不再建议使用UIWebView了。

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType API_DEPRECATED("No longer supported.", ios(2.0, 12.0));
- (void)webViewDidStartLoad:(UIWebView *)webView API_DEPRECATED("No longer supported.", ios(2.0, 12.0));
- (void)webViewDidFinishLoad:(UIWebView *)webView API_DEPRECATED("No longer supported.", ios(2.0, 12.0));
- (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error API_DEPRECATED("No longer supported.", ios(2.0, 12.0));

将javascript嵌入页面中

- (nullable NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script;

下面举几个简单的例子:

  • 获取当前页面的url
    - (void)webViewDidFinishLoad:(UIWebView *)webView {  
    NSString *currentURL = [webView stringByEvaluatingJavaScriptFromString:@"document.location.href"];
    }
  • 获取页面title
    - (void)webViewDidFinishLoad:(UIWebView *)webView {  
    NSString *title = [webview stringByEvaluatingJavaScriptFromString:@"document.title"];
    }
  • 修改界面元素的值。
    NSString *js_result = [webView stringByEvaluatingJavaScriptFromString:@"document.getElementsByName('q')[0].value='Hello World';"];
  • 表单提交:
    NSString *js_result2 = [webView stringByEvaluatingJavaScriptFromString:@"document.forms[0].submit(); "];

调用JavaScript方法

self.context = [self.webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
[self.context evaluateScript:callScript];

暴露方法给JavaScript调用

构建给JavaScript调用的对象JSObject

IDLJSObject *object = [[IDLJSObject alloc] initWithViewController:self];
self.context = [self.viewHolder.webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
self.context[@"appWebViewFinish"] = object;
@interface IDLJSObject:NSObject

@end

@interface IDLJSObject()<IDLOnlineJSExport>
@property (nonatomic , weak) UIViewController *viewController;
@end

@implementation IDLJSObject

#pragma mark - Life Cycle
- (instancetype)initWithViewController:(UIViewController *)viewController {
if (self = [super init]) {
_viewController = viewController;
}
return self;
}

#pragma mark - Protocal IDLOnlineJSExport
- (void)finishWebView {
nm_dispatch_to_main_queue(^{
[self.viewController.navigationController popViewControllerAnimated:YES];
});
}
@end

暴露出去的方法:

@protocol IDLOnlineJSExport <JSExport>
- (void)finishWebView;
@end

1.2 WKWebView

UIWebView 在很长一段时间里面由于占用内存大,JavaScript导致的内存泄漏深受诟病,而在iOS 8之后,Apple在它的WebKit库中新增加了WKWebView,WKWebView采用跨进程方案,Nitro JS 解析器,高达 60fps 的刷新率,并且高度支持H5特性,深受开发者的欢迎。在流程上WKWebView多暴露了一些细节给开发者,使得开发者在整个流程的把控上更加灵活了。

重要的属性

WKWebView的配置
@property (nonatomic, readonly, copy) WKWebViewConfiguration *configuration;

WKWebView重定位Delegate
@property (nullable, nonatomic, weak) id <WKNavigationDelegate> navigationDelegate;

WKWebView交互Delegate
@property (nullable, nonatomic, weak) id <WKUIDelegate> UIDelegate;

//与WKWebView相关联的ScrollView
@property (nonatomic, readonly, strong) UIScrollView *scrollView;

@property (nullable, nonatomic, readonly, copy) NSString *title;
@property (nullable, nonatomic, readonly, copy) NSURL *URL;
@property (nonatomic, readonly, getter=isLoading) BOOL loading;
@property (nonatomic, readonly) double estimatedProgress;
@property (nonatomic, readonly) BOOL hasOnlySecureContent;
@property (nonatomic, readonly) BOOL canGoBack;
@property (nonatomic, readonly) BOOL canGoForward;
@property (nonatomic) BOOL allowsBackForwardNavigationGestures;
@property (nullable, nonatomic, copy) NSString *customUserAgent API_AVAILABLE(macos(10.11), ios(9.0));

加载方法


- (nullable WKNavigation *)loadRequest:(NSURLRequest *)request;

- (nullable WKNavigation *)loadFileURL:(NSURL *)URL allowingReadAccessToURL:(NSURL *)readAccessURL API_AVAILABLE(macos(10.11), ios(9.0));

- (nullable WKNavigation *)loadHTMLString:(NSString *)string baseURL:(nullable NSURL *)baseURL;

- (nullable WKNavigation *)loadData:(NSData *)data MIMEType:(NSString *)MIMEType characterEncodingName:(NSString *)characterEncodingName baseURL:(NSURL *)baseURL API_AVAILABLE(macos(10.11), ios(9.0));

重要的方法

- (nullable WKNavigation *)goBack;
- (nullable WKNavigation *)goForward;
- (nullable WKNavigation *)reload;
- (nullable WKNavigation *)reloadFromOrigin;
- (void)stopLoading;

执行JavaScript方法

- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)(_Nullable id, NSError * _Nullable error))completionHandler;

WKNavigationDelegate

从上图上看关于UIWebView与WKWebView的代理主要区别在于UIWebView只在加载的时候问询一次,而WKWebView在URL对应的内容加载结束之后还会进行一次问询,下面会对二者的代理进行一一对比。

是否加载的问询

#pragma mark - UIWebViewDelegate
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {return YES;}

#pragma mark - WKNavigationDelegate
//加载之前问询
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler { }
//加载之后问询
- (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler { }

同意载入之后,组件就开始下载指定 URL 的内容,在下载之前会调用一次 开始下载 回调,通知开发者 Web 已经开始下载。

#pragma mark - UIWebViewDelegate
- (void)webViewDidStartLoad:(UIWebView *)webView {}

#pragma mark - WKNavigationDelegate
//Invoked when a main frame navigation starts.
- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(null_unspecified WKNavigation *)navigation { }
//Invoked when content starts arriving for the main frame.
- (void)webView:(WKWebView *)webView didCommitNavigation:(null_unspecified WKNavigation *)navigation { }

页面下载完毕之后,UIWebView 会直接载入视图并调用载入成功回调,而WKWebView 会发询问,确定下载的内容被允许之后再载入视图。

#pragma mark - UIWebViewDelegate
- (void)webViewDidFinishLoad:(UIWebView *)webView {}

#pragma mark - WKNavigationDelegate
- (void)webView:(WKWebView *)webView didFinishNavigation:(null_unspecified WKNavigation *)navigation { }

如果载入失败则会调用对应的代理进行处理。


#pragma mark - UIWebViewDelegate
- (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error {}

#pragma mark - WKNavigationDelegate
- (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(null_unspecified WKNavigation *)navigation withError:(NSError *)error { }

- (void)webView:(WKWebView *)webView didFailNavigation:(null_unspecified WKNavigation *)navigation withError:(NSError *)error {}

除了上面的差异外WKWebView还多处了几处回调:

重定向回调:

WKWebView 在收到服务器重定向消息并且跳转询问允许之后,会回调重定向方法,这点是 UIWebView 没有的。

#pragma mark - WKNavigationDelegate
- (void)webView:(WKWebView *)webView didReceiveServerRedirectForProvisionalNavigation:(null_unspecified WKNavigation *)navigation;

因为 WKWebView 是跨进程的方案,当 WKWebView 进程退出时,会对主进程做一次方法回调。

- (void)webViewWebContentProcessDidTerminate:(WKWebView *)webView API_AVAILABLE(macosx(10.11), ios(9.0)) {}

HTTPS 证书自定义处理

- (void)webView:(WKWebView *)webView didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * _Nullable credential))completionHandler

WKUIDelegate

WKUIDelegate 包含了一系列UI相关的代理,包括Alert、Confirm、Prompt 等视图,以及WebView的创建以及关闭等。这个是之前UIWebView所不具备的,需要通过JS桥来实现。还有在 iOS 10之后,WKUIDelegate还新增了链接预览的支持,相关方法也在该协议中。

调用JavaScript方法

调用JavaScript方法是通过对应WebView提供的接口实现,但是WKWebView与UIWebView二者在实现上还是有区别的:WKWebView的接口是异步的,而UIWebView是同步的

#pragma mark - WKWebView
[wkWebView evaluateJavaScript:@"document.title"
completionHandler:^(id _Nullable ret, NSError * _Nullable error) {
NSString *title = ret;
}];

暴露方法给JavaScript调用

创建WKUserContentController,并添加到WKWebViewConfiguration,在初始化的时候传入WKWebView

WKUserContentController *userContent = [[WKUserContentController alloc] init];
[userContent addScriptMessageHandler:id<WKScriptMessageHandler> name:@"MyNative"];

WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
config.userContentController = userContent;

WKWebView *webView = [[WKWebView alloc] initWithFrame:CGRectZero configuration:config];

实现WKScriptMessageHandler,在这里可以接收WKScriptMessage类型的数据。WKScriptMessage类型的数据的body可以存放 NSNumber, NSString, NSDate, NSArray,NSDictionary, 以及 NSNull类型的数据

#pragma mark - WKScriptMessageHandler
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {

}

完成如下工作后Js中就可以通过如下形式进行调用native方法了:

function callNative() {
window.webkit.messageHandlers.MyNative.postMessage('body');
}

WKWebView的关键节点

对于Web开发,业务逻辑一般是基于Web容器的关键节点展开的,我们需要很清楚得了解何时注入对应的Js脚本,何时对Web容器进行设置,何时启动加载,都有哪些回调,何时回调,下面两张图很清楚得解释了这部分内容:


浏览器组成

整个浏览器的大致结构如下所示:

每个浏览器都有自己的内核,Safari的内核就是WebKit,上图的介于浏览器UI Layer 和 Native Layer的就是浏览器的内核WebKit.WebKit Embedding API负责WebKit与浏览器UI Layer进行交互的部分。Platform API 是为了让WebKit更加方便得移植到各个操作系统而提供的一些与平台无关,但是开放给平台的接口,这个和Android 的 HAL层一个道理,一般像FreeRTOS类似的可移植的跨平台操作系统都会存在一个抽象层来隔离各个底层的差异。这些接口会被各个平台以不同的实现来提供支持,比如上图的渲染层面,在iOS系统中,Safari是交给CoreGraphics处理,而在Android系统中,Webkit则是交给Skia。介绍了WebKit Embedding API,Platform API后接下来介绍WebKit最重要的两个部分WebCore和JavaScriptCore两大部分。

WebKit会有多个版本,但是WebCore这个部件是所有WebKit所共享的,不同版本WebKit之间的差异一般在于JavaScript Core, 各个基于WebKit内核的浏览器厂商一般会对各自浏览器的JS引擎进行性能优化,优化的部分就是针对JavaScript Core。这里我们来介绍下WebCore和JavaScriptCore两大部分的分工,我们在打开一个网页的时候,首先会通过网络去下载对应的HTML,CSS,JavaScript 脚本,其中HTML,CSS会被WebCore的 HTML Parser, CSS 解析成DOM树以及CSSOM树,然后以HTML为骨架,CSS为样式将DOM树以及CSSOM树合并,最终生成渲染树。

这时候JavaScript脚本通过词法分析,生成一个个Token序列,这个过程称为分词,分词是通过Lexer来完成的。这个步骤关注的是Token的提取,而不关注每个Token之间的关系。

将词法提取成功后接下来就是语法分析,这个步骤的任务就是如何将词法分析获得的Token序列通过处理形成一个抽象语法树(AST).

然后字节码生成器就会根据AST来生成JavaScript Core 字节码。一般的编译型语言会将这些生成的字节编码存放在内存或者硬盘之中,而对于解释型语言会将生成的字节码立即给JavaScript Core这台虚拟机进行逐行解释执行。一般会由低延时LLint(低级解释器)来解释执行生成的字节码,当遇到多次重复调用或者递归调用,以及循环等情况的时候会通过OSR切换成JIT(实时编译)进行解释执行,JIT主要有三种:

* Baseline JIT 基线JIT
* DFG 低延迟优化的JIT
* FTL 高通量优化的JIT

我们再次回到WebCore 流程:在JavaScript在 JavaScriptCore中 被解释执行的时候,会对WebCore中生成的渲染树有影响,它有可能添加DOM节点,删除DOM节点,修改DOM节点,最终经过修改后的渲染树会通过调用Native API通过平台的渲染框架渲染到屏幕上。

下图是JavaScriptCore 描述得比较好的一张结构图:

JavaScript Core的单线程机制:

在理解JavaScriptCore 机制的时候需要注意的是它的单线程机制,整个JS代码是执行在一条线程里的,它不具备多线程处理能力,它不像OC语言那样可以在自己的执行环境里面申请到多条线程去处理一些耗时任务,从而避免主线程被阻塞。但是接触过JS的伙伴可能会产生疑问,为什么记得也存在多线程异步的概念。这就得益于强大的事件驱动机制。

在遇到耗时任务的时候,JS会吧这个任务抛给由JS宿主提供的工作线程去处理,工作线程处理完毕之后,会发送一个消息通知JS线程当前任务已经执行完毕了,JS 线程得知这个消息后就会转而响应这个消息。但是这里需要注意的是JS线程和工作线程并不在一个运行环境,所以它们不共享一个作用域,所以工作线程中不能操作DOM.

iOS 中的JavaScript Core

Apple通过将WebKit的JS引擎用OC封装,提供了一套JS运行环境以及Native与JS数据类型之间的转换桥梁.整个架构大致长这样:

iOS 中的JavaScript Core 主要由如下类组成:

* JSVirtualMachine
* JSContext
* JSValue
* JSExport

举个例子:

  • 在虚拟机中执行某段代码:
    //创建虚拟机
    JSVirtualMachine *vm = [[JSVirtualMachine alloc] init];
    //创建上下文
    JSContext *context = [[JSContext alloc] initWithVirtualMachine:vm];
    //执行JavaScript代码并获取返回值
    JSValue *value = [context evaluateScript:@"1+1"];
    //转换成OC数据并打印
    NSLog(@"value = %d", [value toInt32]);
  • 在Native中运行JS方法
<script type="text/javascript">
var nativeCallJS = function (parameter) {
alert(parameter);
};
</script>
JSContext *jsContext = [webView valueForKeyPath:@“documentView.webView.mainFrame.javaScriptContext”];
JSValue *jsMethod = jsContext[@"nativeCallJS"];
[jsMethod callWithArguments:@[ @"Hello JS, I am iOS" ]];
  • 在JS中运行Native方法
//Html中按钮点击调用一个OC方法
<button type="button" onclick="jsCallNative('Hello iOS', 'I am JS');">调用OC代码</button>
JSContext *jsContext = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
jsContext[@"jsCallNative"] = ^() {
NSArray *args = [JSContext currentArguments];
for (JSValue *obj in args) {
NSLog(@"%@", obj);
}
};
  • 异常处理:
JSContext *jsContext = [[JSContext alloc] init];
jsContext.exceptionHandler = ^(JSContext *context, JSValue *exception) {
NSLog(@"JS Error: %@", exception);
};
[jsContext evaluateScript:@"(function errTest(){ return a+1; })();"];
  • JSVirtualMachine

JSVirtualMachine是一个完整独立的JavaScript的执行环境,它主要完成如下两个任务:

* 支持并发的JS调用
* JS和Native桥接对象的内存管理

上面提到的单线程机制指的是在一个JSVirtualMachine中,只有一条线程可以跑JS代码,所以我们无法在一个JSVirtualMachine中进行多线程处理JS任务,如果需要多线程处理那么就需要创建多个JSVirtualMachine来分别跑这些任务,从而达到多线程处理的目的。

  • JSContext

一个JSContext对象代表一个JavaScript执行环境,它支持在Native代码中使用JSContext去执行JS代码,访问JS中定义或者计算的值,并使JavaScript可以访问Native的对象、方法、函数.

每个JSContext都只能属于一个JSVirtualMachine,但是一个JSVirtualMachine可以有多个JSContext。并且这些多个JSContext可以相互传值。由于每个JSVirtualMachine都是独立且完整的,这里的独立指的是独立的堆空间以及垃圾回收器,某个虚拟机中的GC无法处理别的虚拟机堆中的对象。所以JSVirtualMachine之间不允许相互传值。

  • JSContext执行JS代码
    JSContext执行JS代码 是通过调用 evaluateScript函数来完成的,它可以向global对象添加函数和对象定义,它的返回值是JavaScript代码中最后一个生成的值。

  • JSContext访问JS对象

可以通过三种方式来访问JavaScript对象,以及设置对应的属性。

  • 通过context的实例方法objectForKeyedSubscript
  • 通过context.globalObject的objectForKeyedSubscript实例方法
  • 通过下标方式
JSValue *value = [context evaluateScript:@"var a = 1+2*3;"];
NSLog(@"a = %@", [context objectForKeyedSubscript:@"a"]);
NSLog(@"a = %@", [context.globalObject objectForKeyedSubscript:@"a"]);
NSLog(@"a = %@", context[@"a"]);

context[@"makeColor"] = ^(NSDictionary *rgb){
float r = [rgb[@"red"] floatValue];
float g = [rgb[@"green"] floatValue];
float b = [rgb[@"blue"] floatValue];
return [NSColor colorWithRed:(r / 255.f) green:(g / 255.f) blue:(b / 255.f) alpha:1.0];
};
JSValue *value = [context evaluateScript:@"makeColor({red:12, green:23, blue:67})"];

相关的API:

@interface JSContext : NSObject

/* 创建一个JSContext,同时会创建一个新的JSVirtualMachine */
(instancetype)init;

/* 在指定虚拟机上创建一个JSContext */
(instancetype)initWithVirtualMachine:(JSVirtualMachine*)virtualMachine;

/* 执行一段JS代码,返回最后生成的一个值 */
(JSValue *)evaluateScript:(NSString *)script;

/* 执行一段JS代码,并将sourceURL认作其源码URL(仅作标记用) */
- (JSValue *)evaluateScript:(NSString *)script withSourceURL:(NSURL*)sourceURL NS_AVAILABLE(10_10, 8_0);

/* 获取当前执行的JavaScript代码的context */
+ (JSContext *)currentContext;

/* 获取当前执行的JavaScript function*/
+ (JSValue *)currentCallee NS_AVAILABLE(10_10, 8_0);

/* 获取当前执行的JavaScript代码的this */
+ (JSValue *)currentThis;

/* Returns the arguments to the current native callback from JavaScript code.*/
+ (NSArray *)currentArguments;

/* 获取当前context的全局对象。WebKit中的context返回的便是WindowProxy对象*/
@property (readonly, strong) JSValue *globalObject;

@property (strong) JSValue *exception;

@property (copy) void(^exceptionHandler)(JSContext *context, JSValue *exception);

@property (readonly, strong) JSVirtualMachine *virtualMachine;

@property (copy) NSString *name NS_AVAILABLE(10_10, 8_0);
@end
@interface JSContext (SubscriptSupport)

/* 首先将key转为JSValue对象,然后使用这个值在JavaScript context的全局对象中查找这个名字的属性并返回 */
(JSValue *)objectForKeyedSubscript:(id)key;

/* 首先将key转为JSValue对象,然后用这个值在JavaScript context的全局对象中设置这个属性。
可使用这个方法将native中的对象或者方法桥接给JavaScript调用 */
(void)setObject:(id)object forKeyedSubscript:(NSObject <NSCopying>*)key;

@end
  • JSValue

一个JSValue实例就是一个JavaScript值的引用。通过JSValue可以在JavaScript和Native之间进行一些基本类型的数据转换,也可以使用它来创建包裹了自定义Native对象的JavaScript对象。甚至可以创建由Native方法或者block实现的JavaScript函数.

每个JSValue对象都持有其JSContext对象的强引用,只要有任何一个与特定JSContext关联的JSValue被持有,这个JSContext就会一直存活。

同时需要注意的是每个JSValue都通过其JSContext间接关联了一个特定的JSVirtualMachine对象。只能将一个JSValue对象传给由相同虚拟机管理的JSValue或者JSContext的实例方法。如果尝试把一个虚拟机的JSValue传给另一个虚拟机,将会触发Objective-C异常。

Apple提供了JSValue与Native进行相互转换的接口:
大家可以查看:JSValue API 苹果官方文档

  • JSExport

通过实现JSExport协议可以开放OC类和它们的实例方法,类方法,以及属性给JS调用。

这个在介绍之前介绍UIWebView的时候已经介绍过了,例子大家可以查看上面的部分。

  1. Hybird

为啥要使用Hybird,为解答这个疑问可以看下Hybird有哪些优点:


* 能够在不更新应用版本的情况下进行功能的快速更新迭代
* 多端复用,减少不同平台重复开发的工作量

就目前而言,除了一些小规模的应用外,大部分的应用都会引入混合开发技术,混合开发技术也从简单的基于WebView + JSBridge的实现,到React Native、Weex这些比较成熟的开源库。关于跨平台开发技术的总结大家可以查看我之前写的 《在部门内分享的Flutter材料》那里有对整个跨平台技术进行了比较全面的总结,这里就不详细展开了。

相信通过上面两部分的介绍大家对WKWebView,以及JavaScriptCore技术有了一个比较清楚的了解了,这两部分是接下来介绍Hybird的重要基础,接下来想和大家聊下Hybird框架的设计,这里只是谈论下Hybird设计的一些关键技术以及思想,后续会带大家解析一些较为流行的Hybird框架,后续也会将自己的一套简单的Hybird框架开源出来,所以这里大家先一起来了解一个Hybird框架应该包含哪些部分,具体涉及到哪些技术。各个部分在设计的时候应该注意哪些问题。

一个完整的 Hybrid 框架主要包括 WebView容器、Bridge、预加载、缓存等模块,还需要发布平台、灰度平台、增量更新、CDN 平台等等,这是很庞大的一个平台,并不是简单的 WebView容器 + Bridge. 当然如果只是简单的项目WebView容器 + Bridge已经足够支持。

下面将针对如下几点进行简要介绍一个Hybird的设计:

1.  WebView容器设计
2. JSBridge设计
2.1 Router 设计
2.2 Actions 设计
3. 预加载和缓存设计
4. 发布 && 灰度平台设计
5. 增量更新设计

WebView容器设计

在前面介绍WebView的时候已经介绍过UIWebView将在iOS 13上废弃使用了,所以除非你的项目需要兼容iOS7版本的话,否则不应该还使用UIWebView。所以剩下的问题就是如何通过WKWebView来设计我们的Hybird框架了。

JSBridge设计

JSBridge是整个Hybird的最重要部分,它是Web与Native交互的核心部分,一般是通过Native与Web商定协议,通过协议告诉对方需要调用哪个方法,使用哪些参数,目前用得比较多的是基于URL的拦截机制,在收到URL请求的时候对URL进行校验提取:

首先校验是否是该项目的Hybird请求,如果是的话从url中提取出请求类别,是Router还是某个对应的Action,具体类别的行为,比如当前请求类别是Action那么具体是调起通话action还是打开通讯录action,请求参数等等。下面将针对如下两点进行展开:

  • 如何注入JSBridge功能:

就目前方案而言用得较多的就是基于URL的拦截机制,也就是在每次收到请求的时候WebView都会有机会对当前请求做个拦截决定是否继续加载某个页面,通过这个时机如果发现是我们定义的JSBridge请求那么就拦截不进行跳转,转而调用本地的方法。这也是我们项目目前采用的一种方法。它的优点是比较通用Android 和 iOS都有这一套机制。所以实现的时候可以很方便得保持一套思路。有助于框架的统一。

另一种是iOS所独有的是WKWebView引入的上面也介绍过了,它就是基于scriptMessageHandler注入,不通过任何拦截的办法,而是直接将一个Native对象或者函数注入到JS里面,可以由web的js代码直接调用。

注入方式如下:

[self.webView.configuration.userContentController addScriptMessageHandler:self name:@"nativeObj"];

这里有个十分关键的地方:如果当前WKWebView没用了需要销毁,需要先移除这个对象注入,否则会造成内存泄漏,WebView和所在VC循环引用,无法销毁。可以通过如下方式进行移除:

[self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"nativeObj"];

相较而言个人还是比较推荐通过scriptMessageHandler方式注入,但是它不如拦截通用,意味着Androd需要另外一套,不过个人还是比较固执得推荐通过scriptMessageHandler来注入,嗯,是的就是固执。

  • 如何通过JSBridge进行实现Web与Native的相互调用:

在iOS平台上个人比较偏好采用scriptMessageHandler方式实现js调用native。通过evaluatingJavaScript实现native调用js。
下面是两者的基本使用方式:

通过 scriptMessageHandler 注入对象:

方法1:

[self.webView.configuration.userContentController addScriptMessageHandler:self name:@"nativeObject"];

方法2:

WKUserContentController *userContent = [[WKUserContentController alloc] init];
[userContent addScriptMessageHandler:id<WKScriptMessageHandler> name:@"nativeObject"];

WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
config.userContentController = userContent;

WKWebView *webView = [[WKWebView alloc] initWithFrame:CGRectZero configuration:config];

其实两者是一样的看你个人喜欢,还要时刻牢记在不用的时候及时销毁。

实现WKScriptMessageHandler代理:

-(void)userContentController:(WKUserContentController *)userContentController 
didReceiveScriptMessage:(WKScriptMessage *)message
{
NSDictionary *msgBody = message.body;
//1 取出协议前缀,通过协议前缀进行校验
//2 取出指令类别
//3 取出具体指令
//4 取出协议版本
//5 取出指令参数
//6 取出回调
//6 根据上述内容调用对应的本地方法
}

JS端调用:

var data = {
module:'xxxx',
type:'xxxx',
action:'xxxx',
version:'xxxx',
params:'xxxx',
callback:'xxxx',
//其他通用参数
};
//传递给客户端
window.webkit.messageHandlers.nativeObject.postMessage(data);

Native 调用 JS

NSString* javascriptCommand = [NSString stringWithFormat:@"calljs('%@');", paramsString];
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];
});
}

这里需要注意的是在调用的时候需要对各个请求鉴权,哪些请求需要登录态,哪些请求不能访问某些本地资源,对某些敏感的页面请求甚至需要设置黑白名单。

在加载DOM的时候调用JS:

上面介绍的evaluatingJavaScript是在客户端执行这条代码的时候立刻去执行当条JS代码,而还可以通过WKUserScript预先准备好JS代码,当WKWebView加载Dom的时候,执行当条JS代码下面是一个简单例子:

WKUserScript *script = [[WKUserScript alloc]initWithSource:source
injectionTime:time
forMainFrameOnly:mainOnly];
WKUserContentController *userController = webView.userContentController;
[userController addUserScript:script]

JSBridge 应该包括那些内容:

1.方法的注入:

* H5注入方法给Native调用
* Native注入方法给H5调用

2.H5 与 Native 的相互调用

* H5主动调用Native
* H5主动调用Native后回调

* Native主动调用H5
* Native主动调用H5后回调

3.获取当前版本JS桥所支持的的方法列表

* H5 获取本地可以支持的方法列表
* Native 获取H5所支持的方法列表

4.协议设计(包含鉴权)

5.异常处理

下面不会对全部进行介绍只会针对关键环节进行简要说明:

  • 启动阶段

JS 桥在启动的时候H5会通过JS桥初始化参数来主动向Native获取一些公共参数,比如当前平台名,设备信息,当前应用版本,用户信息,国家区域信息等。然后Native 和 H5通过上面介绍的方法各自往JSBridge 注入各自的方法。

  • 协议设计

协议主要用于告诉表调用方,我是要使用哪些参数调用哪个模块的哪个方法。下面是我自己实现的一个Hybird的协议,分得比较细,主要是为了将协议归类.待引入组件化后可以根据所属组件分发,整个逻辑会显得更加清晰。

module:             模块名
type: 协议类型,是路由协议还是指令调用协议
action: 指令或者路由名
version: JS桥版本
params: 协议参数
from: 来源
callbackId: 回调id 唯一
callbackFunction: 回调处理函数
//其他通用参数
  1. H5 与 Native 的相互调用
  • H5主动调用Native

H5端调用:

window.webkit.messageHandlers.IDLJSBridge.postMessage(data);

Native端:

实现WKScriptMessageHandler代理,对消息进行处理,为了避免WKScriptMessageHandler过大还可以划分多个WKUserContentController进行处理。或者将处理方法划分到不同对分类中。

-(void)userContentController:(WKUserContentController *)userContentController 
didReceiveScriptMessage:(WKScriptMessage *)message
{
NSDictionary *msgBody = message.body;
//1 取出协议前缀,通过协议前缀进行校验
//2 取出指令类别
//3 取出具体指令
//4 取出协议版本
//5 取出指令参数
//6 取出回调
//6 根据上述内容调用对应的本地方法
}
  • H5主动调用Native后回调

对于有回调的方式和无回调的方式的区别在于在向客户端传递消息的时候不但需要传递必须的数据,还需要传递callbackID以及callbackFunction。callbackId需要保证每次通信都唯一。可以通过“时间戳+随机数”,callbackFunction是回调的统一处理函数。
在JSBridge中存在一个map msgCallbackMap用于存放回调的信息,key为callbackId 值为待调用的callback函数。

在发起调用的时候如果需要回调则在下面结构的callbackId填写生成的id以及callbackFunction。

module:             模块名
type: 协议类型,是路由协议还是指令调用协议
action: 指令或者路由名
version: JS桥版本
params: 协议参数
from: 来源
callbackId: 回调id 唯一
callbackFunction: 回调处理函数
//其他通用参数

一旦H5发起请求后请求到达Native的userContentController 代理方法中,Native根据协议调用对应的本地方法处理请求后查看callbackId如果有值,就拼接请求:callbackFunction(callbackId,result) 通过evaluateJavaScript来执行。这时候流程回到H5,H5再使用callbackId 从 msgCallbackMap中取出对应的handler,调用handler(result)进行处理。

  • Native主动调用H5

这里包括两个部分:

  1. JSBridge注册供调用的方法:
window.registerHandlerForNative: function (handlerName, handler) {
var handlerArr = this.handlerForNativeMap[handlerName];
if (handlerArr === undefined) {
handlerArr = [];
this.handlerForNativeMap[handlerName] = handlerArr;
}
if (handler !== undefined) {
handlerArr.push(handler);
}
},

这时候就可以通过注册对应的handler进行处理了。比如:

window.registerHandlerForNative('callByNative', function () {
console.log('call by native')
});

native调用中转:

window.nativeCallDispatcher: function (handlerName, resultjson) {
var handlerArr = this.handlerForNativeMap[handlerName];
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);
}
}
}
},

这样本地就可以通过构建window.nativeCallDispatcher(handlerName,resultjson)字符串,然后通过evaluateJavaScript进行调用了。

  • Native主动调用H5后回调

这个其实比H5回调Native后回调要简单得多evaluateJavaScript原生支持:

[self.webView evaluateJavaScript:javascriptCommand completionHandler:handler];

上面的handler就是在执行完毕后返回被调用的。所以我们只需要在处理函数中return一个结果就可以了。

window.nativeCallDispatcher: function (handlerName, resultjson) {
var handlerArr = this.handlerForNativeMap[handlerName];
for (var key in handlerArr) {
if (handlerArr.hasOwnProperty(key)) {
var handler = handlerArr[key];
if (handler && typeof (handler) === 'function') {
var resultObj = resultjson ? JSON.parse(resultjson) : {};
var responseObj = handler(resultObj);
return responseObj;
}
}
}
},

Router && Actions 设计
这些都需要在本地封装成一个简洁的模块后直接调用,具体开放的接口一般会依照业务而定,不建议一上来就开放很多接口。这里还需要注意的是在调用这些方法之前需要事先鉴权,或者登录态校验,以及针对敏感性资源建立黑白名单等。

换句话说Hybird开放接口需要一套权限分级的逻辑控制相关的接口调用,最常规的做法就是对开放接口建立分级的管理,不同权限的H5页面只能调用各自权限内的接口。同时支持动态拉取权限白名单从而可以灵活得配置H5页面的权限。

下面摘自:一文掌握iOS开发中的全部web知识

Hybird高阶话题 优化部分

在很多年前刚接触Hybird的时候,我记得问过一个问题,Hybird为啥要将H5页面打包到应用中,不会增大整个包体积吗?因为在当时的印象中Hybird就是一个WebView加载一个Web页面,再由JSBridge提供Web与Native交互的接口。在加载完某个页面后再将当前页面缓存下来供下次调用不就行了,后续接触多了之后发现但是有这个错误的理解是因为当时接触使用Hybird做的业务都是很简单的业务。大部分的业务还都是放在Native 端做,只不过在某些活动页或者说明页的情景下会采用Hybird,但是对于像淘宝,京东那样内容需要快速更新的场景,有可能就需要某个业务的全部页面都要使用Hybird实现。这种情况下就对我们的Hybird框架有很高的要求了。这就涉及到如何优化这部分设计了。

1.离线包机制

对于某个功能一般在应用打包的时候会内置一份全量离线包,以及本地配置文件。在首次打开的时候会先查看本地配置文件是否归属于当前应用版本,以及配置文件的md5值是否和保存在应用中的配置文件md5值相等,如果不相等则拿应用的版本信息从服务端请求一份最新的离线包配置文件保存到本地,如果相等则校验全量包的md5值是否等于本地配置文件中的md5值。如果不相等则删除本地全量包从后台再下载一份。如果相等说明离线包配置和全量包匹配,这时候就可以对全量包进行解压。

在后续访问H5页面的时候,先进行拦截,如果离线包中有对应的内容那么就直接访问本地资源,否则访问线上资源。

接下来就需要考虑一个问题:应用如何感知到某个离线包升级了。这里有两种方式,一种是通过长链接下发,主动通知应用,这种情况可以在每次发布新的离线包的时候,下发配置文件的md5值,客户端在收到这个md5值后和本地配置文件的md5值进行对比,如果不相同则请求最新的配置文件保存到本地。一种是采用轮询的方式比如每10分钟轮询一次,两种都可行,但是个人比较推荐第一种。为了避免App长时间停留在后台而导致无法及时收到更新的通知,我们还需要在App后台进入前台的时候,主动请求一次。

对于单个离线包本地配置文件可以如下设置:


{
"latestVersion": "最新版本号",
"md5": "zip包 MD5值",
"url": "zip包下载地址",
"packages": [
{
"version": "某主包版本号",
"url": "差分包下载地址",
"md5": "差分包md5值"
},
{
"version": "某主包版本号",
"url": "差分包下载地址",
"md5": "差分包md5值"
}
]
}

每次拿到最新的配置文件后就拿本地的离线包版本号与最新的版本进行比较,如果等于最新版本号那么就什么都不干直接返回,如果不等于最新版本号,就拿着当前的版本号,去packages数组中去找,找到version等于当前本地离线包版本的那个配置,拿到补丁包
的下载地址,下载完毕后通过md5校验通过后将其合并到本地包,修改本地包的版本信息。如果在packages中没有找到那么就通过latestVersion对应的URL地址去下载全量包,再解压。

2.差分包制作

客户端在内置了全量包的基础上,为了减少每次下载更新的资源包的体积,一般都采用增量更新策略:每次发布版本的时候,如果此业务线之前已有离线包,则通过离线系统生成差分包发布。增量更新的策略使用的是基于node的 bsdiff/bspatch 二进制差量算法工具npm包bsdp。客户端下载差分包后使用bspatch合成更新包。

3.容错机制

由于网络因素,有可能导致包下载过程由于某些原因导致包下载失败,所以我们在设计过程中需要尽量增加整个系统的容错机制。即使使用非最新的包也不能导致页面异常的现象。所以在整个过程中需要注意如下几点:

* 在没有下载完成并通过md5校验之前不要删除本地离线包。
* 在弱网状态下先使用本地离线包,待切换到Wifi或者网络信号强的情况下开始下载。
* 为了减少网络因素导致的失败,需要增加重试策略.

针对各个业务最后的兜底选择就是每次都使用线上资源。所以如果发现:

* 内置的主包解压失败
* 离线系统接口超时
* 离线资源下载失败
* 增量的离线资源合并失败

这些情况先切换到线上资源,再尝试从后台下载离线包恢复。

一般为了避免离线资源使用带来的不可预料的风险,一般对每一个业务都需要加入使用离线资源的开关和灰度放量的控制

4.数据一致性校验与数据安全性

为了防止客户端下载离线资源时数据被篡改,导致下载的离线包无法解压,我们需要从服务端那边获得离线包的md5值,客户端下载后通过计算得到的资源包的md5值与之比较,可以保证数据的一致性。同时为了保证传输过程中,资源文件不被篡改,对于md5值的传输需要通过RSA加密算法进行加密。在服务端和客户端分别使用一对非对称的密钥进行加解密。

5.离线包下载优化

在介绍网络性能优化的时候介绍过如何加快网络的性能,里面的一些建议都可以借鉴,比如我们在可以在存放离线包资源的CDN中使用HTTP/2协议,这样客户端与CDN只需要建立一次连接,就可以并行下载所有的资源。 在需要下载离线包个数较多的情况下,会比传统的HTTP1有更快的传输速度。同时,客户端只需要运行一次。减少多次触发下载对手机资源的消耗。

在下载离线包的过程中可以通过离线包下载的断点续传和分块下载的功能来加快整个离线包下载的总进度,在发布离线包的时候可以使用CDN就近存储。

6.打包 && 发布 && 离线包管理系统设计

今天写得有点累了,不多说直接贴图,其实图还是蛮直观的:

7.其他

数据,模板分离、预加载内容页数据机制。
Contents
  1. 1. 开篇叨叨