1. 程式人生 > >實現一個優雅的iOS事件匯流排

實現一個優雅的iOS事件匯流排

目標

訂閱登入事件LoginEvent,當self dealloc時候自動取消訂閱

[QTSub(self, LoginEvent) next:^(LoginEvent *event) {

}];

訂閱通知NSNotification,當self dealloc的時候自動取消訂閱

//訂閱通知name
[QTSubNoti(self,"name") next:^(NSNotification *event) {

}];
//訂閱App將要關閉
[[self subscribeAppWillTerminate] next:^(NSNotification *event) {

}];

並且XCode可以自動推斷型別

這裡寫圖片描述

好了,開始囉裡八嗦講原理和設計了,做好準備,文章挺長的。不想看我囉嗦,程式碼在這裡

Notification的痛點

Cocoa Touch提供了一種訊息中心機制:NSNotificationCenter,相信iOS開發者都很熟悉了,

  • addObserver 訂閱通知
  • postNotification 傳送通知
  • removeObserver 取消訂閱

當然,還有一個介面是比較容易忽略的,就是利用block註冊訂閱

NSNotificationCenter * center = [NSNotificationCenter defaultCenter];
id
<NSObject> token = [center addObserverForName:@"name" object:nil queue:nil usingBlock:^(NSNotification * _Nonnull note) { }]; [center removeObserver:token]

實際開發中,Notification又有哪些痛點呢?

Name如何管理?

方式一:hardcode在程式碼裡

[center addObserverForName:@"UserLoginNotification" ...]

優點:無需額外的import,鬆耦合。
缺點:修改和版本管理麻煩

方式二:在相關模組的原始檔裡,比如登入成功的通知放在登入模組裡。

//.h檔案
extern NSString * const UserLoginNotification; //登入成功
//.m檔案
NSString * const UserLoginNotification = @"UserLoginNotification";

優點:便於修改和版本管理
缺點:需要import引入對應的模組,導致強耦合模組,但是得到的卻是弱型別。

有些同學喜歡把name堆到一個頭檔案裡,這種設計理念是不符合軟體設計原則的:“介面隔離原則,不應該強制客戶端依賴那些他們不需要的介面”。想想也有道理:我不需要的通知為啥讓我引入進來對吧?。

弱型別

常見的用Notification傳遞訊息的方式是UserInfo,然後宣告各種key

extern NSString * const kUserId; //使用者id

接收者取出資訊

NSString * userId = [notification.userInfo objectForKey: kUserId];

缺點:必須看文件或者原始碼才知道通知裡具體有什麼,字典是弱型別的,不易做介面的版本管理。

弱型別還有個明顯的劣勢就是無法在編譯期找到型別不匹配的問題。

優點:只要userInfo是JSON,就是鬆耦合的。

膠水程式碼

使用Notificaton不得不寫很多膠水程式碼

取消監聽,不然會crash

- (void)dealloc{
    [center removeObserver:self];
}

取出通知內容

NSString * userId = [notification.userInfo objectForKey:@"userId"]

小結

總的來說,NotificationCenter的通訊方式在完全鬆耦合的場景下是很適用的:傳送者不用關心接收者,傳送者和接受者統一按照JSON等協議通訊。

而實際開發中,很多時候我們並不需要鬆耦合的通訊

  • 業務層程式碼的通訊需要鬆耦合,因為兩個業務通常是獨立開發迭代,通訊按照指定協議即可,不可能開發的時候強制要import另一個業務程式碼進來。
  • 像登入這種基礎服務程式碼,本質上不屬於業務,開發的時候往往需要import對應的framework進來,這時候強型別的通訊方式往往更好。

相信我,和基礎服務程式碼通訊的頻率要遠高於業務之間通訊,甚至業務之間的通訊很多時候也可以沉入到Service層。

匯流排

匯流排本質上是”釋出-訂閱”這種訊息正規化:訂閱者不關心訊息由誰傳送;接收者也不關係訊息由誰接收。

匯流排是為了解決模組或者類之間訊息通訊而存在的,如果我們要實現一個匯流排,我們我們希望它能有哪些特點呢?

  • 介面友好,介面友好,介面友好,重要的事情說三遍
    • 不需要手動取消監聽
    • 引數少,方法短,閱讀起來一目瞭然
    • 基於block的回撥,降低上下文理解難度
  • 相容Notification
  • 效率高
  • 支援強型別/弱型別

