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 的区别,总结如下:

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

原文发布于微信公众号 - 前端黑板报(FeHeiBanBao)

原文发表时间:2018-12-01

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券