iOS開發·runtime原理與實踐: 關聯物件篇(Associated Object)(為分類新增“屬性”,為UI控制元件關聯事件Block體,為了不重複執行)

陳滿iOS發表於2019-03-01

本文Demo傳送門:AssociatedObjectDemo

iOS開發·runtime原理與實踐: 關聯物件篇(Associated Object)(為分類新增“屬性”,為UI控制元件關聯事件Block體,為了不重複執行)

摘要:程式設計,只瞭解原理不行,必須實戰才能知道應用場景。本系列嘗試闡述runtime相關理論的同時介紹一些實戰場景,而本文則是本系列的關聯物件篇。本文中,第一節將介紹關聯物件及如何關聯物件,第二節將介紹關聯物件最常用的一個實戰場景:為分類新增屬性,第三節將介紹關聯物件另一個很重要的實戰場景:為UI控制元件(比如,UIAlertView以及UIButton等等)關聯事件Block體。

1. 什麼是關聯物件

1.1 關聯物件

分類(category)與關聯物件(Associated Object)作為objective-c的擴充套件機制的兩個特性:分類,可以通過它來擴充套件方法;Associated Object,可以通過它來擴充套件屬性;

在iOS開發中,可能category比較常見,相對的Associated Object,就用的比較少,要用它之前,必須匯入<objc/runtime.h>的標頭檔案。

1.2 如何關聯物件

runtime提供了給我們3個API以管理關聯物件(儲存、獲取、移除):

//關聯物件
void objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy)
//獲取關聯的物件
id objc_getAssociatedObject(id object, const void *key)
//移除關聯的物件
void objc_removeAssociatedObjects(id object)
複製程式碼

其中的引數

  • id object:被關聯的物件
  • const void *key:關聯的key,要求唯一
  • id value:關聯的物件
  • objc_AssociationPolicy policy:記憶體管理的策略

2. 關聯物件:為分類新增“屬性”

2.1 分類的限制

先來看@property 的一個例子

@interface Person : NSObject

@property (nonatomic, strong) NSString *name;

@end
複製程式碼

在使用上述@property 時會做三件事:

  • 生成例項變數 _property
  • 生成 getter 方法 - property
  • 生成 setter 方法 - setProperty:
@implementation DKObject {
    NSString *_property;
}

- (NSString *)property {
    return _property;
}

- (void)setProperty:(NSString *)property {
    _property = property;
}

@end
複製程式碼

這些程式碼都是編譯器為我們生成的,雖然你看不到它,但是它確實在這裡。但是,如果我們在分類中寫一個屬性,則會給一個警告,分類中的 @property 並沒有為我們生成例項變數以及存取方法,而需要我們手動實現。

因為在分類中 @property 並不會自動生成例項變數以及存取方法,所以一般使用關聯物件為已經存在的類新增 “屬性”。解決方案:可以使用兩個方法 objc_getAssociatedObject 以及 objc_setAssociatedObject 來模擬屬性 的存取方法,而使用關聯物件模擬例項變數。

2.2 用法解析

  • NSObject+AssociatedObject.m
#import "NSObject+AssociatedObject.h"
#import <objc/runtime.h>

@implementation NSObject (AssociatedObject)

