來自字節大佬首發:基於Flutter的Hybrid Webview容器實踐

背景

Flutter 是一個 UI 框架,實際開發中除了常見的 widget 還須要如地圖、webview等 Native 組件。html

一種方法是 Flutter 通知 Native 喚起 Native 界面,如以前的掃碼插件。缺點是 Native 組件很難和 Flutter 組件進行組合。前端

第二種是經過 Flutter 提供的 PlatformView(AndroidView/UIKitView) 將 Native 組件嵌入到 Flutter的組件樹。使 Flutter 可以像控制普通 widget 那樣控制 Native 組件。node

目標: Flutter 中嵌入 webview widget,這個 webview 須要受 flutter 控制,且可以與 flutter 通訊。ios

思路

圖片

具體實現

target 1: 實現 webview 插件

一、建立插件:web

flutter create -i objc --template=plugin hybrid_webview_flutter

自動生成 HybridWebviewFlutterPlugin 類,打開 Runner.xcworkspace小程序

二、在 info.flist 添加 io.flutter.embedded_views_preview: YES。PlatformView 功能默認關閉,不配置這行就無法使用微信小程序

圖片

三、建立 webview 類,實現 FlutterPlatformView 協議,在構造函數裏獲取 flutter 傳遞過來的參數,建立 webview,建立 FlutterMethodChannel 並設置 block 回調。api

// 註冊flutter 與 ios 通訊通道
NSString* channelName = [NSString stringWithFormat:@"com.calcbit.hybridWebview_%lld", viewId];
_channel = [FlutterMethodChannel methodChannelWithName:channelName binaryMessenger:messenger];
__weak __typeof__(self) weakSelf = self;
[_channel setMethodCallHandler:^(FlutterMethodCall *  call, FlutterResult  result) {
 [weakSelf onMethodCall:call result:result];
}];

四、建立工廠類 WebviewFactory,實現 FlutterPlatformViewFactory 協議,實現協議中的 createWithFrame 方法並返回步驟3建立的 webview數組

//用來建立 ios 原生view
- (nonnull NSObject<FlutterPlatformView> *)createWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id _Nullable)args {
    //args 爲flutter 傳過來的參數
    Webview *webView = [[Webview alloc] initWithWithFrame:frame viewIdentifier:viewId arguments:args binaryMessenger:_messenger];
    return webView;
}

五、步驟1生成的 HybridWebviewFlutterPlugin 註冊插件的方法 registerWithRegistrar 中添加一行註冊 WebviewFactory緩存

[registrar registerViewFactory:[[WebviewFactory alloc] initWithMessenger:registrar.messenger] withId:@"com.calcbit.hybridWebview"];

六、根目錄 lib/ 下新建 hybrid_webview.dart 文件,建立 HybridWebview Widget,build 返回 UiKitView。UiKitView 接收的 viewType 與步驟5註冊 Factory 時的 withId 一致。

creationParams 可傳遞參數給步驟3。

creationParamsCodec 標準平臺通道使用標準消息編解碼器,以支持簡單的相似JSON值的高效二進制序列化 參考StandardMessageCodec

onPlatformViewCreated 在 UiKitView 建立完成後執行,可獲取到 Native 組件的 viewId,註冊 MethodChannel,這時候 channel 可與步驟3建立的 webview 進行通訊

Widget buildWebView() {
    return UiKitView(
      viewType: "com.calcbit.hybridWebview",
      creationParams: {
        "url": widget.url,
      },
      //參數的編碼方式
      creationParamsCodec: const StandardMessageCodec(),
      //webview 建立後的回調
      onPlatformViewCreated: (id) {
        //建立通道
        _channel = new MethodChannel('com.calcbit.hybridWebview_$id');
        //設置監聽
        nativeMessageListener();
      },
      gestureRecognizers: <Factory<OneSequenceGestureRecognizer>>[
        new Factory<OneSequenceGestureRecognizer>(
          () => new EagerGestureRecognizer(),
        ),
      ].toSet(),
    );
}

