如何在WebKit中使用JavaScriptCore

这里先要道个歉。其实有点标题党了

众所周知,WKWebView由于采用了异步处理js的方式,间接砍掉了UIWebView的documentView.webView.mainFrame.javaScriptContext属性,也就不能很方便的使用javaScriptCore让js调用原生方法,最近我在负责这类工作,其中一个要求就是要能实现web端直接使用jsBridge.getData(),jsBridge.openNative()的形式进行调用。

那怎么办呢?

总不能说放弃WebKit用回被苹果抛弃的UIWebView吧?

总不能跟他们说:对不起我做不了吧(虽然我真的很想这样说

在不算特别难的情况下,查找了一下目前iOS主流的jsBrideg方案(这里不客气的说一句在座的各位都是垃圾),没有一个是符合逻辑学的,像什么WebViewJavascriptBridge,dsBridge等等都是同一类东西,即需要web注册啦,调用只能用bridge.call(“方法名”)啦等等等等

虽说如此但我还是从dsBridge中找到了比较好的处理回调的方式:利用输入框来回调,除此之外真的没什么有用的了,真心不建议使用这些第三方,太麻烦了根本不像是有梦想的人写出来的东西,都2018年还得注册才能用。。。自己写一个方便的又不难

我是怎么做的呢

首先我们要确定一下目标:

  1. web端可以直接调用bridge的方法
  2. 安卓那边可以很容易就实现,所以不能依赖前端有额外的注入,不然他们就得增加额外的维护工作,越多的维护内容意味着更容易的出错,这是我们应该避免的
  3. 基于上面那一条,这个额外的工作应该是自动生成的
  4. 我写代码的必要要求:低侵入性

综上所诉:

  1. JavaScriptCore可以很方便的完成,只要能解决怎么注入
  2. 避免前端差别对待只要iOS本地进行注入就行
  3. 自动完成可以交给runtime生成注入的js代码
  4. 这个尽量,必要时用黑魔法也是能接受的(记得写好测试代码)

*以下代码均使用swift

首先我们按照UIWebView时代的需求,准备一个继承自JSExport协议的协议:

final class JSResult: NSObject, HandyJSON {
    var status: Int = 0
    var msg: String?
    var data: [String: Any] = [:]
    func isNotAFunction() -> JSResult{
        status = -1
        msg = "无对应方法"
        return self
    }
    var asyncCallback: ((JSResult)->Void)?
}

@objc protocol JSBridgeCallFunction: JSExport {
    ///从 APP 获取数据
    func get(_ type: String, Data extraParams: NSDictionary) -> JSResult
}

这里有几点用过JSExport都知道的坑:

  1. 如果js调用的方法叫getData,那么原生对应的方法名得叫[get:Data:],如果有三个参数就可以是[get:Da:ta:],swift的话可以给变量取别名是没问题的
  2. 这里字典最好用NSDictionary,其实感觉用[AnyHash: AnyHash]应该也是能行的,但我嫌不好看
  3. 识别不了非JavaScriptCore支持的类型
  4. 虽然传block(闭包)也是可以的,但实际上我这种做法传这个就没什么意义了。因为不是WebKit在调用JavaScriptCore,具体会在下面流程看到
  5. 基于上一点,这个方法都需要一个返回值,这个没任何要求只要是NSObject的子类都行,因为下面的协议需要是@objc的
  6. 返回类型需要能转字典和转JSON,这里为了方便使用了HandyJSON实现
  7. JSResult的内容是根据需求来的,这个只是作为例子,isNotAFunction和asyncCallback是用来做额外处理的,会在后面解释为什么有这两个东西

然后是实现了JSBridgeCallFunction的类

class JSBridge: NSObject, JSBridgeCallFunction {
    func get(_ type: String, Data extraParams: NSDictionary) -> JSResult {
        let result = JSResult()
        guard let type = GetDataType(rawValue: type) else { return result.isNotAFunction() }
        switch type {
        case .USERINFO:
            if let data = User.current.toJSON() {
                result.data = data
            }
        }
        
        return result
    }
}

extension JSBridge {
    enum GetDataType: String {
        ///获取用户信息
        case USERINFO
    }
}

这里为了方便js得知客户端没有实现某些type,所以返回了isNotAFunction(这个名字是从JSContext的exceptionHandler里面学来的)

User也是实现了HandlyJSON所以可以拿简单转字典

前面说了是用输入框进行回调,那么就要去WKWebView处理输入框的WKUIDelegate方法里进行处理

func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
    if let context = JSContext() {
        context.setObject(JSBridge(), forKeyedSubscript: "JSBridge" as NSString)
        context.exceptionHandler = { context, value in
            if let valueStr = value?.toString(), valueStr.contains("is not a function") {//这个是没用的,留着方便调试
                completionHandler("{ status: -1, msg: '无对应方法' }")
            }
        }
        if let result = context.evaluateScript(prompt)?.toObject() as? JSResult {
            if result.asyncCallback != nil {
                result.asyncCallback = { result in
                    completionHandler(result.toJSONString())
                }
            } else {
                completionHandler(result.toJSONString())
            }
            return
        }
    }
    
    completionHandler("")
}

