自己动手打造基于 WKWebView 的混合开发框架(三)——设计插件协议以兼容 Cordova

代码示例:https://github.com/johnlui/Swift-On-iOS/tree/master/BuildYourOwnHybridDevelopmentFramework/BuildYourOwnHybridDevelopmentFramework

开源项目:BlackHawk,纯 Swift 开发的基于 WKWebView 的高性能 Cordova 替代:https://github.com/Lucky-Orange/BlackHawk

本文中,我们将一起构造出一个完整的 js -> Native -> js 回调+传值的数据通道,并设计出插件协议,最终实现在 js API 层完全兼容 Cordova,让现有 Cordova 项目可以无感知迁移。

实现 js 向 Swift 的传值

Console 插件就是 js 向 Swift 传值最好的实例。Console 插件在 iOS 平台是非常必须的,用以在 Xcode 的调试窗口里显示出 js console.log() 的信息,这个功能在 Android 上是自带的。

我们直接给之前传值时候用的 js 对象里增加一个 data 属性,之后在 swift 里将这个参数发送给 Console 的 swift 代码就完成了数据的传输。不多说了,直接上代码。

Console 插件 js 部分

我们在项目根目录下新建一个 www 目录,在里面新建 plugins 目录,在里面新建 Console.js,放入以下代码:

console = {
  log: function (string) {
    window.webkit.messageHandlers.OOXX.postMessage({className: 'Console', functionName: 'log', data: string});
  }
}

这些插件的 js 层代码需要我们提前注入到 wk 里面:

1. 把 www 文件夹拖入工程,这样 Xcode 就会把他们当做资源文件打包进 ipa

2. 在 wk 生成之后,手动 evaluateJavaScript Console.js 进 js runtime:

    ... ...
    
    self.runPluginJS(["Console"])
    
    self.view.addSubview(self.wk)
}

func runPluginJS(names: Array<String>) {
    for name in names {
        if let path = NSBundle.mainBundle().pathForResource(name, ofType: "js", inDirectory: "www/plugins") {
            do {
                let js = try NSString(contentsOfFile: path, encoding: NSUTF8StringEncoding)
                self.wk.evaluateJavaScript(js as String, completionHandler: nil)
            } catch let error as NSError {
                NSLog(error.debugDescription)
            }
        }
    }
}

Console 插件 swift 层

新建 Console 类:

class Console: NSObject {
    func log(data: String) {
        NSLog(data)
    }
}

由于这个方法有参数,所以我们需要修改调用方法,把参数传过去:

let functionSelector = Selector(functionName + ":")
if obj.respondsToSelector(functionSelector) {
    obj.performSelector(functionSelector, withObject: dic["data"]?.description)
} else {
    print("方法未找到!")
}

查看效果

Image

搞定!

实现 swift 向 js 层传值

上面我们已经实现了 js 向 Native 层的传值和反射,接下来我们要做的就是实现 Native 向 js 层的回调。

分析调用流程及数据结构

分析 Accelerometer 这个插件的调用方式。触发方法为:

navigator.accelerometer.getCurrentAcceleration(accelerometerOnSuccess, accelerometerOnError)

处理函数为:

function accelerometerOnSuccess(acceleration) {
    alert('Acceleration X: ' + acceleration.x + '\n' +
          'Acceleration Y: ' + acceleration.y + '\n' +
          'Acceleration Z: ' + acceleration.z + '\n' +
          'Timestamp: '      + acceleration.timestamp + '\n');
};
function accelerometerOnError(e) {
    alert(e);
};

很明显,这个插件用到了两个回调函数,分别处理正确和错误的回调,所以我们需要维护一下 js 层的队列,每次在调用之前把回调函数压入队列,把序号传给 Native 层,等 Native 层返回结果时,再拿着这个序号来回调 js 函数。

数据结构也很明显,回调时会向 js 层传一个 js 对象,而这个我们也有处理经验,直接用 NSDictionary 就可以了。

