iOS WebView 的 Hybrid 框架设计

2018 年 12 月 1 日 前端黑板报

前言

随着移动互联网的发展,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 的区别,总结如下:



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

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

YZWebView 是对 UIWebView 和 WKWebView 进行封装的类,结构设计如下:



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

@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,进度条代理方法。

protocol NJKWebViewProgressDelegate <NSObject>

- (void)webViewProgress:(NJKWebViewProgress *)webViewProgress updateProgress:(float)progress;

@end

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

- (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等)。

#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 代码逻辑:

#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 页面:

协议标准 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 页面:

协议标准 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 方法等。


协议标准 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 等等。

以设置右侧按钮为例:

协议标准 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 一整套解决方案落地并且能够提供开源。

目前5000+人已关注加入我们

       

       


登录查看更多
0

相关内容

【实用书】Python爬虫Web抓取数据,第二版,306页pdf
专知会员服务
117+阅读 · 2020年5月10日
最新《经济学中的强化学习》2020大综述,42页pdf128篇文献
【新书】Java企业微服务,Enterprise Java Microservices,272页pdf
TensorFlow Lite指南实战《TensorFlow Lite A primer》,附48页PPT
专知会员服务
69+阅读 · 2020年1月17日
【电子书】Flutter实战305页PDF免费下载
专知会员服务
22+阅读 · 2019年11月7日
微信小程序支持webP的WebAssembly方案
前端之巅
19+阅读 · 2019年8月14日
美团:基于跨平台框架Flutter的动态化平台建设
前端之巅
14+阅读 · 2019年6月17日
专访阿里亚顿:Serverless与BFF与前端
前端之巅
45+阅读 · 2019年5月8日
从webview到flutter:详解iOS中的Web开发
前端之巅
5+阅读 · 2019年3月24日
爱奇艺基于AI的移动端自动化测试框架的设计
前端之巅
18+阅读 · 2019年2月27日
免费云真机测试 | 让您的应用完美适配 Android Oreo
引力空间站
3+阅读 · 2018年2月2日
咦,用浏览器做人脸检测,竟然这么简单?
机械鸡
4+阅读 · 2017年9月11日
开源巨献:阿里巴巴最热门29款开源项目
算法与数据结构
5+阅读 · 2017年7月14日
Arxiv
15+阅读 · 2018年6月23日
Arxiv
5+阅读 · 2018年6月5日
Arxiv
3+阅读 · 2018年3月29日
Arxiv
3+阅读 · 2012年11月20日
VIP会员
相关资讯
微信小程序支持webP的WebAssembly方案
前端之巅
19+阅读 · 2019年8月14日
美团:基于跨平台框架Flutter的动态化平台建设
前端之巅
14+阅读 · 2019年6月17日
专访阿里亚顿:Serverless与BFF与前端
前端之巅
45+阅读 · 2019年5月8日
从webview到flutter:详解iOS中的Web开发
前端之巅
5+阅读 · 2019年3月24日
爱奇艺基于AI的移动端自动化测试框架的设计
前端之巅
18+阅读 · 2019年2月27日
免费云真机测试 | 让您的应用完美适配 Android Oreo
引力空间站
3+阅读 · 2018年2月2日
咦,用浏览器做人脸检测,竟然这么简单?
机械鸡
4+阅读 · 2017年9月11日
开源巨献:阿里巴巴最热门29款开源项目
算法与数据结构
5+阅读 · 2017年7月14日
Top
微信扫码咨询专知VIP会员