感觉苹果也是基本放弃这个库了。好多地方都不是很方便接入swift(包括初始化居然是optional的。。。)

这里我解释一下,prompt传进来的是类似于JsBridge.getData("USERINFO")的东西,然后直接交给JSContext去映射原生方法

asyncCallback是用来处理异步的,上面这个处理的逻辑其实是很微妙的,如果js那边调用的时候其实是用一个异步回调的话,那么到了上面这段代码的时候其实是把异步转成了同步,那么真正遇到原生里面需要异步处理的时候就会出问题(比如要登陆,登陆结束才能回调js)所以我设计就是如果需要处理原生异步的话,返回的result对象的asyncCallback就不会为空,上面代码判断不为空就重新赋值这个闭包,然后在真正处理结束的地方才会调用result.asyncCallback?()

那么重点来了,为了实现传进来的prompt是类似于JsBridge.getData("USERINFO")的东西,要怎么生成这个注入的js呢,对此我请来了前端的负责人写了一段js:

!(function () {
    function _objToJson (obj) {
        var str = '';
        try {
            str = JSON.stringify(obj);
        } catch (e) {}
        return str;
    }
    function _jsonToObj (str) {
        var obj = {};
        try {
            obj = JSON.parse(str);
        } catch (e) {}
        return obj;
    }
    function _toQuery (method, type, params) {
        var str = params
            ? 'JSBridge.' + method + '("' + type + '",' + _objToJson(params) + ')'
            : 'JSBridge.' + method + '("' + type + '")';
        return str;
    }
    function _getData(type, extraParams, callback) {
        var query = _toQuery('getData', type, extraParams);
        var result = prompt(query);
        if (callback && typeof callback === 'function') {
            callback(result);
        }
        return result;
    }
    var JSBridge = window.JSBridge = {
        getData: _getData
    };
    var doc = document;
    var readyEvent = doc.createEvent('Events');
    readyEvent.initEvent('JSBridgeReady');
    readyEvent.bridge = JSBridge;
    doc.dispatchEvent(readyEvent);
})();

然后我把这段js分割成两段:

static private let jsPrefix =
"""
!(function () {
    function _objToJson (obj) {
        var str = '';
        try {
        str = JSON.stringify(obj);
        } catch (e) {}
        return str;
    }
    function _jsonToObj (str) {
        var obj = {};
        try {
        obj = JSON.parse(str);
        } catch (e) {}
        return obj;
    }
    function _toQuery (method, type, params) {
        var str = params
            ? 'JSBridge.' + method + '("' + type + '",' + _objToJson(params) + ')'
            : 'JSBridge.' + method + '("' + type + '")';
        return str;
    }
    var doc = document;
    var readyEvent = doc.createEvent('Events');
    readyEvent.initEvent('JSBridgeReady');
    readyEvent.bridge = JSBridge;
    doc.dispatchEvent(readyEvent);

"""
static private let jsSufix = "})();"

中间的部分就用runtime来生成了,最终的生成函数:

static func generateJSBridgeJs() -> String {
    var result = "var JSBridge = window.JSBridge = {"
    var functions = ""
    var count: UInt32 = 0
    let methodList = protocol_copyMethodDescriptionList(JSBridgeCallFunction.self, true, true, &count)
    for index in 0..

在页面加载完调用:

func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    webView.evaluateJavaScript(JSBridge.generateJSBridgeJs()) { (result, error) in
        guard let result = result as? Bool, result, error == nil else {
            fatalError("注入失败,请检查JSBridge.generateJSBridgeJs()")
        }
    }
}

江江!搞定,至此不管后端怎么加方法,只要这边JSBridgeCallFunction里添加新的方法就行了,完全不需要修改任何地方

But,其实这个自动化生成有一些限制:

首先我这里根据项目需求,把js调用的函数写死为:

function _\(methodName) (paraA, paraB, callback)

这样就需要和前端协商好参数的顺序了,如果有回调就需要放到最后一位,像有时候callback是必选的,paraB是可选的话,他们一般的习惯都是把paraB放到最后一位去,反过来这种对他们来说就有点反人类了,但无伤大雅,反正不是我在写嘿嘿嘿

实际情况下可能会有更多的参数,但这个其实也很有办法解决:假设只有一个异步回调,那么在前面获取的方法有多少个参数,生成多少个para就行,然后_toQuery改成传数组

但还有可能js传了多个function作为参数,那这个就GG啦,目前我没遇到这种情况所以没动力深入研究解决办法,或许可以拆分成多个函数去进行不同的回调?但判断太多了不好写了

又或者是,前端负责维护一张方法名表,动态获取这张方法名表后去解析动态生成,但这样又跟注册有点像了我又不是很喜欢。。。。

总之目前用在我负责的项目的话这样说足够的,但通用性不强,说不定哪天心血来潮会根据这个思路写一个通用的库

你可能感兴趣的:(如何在WebKit中使用JavaScriptCore)