有赞移动

iOS WebView 的 Hybrid 框架设计

这里写图片描述

前言

随着移动互联网的发展,APP 开发模式也在不断的创新,从最初的 Native 开发到后来的 Hybrid 混合开发,再到最近比较火爆的 React Native、Weex 等项目,这些都标志着 APP 开发已经不再是纯 Native 的工作,还要涉及很多跨平台的技术。

作为一种混合开发模式,Hybrid APP 底层依赖 Native 端的 Web 容器(UIWebview 和 WKWebview),上层使用前端 HTML5、CSS、Javascript 做业务开发,这种开发模式非常适合业务快速拓展和迭代,在不发版本的前提下直接更新线上资源,受到不少公司的青睐与关注。

对于 Hybrid APP 开发,虽然业内早已出现 Cordova(PhoneGap)、jQuery Mobile 等框架,但是由于性能、维护成本等原因,并没有在业内非常流行,有些公司转而选择自己开发一套 Hybrid 框架,但是由于没有丰富的经验和应用场景导致开发出来的 Hybrid 框架后期维护成本很高。本文我将对在公司开发的 Hybrid 解决方案跟大家做一个介绍,希望对各位的技术选型起到帮助,也欢迎大家积极交流。

Hybrid APP 特点

Hybrid APP 优势很明显:

  • 跨平台,开发效率高,节约开发成本
  • 业务快速拓展和迭代
  • 及时修复线上 Bug,不需发版

但是 Hybrid 也有自己的劣势,比如体验上肯定比不了 Native,而且对于一个 Native 开发者而言要理解前后端的技术,对开发者的要求较高,但我相信这是好事儿~~

根据之前的经验,我觉得 Hybrid 需要找到自己的应用场景,比如营销、活动等需要快速试错和占领市场的团队来说,Hybrid 很适用,但是对于像 APP 首页这样要求体验高的场景 Hybrid 就不太适用,具体情况可以根据自己公司的 APP 场景做适当的调整。

Hybrid APP 框架

一个完整的 Hybrid APP 框架主要包括 WebView 容器、Bridge、UI、预加载、缓存等模块儿,当然 Bridge、预加载、缓存等也需要相应前后端的支持,比如发布平台、灰度平台、增量更新、CDN 平台等等。

框架结构如下:

这里写图片描述

在设计这套框架之前,需要弄清楚 Native 与前端的分工,Native 主要提供一个宿主环境,对 WebView 进行封装,提供 Bridge 方法,Header 组件设计,账号信息设计,底层提供预加载和缓存机制,框架的业务方是各个前端团队,所以我们需要站在前端的角度对以上方面进行考虑。本文主要对 WebView、Bridge、Header 设计进行介绍,后续文章会对账号信息设计、预加载和缓存进行持续跟进。

UIWebView 和 WKWebView 兼容

iOS8 以后苹果推出了一套新的 WKWebView,对于 UIWebView 和 WKWebView 的区别,总结如下:

Feature UIWebView WKWebView
JS执行速度
内存占用
进度条
Cookie 自动存储 需手动存储
缓存
NSURLProtocol拦截 可以 不可以

WKWebView 的主要优点是 JS 执行速度快、内存占用小,刚一推出就被开发者所追捧,但是不知道是不是因为苹果爸爸太任性,WKWebView 设计上并没有与 UIWebView 保持一致,无法自动存储 Cookie 和不能通过 NSURLProtocol 自定义请求等坑~导致 WKWebView 并没有被开发者大规模推荐使用。

本套框架的预加载和缓存模块儿需要借助 NSURLProtocol 实现,所以这里还是优先使用 UIWebView(想吐个槽,其实如果预加载和缓存这套系统做好以后,UIWebView 的效果并没不比 WKWebView 差),这里也不能把 WKWebView 一棒子打死不用,对于那些对无需预加载和缓存的页面,可以为前端提供参数(比如 wkwebview=true)让前端自己的去选择是否使用 WKWebView,所以这里需要对 WKWebView 进行兼容。

