《成为大前端》系列 5.1 JSBridge模块化 - 重构之前的代码

526 阅读6分钟

准备模块化

前面讲解了Native与JS通信的知识,不过前面的代码没有大项目的应用价值,而这里的目标是在于设计 一个框架能够用于大项目的生产环境。

之后的文章会将Native与JS通信称为JSBridge

微信小程序 JSBridge

打开 微信小程序 JSBridge API 文档, 我们可以看出微信提供了数以百计的 API,设计这些 API 不管是 Native 还是 JS 端,都需要以模块化的方式组织。

以模块化的方式组织之后,可以提供方便的引入方式和定制化需求,加上编写稳定的文档以供长期维护。一个大型 App 在框架的开发和维护上是核心之一。

如何模块化

一个大型的 App 通常会具备以下 JSBridge 模块:

  • System: 包含系统和设备的相关一些接口,比如其中:wx.getSystemInfo
  • App: 包含本 app 相关的一些接口,比如:getUserAgent 返回 app 的 UA
  • UI:提供 native 级别一些 UI 接口,比如:toast,alert,confirm
  • Camera: 提供 camera 业务相关的接口:比如:拍照、录像、扫码
  • KVDB: key/value 数据库,通常我们需要比 localStorage 还要强的本地存储
  • Image: 系统图片视频模块,比如:选择系统相册图片
  • Sqlite: 提供 sqlite 功能

其实还有很多,包括真实业务模块等等,目前我们使用以上比较通用的模块来做下一步讲解。

我们可以通过伪代码引导我们模块化设计,比如:

JS 端,业务方调用的方式如下:

JSBridge.System.getSystemInfo()
JSBridge.UI.toast('Error: 404')
JSBridge.Camera.takePicture()
JSBridge.KVDB.setString('some key', 'some value')
...

Native 端,以 kotlin 为例:

class JSBridgeUI {
    fun toast(message: String) { ... }
    fun alert(title: String, essage: String, buttons: Array<String>) { ... }
    fun confirm(title: String, message: String) { ... }
}

class JSBridgeCamera {
    fun takePicture() { ... }
    fun takeVideo() { ... }
}

以上伪代码可以看出,初步确定了 JS 端的调用和 Native 的模块(class)对于的设计关系,接下来要做的是怎么把他们关联起来。

模块化架构

从架构上看,是如下图的机构:

  1. js 部分组织各个模块,提供业务方调用
  2. JS 与 Native 通信部分
  3. Native 组织各个模块,提供 Native 的功能和连接客户端业务

到这里完成了大体的结构设计,接下来是实践部分,将分成 JS、iOS 和 Android 三个端分别讲解。

重构代码之JS端

前面我们讲了模块化的结构设计,这篇我们会将之前的代码进行重构

重构HTML为远程的web项目

我们将App内置的test.html变成一个外部的真实web项目,并且WebView使用http协议去访问

重构后的结构:

web/
  index.html      // 页面
  jsbridge.js     // 提取调用native的js代码
  start_server.sh // 启动服务器的shell

以上结构可以重这里看到:

gitee.com/zzmingo/boo…

sh start_server.sh,我们用python的服务器模块来轻松启动服务器, 然后访问:http://localhost:8000/ 可以浏览页面

所有Native WebView加载时改为加载这个远程地址就行,后面重构Native的会讲到

重构提出jsbridge.js

(function() {

    var currentCallbackId = 0

    function callNative(method, data, callback) {
        // 生成一个唯一callbackId
        var callbackId = "nativeCallback_" + (currentCallbackId++)

        if (callback) {
          // 给window添加callback
          window[callbackId] = (result) => {
              delete window[callbackId]
              callback(result)
          }
        }

        var stringData = JSON.stringify(data)
        if (window.androidBridge) {
            // android端传递三个参数
            window.androidBridge.callNative(callbackId, method, stringData)
        } else {
            // iOS不支持多参数,我们传递json对象
            window.webkit.messageHandlers.iOSBridge.postMessage({
              callbackId: callbackId,
              method: method,
              data: stringData
            })
        }
    }

    // 将方法挂到window.JSBridge下
    var JSBridge = window.JSBridge = {}
    JSBridge.callNative = callNative

})()

说明:

  • callNative增加了method参数,代表调用的模块方法,比如:UI.toast
  • window.androidBridge.callNative也增加了method参数传递
  • window.webkit.messageHandlers.iOSBridge.postMessage也增加了method参数传递

调用:

JSBridge.callNative('UI.toast', {
  message: 'This is toast!'
})

可以在jsbridge.js里添加一些代码,改进一下更优雅:

...
var JSBridge = window.JSBridge = {}
JSBridge.callNative = callNative

JSBridge.UI = {}
JSBridge.UI.toast = function(message) {
  // 这个方法不需要callback
  callNative('UI.toast', { message: message })
}

调用:

JSBridge.UI.toast('This is toast!')

其他模块陆续慢慢加进来,目前使用UI.toast方法继续讲。

重构代码之Android端

