政策转向,面向 B 端的业务不得不转向 C 端;而 C 端用户显然对性能和交互更为挑剔,于是老板决定上原生。为了赶工期,采用 Native + Web 的开发方式;经过调研,Native 和 Web 之间较为常用的通信方式为 JSBridge。为此,小组还开发了一个
包
来满足业务需要。
WebView
如果 iOS/Android 要访问 Web 页面,需要使用 WebView(一个嵌入在操作系统里的组件);
WKWebView
基于 Safari,
Android System WebView
基于 Chrome,当然还有微软的
Microsoft Edge WebView2
。
在我看来,WebView 像是一个继承自浏览器引擎的类,具有完整的浏览器功能;开发者可以在其基础上对其进行定制化来满足业务需要;比如说,可以对 UA 进行定制,这样 Web 就可以对当前的运行环境做判断:
1
2
3
4
5
6
7
8
9
10
|
export const isWeChatWebView = () => {
/**
* 如果 UA 不满足条件,可以拒绝服务
* 比如“请在微信中访问”
*/
if (/(WeChat)/i.test(navigator.userAgent)) {
return true;
}
return false;
};
|
以 WKWebView 为例,
WKWebViewConfiguration
用来配置 WKWebView,
URLRequest
用来指定访问的内容。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
class ViewController: UIViewController, WKUIDelegate {
var webView: WKWebView!
// 加载 WKWebView
override func loadView() {
// 使用 WKWebViewConfiguration 对 WKWebView 进行配置
let webConfiguration = WKWebViewConfiguration()
webView = WKWebView(frame: .zero, configuration: webConfiguration)
webView.uiDelegate = self
view = webView
}
override func viewDidLoad() {
// 调用父级 viewDidLoad
super.viewDidLoad()
// 指定访问的 URL
let myURL = URL(string: "https://www.apple.com")
let myRequest = URLRequest(url: myURL!)
// 加载 URL
webView.load(myRequest)
}
}
|
JSBridge
因为 Native 和 Web 处于两个不同的运行环境中,为了进行通信,需要一个桥的东西来连接 Native 和 Web,这个桥就是 JSBridge。对于 Native 和 Web 这两个角色,Native 拥有更大的权限,它可以通过初始化一个 WebView 打开页面,也可以直接销毁 WebView 实例关闭页面;既然 Native 对 WebView 可以有完全的控制,那么 Native 就可以通过更改 WebView 实例中的 JS 运行时来间接的影响 Web 的行为,比如:
- 在 window 上添加双方约定好的属性,比如 window.attr4Shared:
- 拦截 Web 对外部资源的访问,比如链接跳转、
URLScheme
;
- 拦截全局方法的默认行为,比如 window.prompt:
Android 使用 shouldOverrideUrlLoading 拦截对 URL 的访问(Web 调用 Native)
1
2
3
4
5
6
7
8
9
10
11
12
|
// 无法在短时间内回调多次 shouldOverrideUrlLoading 方法,也就是说频繁交互的情况下,会有较大概率只回调一次该方法
public class CustomWebViewClient extends WebViewClient {
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
if (url.equals("xxx")) {
// 执行 Web 逻辑
view.loadUrl("javascript:setAllContent(" + json + ");")
return true;
}
return super.shouldOverrideUrlLoading(view, url);
}
}
|
Android 使用 addJavascriptInterface 向 Web 注入内容(Web 调用 Native)
1
2
3
4
5
6
7
8
9
|
// 该方法是阻塞的,会等待 Native 方法的返回,Native 会在一个后台线程中执行该方法调用
class Bridge {
@JavascriptInterface
fun send(msg: String) {
doSomething()
}
}
// Web 加载完毕后,window 对象上会多出一个 _sBridge 属性
webview.addJavascriptinterface(Bridge(), "_sBridge")
|
Android 使用 WebChromeClient 拦截 Web API(Web 调用 Native)
1
2
3
4
5
6
7
|
public class JSBridgeWebChromeClient extends WebChromeClient {
@Override
public boolean onJsPrompt(WebView view, String url, Stringt message, String defaultValue, JsPromptResult result) {
// 处理具体逻辑
return true;
}
}
|
Android 使用 evaluateJavascript 调用 Web(Native 调用 Web)
1
2
3
4
5
6
7
8
|
private void testEvaluateJavascript(WebView webView) {
webView.evaluateJavascript("window.getGreetings()", new ValueCallback <String> () {
@Override
public void onReceiveValue(String value) {
// 处理结果
}
});
}
|
iOS 使用 WKNavigationDelegate 拦截对 URL 的访问(Web 调用 Native)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
func webView(
_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
if navigationAction.request.url?.scheme == "haleyaction" {
let url = navigationAction.request.url
handleCustomAction(url: url!)
// 取消加载
decisionHandler(WKNavigationActionPolicy.cancel)
return
}
// 允许加载
decisionHandler(WKNavigationActionPolicy.allow)
}
func handleCustomAction(url: URL) {
let host = url.host!
switch host {
case "scanClick":
print("saoyisao")
case "shareClick":
share(url: url)
case "getLocation":
getLocation()
case "setColor":
changeBackGroundColor(url: url)
case "payAction":
payAction(url: url)
case "shake":
sharkeAction()
case "back":
goBack()
default:
break
}
}
|
iOS 使用 WKScriptMessageHandler 向 Web 注入内容(Web 调用 Native)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
let wkConfig = WKWebViewConfiguration()
let wkUserContentController = WKUserContentController()
/**
Web 加载完毕后,window.webkit.messageHandlers 对象上会多出一个 getUserInfo 属性
可以通过调用 getUserInfo.postMessage 完成对 Native 的通信
*/
wkUserContentController.add(
self as WKScriptMessageHandler,
name: "getUserInfo"
)
wkConfig.userContentController = wkUserContentController
/**
https://developer.apple.com/documentation/webkit/wkscriptmessagehandler/1396222-usercontentcontroller
重写 WKScriptMessageHandler 中的 userContentController
*/
extension WKWebViewWithMessageHandler: WKScriptMessageHandler {
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
let msg = message.body as! String
let ac = UIAlertController(title: msg, message: msg, preferredStyle: .alert)
ac.addAction(UIAlertAction(title: "Ok", style: .default))
self.present(ac, animated: true)
}
}
|
iOS 使用 WKUIDelegate 拦截 Web API(Web 调用 Native)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
extension ViewController: WKUIDelegate {
// 获取 window.alert 内容
func webView(
_ webView: WKWebView,
runJavaScriptAlertPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping () -> Void
) {
// 逻辑处理
}
}
extension ViewController: WKUIDelegate {
// 获取 window.confirm 内容
func webView(
_ webView: WKWebView,
runJavaScriptConfirmPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping (Bool) -> Void
) {
// 逻辑处理
}
}
extension ViewController: WKUIDelegate {
// 获取 window.prompt 内容
func webView(
_ webView: WKWebView,
runJavaScriptTextInputPanelWithPrompt prompt: String,
defaultText: String?,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping (String?) -> Void
) {
// 逻辑处理
}
}
|
iOS 使用 evaluateJavaScript 调用 Web(Native 调用 Web)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// evaluateJavaScript
extension NativeInvokeJavaScriptBrowserViewController: WKNavigationDelegate {
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
switch requestJavaScriptMethod!
{
case "Switch2Font":
wkWebView.evaluateJavaScript("window.nativeRegister.onSwitch2Font('true')")
case "Switch2Back":
wkWebView.evaluateJavaScript("window.nativeRegister.onSwitch2Font('false')")
default:
wkWebView.evaluateJavaScript("window.nativeRegister.sayHello()")
}
}
}
|
具体实现
首先,需要定义一个全局对象,用来保存 JSBridge 相关的属性/方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
export const isAndroid = () => {
if (/Android/i.test(navigator.userAgent)) {
return true;
}
return false;
};
export const isIOS = () => {
if (/(iPhone|iPad|iPod|iOS)/i.test(navigator.userAgent)) {
return true;
}
return false;
};
export const isXLMWebView = () => {
if (/(XiaoLanMa)/i.test(navigator.userAgent)) {
return true;
}
return false;
};
window.XiaoLanMaBridge = {
isAndroid,
isIOS,
isXLMWebView
};
|
isAndroid | isIOS | isXLMWebView 这三个属性是用来给 Web 判断运行环境的,如果运行环境不满足需要,可以拒绝服务。
然后,和 Native 明确 Web 需要调用的方法;Native 创建 WebView 时,向 WebView 的上下文注入一些方法,方便 Web 调用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
|
interface UploadParams {
chooseType: 'Camera' | 'PhotoAlbum' | 'File'; // 拍照 |相册|文件
isPrivate: boolean; // 是否开启“私有化”上传
maxNumber: number; // 最大文件个数
mimeTypes: Array<'image/jpeg' | 'image/png' | 'application/pdf'>; // 支持的文件类型
}
// 这些都是和 APP 开发人员约定好的
export const nativeEventMapForWeb = {
// 文件上传
fileUpload(params: UploadParams, callbackName) {
if (isAndroid) {
window.XiaoLanMaBridgeForNative.fileUpload(
JSON.stringify(params),
callbackName
);
}
if (isIOS) {
window.webkit.messageHandlers.fileUpload.postMessage(
JSON.stringify({
callbackName,
data: params
})
);
}
},
// 获取用户信息
getUserInfo(params, callbackName) {
if (isAndroid) {
window.XiaoLanMaBridgeForNative.getUserInfo(
JSON.stringify(params),
callbackName
);
}
if (isIOS) {
window.webkit.messageHandlers.getUserInfo.postMessage(
JSON.stringify({
callbackName,
data: params
})
);
}
},
// 关闭 WebView
closeWebView(params) {
if (isAndroid) {
window.XiaoLanMaBridgeForNative.closeWebView(JSON.stringify(params));
}
if (isIOS) {
window.webkit.messageHandlers.closeWebView.closeWebView(
JSON.stringify({
data: params
})
);
}
}
};
|
接着,还需要一个对象来保存不同 EventName 对应的回调函数:
1
2
3
|
window.XiaoLanMaBridge = {
callNativeCallback: {} // 用来保存 callNative 对应的回调函数
};
|
这样,当 Native 部分的逻辑执行完毕后,就可以通过调用我们事先约定好的全局函数将执行结果告诉 Web 端。
最后,我们需要实现一个Web 调用 Native 功能的方法 callNative,用来注册 EventName 对应的回调函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
window.XiaoLanMaBridge = {
callNative(nativeEventName, paramsToNative, callback, needLoop) {
if (!isXLMWebView) {
throw new Error('请在 xxx 中运行');
}
const callbackName = `${nativeEventName}Callback`;
if (needLoop) {
// 需要轮询调用 callback,比如查看“上传进度”
if (callback) {
// 保存 nativeEventName 对应的回调函数
this.callNativeCallback[callbackName] = responseFromNative => {
// Native 回传的结果一般为字符串
const { success, data, error, isFinished } =
JSON.parse(responseFromNative);
if (success) {
callback({ data, isFinished });
} else {
callback({ error, isFinished: true });
}
// 如果轮询结束,记得清理回调函数
if (isFinished) {
delete this.callNativeCallback[callbackName];
}
};
// 调用 Native 逻辑,Native 处理完毕之后,会去调用我们的 callback
nativeEventMapForWeb[nativeEventName](paramsToNative, callbackName);
}
} else {
// 不需要轮询调用 callback,所以 reslove 就是此时的 callback
return new Promise((resolve, reject) => {
this.callNativeCallback[callbackName] = responseFromNative => {
// 返回值需要和 Native 提前定义好
const { success, data, error } = JSON.parse(responseFromNative);
if (success) {
resolve(data); // 返回结果
} else {
reject(error); // 抛出错误
}
delete this.callNativeCallback[callbackName]; // 记得清理回调函数
};
nativeEventMapForWeb[nativeEventName](paramsToNative, callbackName);
});
}
}
};
|
以上就是 Web 主动调用 Native 方法的大致逻辑了;那么 Web 怎么订阅 Native 事件呢?比如 APP 从后台切换到前台、系统切换 Dark 模式等。
这种场景也需要和 Native 开发人员确定订阅场景;当场景被触发时,Native 需要主动调用 nativeRegister 中的方法来通知 Web:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
window.XiaoLanMaBridge = {
// 这里需要预先声明好所有的回调场景
nativeRegister: {
onSwitch2Font() {
// 可以看出,运行之前,需要先向 nativeRegisterCallback 添加 onSwitch2FontCallback 回调
window.XiaoLanMaBridge.nativeRegisterCallback.onSwitch2FontCallback(
() => ({
success: true
})
);
},
onTheme2Light() {
window.XiaoLanMaBridge.nativeRegisterCallback.onTheme2LightCallback(
() => ({
success: true
})
);
},
onTheme2Dark() {
window.XiaoLanMaBridge.nativeRegisterCallback.onTheme2DarkCallback(
() => ({
success: true
})
);
}
},
// 保存 nativeRegister 回调
nativeRegisterCallback: {}
};
|
接着,也需要在 window.XiaoLanMaBridge 定义一个 addNativeEventListener 方法,用来将对应的回调函数保存至 nativeRegisterCallback 中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
window.XiaoLanMaBridge = {
addNativeEventListener(nativeEventName, callback) {
if (!isXLMWebView) {
throw new Error('请在 xxx 中运行');
}
if (this.nativeRegister[nativeEventName]) {
this.nativeRegisterCallback[`${nativeEventName}CallBack`] = ({
success
}) => {
callback(success);
};
} else {
throw new Error(`${webEventName} 事件不存在`);
}
}
};
|
其实这种事件订阅的场景就是上一个调用 Native 方法的简化版;区别是 nativeRegister 中的方法需要 Native 主动去调用。
具体应用
当需要通过 Native 选择并上传文件时,可以调用 upload 来实现;因为 Web 需要回显上传进度,需要轮询调用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
window.XiaoLanMaBridge.callNative(
'upload',
{
chooseType: 'File',
isPrivate: true,
maxNumber: 1,
mimeTypes: ['application/pdf']
},
({ data, error, isFinished }) => {
if (error) {
// 上传出错
Toast.error(error);
} else {
if (isFinished) {
console.log(data);
Toast.success('上传成功');
} else {
Toast.loading(`已上传 ${data.percent}%,请稍后`);
}
}
},
true
);
|
当需要通过 Native 获取用户信息时,可以调用 getUserInfo 通知 APP 请求接口获取最新的用户信息并返回:
1
2
3
4
5
6
7
8
|
(async () => {
try {
const userInfo = await window.XiaoLanMaBridge.callNative('getUserInfo');
console.log('userInfo', userInfo);
} catch (error) {
Toast.error(error);
}
})();
|
当 APP 从后台切回前台时,想要刷新数据,可以通过订阅事件来解决:
1
2
3
4
5
|
window.XiaoLanMaBridge.addNativeEventListener('onSwitch2Font', isSuccess => {
if (isSuccess) {
this.getData();
}
});
|
参考