target 2: flutter 與 native 通訊

native 調用 flutter

target 1 步驟3 建立的 FlutterMethodChannel channel 能夠調用 invokeMethod 方法傳遞消息名,參數給 flutter,並設置 flutter 回調

回調參數 result 多是 flutter Feature 返回值,也有多是 flutter 運行時報錯

flutter 調用 native

有了 target 1 步驟 6 onPlatformViewCreated 裏建立的 channel,使用 channel.invokeMethod 調用 native 方法,第一個參數爲消息名,第二個爲可選參數。返回一個 Future(相似 js 的 Promise)

在 target 1 步驟 3 OC 建立 FlutterMethodChannel 時的 block 可接收到 flutter 的調用信息。第一個參數 FlutterMethodCall 包含了 flutter 調用的消息名與參數,第二個參數 FlutterResult 是一個回調函數,傳遞給 flutter 返回值。

爲了擴展性,這裏將 invokeMethod 的第一個參數固定爲 __flutterCallJs,第二個參數固定爲數組,數組第一個參數固定爲 js 的目標方法。這樣只是用 __flutterCallJs 就不用每增長一個方法就去修改 native 的代碼。Native 調 flutter 的消息名不固定是由於咱們可以常常修改 flutter,可是不會常常修改 native

-(void)onMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result{
    if ([[call method] isEqualToString:@"__flutterCallJs"]) {
        NSString *action = [call.arguments firstObject];
        NSArray *params;
        if ([call.arguments count] > 1) {
            params = [call.arguments subarrayWithRange:NSMakeRange(1, [call.arguments count] -1)];
        } else {
            params = @[];
        }
        //  在主線程更新 webview,否則會崩
        dispatch_async(dispatch_get_main_queue(), ^{
            [self->_context[@"__flutterCallJs"] callWithArguments:@[action, params, ^(JSValue *value) {
                NSArray *arr = [value toArray];
                result(arr);
            }]];
        });
    } else if ([[call method] isEqualToString:@"evaluateJavaScript"]) {
        // 注入 js
        NSString* jsString = [call arguments];
        dispatch_async(dispatch_get_main_queue(), ^{
            [self->_webView stringByEvaluatingJavaScriptFromString:jsString];
        });
    }
}

下圖列舉了 native 與 flutter 值的轉換

圖片

經過實踐限制 flutter 調 oc 限制的參數爲 bool, int, double, string,List, Map, Null Set不能傳會報錯,map 的 key 必須爲 string,否則 flutter 傳給 OC 沒問題,OC傳給 js 的時候會剔除掉。如 {'a':1,2:2} 傳到 js 就變成了 {'a':1}

target 3: webview 與 native 通訊

native 調用 js

在 target 2 中 OC 已經可以接收到 flutter 傳遞過來的消息,這時候 OC 須要將消息傳給 js。能夠經過 KVC 獲取到 UIWebView 的 JSContext(WKWebView取不到context可是能夠經過消息形式)

在 webview 定義全局函數 __flutterCallJs 用來接收 OC 傳遞過來的值。

JSContext 執行 __flutterCallJs 透傳 flutter 傳過來的參數,並多傳一個 block 參數,block 在 js 裏會變成函數,js 側調用這個函數相似 callback

OC 的 block 接收到 js 執行的回調,調用 FlutterResult,將回調結果返回給 flutter

除了獲取 js context 執行 js,webview 常見的還有注入 js,能夠接收 flutter 傳來的 js string 注入到 webview

js 調用 native

一、JSContext 直接注入 bolck,js 調用這個函數

_context[@"globalFuction"] = ^(JSValue *value) {
 NSLog("%@", value);
};

二、經過 JSExport 協議,只有 JSExport 裏聲明的方法纔會被 js 訪問到

