在webview_flutter中封装JSBridge

本文同步在我的博客shymean.com上,欢迎关注javascript

最近的业务须要使用Flutter开发App应用了,其中打算将部分已有的Web应用进行复用,所以须要研究一下Flutter的Hybird应用开发。本文主要整理在Flutter中使用Webview的教程和碰见的一些问题,最后给出了关于Flutter中对JSBridge的简单封装。html

本文完整代码均放在github上面。参考前端

使用webview_flutter

webview_flutter是官方维护的一个插件,所以仍是比较可靠的,直接运行示例代码java

iOS打开网页加载白屏,须要在ios/Runner/Info.plist中配置android

<key>io.flutter.embedded_views_preview</key>
<true/>
复制代码

Android也须要配置网络权限,在文件/android/app/src/main/AndroidManifest.xml中加入ios

<uses-permission android:name="android.permission.INTERNET"/>
<application>...</application>
复制代码

设置UA

WebView构造参数userAgent中传入自定义的ua字符串便可,这样在网页中就能够根据UA判断当前运行平台git

const ua = navigator.userAgent

let pageType
//
if (/xxx-app/i.test(ua)) {
  pageType = 'app'
}else {
  // 其余平台
  // ...
}
复制代码

设置Header

须要注意的是这里设置的是请求首次URL时对应的header,并非设置浏览器每次请求的header,如Cookie等信息,仍是须要经过evaluateJavascript手动进行设置github

_controller.future.then((controller) {
  _webViewController = controller;
  String tokenName = 'token';
  String tokenValue = 'TkzMDQ5MTA5fQ.eyybmJ1c2ViJAifQ.hcHiVAocMBw4pg';

  Map<String, String> header = {'Cookie': '$tokenName=$tokenValue', 'x-test':'123213'};
  _webViewController.loadUrl('http://127.0.0.1:9999/2.html', headers: header);
});
复制代码

拦截网络请求

经过navigationDelegate能够实现关于网络请求的拦截操做如window.locationiframe.src等,所以能够实现经过自定义schema实现JavaScript与Native互相通讯,web

navigationDelegate: (NavigationRequest request) {
  print(request.url);
  // 能够实现schema相关功能
  if (request.url.startsWith('xxx-app')) {
    // todo 解析path和query,实现对应API 
    return NavigationDecision.prevent;
  }
  print('allowing navigation to $request');
  return NavigationDecision.navigate;
},
复制代码

在JS中,则能够经过创建可以被拦截的网络请求来实现通讯,下面咱们会介绍webview_flutter封装的javascriptChannels,所以这里仅作了解便可json

requestBtn.onclick = () => {
  let iframe = document.createElement('iframe')
  iframe.style.display = 'none'
  iframe.src = 'xxx-app://toast'
  document.body.appendChild(iframe)
  // 这种方式没法拦截到Ajax发送的网络请求
}
复制代码

拦截返回操做

默认地,在Webview中,经过返回按钮或者右滑返回(iOS下),会返回上一个原生页面而不是上一个webview页面,若是但愿拦截该操做,能够在Webview组件外包裹一层WillPopScope组件

WillPopScope(
  onWillPop: () async {
    var canBack = await _webViewController?.canGoBack();
    if (canBack) {
      // 当网页还有历史记录时,返回webview上一页
      await _webViewController.goBack();
    } else {
      // 返回原生页面上一页
      Navigator.of(context).pop();
    }
    return false;
  },
  child: WebView(...),
)
复制代码

webview_flutter不支持alert

参考issue,可使用flutter_webview_plugin或者自定义alert

交互

Native调用JavaScript

经过webviewController 的evaluateJavascript方法调用Webview中的方法

controller.data.evaluateJavascript('console.log("123")')
复制代码

该方法返回的是Future<String>,其结果为对应JS代码执行的返回结果。

等待客户端准备完毕

因为webview_flutter_controller.future是在网页都加载完毕以后才执行的,此时网页中的同步代码都已执行完毕。

换句话说,使用evaluateJavascript执行的代码均发生在window.onload事件以后,参考issue,

可是在某些场景下,JavaScript须要等待接口初始化完毕以后,才能在网页中调用对应接口,这个需求能够经过evaluateJavascriptdispatchEvent来实现。

// 通知网页webview加载完毕
void triggerAppReady(controller) {
  var code = 'window.dispatchEvent(new Event("appReady"))';
  controller.evaluateJavascript(code);
}

_controller.future.then((controller)) {
  triggerAppReady(controller);
});
复制代码

