再造蟲洞:一次 Objective-C 到 Swift 的改寫之旅

語言: CN / TW / HK

再造蟲洞:一次 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