在webview_flutter中封装JSBridge
最近的业务需要使用Flutter开发App应用了,其中打算将部分已有的Web应用进行复用,因此需要研究一下Flutter的Hybird应用开发。本文主要整理在Flutter中使用Webview的教程和遇见的一些问题,最后给出了关于Flutter中对JSBridge的简单封装。
本文完整代码均放在github上面。参考
使用webview_flutter
webview_flutter是官方维护的一个插件,因此还是比较可靠的,直接运行示例代码
iOS打开网页加载白屏,需要在ios/Runner/Info.plist
中配置
<key>io.flutter.embedded_views_preview</key>
<true/>
Android也需要配置网络权限,在文件/android/app/src/main/AndroidManifest.xml
中加入
<uses-permission android:name="android.permission.INTERNET"/>
<application>...</application>
设置UA
在WebView
构造参数userAgent
中传入自定义的ua字符串即可,这样在网页中就可以根据UA判断当前运行平台
const ua = navigator.userAgent
let pageType
//
if (/xxx-app/i.test(ua)) {
pageType = 'app'
}else {
// 其他平台
// ...
}
设置Header
需要注意的是这里设置的是请求首次URL时对应的header,并不是设置浏览器每次请求的header,如Cookie等信息,还是需要通过evaluateJavascript
手动进行设置
_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.location
、iframe.src
等,因此可以实现通过自定义schema实现JavaScript与Native互相通信,
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
,因此这里仅做了解即可
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需要等待接口初始化完毕之后,才能在网页中调用对应接口,这个需求可以通过evaluateJavascript
和dispatchEvent
来实现。
// 通知网页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。在实际业务中,还需要考虑版本兼容、数据埋点等需求,在接下来的业务开发中,会逐步尝试将这些功能一一完善。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。