- (void)setAssociatedObject:(id)associatedObject
{
    objc_setAssociatedObject(self, @selector(associatedObject), associatedObject, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (id)associatedObject
{
    return objc_getAssociatedObject(self, _cmd);
}

@end
複製程式碼
  • ViewController.m
- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSObject *objc = [[NSObject alloc] init];
    objc.associatedObject = @"Extend Category";
    
    NSLog(@"associatedObject is = %@", objc.associatedObject);
}
複製程式碼

其中, _cmd 代指當前方法的選擇子,也就是 @selector(categoryProperty)_cmd在Objective-C的方法中表示當前方法的selector,正如同self表示當前方法呼叫的物件例項。這裡強調當前,_cmd的作用域只在當前方法裡,直指當前方法名@selector。

因而,亦可以寫成下面的樣子:

- (id)associatedObject
{
    return objc_getAssociatedObject(self, @selector(associatedObject));
}
複製程式碼

另外,檢視OBJC_ASSOCIATION_RETAIN_NONATOMIC,可以發現它是一個列舉型別,完整列舉項如下所示:

typedef OBJC_ENUM(uintptr_t, objc_AssociationPolicy) {
    OBJC_ASSOCIATION_ASSIGN = 0,           /**< Specifies a weak reference to the associated object. */
    OBJC_ASSOCIATION_RETAIN_NONATOMIC = 1, /**< Specifies a strong reference to the associated object. 
                                            *   The association is not made atomically. */
    OBJC_ASSOCIATION_COPY_NONATOMIC = 3,   /**< Specifies that the associated object is copied. 
                                            *   The association is not made atomically. */
    OBJC_ASSOCIATION_RETAIN = 01401,       /**< Specifies a strong reference to the associated object.
                                            *   The association is made atomically. */
    OBJC_ASSOCIATION_COPY = 01403          /**< Specifies that the associated object is copied.
                                            *   The association is made atomically. */
};
複製程式碼

從這裡的註釋我們能看到很多東西,也就是說不同的 objc_AssociationPolicy 對應了不通的屬性修飾符,整理成表格如下:

objc_AssociationPolicy modifier
OBJC_ASSOCIATION_ASSIGN assign
OBJC_ASSOCIATION_RETAIN_NONATOMIC nonatomic, strong
OBJC_ASSOCIATION_COPY_NONATOMIC nonatomic, copy
OBJC_ASSOCIATION_RETAIN atomic, strong
OBJC_ASSOCIATION_COPY atomic, copy

而我們在程式碼中實現的屬性 associatedObject 就相當於使用了 nonatomicstrong 修飾符。

2.3 實戰場景

需求:比如你為UIView新增事件,可以在上面新增一個UITapGestureRecognizer,但是這個點選事件無法攜帶NSString資訊(雖然可以攜帶int型別的tag),這就無法讓後續響應該事件的方法區分到底是哪裡啟用的事件。那麼,你是否能為這種新增事件的方式攜帶另外的資訊呢?

方案就是為UITapGestureRecognizer追加一個“屬性”,利用runtime新建一個UITapGestureRecognizer的分類即可。

分類

  • UITapGestureRecognizer+NSString.h
#import <UIKit/UIKit.h>

@interface UITapGestureRecognizer (NSString)
//類擴充新增屬性
@property (nonatomic, strong) NSString *dataStr;

@end
複製程式碼
  • UITapGestureRecognizer+NSString.m
#import "UITapGestureRecognizer+NSString.h"
#import <objc/runtime.h>
//定義常量 必須是C語言字串
static char *PersonNameKey = "PersonNameKey";

@implementation UITapGestureRecognizer (NSString)

- (void)setDataStr:(NSString *)dataStr{
    objc_setAssociatedObject(self, PersonNameKey, dataStr, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

-(NSString *)dataStr{
    return objc_getAssociatedObject(self, PersonNameKey);
}

@end
複製程式碼

呼叫處

  • VC的tableView:cellForRowAtIndexPath:代理方法中由cell激發事件
UITapGestureRecognizer *signViewSingle0 = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapAction:)];
            //partnercode
signViewSingle0.dataStr = [cell.cellMdl.partnercode copy];
[cell.contractView addGestureRecognizer:signViewSingle0];
複製程式碼
  • VC單獨寫一個響應方法
- (void)tapAction:(UITapGestureRecognizer *)sender
{
    UITapGestureRecognizer *tap = (UITapGestureRecognizer *)sender;
    //partnercode
    [self requestCallConSetWithPartnerCode:tap.dataStr];
}
複製程式碼

如此一來,響應事件的方法就可以根據事件啟用方攜帶過來的資訊進行下一步操作了,比如根據它攜帶過來的某個引數進行網路請求等等。

2.4 應用到此知識點的第三方框架

iOS開發·runtime原理與實踐: 關聯物件篇(Associated Object)(為分類新增“屬性”,為UI控制元件關聯事件Block體,為了不重複執行)
iOS開發·runtime原理與實踐: 關聯物件篇(Associated Object)(為分類新增“屬性”,為UI控制元件關聯事件Block體,為了不重複執行)

2.5 這樣就能生成_變數?

儘管可以模擬地為分類新增“屬性”,但畢竟只是模擬。在分類中@property不會生成_變數,也不會實現getter和setter方法。我們實現的只是getter和setter方法,並沒有自動生成下劃線開頭的變數!

3. 關聯物件:為UI控制元件關聯事件Block體

3.1 UIAlertView

開發iOS時經常用到UIAlertView類,該類提供了一種標準檢視,可向使用者展示警告資訊。當使用者按下按鈕關閉該檢視時,需要用委託協議(delegate protocol)來處理此動作,但是,要想設定好這個委託機制,就得把建立警告檢視和處理按鈕動作的程式碼分開。由於程式碼分作兩塊,所以讀起來有點亂。

方案1 :傳統方案

比方說,我們在使用UIAlertView時,一般都會這麼寫:

  • Test2ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    [self.view setBackgroundColor:[UIColor whiteColor]];
    self.title = @"Test2ViewController";
    
    [self popAlertViews1];
}

