1. 程式人生 > IOS開發 >iOS NSNotification使用及原理實現

iOS NSNotification使用及原理實現

概述

NSNotification是蘋果提供的一種”同步“單向且執行緒安全的訊息通知機制(並且訊息可以攜帶資訊),觀察者通過向單例的通知中心註冊訊息,即可接收指定物件或者其他任何物件發來的訊息,可以實現”單播“或者”廣播“訊息機制,並且觀察者和接收者可以完全解耦實現跨層訊息傳遞;

同步:訊息傳送需要等待觀察者處理完成訊息後再繼續執行;

單向:傳送者只傳送訊息,接收者不需要回復訊息;

執行緒安全:訊息傳送及接收都是在同一個線性完成,不需要處理執行緒同步問題,這個後面會詳述;

使用

NSNotification

NSNotification包含了訊息傳送的一些資訊,包括name訊息名稱、object

訊息傳送者、userinfo訊息傳送者攜帶的額外資訊,其類結構如下:

@interface NSNotification : NSObject <NSCopying,NSCoding>

@property (readonly,copy) NSNotificationName name;
@property (nullable,readonly,retain) id object;
@property (nullable,copy) NSDictionary *userInfo;

- (instancetype)initWithName:(NSNotificationName)name object:(nullable id)object userInfo:(nullable NSDictionary *)userInfo API_AVAILABLE(macos(10.6),ios(4.0),watchos(2.0),tvos(9.0)) NS_DESIGNATED_INITIALIZER;
- (nullable instancetype)initWithCoder:(NSCoder *)coder NS_DESIGNATED_INITIALIZER;

@end

@interface NSNotification (NSNotificationCreation)

+ (instancetype)notificationWithName:(NSNotificationName)aName object:(nullable id)anObject;
+ (instancetype)notificationWithName:(NSNotificationName)aName object:(nullable id)anObject userInfo:(nullable NSDictionary *)aUserInfo;

- (instancetype)init /*API_UNAVAILABLE(macos,ios,watchos,tvos)*/;	/* do not invoke; not a valid initializer for this class */

@end
複製程式碼

可以通過例項方式構建NSNotification物件,也可以通過類方式構建;

NSNotificationCenter

NSNotificationCenter訊息通知中心,全域性單例模式(每個程式都預設有一個預設的通知中心,用於程式內通訊),通過如下方法獲取通知中心短息:

對於macOS系統,每個程式都有一個預設的分散式通知中心NSDistributedNotificationCenter,具體可參見NSDistributedNotificationCenter

+ (NSNotificationCenter *)defaultCenter
複製程式碼

具體的註冊通知訊息方法如下:

//註冊觀察者
- (void)addObserver:(id)observer selector:(SEL)aSelector name:(nullable NSNotificationName)aName object:(nullable id)anObject;
- (id <NSObject>)addObserverForName:(nullable NSNotificationName)name object:(nullable id)obj queue:(nullable NSOperationQueue *)queue usingBlock:(void (^)(NSNotification *note))block API_AVAILABLE(macos(10.6),tvos(9.0));
複製程式碼

註冊觀察者方法提供了兩種形式:selectorblock,對於新增指定觀察者物件的方式,observer不能為nilblock方式會執行copy方法,返回的是使用的匿名觀察者物件,且指定觀察者處理訊息的操作物件NSOperationQueue

對於指定的訊息名稱name及傳送者物件object都可以為空,即接收所有訊息及所有傳送物件傳送的訊息;若指定其中之一或者都指定,則表示接收指定訊息名稱及傳送者的訊息;

對於block方式指定的queue佇列可為nil,則預設在傳送訊息執行緒處理;若指定主佇列,即主執行緒處理,避免執行UI操作導致異常;

注意:註冊觀察者通知訊息應避免重複註冊,會導致重複處理通知訊息,且block對持有外部物件,因此需要避免引發迴圈引用問題;

訊息傳送方法如下:

//傳送訊息
- (void)postNotification:(NSNotification *)notification;
- (void)postNotificationName:(NSNotificationName)aName object:(nullable id)anObject;
- (void)postNotificationName:(NSNotificationName)aName object:(nullable id)anObject userInfo:(nullable NSDictionary *)aUserInfo;
複製程式碼

可以通過NSNotification包裝的通知訊息物件傳送訊息,也可以分別指定訊息名稱、傳送者及攜帶的資訊來傳送,且為同步執行模式,需要等待所有註冊的觀察者處理完成該通知訊息,方法才會返回繼續往下執行,且對於block形式處理通知物件是在註冊訊息指定的佇列中執行,對於非block方式是在同一執行緒處理;

