美文网首页iOS知识收集iOS开发iOS高级
iOS WebView的Hybrid框架设计

iOS WebView的Hybrid框架设计

作者: 子胥 | 来源:发表于2017-03-13 11:47 被阅读1403次

    前言

    随着移动互联网的发展,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 代理的回调代理。

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

    相关文章

      网友评论

      • 着魔的毛豆:你是原作者?
        子胥:或者说是团队的力量
        子胥:恩
      • 张三呢:还有个问题,你们app底部的tabbar有没有用到类似header的动态调整方式,让tabbar动态显示与隐藏?
      • 张三呢:写的很棒,想请教下,你们采用wkwebview的时候,如何解决cookie的问题?
      • b59457960ac9:我的神,什么时候出后续篇,说好的预加载和缓存呢?先谢谢大神了
      • b59457960ac9:受教了,最近在了解这块!能分享下你的demo学习下吗?
        子胥:@Twistar 都提供使用,各个业务线不同的:smile:
        b59457960ac9:@子胥 恩,没事!还是谢谢你的回复!现在你们公司用的是WKWebView还是UIWebView呢?
        子胥:暂时还不能开源,需要再优化下内容。

      本文标题:iOS WebView的Hybrid框架设计

      本文链接:https://www.haomeiwen.com/subject/ddufnttx.html