再造虫洞:一次 Objective-C 到 Swift 的改写之旅
既然 Swift 是未来,那手工将一些 Objective-C 的代码转成 Swift 就挺有必要。但如果只是简单的改写,而不使用 Swift 的特点,这个过程就会变得乏味。改写应当是一种再思考、再设计的过程。
作者:@nixzhu
我第一次知道有个叫 MMWormhole 的项目时,这个名字让我很是激动。又了解到它主要用于 iOS 扩展与主应用的实时通信,更让这个名字十分合理。因为物理上的虫洞就是一种能让我们不受空间的限制,远距离传递信息的超空间通道。
于是我去看它的代码,奇怪的是它为何用 Objective-C 写成。按理说,Swift 的发布也有一段时间了。这个项目又是用于扩展和主应用的通信,而且主要是为 WATCH 和对应 iPhone 应用的通信,用 Swift 来写不是更具有未来感吗?
因此我想到去改写,并看看用 Swift 去实现它是否会遇到困难。
分析
MMWormhole 的主要代码不过 300 行,看起来很容易,因此先分析一下它的 API
首先是初始化:
- (instancetype)initWithApplicationGroupIdentifier:(NSString *)identifier
optionalDirectory:(NSString *)directory
它接收一个 App Group ID 和一个可选的目录名,表明了虫洞的实现需要 App Group 的支持。这很好理解,因为 iOS 应用的扩展和主应用并不在同一个沙盒内,要让它们通信,只能用 App Group,或者网络。
有了虫洞,就可以往里面传递消息:
- (void)passMessageObject:(id <NSCoding>)messageObject
identifier:(NSString *)identifier;
它需要消息的名字,以及一个满足 NSCoding 协议的对象。具其文档解释,是因为它使用了 NSKeyedArchiver 来作为序列化媒介来将 messageObject 存储于 App Group 所在的文件系统里,以便虫洞的另一端读取。在实现上,为了及时性,它会使用 Darwin Notify Center 来发送一个名为 identifier 的通知。这种通知作用于整个系统范围,因此可以在 Extension 与 Container App 之间通信,但接收端必须处于 awake 状态。
有了传递,自然就有接收:
- (void)listenForMessageWithIdentifier:(NSString *)identifier
listener:(void (^)(id messageObject))listener;
这个方法监听特定的消息,并在听到时执行一个 block,很好理解。而且很明显,我们可以为一种消息增加多个监听者。只要多调用这个方法几次即可。
有了监听,就该有取消监听:
- (void)stopListeningForMessageWithIdentifier:(NSString *)identifier;
但很遗憾,这个方法会移除所有监听此消息的监听者,而不能单个的移除。如果我们要用 Swift 改写,这该是一个可以改进的地方。
另外还有三个方法:
- (id)messageWithIdentifier:(NSString *)identifier;
- (void)clearMessageContentsForIdentifier:(NSString *)identifier;
- (void)clearAllMessageContents;
分别用于根据消息的 ID 获取消息对象(在初始化时很有用,可以获取“过去”的消息),以及从文件系统中清除消息对象(单个,或全部)
改写
先定 API 如下:
init(appGroupIdentifier: String, messageDirectoryName: String)
func passMessage(message: Message?, withIdentifier identifier: String)
func bindListener(listener: Listener, forMessageWithIdentifier identifier: String)
func removeListener(listener: Listener, forMessageWithIdentifier identifier: String)
func removeListenerByName(name: String, forMessageWithIdentifier identifier: String)
func removeAllListenersForMessageWithIdentifier(identifier: String)
func messageWithIdentifier(identifier: String) -> Message?
func destroyMessageWithIdentifier(identifier: String)
func destroyAllMessages()
除了 API 的命名外,并无太大区别。只是现在我们可以为某个消息移除单个 Listener 了。至于具体的实现,首先是一些类型定义:
typealias Message = NSCoding
将 Message 作为 NSCoding 的别名,非常直观。然后是 Listener:
struct Listener {
typealias Action = Message? -> Void
let name: String
let action: Action
init(name: String, action: Action) {
self.name = name
self.action = action
}
}
Listener 有一个名字和一个操作。这也是有别于 MMWormhole 的地方,它的 listener 只是一个 block,相当于这里的 action,而没有名字,因此无法单独移除。
接下来我们实现 passMessage:
func passMessage(message: Message?, withIdentifier identifier: String) {
if identifier.isEmpty {
fatalError("ERROR: Message need identifier")
}
if let message = message {
var success = false
if let filePath = filePathForIdentifier(identifier) {
let data = NSKeyedArchiver.archivedDataWithRootObject(message)
success = data.writeToFile(filePath, atomically: true)
}
if success {
if let center = CFNotificationCenterGetDarwinNotifyCenter() {
CFNotificationCenterPostNotification(center, identifier, nil, nil, 1)
}
}
} else {
if let center = CFNotificationCenterGetDarwinNotifyCenter() {
CFNotificationCenterPostNotification(center, identifier, nil, nil, 1)
}
}
}
也很简单,首先确保消息的 identifier 不为空,不然接收端没办法区别不同的消息。然后根据消息主体的有无(有时候我们只需要 identifier 即可)来决定 CFNotificationCenterPostNotification 的时机,如有,就生成一个 filePath 并用 NSKeyedArchiver 将消息压缩为 NSData 在写入文件,在保证成功的前提下发送通知;如无,直接发送通知。
然后是实现 bindListener,这是真正的考验,因为 CFNotificationCenterAddObserver
void CFNotificationCenterAddObserver (
CFNotificationCenterRef center,
const void *observer,
CFNotificationCallback callBack,
CFStringRef name,
const void *object,
CFNotificationSuspensionBehavior suspensionBehavior
);
需要的参数中的第三个 CFNotificationCallback 是函数指针,而 Swift (1.2) 还不能创建函数指针。基本上,这就会强制你写 Objective-C 代码,这也解决了之前的疑惑,为何 MMWormhole 用 Objective-C 来写。很明显,既然具体的实现离不开 Objective-C,那不妨全部用 Objective-C 来写。
但是(是的,世界上充满了但是)我还不打算放弃,因为在 Swift 中依然可以使用 Objective-C 的运行时。通过它,也许我们不需要显式的 Objective-C 代码就能构造出一个函数指针来。
根据这篇文章提到的一种 hack 方法(也就意味着有风险),我们可以将一个 Swift 的闭包转换为一个某个对象的 IMP,而 IMP 正是函数指针的一个别名。因此,bindListener 的实现如下:
func bindListener(listener: Listener, forMessageWithIdentifier identifier: String) {
if let center = CFNotificationCenterGetDarwinNotifyCenter() {
let messageListener = MessageListener(messageIdentifier: identifier, listener: listener)
messageListenerSet.insert(messageListener)
let block: @objc_block (CFNotificationCenter!, UnsafeMutablePointer<Void>, CFString!, UnsafePointer<Void>, CFDictionary!) -> Void = { _, _, _, _, _ in
if self.messageListenerSet.contains(messageListener) {
messageListener.listener.action(self.messageWithIdentifier(identifier))
}
}
let imp: COpaquePointer = imp_implementationWithBlock(unsafeBitCast(block, AnyObject.self))
let callBack: CFNotificationCallback = unsafeBitCast(imp, CFNotificationCallback.self)
CFNotificationCenterAddObserver(center, unsafeAddressOf(self), callBack, identifier, nil, CFNotificationSuspensionBehavior.DeliverImmediately)
// Try fire Listener's action for first time
listener.action(messageWithIdentifier(identifier))
}
}
之所以一定要实现这个 callBack,是因为我们必须在这个 callBack 里调用我们的 Listener 的 Action 闭包以便执行使用此消息的一些操作。另外请注意 block 的形式参数都是 _, _, _, _, _,
一半原因是我的实现不需要使用到它们,另一半原因是这终究是一种 hack 的方法,也许有失效的一天,而不使用其参数可能减轻不利影响。
需要注意的是,在 Wormhole 内部,我增加了一个 MessageListener:
func ==(lhs: Wormhole.MessageListener, rhs: Wormhole.MessageListener) -> Bool {
return lhs.hashValue == rhs.hashValue
}
struct MessageListener: Hashable {
let messageIdentifier: String
let listener: Listener
var hashValue: Int {
return (messageIdentifier + "<nixzhu.Wormhole>" + listener.name).hashValue
}
}
用于封装 Listener 和 messageIdentifier。而且它满足 Hashable 协议,这样用集合 var messageListenerSet = Set<MessageListener>()
来装载所有的 MessageListener 就能带来好处:方便判断 Listener 的有效性,自动更新监听同一个 Message 的同名 Listener,也可以单独移除某一个 Listener。
除了 removeListener 外,其它的 API 就只是基本的改写,并无介绍的必要,有兴趣的读者请自行阅读代码,地址为:https://github.com/nixzhu/Wormhole。
欢迎转载,但请一定注明出处! https://github.com/nixzhu/dev-blog