而后在网页中监听appReady方法

window.addEventListener('appReady', ()=>{
  // 初始化网页应用逻辑
  init()
})
复制代码

JavaScript调用Native

在初始化Webview组件的时候传入javascriptChannels构造参数注册提供给浏览器的API

WebView( 
    javascriptChannels: <JavascriptChannel>[
        _toasterJavascriptChannel(context),
    ].toSet())
复制代码

单个API定义相似于

JavascriptChannel _toasterJavascriptChannel(BuildContext context) {
    return JavascriptChannel(
        name: 'Toaster',
        onMessageReceived: (JavascriptMessage message) {
          Scaffold.of(context).showSnackBar(
            SnackBar(content: Text(message.message)),
          );
        });
}
复制代码

会向浏览器注入一个全局变量Toaster,而后就在JavaScript中调用了

btn1.onclick = function () {
    Toaster.postMessage('hello native') // 经过message.message获取到'hello native'参数
};
复制代码

封装JSBridge

从前面的交互能够看出一些问题

  • javascriptChannels参数中,须要传入多个JavascriptChannel对象,每一个对象都会想Webview的JS环境中添加一个全局变量,
  • 在JS中对于每一个API,都须要经过methondName.postMessage的方法调用,不方便统一管理及维护

基于这些问题,咱们能够进一步封装,一种更好的方式是将全部API都挂载到一个全局对象中,如微信浏览器中的JSSDK