注意:訊息傳送型別需要與註冊時型別一致,即若註冊觀察者同時指定了訊息名稱及傳送者,則傳送訊息也需要同時指定,否則無法接收到訊息;

移除觀察者方法如下:

//移除觀察者
- (void)removeObserver:(id)observer;
- (void)removeObserver:(id)observer name:(nullable NSNotificationName)aName object:(nullable id)anObject;
複製程式碼

可移除指定的觀察者所有通知訊息,即該觀察者不再接收任何訊息,一般用於觀察者物件dealloc釋放後呼叫,但在ios9macos10.11之後不需要手動呼叫,dealloc已經自動處理;

If your app targets iOS 9.0 and later or macOS 10.11 and later,you don't need to unregister an observer in its dealloc method. Otherwise,you should call this method or removeObserver:name:object: before observer or any object specified in addObserverForName:object:queue:usingBlock: or addObserver:selector:name:object:is deallocated.

也可以指定訊息名稱或者傳送者移除單一或者所有的訊息(通過置nil可移除對應型別下的所有訊息);

NSNotificationQueue

NSNotificationQueue通知佇列實現了通知訊息的管理,如訊息傳送時機、訊息合併策略,並且為先入先出方式管理訊息,但實際訊息傳送仍然是通過NSNotificationCenter通知中心完成;

@interface NSNotificationQueue : NSObject
@property (class,strong) NSNotificationQueue *defaultQueue;

- (instancetype)initWithNotificationCenter:(NSNotificationCenter *)notificationCenter NS_DESIGNATED_INITIALIZER;

- (void)enqueueNotification:(NSNotification *)notification postingStyle:(NSPostingStyle)postingStyle;
- (void)enqueueNotification:(NSNotification *)notification postingStyle:(NSPostingStyle)postingStyle coalesceMask:(NSNotificationCoalescing)coalesceMask forModes:(nullable NSArray<NSRunLoopMode> *)modes;

- (void)dequeueNotificationsMatching:(NSNotification *)notification coalesceMask:(NSUInteger)coalesceMask;
複製程式碼

可以通過defaultQueue獲取當前執行緒繫結的通知訊息佇列,也可以通過initWithNotificationCenter:來指定通知管理中心,具體的訊息管理策略如下:

NSPostingStyle:用於配置通知什麼時候傳送

  • NSPostASAP:在當前通知呼叫或者計時器結束髮出通知
  • NSPostWhenIdle:當runloop處於空閒時發出通知
  • NSPostNow:在合併通知完成之後立即發出通知。

NSNotificationCoalescing(注意這是一個NS_OPTIONS):用於配置如何合併通知

  • NSNotificationNoCoalescing:不合並通知
  • NSNotificationCoalescingOnName:按照通知名字合併通知
  • NSNotificationCoalescingOnSender:按照傳入的object合併通知

對於NSNotificationQueue通知佇列若不是指定NSPostNow立即傳送模式,則可以通過runloop實現非同步傳送;

NSNotification與多執行緒

對於NSNotification與多執行緒官方檔案說明如下:

In a multithreaded application,notifications are always delivered in the thread in which the notification was posted,which may not be the same thread in which an observer registered itself.

即是NSNotification的傳送與接收處理都是在同一個執行緒中,對於block形式則是接收處理在指定的佇列中處理,上面已說明這點,這裡重點說明下如何接收處理在其他執行緒處理。

For example,if an object running in a background thread is listening for notifications from the user interface,such as a window closing,you would like to receive the notifications in the background thread instead of the main thread. In these cases,you must capture the notifications as they are delivered on the default thread and redirect them to the appropriate thread.

如官方說明;對於處理通知執行緒不是主執行緒的,如後臺執行緒,存在此處理場景,並且官方也提供了具體的實施方案:

一種重定向的實現思路是自定義一個通知佇列(注意,不是NSNotificationQueue物件,而是一個陣列),讓這個佇列去維護那些我們需要重定向的Notification。我們仍然是像平常一樣去註冊一個通知的觀察者,當Notification來了時,先看看post這個Notification的執行緒是不是我們所期望的執行緒,如果不是,則將這個Notification儲存到我們的佇列中,併傳送一個mach訊號到期望的執行緒中,來告訴這個執行緒需要處理一個Notification。指定的執行緒在收到訊號後,將Notification從佇列中移除,並進行處理。

官方demo如下:

@interface MyThreadedClass: NSObject
/* Threaded notification support. */
@property NSMutableArray *notifications;
@property NSThread *notificationThread;
@property NSLock *notificationLock;
@property NSMachPort *notificationPort;
 