js 层队列及队列管理

在 www/plugins 文件夹中新建一个 Base.js,用于生成和处理队列:

Queue = [];
Task = {
  id: 0,
  callback: function(){},
  errorCallback: function(){},
  init: function(id, callback, errorCallback) {
    this.id = id;
    this.callback = callback;
    this.errorCallback = errorCallback;
    return this
  }
};
fireTask = function(i, j) {
  Queue[i].callback(JSON.parse(j));
};
onError = function (i, j) {
  Queue[i].errorCallback(j);
};

注入 Base.js:

self.runPluginJS(["Base", "Console"])

swift 层修改

细心的人可能发现了,前面 Console 插件修改了核心反射调用方法,会让没有参数的反射失效。所以现在一个插件系统是必须的了,我们要从根本上解决这个问题,并带来更多功能和安全性上的提升。

创建插件基础类

新建 Plugin 插件基础类:

class Plugin: NSObject {
    var wk: WKWebView!
    var taskId: Int!
    var data: String?
    required override init() {
    }
    func callback(values: NSDictionary) -> Bool {
        do {
            let jsonData = try NSJSONSerialization.dataWithJSONObject(values, options: NSJSONWritingOptions())
            if let jsonString = NSString(data: jsonData, encoding: NSUTF8StringEncoding) as? String {
                let js = "fireTask(\(self.taskId), '\(jsonString)');"
                self.wk.evaluateJavaScript(js, completionHandler: nil)
                return true
            }
        } catch let error as NSError{
            NSLog(error.debugDescription)
            return false
        }
        return false
    }
    func errorCallback(errorMessage: String) {
        let js = "onError(\(self.taskId), '\(errorMessage)');"
        self.wk.evaluateJavaScript(js, completionHandler: nil)
    }
}

更改参数传递方式

我们修改反射类型的基类,并使用 data 类成员变量来存储 js 传过来的字符串数据:

if let cls = NSClassFromString(NSBundle.mainBundle().objectForInfoDictionaryKey("CFBundleName")!.description + "." + className) as? Plugin.Type{
    let obj = cls.init()
    obj.wk = self.wk
    obj.taskId = dic["taskId"]?.integerValue
    obj.data = dic["data"]?.description
    let functionSelector = Selector(functionName)
    if obj.respondsToSelector(functionSelector) {
        obj.performSelector(functionSelector)
    } else {
        print("方法未找到!")
    }
} else {
    print("类未找到!")
}

OK,让我们开始真正的插件的构建。

重建 Console 插件

删掉之前的 Callme 类和 Console 类,新建 Console.swift:

class Console: Plugin {
    func log() {
        if let string = self.data {
            NSLog("OOXX >>> " + string)
        }
    }
}

查看成果

Image

js 向 Native 层传值成功!

新建 Accelerometer 插件

js 部分

在 www/plugins 目录下新建 Accelerometer.js:

navigator.accelerometer = {
  getCurrentAcceleration: function(onSuccess, onError) {
    Queue.push(Task.init(Queue.length, onSuccess, onError));
    window.webkit.messageHandlers.OOXX.postMessage({className: 'Accelerometer', functionName: 'getCurrentAcceleration', taskId: Queue.length - 1});
  }
}

Swift 部分

注入 Accelerometer.js:

self.runPluginJS(["Base", "Console", "Accelerometer"])

新建 Accelerometer.swift:

import CoreMotion

class Accelerometer: Plugin {
    var motionManager: CMMotionManager!
    
    var isRunning = false
    
    // defaults to 10 msec
    let kAccelerometerInterval: NSTimeInterval = 10
    // g constant: -9.81 m/s^2
    let kGravitationalConstant = -9.81
    
