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 代理的回調代理。

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容