- (void) setUpThreadingSupport;
- (void) handleMachMessage:(void *)msg;
- (void) processNotification:(NSNotification *)notification;
@end
複製程式碼

通知執行緒定義類MyThreadedClass包含了用於記錄所有通知訊息的通知訊息佇列notifications,記錄當前通知接收執行緒notificationThread,多執行緒併發處理需要的互斥鎖NSLock,用於執行緒間通訊通知處理執行緒處理通知訊息的NSMachPort;並提供了設定執行緒屬性、處理mach訊息及處理通知訊息的例項方法;

對於setUpThreadSupport方法如下:

- (void) setUpThreadingSupport {
    if (self.notifications) {
        return;
    }
    self.notifications      = [[NSMutableArray alloc] init];
    self.notificationLock   = [[NSLock alloc] init];
    self.notificationThread = [NSThread currentThread];
 
    self.notificationPort = [[NSMachPort alloc] init];
    [self.notificationPort setDelegate:self];
    [[NSRunLoop currentRunLoop] addPort:self.notificationPort
            forMode:(NSString __bridge *)kCFRunLoopCommonModes];
}
複製程式碼

主要是初始化類屬性,並指定NSMachPort代理及新增至處理執行緒的runloop中;若mach訊息到達而接收執行緒的runloop沒有執行時,核心會儲存此訊息,直到下一次runloop執行;也可以通過performSelectro:inThread:withObject:waitUtilDone:modes實現,不過對於子執行緒需要開啟runloop,否則該方法失效,且需指定waitUtilDone引數為NO非同步呼叫;

NSMachPortDelegate協議方法處理如下:

- (void) handleMachMessage:(void *)msg {
    [self.notificationLock lock];
 
    while ([self.notifications count]) {
        NSNotification *notification = [self.notifications objectAtIndex:0];
        [self.notifications removeObjectAtIndex:0];
        [self.notificationLock unlock];
        [self processNotification:notification];
        [self.notificationLock lock];
    };
 
    [self.notificationLock unlock];
}
複製程式碼

NSMachPort協議方法主要是檢查需要處理的任何通知訊息並迭代處理(防止併發傳送大量埠訊息,導致訊息丟失),處理完成後同步從訊息佇列中移除;

通知處理方法如下:

- (void)processNotification:(NSNotification *)notification {
    if ([NSThread currentThread] != notificationThread) {
        // Forward the notification to the correct thread.
        [self.notificationLock lock];
        [self.notifications addObject:notification];
        [self.notificationLock unlock];
        [self.notificationPort sendBeforeDate:[NSDate date]
                components:nil
                from:nil
                reserved:0];
    }
    else {
        // Process the notification here;
    }
}
複製程式碼

為區分NSMachPort協議方法內部呼叫及通知處理訊息回撥,需要通過判定當前處理執行緒來處理不同的通知訊息處理方式;對於通知觀察回撥,將訊息新增至訊息佇列併傳送執行緒間通訊mach訊息;其實本方案的核心就是通過執行緒間非同步通訊NSMachPort來通知接收執行緒處理通知佇列中的訊息;

對於接收執行緒需要呼叫如下方法啟動通知訊息處理:

[self setupThreadingSupport];
[[NSNotificationCenter defaultCenter]
        addObserver:self
        selector:@selector(processNotification:)
        name:@"NotificationName"//通知訊息名稱,可自定義
        object:nil];
複製程式碼

官方也給出了此方案的問題及思考:

First,all threaded notifications processed by this object must pass through the same method (processNotification:). Second,each object must provide its own implementation and communication port. A better,but more complex,implementation would generalize the behavior into either a subclass of NSNotificationCenter or a separate class that would have one notification queue for each thread and be able to deliver notifications to multiple observer objects and methods

其中指出更好地方式是自己去子類化一個NSNotficationCenter(github上有人實現了此方案,可參考GYNotificationCenter)或者單獨寫一個類類處理這種轉發。

原理解析

通知開源gnustep-base-1.25.0程式碼來分析通知的具體實現;

_GSIMapTable對映表資料結構圖如下:

GSIMapTable

相關的資料結構如下:

typedef struct _GSIMapBucket GSIMapBucket_t;
typedef struct _GSIMapNode GSIMapNode_t;

typedef GSIMapBucket_t *GSIMapBucket;
typedef GSIMapNode_t *GSIMapNode;

typedef struct _GSIMapTable GSIMapTable_t;
typedef GSIMapTable_t *GSIMapTable;

struct	_GSIMapNode {
    GSIMapNode	nextInBucket;	/* Linked list of bucket.	*/
    GSIMapKey	key;
#if	GSI_MAP_HAS_VALUE
    GSIMapVal	value;
#endif
};