创建另外的kotlin包来容纳新代码

app/java/com.example.tobebigfe点右键,进行如下图操作:

注意你的android路径有可能是这样的,如图第1点中,不是扁平的包名,那么可以勾上第2点中的两项:

输入jsbridge,创建:

再在com.example.tobebigfe.jsbridge下创建kotlin文件,文件名WebActivity

WebActivity.kt

abstract class WebActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        WebView.setWebContentsDebuggingEnabled(true)
        webView.settings.javaScriptEnabled = true
        webView.settings.cacheMode = LOAD_NO_CACHE
        webView.webViewClient = WebViewClient()
        webView.addJavascriptInterface(BridgeObject(webView), "androidBridge")
        webView.loadUrl(getLoadUrl())
    }

    abstract fun getLoadUrl(): String

    interface BridgeModule {
        fun callFunc(func: String, arg: JSONObject)
    }

    inner class BridgeObject(val webView: WebView) {

        private val bridgeModuleMap = mutableMapOf<String, BridgeModule>()

        init {
            bridgeModuleMap["UI"] = JSBridgeUI()
        }

        @JavascriptInterface
        fun callNative(callbackId: String, method: String, arg: String) {
            Log.e("WebView", "callNative ok. args is $arg")
            val jsonArg = JSONObject(arg)
            val split = method.split(".")
            val moduleName = split[0]
            val funcName = split[1]

            val module = bridgeModuleMap[moduleName]
            module?.callFunc(funcName, jsonArg)
        }
    }

    inner class JSBridgeUI : BridgeModule {

        override fun callFunc(func: String, arg: JSONObject) {
            when (func) {
                "toast" -> toast(arg)
            }
        }

        private fun toast(arg: JSONObject) {
            val message = arg.getString("message")
            Toast.makeText(this@WebActivity, message, Toast.LENGTH_SHORT).show()
        }
    }
}

一步步讲解

首先,提取WebView相关代码到WebActivity中:

abstract class WebActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        WebView.setWebContentsDebuggingEnabled(true)
        webView.settings.javaScriptEnabled = true
        webView.settings.cacheMode = LOAD_NO_CACHE
        webView.webViewClient = WebViewClient()
        webView.addJavascriptInterface(BridgeObject(webView), "androidBridge")

        // 调用getLoadUrl获取加载的链接
        webView.loadUrl(getLoadUrl())
    }

    // 抽象方法,让子类实现,后面MainActivity中会体现作用
    abstract fun getLoadUrl(): String
}

声明一个接口,这个接口定义callFunc方法,所有的JSBridge模块需要实现这个接口

interface BridgeModule {
    fun callFunc(func: String, arg: JSONObject)
}

声明UI模块:

inner class JSBridgeUI : BridgeModule {

    override fun callFunc(func: String, arg: JSONObject) {
        when (func) {
            // toast方法
            "toast" -> toast(arg)
        }
    }

    private fun toast(arg: JSONObject) {
        val message = arg.getString("message")
        Toast.makeText(this@WebActivity, message, Toast.LENGTH_SHORT).show()
    }
}

改动BridgeObject:

inner class BridgeObject(val webView: WebView) {

    // 声明存放模块的地方
    // 比如:
    //   UI -> JSBridgeUI
    //   System -> JSBridgeSystem
    private val bridgeModuleMap = mutableMapOf<String, BridgeModule>()

    init {
        // 添加模块
        bridgeModuleMap["UI"] = JSBridgeUI()
    }

    @JavascriptInterface
    fun callNative(callbackId: String, method: String, arg: String) {
        Log.e("WebView", "callNative ok. args is $arg")

        // 解析arg
        val jsonArg = JSONObject(arg)

        // 解析method,比如UI.toast,解析后 moduleName=UI funcName=toast
        val split = method.split(".")
        val moduleName = split[0]
        val funcName = split[1]

        // 拿到模块
        val module = bridgeModuleMap[moduleName]
        // 调用callFunc
        module?.callFunc(funcName, jsonArg)
    }
}

MainActivity.kt

class MainActivity : WebActivity() {

    // 实现getLoadUrl,返回具体的链接,是我们用start_server.sh启动的服务器链接,
    // 注意使用服务器的ip
    override fun getLoadUrl(): String {
        return "http://192.168.31.101:8000"
    }
}

运行效果

运行前,需要在AndroidManifest.xml增加网络设置:

效果:

重构代码之iOS端

创建新的Group来容纳新代码


创建WebViewController.swift


WebViewController.swift

import UIKit
import WebKit

class WebViewController: UIViewController, WKUIDelegate {

    var webView: WKWebView!

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let config = WKWebViewConfiguration()
        let birdge = BridgeHandler()
        config.userContentController.add(birdge, name: "iOSBridge")
        
        webView = WKWebView(frame: self.view.frame, configuration: config)
        webView.uiDelegate = self
        self.view.addSubview(webView)
        
        birdge.webView = webView
        birdge.viewController = self
        birdge.initModules()
        