YZWebView 是对 UIWebView 和 WKWebView 进行封装的类,结构设计如下:
这里写图片描述

YZWebViewDelegate,UIWebView 和 WKWebView 代理的回调代理。

1
2
3
4
5
6
7
8
9
@protocol YZWebViewDelegate <NSObject>
@optional
- (BOOL)webView:(YZWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType;
- (void)webViewDidStartLoad:(YZWebView *)webView;
- (void)webViewDidFinishLoad:(YZWebView *)webView;
- (void)webView:(YZWebView *)webView didFailLoadWithError:(NSError *)error;
@end

NJKWebViewProgressDelegate,进度条代理方法。

1
2
3
4
5
@protocol NJKWebViewProgressDelegate <NSObject>
- (void)webViewProgress:(NJKWebViewProgress *)webViewProgress updateProgress:(float)progress;
@end

YZWebView 初始化方法,通过参数usingUIWebView 来决定初始化 WKWebView 或者 UIWebView,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
- (instancetype)initWithFrame:(CGRect)frame usingUIWebView:(BOOL)usingUIWebView {
self = [super initWithFrame:frame];
if (self) {
_usingUIWebView = usingUIWebView;
[self p_initSelf];
}
return self;
}
- (void)p_initSelf {
Class wkWebView = NSClassFromString(@"WKWebView");
if (wkWebView && !self.usingUIWebView) {
[self initWKWebView]; //初始化WKWebView
} else {
[self initUIWebView]; //初始化UIWebView
}
[self addSubview:self.currentWebView];
}
- (void)initWKWebView {
......
WKWebView *webView =
[[NSClassFromString(@"WKWebView") alloc] initWithFrame:self.bounds
configuration:webViewConfig];
webView.UIDelegate = self;
webView.navigationDelegate = self;
[webView setAutoresizesSubviews:YES];
[webView.scrollView setDecelerationRate:UIScrollViewDecelerationRateNormal];
[webView setAutoresizingMask:UIViewAutoresizingFlexibleHeight|UIViewAutoresizingFlexibleBottomMargin];
webView.backgroundColor = [UIColor clearColor];
[webView.scrollView setShowsHorizontalScrollIndicator:NO];
[webView addObserver:self
forKeyPath:@"estimatedProgress"
options:NSKeyValueObservingOptionNew
context:nil];
_currentWebView = webView;
}
//NJKWebViewProgress 没兼容WKWebView,这里需要通过KVO进行监测
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context {
if ([keyPath isEqualToString:@"estimatedProgress"]) {
self.estimatedProgress = [change[NSKeyValueChangeNewKey] doubleValue];
if (_progressDelegate && [_progressDelegate respondsToSelector:@selector(webViewProgress:updateProgress:)]) {
[_progressDelegate webViewProgress:nil updateProgress:_estimatedProgress];
}
}
}
- (void)initUIWebView {
......
UIWebView *uiWebView = [[UIWebView alloc] initWithFrame:self.bounds];
[uiWebView setAutoresizesSubviews:YES];
[uiWebView setScalesPageToFit:YES];
[uiWebView.scrollView setDecelerationRate:UIScrollViewDecelerationRateNormal];
[uiWebView setAutoresizingMask:UIViewAutoresizingFlexibleHeight|UIViewAutoresizingFlexibleBottomMargin];
uiWebView.keyboardDisplayRequiresUserAction = NO;
uiWebView.backgroundColor = [UIColor clearColor];
[uiWebView.scrollView setShowsHorizontalScrollIndicator:NO];
uiWebView.delegate = self;
self.njkWebViewProgress = [[NJKWebViewProgress alloc] init];
uiWebView.delegate = _njkWebViewProgress;
_njkWebViewProgress.webViewProxyDelegate = self;
_njkWebViewProgress.progressDelegate = self;
_currentWebView = uiWebView;
}

WebView 最关键的地方就是能捕获到前端资源的请求,UIWebView 的捕获方法是 webView:shouldStartLoadWithRequest:request navigationType:,WKWebView 的捕获方法是 webView:decidePolicyForNavigationAction:decisionHandler:,同时 WebView 有完整的生命周期回调(start,finish,fail等)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#pragma mark - UIWebViewDelegate
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {
self.currentRequest = request;
BOOL result = [self callback_webViewShouldStartLoadWithRequest:request navigationType:navigationType];
return result;
}
......
#pragma mark - WKNavigationDelegate
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
self.currentRequest = navigationAction.request;
BOOL result = [self callback_webViewShouldStartLoadWithRequest:navigationAction.request
navigationType:navigationAction.navigationType];
if (result) {
decisionHandler(WKNavigationActionPolicyAllow);
} else {
decisionHandler(WKNavigationActionPolicyCancel);
}
}
......
#pragma mark - YZWebViewCallback
- (BOOL)callback_webViewShouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(NSInteger)navigationType {
BOOL result = YES;
if ([self.delegate respondsToSelector:@selector(webView:
shouldStartLoadWithRequest:
navigationType:)]) {
if (navigationType == -1) {
navigationType = UIWebViewNavigationTypeOther;
}
result = [self.delegate webView:self shouldStartLoadWithRequest:request navigationType:navigationType];
}
return result;
}
......
#pragma mark - NJKWebViewProgressDelegate
- (void)webViewProgress:(NJKWebViewProgress *)webViewProgress updateProgress:(float)progress {
self.estimatedProgress = progress;
if (_progressDelegate && [_progressDelegate respondsToSelector:@selector(webViewProgress:updateProgress:)]) {
[_progressDelegate webViewProgress:webViewProgress updateProgress:_estimatedProgress];
}
}