    func getCurrentAcceleration() {
        if motionManager == nil {
            motionManager = CMMotionManager()
        }
        if motionManager.accelerometerAvailable {
            motionManager.accelerometerUpdateInterval = self.kAccelerometerInterval / 1000
            motionManager.startAccelerometerUpdatesToQueue(NSOperationQueue.mainQueue(), withHandler: { (data, error) -> Void in
                let dic = NSMutableDictionary()
                dic["x"] = data!.acceleration.x * self.kGravitationalConstant
                dic["y"] = data!.acceleration.y * self.kGravitationalConstant
                dic["z"] = data!.acceleration.z * self.kGravitationalConstant
                dic["timestamp"] = NSDate().timeIntervalSince1970
                if self.callback(dic) {
                    self.motionManager.stopAccelerometerUpdates()
                }
            })
            if !self.isRunning {
                self.isRunning = true
            }
        } else {
            self.errorCallback("accelerometer not available!")
        }
    }
}

检验成果

Image

错误回调正常。出现这种错误是因为模拟器没有加速度传感器,我使用同样方式在真机上进行了测试,得到了如下结果:

Image

成功!

总结

至此,《自己动手打造基于 WKWebView 的混合开发框架》系列文章就全部完成了。在该系列文章中,我跟大家一起完成了一个基于 WKWebView 的简易混合开发框架,将一些 Native 接口暴露给了 js runtime,让 js 有了一些本不具备的强大的功能,在此基础上,我们可以按照我们制定出的插件标准持续不断地加入我们需要的接口,满足我们工作中的需要。

BlackHawk 到底是什么

本系列文章基本讲述了 BlackHawk 的原理和搭建过程,大家也可以看出,BlackHawk 是比 Cordova 更低的一层,性质应该是基本反射层。而我们做的 Cordova 兼容层只是 BlackHawk 之上的一个应用而已。如果大家只是为自己的 iOS APP 搭建混合开发环境,完全可以直接使用 BlackHawk 的底层和插件协议,不用遵循 Cordova 现有插件的 API 标准,可以做得更加简单粗暴有效。

BlackHawk 是怎么出现的

BlackHawk 是我在工作中进行技术预研的一项成果,目标是构造出一个标准 Cordova 环境,遵循现有事实标准跨平台。最初我打算直接在 Swift 主项目里引入 OC 的Cordovalib 子项目,发现反射有问题,接着我想用 Swift 封装 Cordova 再给主项目使用,后来发现二层子项目里无法反射。之后没有办法,打算再造 Cordova,从 js API 层面对 Cordova 进行兼容,最终得到了 BlackHawk。

为何开源

最后某天中午我脑洞大开:既然 Cordova 是开源的,那我就可建立一个 Swift 项目,再把 Cordovalib 里的所有 OC 代码复制过来,采用混合编译的方式运行,再在其 OC 的类之上封装一层不就可以了。按照这个思路成功跑了起来,实现了 Cordova 原生的反射,并实现了完全兼容所有 Cordova 插件并可以直接用 Swift 写 Cordova 插件的目的。最后采用了这个方案,毕竟能减少不少工作量,于是就把 BlackHawk 开源了。

小感悟

在这个过程过我发现 OC 的 Category(Swift 中的 Extension/扩展)在以 Swift 为基础语言新建的项目中不能运行,这算是一个额外的 tip 吧。在解决这个问题的过程中,我被迫写了几十行 OC 代码,那叫一个蛋疼呀,我都快哭了。奉劝大家还是早日迁移到 Swift 来享受美好吧~对了,这个对 Cordova 的 Swift 封装可能某一天会开源哦~


《自己动手打造基于 WKWebView 的混合开发框架》系列文章到此结束,谢谢大家!

WRITTEN BY

avatar
2015.9.3   /   热度:8466   /   分类: iOS & Swift

评论:

Gray
2017-02-10 16:04
Base.js 的Task对象会被覆盖,需要处理下。
function Task(id, callback, errorCallback) {
    var mTask = new Object;
    mTask.id = id;
    mTask.callback = callback;
    mTask.errorCallback = errorCallback;
    mTask.once = false;
    return mTask;
}
Curtain
2016-07-07 15:45
console = {
  log: function (string) {
    window.webkit.messageHandlers.OOXX.postMessage({className: 'Console', functionName: 'log', data: string});
  }
}