struct	_GSIMapBucket {
    uintptr_t	nodeCount;	/* Number of nodes in bucket.	*/
    GSIMapNode	firstNode;	/* The linked list of nodes.	*/
};

struct	_GSIMapTable {
  NSZone	*zone;
  uintptr_t	nodeCount;	/* Number of used nodes in map.	*/
  uintptr_t	bucketCount;	/* Number of buckets in map.	*/
  GSIMapBucket	buckets;	/* Array of buckets.		*/
  GSIMapNode	freeNodes;	/* List of unused nodes.	*/
  uintptr_t	chunkCount;	/* Number of chunks in array.	*/
  GSIMapNode	*nodeChunks;	/* Chunks of allocated memory.	*/
  uintptr_t	increment;
#ifdef	GSI_MAP_EXTRA
  GSI_MAP_EXTRA	extra;
#endif
};
複製程式碼

GSIMapTable對映表包含了指向GSIMapNode單連結串列節點的指標陣列nodeChunks,通過buckets陣列記錄單連結串列節點指標陣列的各個連結串列的節點數量及連結串列首部地址,其中bucketCountnodeCountchunkCount分別記錄了node節點、節點單連結串列資訊陣列、節點單連結串列指標陣列的數目;

具體的從對映表中新增/刪除的程式碼如下:

GS_STATIC_INLINE GSIMapBucket
GSIMapPickBucket(unsigned hash,GSIMapBucket buckets,uintptr_t bucketCount)
{
    return buckets + hash % bucketCount;
}

GS_STATIC_INLINE GSIMapBucket
GSIMapBucketForKey(GSIMapTable map,GSIMapKey key)
{
    return GSIMapPickBucket(GSI_MAP_HASH(map,key),map->buckets,map->bucketCount);
}

GS_STATIC_INLINE void
GSIMapLinkNodeIntoBucket(GSIMapBucket bucket,GSIMapNode node)
{
    node->nextInBucket = bucket->firstNode;
    bucket->firstNode = node;
}

GS_STATIC_INLINE void
GSIMapUnlinkNodeFromBucket(GSIMapBucket bucket,GSIMapNode node)
{
    if (node == bucket->firstNode)
    {
        bucket->firstNode = node->nextInBucket;
    }
    else
    {
        GSIMapNode	tmp = bucket->firstNode;
        
        while (tmp->nextInBucket != node)
        {
            tmp = tmp->nextInBucket;
        }
        tmp->nextInBucket = node->nextInBucket;
    }
    node->nextInBucket = 0;
}
複製程式碼

其實就是一個hash表結構,既可以以陣列的形式取到每個單連結串列首元素,也可以以連結串列的形式獲取,通過陣列能夠方便取到每個單向連結串列,再利用連結串列結構增刪。

通知全域性物件表結構如下:

typedef struct NCTbl {
    Observation		*wildcard;	/* Get ALL messages*/
    GSIMapTable		nameless;	/* Get messages for any name.*/
    GSIMapTable		named;		/* Getting named messages only.*/
    unsigned		lockCount;	/* Count recursive operations.	*/
    NSRecursiveLock	*_lock;		/* Lock out other threads.	*/
    Observation		*freeList;
    Observation		**chunks;
    unsigned		numChunks;
    GSIMapTable		cache[CACHESIZE];
    unsigned short	chunkIndex;
    unsigned short	cacheIndex;
} NCTable;
複製程式碼

其中資料結構中重要的是兩張GSIMapTable表:namednameless,及單連結串列wildcard

  • named,儲存著傳入通知名稱的通知hash表;
  • nameless,儲存沒有傳入通知名稱的hash表;
  • wildcard,儲存既沒有通知名稱又沒有傳入object的通知單連結串列;

儲存含有通知名稱的通知表named需要註冊object物件,因此該表結構體通過傳入的name作為key,其中value同時也為GSIMapTable表用於儲存對應的object物件的observer物件;

對沒有傳入通知名稱只傳入object物件的通知表nameless而言,只需要儲存objectobserver的對應關係,因此object作為keyobserver作為value

具體的新增觀察者的核心函式(block形式只是該函式的包裝)大致程式碼如下:

- (void) addObserver: (id)observer
            selector: (SEL)selector
                name: (NSString*)name
              object: (id)object
{
    Observation	*list;
    Observation	*o;
    GSIMapTable	m;
    GSIMapNode	n;