        let url = URL(string: getLoadUrl())!
        let request = URLRequest(url: url)
        webView.load(request)
    }
    
    func getLoadUrl() -> String {
        return ""
    }
}

protocol BridgeModule : class {
    func callFunc(_ funcName: String, arg: [String: Any?])
}

class BridgeHandler : NSObject, WKScriptMessageHandler {
    
    var webView: WKWebView!
    var viewController: WebViewController!
    var moduleDict = [String:BridgeModule]()
    
    func initModules() {
        moduleDict["UI"] = JSBridgeUI(viewController: viewController)
    }
    
    func userContentController(
        _ userContentController: WKUserContentController,
        didReceive message: WKScriptMessage)
    {
        let body = message.body as! [String: Any]
        print("WebView callNative ok. body is \(body)")
        
        let callbackId = body["callbackId"] as! String
        let method = body["method"] as! String
        let data = body["data"] as! String
        guard let arg = try? JSONSerialization.jsonObject(with: data.data(using: .utf8)!, options: []) else {
            return
        }
        
        let split = method.split(separator: ".")
        let moduleName = String(split[0])
        let funcName = String(split[1])
        
        moduleDict[moduleName]?.callFunc(funcName, arg: arg as! [String:Any])
    }
}

class JSBridgeUI : BridgeModule {
    
    let viewController: WebViewController
    
    init(viewController: WebViewController) {
        self.viewController = viewController
    }
    
    func callFunc(_ funcName: String, arg: [String : Any?]) {
        switch funcName {
        case "toast":
            toast(arg)
        default: break
        }
    }
    
    func toast(_ arg: [String : Any?]) {
        let message = arg["message"] as! String
        viewController.view.makeToast(message)
    }
}

代码解析

声明protocol BridgeModule,所有的module将实现这个protocol

protocol BridgeModule : class {
    func callFunc(_ funcName: String, arg: [String: Any?])
}

JSBridgeUI即UI模块的代码:

class JSBridgeUI : BridgeModule {
    
    let viewController: WebViewController
    
    init(viewController: WebViewController) {
        self.viewController = viewController
    }
    
    func callFunc(_ funcName: String, arg: [String : Any?]) {
        switch funcName {
        case "toast":
            toast(arg)
        default: break
        }
    }
    
    func toast(_ arg: [String : Any?]) {
        let message = arg["message"] as! String
        viewController.view.makeToast(message)
    }
}

BridgeHandler代码:

class BridgeHandler : NSObject, WKScriptMessageHandler {
    
    var webView: WKWebView!

    // 声明VC,因为有些模块操作UI时需要用到,比如: UI.toast
    var viewController: WebViewController!

    // 声明一个dict用于添加模块
    // 比如:
    //   UI -> JSBridgeUI
    //   System -> JSBridgeSystem
    var moduleDict = [String:BridgeModule]()
    
    // 初始化所有模块
    func initModules() {
        moduleDict["UI"] = JSBridgeUI(viewController: viewController)
    }
    
    func userContentController(
        _ userContentController: WKUserContentController,
        didReceive message: WKScriptMessage)
    {
        let body = message.body as! [String: Any]
        print("WebView callNative ok. body is \(body)")
        
        let callbackId = body["callbackId"] as! String

        // 获取method,比如:UI.toast
        let method = body["method"] as! String

        // 解析data
        let data = body["data"] as! String
        guard let arg = try? JSONSerialization.jsonObject(with: data.data(using: .utf8)!, options: []) else {
            return
        }
        
        // 将UI.tast split
        let split = method.split(separator: ".")
        let moduleName = String(split[0]) // UI
        let funcName = String(split[1])   // toast
        
        // 调用相关模块
        moduleDict[moduleName]?.callFunc(funcName, arg: arg as! [String:Any])
    }
}

WebViewController:

// 改为getLoadUrl来得到链接地址,getLoadUrl由具体的子类实现
let url = URL(string: getLoadUrl())!
let request = URLRequest(url: url)
webView.load(request)

ViewController.swift

import UIKit

class ViewController : WebViewController {

    // 覆盖getLoadUrl,返回具体的链接,是我们用start_server.sh启动的服务器链接,
    // 注意使用服务器的ip
    override func getLoadUrl() -> String {
        return "http://192.168.31.101:8000"
    }
}

iOS Toast

iOS内置没有toast功能,我们引入一个Toast.swift代码到文件夹ToBeBigFE

gitee.com/zzmingo/boo…

运行效果

WebView缓存问题

在开发过程会遇到webView有缓存,自己明明改了代码,但是打开的页面还是之前的样子, 那么在开发时我们加入如下代码:

在WebViewController的viewDidLoad中加入

let dataStore = WKWebsiteDataStore.default()
let dateFrom: Date = Date(timeIntervalSince1970: 0)
dataStore.removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), modifiedSince: dateFrom) {}

结语

到这里就完成了模块化重构,当然代码组织方式可以优化成多个文件,目前先放在一个文件中,等后面实现更多模块时再优化。