需要强调的一点是:(UIWebView 的捕获方法是webView:shouldStartLoadWithRequest:request navigationType:,WKWebView 的捕获方法是webView:decidePolicyForNavigationAction:decisionHandler)这两个方法只能控制一个请求可不可以被 WebView 发出,比如 Bridge 就可以在这层进行捕获,但是并不可以做请求定制的功能。请求的定制需要借助 NSURLProtocol。

Bridge设计

Hybrid APP 的交互无非是 Native 调用前端页面的 JS 方法,或者前端页面通过 JS 调用 Native 提供的接口,两者交互的桥梁皆 Webview:
这里写图片描述

通过调研,前端可以通过在 DOM 注入 iframe 发起 Bridge 请求,该请求可以被webView:shouldStartLoadWithRequest:request navigationType:方法捕获,从而执行相应的操作,但是属于异步操作;还有一种前端可以通过 Ajax 发起 Bridge 请求,可以有同步异步两种方式,不过在 WebView 这层捕获不到此请求,只能通过 NSURLProtocol 拦截,所以这也是 WKWebView 的一个限制。

WebViewJavascriptBridge是一个不错的 JavaScript 与 Native 之间双向通信的库,多个厂家包括 Facebook 在使用,并且新的版本开始支持 WKWebView,对了解 Native 与 JS 的交互非常有帮助。

Bridge 设计至关重要,设计的好坏对后续开发、前端框架维护会造成深远的影响,并且这种影响往往是不可逆的,所以这里需要前端与 Native 好好配合,提供通用的接口。对于一个公司来说,往往一套底层框架需要服务于多条业务线、多个 APP,这就需要在设计的时候考虑好哪些桥接可以在框架层实现(比如跳转 Web 页面,设置数据,获取数据,Back 事件,Close 事件,Alert 弹框,获取定位等等),而与业务相关的桥接需要框架提供接口让业务方去注册(比如跳转 Native 页面,授权跳转等等)。

首先设计数据格式,根据 URL 格式:

scheme://host/path?query