    //入參檢查異常處理
    ...
		//table加鎖保持資料一致性
    lockNCTable(TABLE);
		//建立Observation物件包裝相應的呼叫函式
    o = obsNew(TABLE,selector,observer);
		//處理存在通知名稱的情況
    if (name)
    {
        //table表中獲取相應name的節點
        n = GSIMapNodeForKey(NAMED,(GSIMapKey)(id)name);
        if (n == 0)
        {
           //未找到相應的節點,則建立內部GSIMapTable表,以name作為key新增到talbe中
          m = mapNew(TABLE);
          name = [name copyWithZone: NSDefaultMallocZone()];
          GSIMapAddPair(NAMED,(GSIMapKey)(id)name,(GSIMapVal)(void*)m);
          GS_CONSUMED(name)
        }
        else
        {
            //找到則直接獲取相應的內部table
          	m = (GSIMapTable)n->value.ptr;
        }

        //內部table表中獲取相應object物件作為key的節點
        n = GSIMapNodeForSimpleKey(m,(GSIMapKey)object);
        if (n == 0)
        {
          	//不存在此節點,則直接新增observer物件到table中
            o->next = ENDOBS;//單連結串列observer末尾指向ENDOBS
            GSIMapAddPair(m,(GSIMapKey)object,(GSIMapVal)o);
        }
        else
        {
          	//存在此節點,則獲取並將obsever新增到單連結串列observer中
            list = (Observation*)n->value.ptr;
            o->next = list->next;
            list->next = o;
        }
    }
    //只有觀察者物件情況
    else if (object)
    {
      	//獲取對應object的table
        n = GSIMapNodeForSimpleKey(NAMELESS,(GSIMapKey)object);
        if (n == 0)
        {
          	//未找到對應object key的節點,則直接新增observergnustep-base-1.25.0
            o->next = ENDOBS;
            GSIMapAddPair(NAMELESS,(GSIMapVal)o);
        }
        else
        {
          	//找到相應的節點則直接新增到連結串列中
            list = (Observation*)n->value.ptr;
            o->next = list->next;
            list->next = o;
        }
    }
    //處理即沒有通知名稱也沒有觀察者物件的情況
    else
    {
      	//新增到單連結串列中
        o->next = WILDCARD;
        WILDCARD = o;
    }
		//解鎖
    unlockNCTable(TABLE);
}
複製程式碼

對於block形式程式碼如下:

- (id) addObserverForName: (NSString *)name 
                   object: (id)object 
                    queue: (NSOperationQueue *)queue 
               usingBlock: (GSNotificationBlock)block
{
    GSNotificationObserver *observer = 
        [[GSNotificationObserver alloc] initWithQueue: queue block: block];

    [self addObserver: observer 
             selector: @selector(didReceiveNotification:) 
                 name: name 
               object: object];

    return observer;
}

- (id) initWithQueue: (NSOperationQueue *)queue 
               block: (GSNotificationBlock)block
{
    self = [super init];
    if (self == nil)
        return nil;

    ASSIGN(_queue,queue);
    _block = Block_copy(block);
    return self;
}

- (void) didReceiveNotification: (NSNotification *)notif
{
    if (_queue != nil)
    {
        GSNotificationBlockOperation *op = [[GSNotificationBlockOperation alloc] 
            initWithNotification: notif block: _block];

        [_queue addOperation: op];
    }
    else
    {
        CALL_BLOCK(_block,notif);
    }
}
複製程式碼

對於block形式通過建立GSNotificationObserver物件,該物件會通過Block_copy拷貝block,並確定通知操作佇列,通知的接收處理函式didReceiveNotification中是通過addOperation來實現指定操作佇列處理,否則直接執行block

傳送通知的核心函式大致邏輯如下:

- (void) _postAndRelease: (NSNotification*)notification
{
    //入參檢查校驗
    //建立儲存所有匹配通知的陣列GSIArray
   	//加鎖table避免資料一致性問題
    //獲取所有WILDCARD中的通知並新增到陣列中
    //查詢NAMELESS表中指定對應觀察者物件object的通知並新增到陣列中
		//查詢NAMED表中相應的通知並新增到陣列中
    //解鎖table
    //遍歷整個陣列並依次呼叫performSelector:withObject處理通知訊息傳送
    //解鎖table並釋放資源
}
複製程式碼

上面傳送的重點就是獲取所有匹配的通知,並通過performSelector:withObject傳送通知訊息,因此通知傳送和接收通知的執行緒是同一個執行緒(block形式通過操作佇列來指定佇列處理);

Reference

Notification Programming Topics

NotificationCenter

Foundation: NSNotificationCenter

Notification與多執行緒

NSDistributedNotificationCenter

深入理解iOS NSNotification

深入理解NSNotificationCenter

iOS通訊模式(KVO、Notification、Delegate、Block、Target-Action的區別)