#pragma mark - way1
- (void)popAlertViews1{
    UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Question" message:@"What do you want to do?" delegate:self cancelButtonTitle:@"Cancel" otherButtonTitles:@"Continue", nil];
    [alert show];
}

// UIAlertViewDelegate protocol method
- (void)alertView:(UIAlertView *)alertView clickedButtonAtIndex:(NSInteger)buttonIndex
{
    if (buttonIndex == 0) {
        [self doCancel];
    } else {
        [self doContinue];
    }
}
複製程式碼

如果想在同一個類裡處理多個警告資訊檢視,那麼程式碼就會變得更為複雜,我們必須在delegate方法中檢查傳入的alertView引數,並據此選用相應的邏輯。

要是能在建立UIAlertView的時候直接把處理每個按鈕的邏輯都寫好,那就簡單多了。這可以通過關聯物件來做。建立完警告檢視之後,設定一個與之關聯的“塊”(block),等到執行delegate方法時再將其讀出來。下面對此方案進行改進。

方案2:關聯Block體

除了上一個方案中的傳統方法,我們可以利用關聯物件為UIAlertView關聯一個Block:首先在建立UIAlertView的時候設定關聯一個回撥(objc_setAssociatedObject),然後在UIAlertView的代理方法中取出關聯相應回撥(objc_getAssociatedObject)。

  • Test2ViewController.m
#pragma mark - way2
- (void)popAlertViews2 {

    UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Question" message:@"What do you want to do?" delegate:self cancelButtonTitle:@"Cancel" otherButtonTitles:@"Continue", nil];
    void (^clickBlock)(NSInteger) = ^(NSInteger buttonIndex){
        if (buttonIndex == 0) {
            [self doCancel];
        } else {
            [self doContinue];
        }
    };
    objc_setAssociatedObject(alert,CMAlertViewKey,clickBlock,OBJC_ASSOCIATION_COPY);
    [alert show];
}

// UIAlertViewDelegate protocol method
- (void)alertView:(UIAlertView*)alertView clickedButtonAtIndex:(NSInteger)buttonIndex{

    void (^clickBlock)(NSInteger) = objc_getAssociatedObject(alertView, CMAlertViewKey);
    clickBlock(buttonIndex);
}
複製程式碼
方案3:繼續改進:封裝關聯的Block體,作為屬性

上面方案,如果需要的位置比較多,相同的程式碼會比較冗餘地出現,所以我們可以將設定Block的程式碼封裝到一個UIAlertView的分類中去。

  • UIAlertView+Handle.h
#import <UIKit/UIKit.h>

// 宣告一個button點選事件的回撥block
typedef void (^ClickBlock)(NSInteger buttonIndex) ;

@interface UIAlertView (Handle)

@property (copy, nonatomic) ClickBlock callBlock;

@end
複製程式碼
  • UIAlertView+Handle.m
#import "UIAlertView+Handle.h"
#import <objc/runtime.h>

@implementation UIAlertView (Handle)