定義一個 JSExport 協議,並在 Class A 實現,將 A 實例化並做爲全局變量注入到 JSContext,這裏爲了方便直接在 webview 定義實現 JSExport,將 當期實例 self 注入到 JSContext

//定義一個JSExport protocol
@protocol JSExportProtocol <JSExport>
 JSExportAs(jsCallFlutter, - (void)jsCallFlutter:(JSValue *)action params:(JSValue *)params callback:(JSValue *)callback);
@end

//將self添加到context中
_context[@"__OCObj"] = self;
};

這時候 js 全局鏈就會有 __OCObj 對象,調用 __OCObj.jsCallFlutter 傳遞參數給 OC,約定 最後一個參數爲 callback,js Function 到 OC 裏面會轉換成 block

OC 經過 FlutterMethodChannel 調用 flutter 得到返回值後經過這個 block 觸發 js 的 callback

#pragma mark - jsExport
- (void)jsCallFlutter:(JSValue *)action params:(JSValue *)params callback:(JSValue *)callback {
    NSString *actionName = [NSString stringWithFormat:@"%@", action];
    NSArray *arr = [params toArray];
    [self->_channel invokeMethod:actionName arguments:arr result:^(id  _Nullable result) {
        if ([result isKindOfClass:[NSClassFromString(@"FlutterError") class]]) {
            [callback callWithArguments:@[[result valueForKey:@"_message"], [NSNull null]]];
        } else {
            id results;
            if (result) {
                results = result;
            } else {
                results = [NSNull null];
            }
            //  在主線程更新 webview
            dispatch_async(dispatch_get_main_queue(), ^{
                [callback callWithArguments:@[[NSNull null], results]];
            });
        }
    }];
}

經實踐,限制 js 傳給 OC 的值爲 boolean, number, string, array, obj, null/undefined

null/undefined 都會轉成 null,fn/set/map都會在OC變成空字典 {},{1: 'a'} 到了 OC key 也會轉成 string

target 4: cookie 共享

webView/OC,RN/OC cookie 都是共享的。可是 flutter 比較奇怪,用過的 dart:io 與 dio 都不自動帶上cookie,查看了 dio_cookie_manager 與 cookie_jar 的實現,發現 dio 是利用這兩個庫本身在 dart 維護了 cookie 信息,而後添加到 dio.interceptors 裏,隨 request 帶上,監聽 response 存儲。

// dio & dio_cookie_manager 代碼
Future onRequest(RequestOptions options) async {
    var cookies = cookieJar.loadForRequest(options.uri);
    cookies.removeWhere((cookie) {
      if (cookie.expires != null) {
        return cookie.expires.isBefore(DateTime.now());
      }
      return false;
    });
    String cookie = getCookies(cookies);
    if (cookie.isNotEmpty) options.headers[HttpHeaders.cookieHeader] = cookie;
  }
  @override
  Future onResponse(Response response) async => _saveCookies(response);
  _saveCookies(Response response) {
    if (response != null && response.headers != null) {
      List<String> cookies = response.headers[HttpHeaders.setCookieHeader];
      if (cookies != null) {
        cookieJar.saveFromResponse(
          response.request.uri,
          cookies.map((str) => Cookie.fromSetCookieValue(str)).toList(),
        );
      }
    }
  }

因爲這種實現至關於把 response 的 cookie 維護在 dart 層面,因此 OC 的請求就不會有這些信息,webView 環境也不會有。

而後?

圖片

與其將 cookie 信息維護在 dart,爲何不直接維護在 OC,那樣OC/webView的請求還能帶上。

方案

  • OC 與 webView 的 cookie 是互通的,不用手動處理
  • dart req/res 調用 MethodChannel 讀/存 cookie
  • OC 存 cookie 至 NSHTTPCookieStorage,並同步至 webView 的 document.cookie

實現