定義事件

Notification用字串來唯一事件,用一個類就代表了所有通知。而我們需要同時支援強型別和弱型別事件,怎麼辦呢?

  • 用類名來區分事件,從而實現強型別:訂閱者subscribe類名,釋出者dispatch類。
  • 用字串eventType來對類事件進行二級劃分,從而實現弱型別。

協議定義如下

@protocol QTEvent<NSObject>
@optional
- (NSString *)eventType;
@end

這樣,我們就可以相容Notification了

@interface NSNotification (QTEvent)<QTEvent>
@end
@implementation NSNotification (QTEvent)
- (NSString *)eventType{
    return self.name;
}
@end

然後強型別事件client自己定義類,弱型別事件可以採用框架提供的統一類,比如:

@interface QTJsonEvent : NSObject<QTEvent>
+ (instancetype)eventWithId:(NSString *)uniqueId jsonObject:(NSDictionary *)data;
@end

介面

由於我們的事件是用類來定義的,所以介面不難定義:

@interface QTEventBus : NSObject
- (...)on:(Class)eventClass; //訂閱事件
- (void)dispatch:(id<QTEvent>)event; //釋出事件
@end

取消監聽

手動取消

我們需要返回給client一個數據結構來取消監聽,我們選擇抽象的協議作為返回

@protocol QTEventToken<NSObject>
//取消監聽
- (void)dispose;
@end

用協議作為返回值的好處是隱藏了內部的實現,這樣內部實現就可以獨立的變化,而對外透明。

然後內部建立一個具體的類,並且在dispose呼叫一個傳入的block,在傳入的block取消訂閱

這是函式式的程式設計思想,把dispose抽象成一個傳入的函式。

@interface _QTEventToken: NSObject<QTEventToken>
...
- (void)dispose{
    @synchronized(self){
        if (_isDisposed) {
            return;
        }
        _isDisposed = YES;
    }
    if (self.onDispose) {
        self.onDispose(self.uniqueId);
    }
}

自動取消

如何實現自動取消訂閱呢?根據二八原則,我們來思考下百分之八十的情況下在什麼時候取消監聽?

在物件釋放的時候。

如果回撥方式選擇target/action,可以選擇支援弱引用的集合(NSMapTable等)。但是我們設計的回撥介面是基於block的,匯流排必須強持有這個block,所以就不能簡單的使用這些弱引用集合了。

那麼,如何知道一個物件被釋放了呢?

關聯物件

由於一個物件可能多次呼叫,所以我們的關聯物件應該支援一次取消多個註冊。QTDisposeBag接收多個id<QTEventToken>,然後在dealloc的時候呼叫他們的dispose。

- (void)dealloc{
    for (id<QTEventToken> token in self.tokens) {
        if ([token respondsToSelector:@selector(dispose)]) {
            [token dispose];
        }
    }
}

然後,用關聯物件的方式,繫結到指定物件上,這樣它的生命週期就和指定物件繫結在一起了

- (QTDisposeBag *)eb_disposeBag{
    QTDisposeBag * bag = objc_getAssociatedObject(self, &event_bus_disposeContext);
    if (!bag) {
        bag = [[QTDisposeBag alloc] init];
        objc_setAssociatedObject(self, &event_bus_disposeContext, bag, OBJC_ASSOCIATION_RETAIN);
    }
    return bag;
}

效率

在分析效率之前,我們下來看看匯流排的資料模型:

一個ClassName對應著多個監聽者: Name -> [subscribers],而匯流排維護著多個這種對映關係。

最直接想到的資料結構:字典巢狀陣列,但是我們都知道陣列刪除一個元素的時候是需要額外的s時間消耗的,平均O(n)。

為了實現增加和刪除效率是O(1),可以選擇另外一種資料結構:雙線連結串列。

所以,最後我們的資料結構是:字典 + 雙向連結串列,這樣我們在增加和刪除元素的時間消耗都是O(1)的。

當然由於匯流排有可能在多個執行緒被呼叫,所以這個資料結構應該是執行緒安全的。