- (void)setCallBlock:(ClickBlock)callBlock
{
    objc_setAssociatedObject(self, @selector(callBlock), callBlock, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (ClickBlock )callBlock
{
    return objc_getAssociatedObject(self, _cmd);
    //    return objc_getAssociatedObject(self, @selector(callBlock));
}

@end
複製程式碼
  • Test2ViewController.m
#pragma mark - way3
- (void)popAlertViews3 {

    UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"Question" message:@"What do you want to do?" delegate:self cancelButtonTitle:@"Cancel" otherButtonTitles:@"Continue", nil];
    [alert setCallBlock:^(NSInteger buttonIndex) {
        if (buttonIndex == 0) {
            [self doCancel];
        } else {
            [self doContinue];
        }
    }];

    [alert show];
}

// UIAlertViewDelegate protocol method
- (void)alertView:(UIAlertView*)alertView clickedButtonAtIndex:(NSInteger)buttonIndex{

    void (^block)(NSInteger) = alertView.callBlock;
    block(buttonIndex);
}
複製程式碼
方案4:繼續改進:封裝關聯的Block體,跟初始化方法綁在一起

練習:可以對這個分類進一步改進,將設定Block屬性的方法與初始化方法寫在一起。

3.2 UIButton

除了上述的UIAlertView,這節以UIButton為例,使用關聯物件完成一個功能函式:為UIButton增加一個分類,定義一個方法,使用block去實現button的點選回撥。

  • UIButton+Handle.h
#import <UIKit/UIKit.h>
#import <objc/runtime.h>    // 匯入標頭檔案

// 宣告一個button點選事件的回撥block
typedef void(^ButtonClickCallBack)(UIButton *button);

@interface UIButton (Handle)

// 為UIButton增加的回撥方法
- (void)handleClickCallBack:(ButtonClickCallBack)callBack;

@end
複製程式碼
  • UIButton+Handle.m
#import "UIButton+Handle.h"

// 宣告一個靜態的索引key,用於獲取被關聯物件的值
static char *buttonClickKey;

@implementation UIButton (Handle)

- (void)handleClickCallBack:(ButtonClickCallBack)callBack {
    // 將button的例項與回撥的block通過索引key進行關聯:
    objc_setAssociatedObject(self, &buttonClickKey, callBack, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    
    // 設定button執行的方法
    [self addTarget:self action:@selector(buttonClicked) forControlEvents:UIControlEventTouchUpInside];
}

- (void)buttonClicked {
    // 通過靜態的索引key,獲取被關聯物件(這裡就是回撥的block)
    ButtonClickCallBack callBack = objc_getAssociatedObject(self, &buttonClickKey);
    
    if (callBack) {
        callBack(self);
    }
}

@end
複製程式碼

在Test3ViewController中,匯入我們寫好的UIButton分類標頭檔案,定義一個button物件,呼叫分類中的這個方法:

  • Test3ViewController.m
    [self.testButton handleClickCallBack:^(UIButton *button) {
        NSLog(@"block --- click UIButton+Handle");
    }];
複製程式碼

4. 關聯物件:關聯觀察者物件

有時候我們在分類中使用NSNotificationCenter或者KVO,推薦使用關聯的物件作為觀察者,儘量避免物件觀察自身。

例如大名鼎鼎的AFNetworking為菊花控制元件監聽NSURLSessionTask以獲取網路進度的分類:

  • UIActivityIndicatorView+AFNetworking.m
@implementation UIActivityIndicatorView (AFNetworking)

- (AFActivityIndicatorViewNotificationObserver *)af_notificationObserver {
    
    AFActivityIndicatorViewNotificationObserver *notificationObserver = objc_getAssociatedObject(self, @selector(af_notificationObserver));
    if (notificationObserver == nil) {
        notificationObserver = [[AFActivityIndicatorViewNotificationObserver alloc] initWithActivityIndicatorView:self];
        objc_setAssociatedObject(self, @selector(af_notificationObserver), notificationObserver, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    return notificationObserver;
}

- (void)setAnimatingWithStateOfTask:(NSURLSessionTask *)task {
    [[self af_notificationObserver] setAnimatingWithStateOfTask:task];
}

@end
複製程式碼
@implementation AFActivityIndicatorViewNotificationObserver

- (void)setAnimatingWithStateOfTask:(NSURLSessionTask *)task {
    NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter];

    [notificationCenter removeObserver:self name:AFNetworkingTaskDidResumeNotification object:nil];
    [notificationCenter removeObserver:self name:AFNetworkingTaskDidSuspendNotification object:nil];
    [notificationCenter removeObserver:self name:AFNetworkingTaskDidCompleteNotification object:nil];
    
    if (task) {
        if (task.state != NSURLSessionTaskStateCompleted) {
            UIActivityIndicatorView *activityIndicatorView = self.activityIndicatorView;
            if (task.state == NSURLSessionTaskStateRunning) {
                [activityIndicatorView startAnimating];
            } else {
                [activityIndicatorView stopAnimating];
            }

            [notificationCenter addObserver:self selector:@selector(af_startAnimating) name:AFNetworkingTaskDidResumeNotification object:task];
            [notificationCenter addObserver:self selector:@selector(af_stopAnimating) name:AFNetworkingTaskDidCompleteNotification object:task];
            [notificationCenter addObserver:self selector:@selector(af_stopAnimating) name:AFNetworkingTaskDidSuspendNotification object:task];
        }
    }
}
複製程式碼

5. 關聯物件:為了不重複執行

有時候OC中會有些方法是為了獲取某個資料,但這個獲取的過程只需要執行一次即可,這個獲取的演算法可能有一定的時間複雜度和空間複雜度。那麼每次呼叫的時候就必須得執行一次嗎?有沒有辦法讓方法只執行一次,每次呼叫方法的時候直接獲得那一次的執行結果?有的,方案就是讓某個物件的方法獲得的資料結果作為“屬性”與這個物件進行關聯。

有這麼一個需求:需要將字典轉成模型物件

方案:我們先獲取到物件所有的屬性名(只執行一次),然後加入到一個陣列裡面,然後再遍歷,利用KVC進行鍵值賦值。在程式執行的時候,抓取物件的屬性,這時候,要利用到執行時的關聯物件了,詳情見下面的程式碼。

  • 獲取物件所有的屬性名
+ (NSArray *)propertyList {
    
    // 0. 判斷是否存在關聯物件,如果存在,直接返回
    /**
     1> 關聯到的物件
     2> 關聯的屬性 key
     
     提示:在 OC 中,類本質上也是一個物件
     */
    NSArray *pList = objc_getAssociatedObject(self, propertiesKey);
    if (pList != nil) {
        return pList;
    }
    
    // 1. 獲取`類`的屬性
    /**
     引數
     1> 類
     2> 屬性的計數指標
     */
    unsigned int count = 0;
    // 返回值是所有屬性的陣列 objc_property_t
    objc_property_t *list = class_copyPropertyList([self class], &count);
    
    NSMutableArray *arrayM = [NSMutableArray arrayWithCapacity:count];
    
    // 遍歷陣列
    for (unsigned int i = 0; i < count; ++i) {
        // 獲取到屬性
        objc_property_t pty = list[i];
        
        // 獲取屬性的名稱
        const char *cname = property_getName(pty);
        
        [arrayM addObject:[NSString stringWithUTF8String:cname]];
    }
    NSLog(@"%@", arrayM);
    
    // 釋放屬性陣列
    free(list);
    
    // 設定關聯物件
    /**
     1> 關聯的物件
     2> 關聯物件的 key
     3> 屬性數值
     4> 屬性的持有方式 reatin, copy, assign
     */
    objc_setAssociatedObject(self, propertiesKey, arrayM, OBJC_ASSOCIATION_COPY_NONATOMIC);
    
    return arrayM.copy;
}
複製程式碼
  • KVC進行鍵值賦值
+ (instancetype)objectWithDict:(NSDictionary *)dict {
    id obj = [[self alloc] init];
    
    //    [obj setValuesForKeysWithDictionary:dict];
    NSArray *properties = [self propertyList];
    
    // 遍歷屬性陣列
    for (NSString *key in properties) {
        // 判斷字典中是否包含這個key
        if (dict[key] != nil) {
            // 使用 KVC 設定數值
            [obj setValue:dict[key] forKeyPath:key];
        }
    }
   
    
    return obj;
}
複製程式碼

相關文章