實現一個優雅的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的時候,有哪些變數:
- 回撥block執行的佇列: queue
- 和哪個物件的生命週期繫結在一起:object
- 事件的二級劃分:eventType
- 回撥的程式碼塊: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];