wx.onMenuShareTimeline({
  title: '', // 分享标题
  link: '', // 分享连接,该连接域名或路径必须与当前页面对应的公众号JS安全域名一致
  imgUrl: '', // 分享图标
  success: function () {
  // 用户点击了分享后执行的回调函数
  }
},
复制代码

若是按照约定统一调用Native方法的结构,咱们就能够实现只注册一个全局对象来封装全部API的方法。

约定请求类型

JavaScript

咱们统一调用结构为{method: api方法名, params: 调用参数, callbcak: 回调函数}这种形式,

function _callMethod(config) {
  // 经过JavaScriptChannel注入的全局对象
  window.AppSDK.postMessage(JSON.stringify(config))
}

function toast(data){
  _callMethod({
    method: 'toast',
    params: data,
  })
}

// 调用toast方法
toast({message:'hello from js'})
复制代码

因为postMessage支持的数据格式有限,咱们统一将参数序列化为JSON字符串,在接收消息时将字符串反序列化为Dart实体。

因为回到函数没法被序列化,咱们能够经过一种取巧的方法实现:

  • 在调用postMessage前,构造一个全局的回调函数,并将该回调函数的名字经过参数callback一块儿传递给Flutter
  • 当Flutter执行完对应逻辑时,根据参数的callbackName,使用evaluateJavascript("window.$callbackName()")方法,就能够调用实现注册的回调函数了

下面对_callMethod进行完善,并增长了注册全局回调函数的逻辑

let callbackId = 1

function _callMethod(config) {
  const callbackName = `__native_callback_${callbackId++}`
  // 注册全局回调函数
  if (typeof config.callback === 'function') {
    const callback = config.callback.bind(config)
    window[callbackName] = function(args) {
      callback(args)
      delete window[callbackName]
    }
  }
  config.callback = callbackName
  // 经过JavaScriptChannel注入的全局对象
  window.AppSDK.postMessage(JSON.stringify(config))
}
// 咱们在客户端实现:完成api调用后,会判断并执行该全局回调函数的逻辑
复制代码

Dart

上面调用的window.AppSDK是经过JavascriptChannel注册的

JavascriptChannel _appSDKJavascriptChannel(BuildContext context) {
  return JavascriptChannel(
    name: 'AppSDK',
    onMessageReceived: (JavascriptMessage message) {
      // 将JSON字符串转成Map
      Map<String, dynamic> config = jsonDecode(message.message);
    });
}
复制代码

为了增长类型约束,咱们先将config这个Map转成一个实体对象

// 约定JavaScript调用方法时的统一模板
class JSModel {
  String method; // 方法名
  Map params; // 参数
  String callback; // 回调函数名

  JSModel(this.method, this.params, this.callback);

  // 实现jsonEncode方法中会调用实体类的toJSON方法
  Map toJson() {
    Map map = new Map();
    map["method"] = this.method;
    map["params"] = this.params;
    map["callback"] = this.callback;
    return map;
  }

  // 将JS传过来的JSON字符串转换成MAP,而后初始化Model实例
  static JSModel fromMap(Map<String, dynamic> map) {
    JSModel model =
        new JSModel(map['method'], map['params'], map['callback']);
    return model;
  }

  @override
  String toString() {
    return "JSModel: {method: $method, params: $params, callback: $callback}";
  }
}

// 而后就能够经过jsonDecode将JSON字符串转为实例类了
var model = JsBridge.fromMap(jsonDecode(jsonStr));
复制代码

封装API和回调

根据约定,须要经过jsBridgeModel.method来判断须要执行的方法,咱们将这部分的逻辑封装在一个新的类中

class JsSDK {
  static WebViewController controller;

  // 格式化参数
  static JSModel parseJson(String jsonStr) {
    try {
      return JSModel.fromMap(jsonDecode(jsonStr));
    } catch (e) {
      print(e);
      return null;
    }
  }

  static String toast(context, JSModel jsBridge) {
    String msg = jsBridge.params['message'] ?? '';
    Scaffold.of(context).showSnackBar(
      SnackBar(content: Text(msg)),
    );
    return 'success'; // 接口返回值,会透传给JS注册的回调函数
  }

  // 向H5暴露接口调用
  static void executeMethod(BuildContext context, WebViewController controller, String message) {
    // 根据JSON字符串构造JSModel对象,
    // 而后执行model对应方法
    // 判断是否有callback参数,若是有,则经过evaluateJavascript调用全局函数
  }
}
复制代码

下面是整个executeMethod方法的实现

static String toast(context, JsBridge jsBridge) {
  String msg = jsBridge.params['message'] ?? '';
  Scaffold.of(context).showSnackBar(
    SnackBar(content: Text(msg)),
  );
  return 'success'; // 接口返回值,会透传给JS注册的回调函数
}

static void executeMethod(BuildContext context, WebViewController controller, String message) {
  var jsBridge = JsSDK.parseJson(message);

  // 全部的API均经过handlers进行映射,键值对应前端传入的methodName
  var handlers = {
    // test toast
    'toast': () {
      return JsSDK.toast(context, jsBridge);
    }
  };

  // 运行method对应方法实现
  var method = jsBridge.method;
  dynamic result; // 获取接口返回值
  if (handlers.containsKey(method)) {
    try {
      result = handlers[method]();
    } catch (e) {
      print(e);
    }
  } else {
    print('无$method对应接口实现');
  }

  // 统一处理JS注册的回调函数
  if (jsBridge.callback != null) {
    var callback = jsBridge.callback;
    // 将返回值做为参数传递给回调函数
    var resultStr = jsonEncode(result?.toString() ?? '');
    controller.evaluateJavascript("$callback($resultStr);");
  }
}
复制代码

至此,咱们就完成了JavaScript调用原生API的一系列封装。

向Dart提供钩子函数

在大部分业务场景下,基本上都是JavaScript调用原生提供的接口完成需求;但在一些特定的场景下,也须要JavaScript提供一些接口或钩子由原生调用。

一个比较熟悉的场景是:网页中的点击购买出现SKU弹窗,此时点击返回时,更但愿关闭SKU弹窗而不是返回上一页。

所以咱们还须要考虑JS向原生提供钩子的场景,与上面的sdk封装相似,能够将全部的钩子统一放在一个全局对象上

window.callJS = {}


复制代码

而后在打开sku弹窗时注册一个goBack方法,

let canGoBack = true
toggleBack.onclick = ()=>{
  // 返回0则不返回
  return 0
}
复制代码

根据约定,在dart的返回判断中,会调用window.callJS.goBack并根据返回值判断是否须要取消返回上一页的操做

onWillPop: () async {
  try {
    String value = await controller.evaluateJavascript('window.callJS.goBack()');
    // 注意执行返回结果会转换成字符串,好比JS的布尔值True也会转换成字符串'1'
    bool canBack = value == '1';
    return canBack;
  } catch (e) {
    return true;
  }
}
复制代码

这种作法看起来不是很优雅,由于咱们要在JS中操做全局变量,在上面的例子中,若是关闭了SKU弹窗,咱们还须要处理移除全局方法callJS.goBack,不然会致使返回键失效。待我查查看有没有其余更合理的作法,而后再更新~

小结

本文主要整理了webview_flutter的一些基本用法,了解了Flutter与JavaScript的相互调用,最后研究了如何封装一个简易的JSBridge。在实际业务中,还须要考虑版本兼容、数据埋点等需求,在接下来的业务开发中,会逐步尝试将这些功能一一完善。

相关文章
相关标签/搜索