JS里面的这个方法如何去调用到安卓呢,您这样的做法是不是JS那边得判断设备 iOS设备调用window.webkit.messagexxxxxxx 安卓是调用另外一个?
JohnLui
2017-02-10 18:03
@Curtain:安卓的 webview 也有相应的方法,最好的方式应该是封装一个转换函数,统一进行处理。
skyon
2016-06-17 15:32
请问有没有动态web 页面的demo,如果想拦截动态web页面的某个js 方法,该如何实现
kevin
2016-05-17 15:58
很好的文章,支持博主。
我想问下WKWebView是在iOS8之后才有,那请问iOS7是用哪个替换呢?还是用UIWebView吗?
iOSQiao
2016-05-13 13:58
我也遇到这样的问题,Plugin.Type 说是找不到类,换成NSObject.Type就行,求博主解答
包红旭
2016-04-22 18:13
如何能正确的在wkwebview头尾加view,现在加上去了,但是尾部加的tableview的位置不对,有的地方俩者之间会留一块空白,有的地方直接给截断了
韩文博
2016-01-27 12:49
又遇到一个问题,用你写的WKWebView加载速度和 Webview对比了下 慢了2秒  不知道什么原因
韩文博
2016-01-27 19:58
@韩文博:加载顺序调整下即可,我的解决方法是把wk  放 viewDidLoad里了  也可以和js交互,不过WK没有缓存 还在想办法中 想让js css 别每次都重新再请求 很慢的
韩文博
2016-01-27 10:49
原来如此 。。我又再次明白楼主的用心良苦了  懂了,原来是可以调到的。。。。swift和php真是。。。套路不像  误解了- -抱歉
韩文博
2016-01-27 10:41
NSClassFromString(NSBundle.mainBundle().objectForInfoDictionaryKey("CFBundleName")!.description + "." + className) as? Plugin.Type
这句代码写的不好,但我又不知道怎么改
既然知道是Plugin.Type了  那还定义className干嘛呢? 我网页里传来一个className只要不叫Plugin等于是白传呗?
我建议博主改成获得的  让   className.Type变成动态的   className传什么就调什么
JohnLui
2016-01-27 10:51
@韩文博:Swift 是静态编译型语言,如果你研究过反射,你会发现我这个方法不仅实现了你想要的功能,而且是唯一的办法。Plugin.Type 是一种编译器欺骗,可以代指 Plugin 类以及他的子类。
韩文博
2016-01-27 19:55
@JohnLui:是的很神奇 以前的编程经验导致我以为这是个错的 倒腾了好久。。才发现 Plugin的子类也可以调用  感谢 文章写的很细  我基于你这个可以和js交互了    WKWebview我发现他没缓存挺闹心的 有时加载速度会比较长 不知道您是否有好方法
whh
2016-01-26 17:41
请问如何在wkwebview中添加请求的包头参数,在uiwebview中可以自己创建request,但是在wk中似乎并没有用
韩文博
2016-01-26 01:22
额  好了 我懂了  我懂了   我自己粗心了   。。。
韩文博
2016-01-26 01:16
郁闷 if let cls = NSClassFromString(NSBundle.mainBundle().objectForInfoDictionaryKey("CFBundleName")!.description + "." + className) as? Plugin.Type{

怎么写都不行  换成 NSObject 就行  这是啥原因的呢? 命名空间?咋解决
韩文博
2016-01-25 23:43
又遇到这个问题了  我把NSObject 换成  Plugin为什么不行了呢 说找不到类了
韩文博
2016-01-26 00:08
@韩文博:请求作者帮帮忙
iOSQiao
2016-05-13 14:47
@韩文博:我找到原因了,因为没有让className的对象继承Plugin !!!!!! 所以肯定会说找不到这个类的

发表评论:

© 2011-2017 岁寒  |  Powered by Emlog