与前端约定请求的格式是:

hybrid_scheme://hybrid_api?hybrid_params={params need encode}&callback=callback_ID

客户端需要根据约定,在 Bridge 处理结束后通过 WebView window 对象中的 callback_ID 调用回调,数据返回的格式约定为:

{
data : {},
err : 0, //非0提示msg
msg : “success or fail message”
}

Native 解析 Bridge 代码逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#pragma mark - YZWebViewDelegate
- (BOOL)webView:(YZWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {
......
if ([_jsBridge webViewShouldStartLoadWithRequest:request navigationType:navigationType]) {
//符合桥接规则
return NO;
}
return YES;
}
- (BOOL)webViewShouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {
NSURL *url = [request URL];
if ([self isWebViewJavascriptBridgeURL:url]) {
if ([self isWebViewJavascriptBridgeHost:url.host]) {
YZURLParseModel* model = [self p_parseWebViewRequestWithURL:url];
if ([[_messageHandlers allKeys] containsObject:model.method]) {
YZJSBridgeHandler messageHandler = _messageHandlers[model.method];
if (messageHandler) {
messageHandler(model.params);
}
.....
} else {
......
}
}
return YES;
} else {
return NO;
}
}
- (void)registerHandler:(NSString *)handlerName handler:(YZJSBridgeHandler)handler {
if (![_hostsArray containsObject:handlerName]) {
[_hostsArray addObject:handlerName];
}
_messageHandlers[handlerName] = [handler copy];
}
//业务注册桥接接口
static NSMutableDictionary* vocationalAsyncJSBridge = nil;
+ (void)setVocationalJSBridgeWithHandler:(NSString *)handlerName handler:(YZJSBridgeHandler)handler {
if (!vocationalAsyncJSBridge) {
vocationalAsyncJSBridge = [[NSMutableDictionary alloc] initWithCapacity:1];
}
vocationalAsyncJSBridge[handlerName] = handler;
}
+ (void)setVocationalJSBridge:(NSMutableDictionary*)dic {
if (!vocationalAsyncJSBridge) {
vocationalAsyncJSBridge = [[NSMutableDictionary alloc] initWithCapacity:1];
}
[vocationalAsyncJSBridge addEntriesFromDictionary:dic];
}

公共 Bridge 设计

  • 跳转
    跳转包括三类:
    ① 页面内跳转,无需走 hybrid。
    ② H5 跳转新开 WebView 页面。
    ③ H5 跳转 Native 页面。

H5 跳转新开 WebView 页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
协议标准 hybrid_scheme://gotoWebview?params={url:~}
__weak typeof(self) weakSelf = self;
//gotoWebview桥接
[self.jsBridge registerHandler:@"gotoWebview" handler:^(id data) {
__strong typeof(self) strongSelf = weakSelf;
if (strongSelf.jsBridge.delegate &&
[strongSelf.jsBridge.delegate respondsToSelector:@selector(gotoWebPageWithDatas:)]) {
if ([strongSelf.jsBridge.delegate gotoWebPageWithDatas:data]) //业务子类进行拓展
return;
}
if ([data isKindOfClass:[NSDictionary class]]) {
NSString* url = [data objectForKey:@"url"];
if (url) {
YZWebViewContainerViewControllerBase* vc = [[strongSelf getCurrentViewController] routeWithParams:@{@"url" : [NSURL URLWithString:url]}];
[strongSelf.navigationController pushViewController:vc animated:YES];
}
}
}];

H5 跳转 Native 页面:

1
2
3
4
5
6
7
8
9
10
11
12
协议标准 hybrid_scheme://gotoNative?params={page:~}
__weak typeof(self) weakSelf = self;
//gotoNative桥接
[self.jsBridge registerHandler:@"gotoNative" handler:^(id data) {
__strong typeof(self) strongSelf = weakSelf;
if (strongSelf.jsBridge.delegate &&
[strongSelf.jsBridge.delegate respondsToSelector:@selector(gotoNativeWithDatas:)]) {
if ([strongSelf.jsBridge.delegate gotoNativeWithDatas:data]) //业务子类进行拓展
return;
}
}];

  • 功能 API
    例:Back 事件、Reload 事件、Share 方法等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
协议标准 hybrid_scheme://doAction?params={action:back}
协议标准 hybrid_scheme://doAction?params={action:reload}
协议标准 hybrid_scheme://doAction?params={action:share, title:, subtitle:, context:, imgUrl:}
//doAction桥接
[self.jsBridge registerHandler:@"doAction" handler:^(id data) {
__strong typeof(self) strongSelf = weakSelf;
if (strongSelf.jsBridge.delegate &&
[strongSelf.jsBridge.delegate respondsToSelector:@selector(doActionWithDatas:)]) {
if ([strongSelf.jsBridge.delegate doActionWithDatas:data]) //业务子类进行拓展
return;
}
if ([data isKindOfClass:[NSDictionary class]]) {
NSString* action = [data objectForKey:@"action"];
if (action) {
if ([action isEqualToString:@"back"]) {
[strongSelf p_back];
} else if ([action isEqualToString:@"page_reload"]) {
[strongSelf p_reload];
} else if ([action isEqualToString:@"share"]) {
[strongSelf p_shareWithParams:data];
}
}
}
}];
  • Header 组件设计
    对于 Header 组件,需要完成以下功能:
    ① Header 的左侧具有返回键和关闭键(类似微信等 APP),右侧可配置文字和图标,并且可以控制回调。
    ② Title 通常在 WebView 加载完成后去获取 document.title 来显示,这里可以做到可配置。
    ③ Title 可以设置一些特别的 TitleView,比如 SegmentView、ListView 等等。

以设置右侧按钮为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
协议标准 hybrid_scheme://configNative?params={configs:[{type:nav_item_right, title:, icon_url:, action:, action_parameters: }]}
//configNative桥接
[self.jsBridge registerHandler:@"configNative" handler:^(id data) {
__strong typeof(self) strongSelf = weakSelf;
if (strongSelf.jsBridge.delegate &&
[strongSelf.jsBridge.delegate respondsToSelector:@selector(configNativeWithDatas:)]) {
if ([strongSelf.jsBridge.delegate configNativeWithDatas:data]) //业务子类进行拓展
return;
}
if ([data isKindOfClass:[NSDictionary class]]) {
NSString* strOfConfigs = data[@"configs"];
if (strOfConfigs == nil || [strOfConfigs isEqualToString:@""])
return;
NSError *error = nil;
NSArray *arrConfigs = [NSJSONSerialization JSONObjectWithData:[strOfConfigs dataUsingEncoding:NSUTF8StringEncoding]
options:NSJSONReadingAllowFragments error:&error];
if (error || !arrConfigs)
return;
for (NSDictionary* config in arrConfigs) {
NSString *strType = config[@"type"];
if (!strType || [strType isEqualToString:@""])
continue;
if ([strType isEqualToString:@"nav_item_right"]) {
YZNavigationItemConfigModel *model = [[YZNavigationItemConfigModel alloc] initWithDictionary:config error:&error];
if (error) {
error = nil;
continue;
}
[strongSelf.rightMenuBarButtonItems addObject:[strongSelf p_configRightBarButtonItemsWithModel:model]];
if (strongSelf.rightMenuBarButtonItems) {
[strongSelf.navigationItem setRightBarButtonItems:strongSelf.rightMenuBarButtonItems];
}
}
}
}
}];

总结

Hybrid 框架依靠快速迭代,快速试错在业务开发中使用非常广泛。本文初衷是想为那些准备使用Hybrid框架的人提供设计上的思路,并通过实际的事例去展示结果,希望对 Hybrid 感兴趣的朋友一起来把 Hybrid 一整套解决方案落地并且能够提供开源。