IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    JSBridge 初上手 · 看不见我的美 · 是你瞎了眼

    馬腊咯稽发表于 2023-06-05 00:00:00
    love 0
    JSBridge 初上手

    政策转向,面向 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:
      • Android 可以通过 evaluateJavascript 调用 Web 逻辑;
      • iOS 可以通过 evaluateJavaScript:javaScriptString 调用 Web 逻辑;
      • Web 可以调用 Android 通过 addJavascriptInterface 注入的全局属性/方法;
      • Web 可以调用 iOS 通过 WKScriptMessageHandler 注入的全局属性/方法。
    • 拦截 Web 对外部资源的访问,比如链接跳转、 URLScheme ;
      • Android 可以通过 shouldOverrideUrlLoading 拦截 Web 对 URL 访问,实现 Web 对 Android 逻辑的调用;
      • iOS 可以通过 WKNavigationDelegate 拦截 Web 对 URL 访问,实现 Web 对 iOS 逻辑的调用。
    • 拦截全局方法的默认行为,比如 window.prompt:
      • Android 可以通过 WebChromeClient 拦截 Web API 默认行为,实现 Web 对 Android 逻辑的调用;
      • iOS 可以通过 WKUIDelegate 拦截 Web API 默认行为,实现 Web 对 iOS 逻辑的调用。

    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();
     }
    });
    

    参考

    • Android JSBridge 原理与实现
    • iOS 与 JavaScript 的交互(三)WKWebView


沪ICP备19023445号-2号
友情链接