一、 dart 存取 cookie 存到 OC

  // 存
  static Future<bool> setCookie({ String domain, String name, String value, int exp }) async {
    bool result = await _channel.invokeMethod('setCookie', [domain, name, value, exp]);
    return result;
  }
  // 取
  static Future<List<Map>> getCookie(String url) async {
    final List res = await _channel.invokeMethod('getCookie', url);
    List<Map> listMap = new List<Map>.from(res);
    return listMap;
  }

二、OC 存取 dart 傳過來的值,並在 OC 發送請求時帶上這些 cookie

// 讀 cookie
NSArray *cookieArray = [NSArray arrayWithArray:[[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies]];

// 存 cookie
NSHTTPCookie *cookie = [NSHTTPCookie cookieWithProperties:cookieProperties];
[[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookie:cookie];

// 請求帶上 cookie
NSDictionary *cookieHeaderDic = [NSHTTPCookie requestHeaderFieldsWithCookies:[[NSHTTPCookieStorage sharedHTTPCookieStorage] cookies]];
[request setValue:[cookieHeaderDic objectForKey:@"Cookie"] forHTTPHeaderField:@"Cookie"];

三、OC 接收到 dart 傳過來的 cookie 時順帶將 cookie 寫入 webView

 NSString *jsStr = [NSString stringWithFormat:@"document.cookie='%@=%@;expires=%ld'",name,value,exp];
[_webView stringByEvaluatingJavaScriptFromString:jsStr];

實踐應用

WebView 控制 flutter 導航欄右側 BarButtonItem

  • js 傳遞配置數組給 flutter,將 callback 存儲在 js
  • flutter 根據配置渲染 AppBar actions,設置點擊回調將按鈕類型回傳 js
  • js 根據 flutter 傳過來的值調用以前緩存的 callback,調用結果返回給 flutter
image.png image.png

Flutter 裏跑 webview 顯然不是明智的作法,flutter 官方默認都關閉 PlatformView 功能。相對於 hybrid 和 RN 只有 JSC 通訊,這裏的 webview 又多了一層 flutter 通訊。

可是特殊場景下也不是不能夠這麼玩。相似在 RN / 小程序 裏跑webview,在小程序裏套 webview 減少包體積,避開審覈快速迭代的作法不在少數。

也有在微信小程序裏利用 miniprograme.navigateTo 觸發app.pageNotFound 作 IOC 的,雖然慢了點繞了點,可是提升了開發效率與迭代速度。

圖片

Anyway, Keep Balance.

題外話 - 做爲頁面仔咱們作跨端的優劣勢

怕被磚,先聲明如下爲純扯淡內容

圖片
優點
  • 快,一次開發處處跑,對比安卓一堆機型一堆特殊 api,固定 webview 內核簡直美滋滋
  • 快,hotreload爽的不行
  • 快,增量熱更新繞過審覈
  • 快,開發體驗,MVVM,聲明式開發加上組件庫簡直拼積木,iOS 命令式開發連尾燈都看不到
  • 快,CSS 牛逼,Android 還得整 xml,iOS 更是慘到手寫代碼佈局
  • 快,開發環境簡單,node / web 一把梭,native 一堆奇奇怪怪的配置
劣勢
  • 慢,能力限制在 webview 的環境裏,webview 限制了上限,擴展功能須要 native 排期施捨
  • 慢,單線程,渲染還會互斥,仰仗 native 幫忙分拆邏輯線程和UI線程進行優化
  • 慢,JIT 幹不過 AOT
  • 慢,渲染干不過 native,多了層中間商 webview 賺差價,無限列表 webview 連 native 的尾燈都看不到
    • native 渲染: view -> layout -> renderNode -> 合成 -> GPU渲染
    • webview: html -> dom tree -> render tree -> render layer -> 合成 -> gpu渲染

PS: 據說 flutter 很強,但它並非前端專屬玩具,由於 native 上手更有優點(尤爲 Android)

圖片

綜上, 纔是咱們的優點啊,鑽牛角尖跟 native 比性能,何須呢。

圖片


The End

圖片