鏈式引數

我們來思考下注冊Event的時候,有哪些變數:

  1. 回撥block執行的佇列: queue
  2. 和哪個物件的生命週期繫結在一起:object
  3. 事件的二級劃分:eventType
  4. 回撥的程式碼塊:next

這四個變數除了next是必須的,其他的都是可選的。一種很笨的做法是窮舉法:

[bus subscribeNext:]
[bus subscribeOnQueue:next:]
[bus subscribeOnQueue:freeWith:next]
...

這種複雜物件的建立,我們可以用一個工廠來一步步建立:

typedef void (^QTEventNextBlock)(Value event);
@interface QTEventSubscriberMaker<Value> : NSObject
- (id<QTEventToken>)next:(QTEventNextBlock)hander;
@property (readonly) QTEventSubscriberMaker<Value> *(^atQueue)(dispatch_queue_t);
@property (readonly) QTEventSubscriberMaker<Value> *(^ofType)(NSString *);
@property (readonly) QTEventSubscriberMaker<Value> *(^freeWith)(id);
@end

EventBus提供一個介面返回QTEventSubscriberMaker物件,讓client用組合的方式建立:

- (QTEventSubscriberMaker<id> *(^)(Class eventClass))on{
    //返回一個block,從而實現點語法
    return ^QTEventSubscriberMaker *(Class eventClass){...};
}

接著就可以用點語法任意組合引數了:

bus.on(LoginEvent.class).atQueue(main).next(^(LoginEvent * event{

}));

簡化介面

我們的監聽要跟著某一個物件的生命週期走,這時候新增一個NSObject的Category,讓self成為一個引數輸入能夠進一步簡化呼叫流程

@implementation NSObject (QTEventBus)
- (QTEventSubscriberMaker *)subscribe:(Class)eventClass{
    return [QTEventBus shared].on(eventClass).freeWith(self);
}
@end

執行緒模型

事件的派發可以分為兩個步驟:傳送者dispatch,接收者回調block

設計回撥的時候,有一些問題不得不考慮:那就是整個通訊過程是同步還是非同步的?都設計成非同步的可以嗎?

當然不可以都設計成非同步的,舉個簡單的例子:在某些事件的時候,你需要完成某些初始化工作,這些初始化工作未完成的時候,當前執行緒是不可以走下去的。

所以執行緒模型預設的設計成了同步,也就是說:傳送方dispatch -> eventbus分發 -> 執行回撥block這些都是同步的。

通過提供方法,來實現dispatch和回撥block的非同步

//在匯流排內部佇列上dispatch
- (void)dispatchOnBusQueue:(id<QTEvent>)event;
//主執行緒非同步回撥
bus.on(LoginEvent.class).atQueue(main)

神奇的巨集定義

為了在編譯期支援強型別,所以被QTEventSubscriberMaker定義成了範型型別

@interface QTEventSubscriberMaker<Value> : NSObject
typedef void (^QTEventNextBlock)(Value event) NS_SWIFT_UNAVAILABLE("");
- (id<QTEventToken>)next:(QTEventNextBlock)hander;
@end

但是這就有一個問題,我必須這麼寫,XCode才能自動推斷出型別

QTEventSubscriberMaker<QTMockIdEvent *> * event = self.eventBus.on(QTMockIdEvent.class).ofType(_id).freeWith(self)
[event next:...]

毫無疑問這種介面是及其不友好的,並且這個程式碼還有個大問題:程式碼很長。

這時候一個強大工具可以幫助我們來解決這個問題:巨集定義

比如這樣的一個巨集定義:

#define QTSub(_object_,_className_) ((QTEventSubscriberMaker<_className_ *> *)[_object_ subscribe:[_className_ class]])

總結

定義事件

@interface QTLoginEvent : NSObject<QTEvent>
@property (copy, nonatomic) NSString * userId;
@end

訂閱事件

//注意eventBus會持有這個block,需要弱引用self
[QTSub(self,QTLoginEvent) next:^(QTLoginEvent * event) {
   NSLog(@"%ld",event.userId);
}];

釋出事件

QTLoginEvent * event;
[QTEventBus.shared dispatch:event];