iOS面試中經常問的點 – RunTime

其二發表於2019-03-04

一. RunTime簡介

我將iOS的一些學習視訊書籍資料總結在“碼農Style”公眾號裡,需要的小夥伴可以自行獲取

想要一起探討學習iOS底層原理,架構的可以加我Q_2336684744歡迎一起學習交流

RunTime簡稱執行時。OC就是執行時機制,也就是在執行時候的一些機制,其中最主要的是訊息機制。

對於C語言,函式的呼叫在編譯的時候會決定呼叫哪個函式,如果呼叫未實現的函式就會報錯。
對於OC語言,屬於動態呼叫過程,在編譯的時候並不能決定真正呼叫哪個函式,只有在真正執行的時候才會根據函式的名稱找到對應的函式來呼叫。在編譯階段,OC可以呼叫任何函式,即使這個函式並未實現,只要宣告過就不會報錯。

二. RunTime訊息機制

訊息機制是執行時裡面最重要的機制,OC中任何方法的呼叫,本質都是傳送訊息。
使用執行時,傳送訊息需要匯入框架<objc/message.h>並且xcode5之後,蘋果不建議使用底層方法,如果想要使用執行時,需要關閉嚴格檢查objc_msgSend的呼叫,BuildSetting->搜尋msg 改為NO。

下來看一下例項方法呼叫底層實現

Person *p = [[Person alloc] init];
[p eat];
// 底層會轉化成
//SEL:方法編號,根據方法編號就可以找到對應方法的實現。
[p performSelector:@selector(eat)];
//performSelector本質即為執行時,傳送訊息,誰做事情就呼叫誰 
objc_msgSend(p, @selector(eat));
// 帶引數
objc_msgSend(p, @selector(eat:),10);
複製程式碼

類方法的呼叫底層

// 本質是會將類名轉化成類物件,初始化方法其實是在建立類物件。
[Person eat];
// Person只是表示一個類名,並不是一個真實的物件。只要是方法必須要物件去呼叫。
// RunTime 呼叫類方法同樣,類方法也是類物件去呼叫,所以需要獲取類物件,然後使用類物件去呼叫方法。
Class personclass = [Persion class];
[[Persion class] performSelector:@selector(eat)];
// 類物件傳送訊息
objc_msgSend(personclass, @selector(eat));
複製程式碼

**@selector (SEL):是一個SEL方法選擇器。**SEL其主要作用是快速的通過方法名字查詢到對應方法的函式指標,然後呼叫其函式。SEL其本身是一個Int型別的地址,地址中存放著方法的名字。
對於一個類中。每一個方法對應著一個SEL。所以一個類中不能存在2個名稱相同的方法,即使引數型別不同,因為SEL是根據方法名字生成的,相同的方法名稱只能對應一個SEL。

執行時傳送訊息的底層實現
每一個類都有一個方法列表 Method List,儲存這類裡面所有的方法,根據SEL傳入的方法編號找到方法,相當於value – key的對映。然後找到方法的實現。去方法的實現裡面去實現。如圖所示。

執行時傳送訊息的底層實現

那麼內部是如何動態查詢對應的方法的?
首先我們知道所有的類中都繼承自NSObject類,在NSObjcet中存在一個Class的isa指標。

typedef struct objc_class *Class;
@interface NSObject <NSObject> {
    Class isa  OBJC_ISA_AVAILABILITY;
}
複製程式碼

我們來到objc_class中檢視,其中包含著類的一些基本資訊。

struct objc_class {
  Class isa; // 指向metaclass
  
  Class super_class ; // 指向其父類
  const char *name ; // 類名
  long version ; // 類的版本資訊,初始化預設為0,可以通過runtime函式class_setVersion和class_getVersion進行修改、讀取
  long info; // 一些標識資訊,如CLS_CLASS (0x1L) 表示該類為普通 class ,其中包含物件方法和成員變數;CLS_META (0x2L) 表示該類為 metaclass,其中包含類方法;
  long instance_size ; // 該類的例項變數大小(包括從父類繼承下來的例項變數);
  struct objc_ivar_list *ivars; // 用於儲存每個成員變數的地址
  struct objc_method_list **methodLists ; // 與 info 的一些標誌位有關,如CLS_CLASS (0x1L),則儲存物件方法,如CLS_META (0x2L),則儲存類方法;
  struct objc_cache *cache; // 指向最近使用的方法的指標,用於提升效率;
  struct objc_protocol_list *protocols; // 儲存該類遵守的協議
}
複製程式碼

下面我們就以p例項的eat方法來看看具體訊息傳送之後是怎麼來動態查詢對應的方法的。

  1. 例項方法[p eat];底層呼叫[p performSelector:@selector(eat)];方法,編譯器在將程式碼轉化為objc_msgSend(p, @selector(eat));
  2. objc_msgSend函式中。首先通過pisa指標找到p對應的class。在Class中先去cache中通過SEL查詢對應函式method,如果找到則通過method中的函式指標跳轉到對應的函式中去執行。
  3. cache中未找到。再去methodList中查詢。若能找到,則將method加入到cache中,以方便下次查詢,並通過method中的函式指標跳轉到對應的函式中去執行。
  4. methodlist中未找到,則去superClass中查詢。若能找到,則將method加入到cache中,以方便下次查詢,並通過method中的函式指標跳轉到對應的函式中去執行。

三. 使用RunTime交換方法:

當系統自帶的方法功能不夠,需要給系統自帶的方法擴充套件一些功能,並且保持原有的功能時,可以使用RunTime交換方法實現。
這裡要實現image新增圖片的時候,自動判斷image是否為空,如果為空則提醒圖片不存在。
方法一:使用分類

+ (nullable UIImage *)xx_ccimageNamed:(NSString *)name
{
    // 載入圖片    如果圖片不存在則提醒或發出異常
   UIImage *image = [UIImage imageNamed:name];
    if (image == nil) {
        NSLog(@"圖片不存在");
    }
    return image;
}
複製程式碼

缺點:每次使用都需要匯入標頭檔案,並且如果專案比較大,之前使用的方法全部需要更改。

方法二 :RunTime交換方法
交換方法的本質其實是交換兩個方法的實現,即調換xx_ccimageNamed和imageName方法,達到呼叫xx_ccimageNamed其實就是呼叫imageNamed方法的目的

那麼首先需要明白方法在哪裡交換,因為交換隻需要進行一次,所以在分類的load方法中,當載入分類的時候交換方法即可。

 +(void)load
{
    // 獲取要交換的兩個方法
    // 獲取類方法  用Method 接受一下
    // class :獲取哪個類方法 
    // SEL :獲取方法編號,根據SEL就能去對應的類找方法。
    Method imageNameMethod = class_getClassMethod([UIImage class], @selector(imageNamed:));
    // 獲取第二個類方法
    Method xx_ccimageNameMrthod = class_getClassMethod([UIImage class], @selector(xx_ccimageNamed:));
    // 交換兩個方法的實現 方法一 ,方法二。
    method_exchangeImplementations(imageNameMethod, xx_ccimageNameMrthod);
    // IMP其實就是 implementation的縮寫:表示方法實現。
}
複製程式碼

交換方法內部實現:

  1. 根據SEL方法編號在Method中找到方法,兩個方法都找到
  2. 交換方法的實現,指標交叉指向。如圖所示:
    交換方法內部實現

注意:交換方法時候 xx_ccimageNamed方法中就不能再呼叫imageNamed方法了,因為呼叫imageNamed方法實質上相當於呼叫 xx_ccimageNamed方法,會迴圈引用造成死迴圈。

RunTime也提供了獲取物件方法和方法實現的方法。

// 獲取方法的實現
class_getMethodImplementation(<#__unsafe_unretained Class cls#>, <#SEL name#>) 
// 獲取物件方法
class_getInstanceMethod(<#__unsafe_unretained Class cls#>, <#SEL name#>)
複製程式碼

此時,當呼叫imageNamed:方法的時候就會呼叫xx_ccimageNamed:方法,為image新增圖片,並判斷圖片是否存在,如果不存在則提醒圖片不存在。

四. 動態新增方法

如果一個類方法非常多,其中可能許多方法暫時用不到。而載入類方法到記憶體的時候需要給每個方法生成對映表,又比較耗費資源。此時可以使用RunTime動態新增方法

動態給某個類新增方法,相當於懶載入機制,類中許多方法暫時用不到,那麼就先不載入,等用到的時候再去載入方法。

動態新增方法的方法:
首先我們先不實現物件方法,當呼叫performSelector: 方法的時候,再去動態載入方法。
這裡同上建立Person類,使用performSelector: 呼叫Person類物件的eat方法。

Person *p = [[Person alloc]init];
// 當呼叫 P中沒有實現的方法時,動態載入方法
[p performSelector:@selector(eat)];
複製程式碼

此時編譯的時候是不會報錯的,程式執行時才會報錯,因為Person類中並沒有實現eat方法,當去類中的Method List中發現找不到eat方法,會報錯找不到eat方法。

報錯資訊:未被選擇器傳送到例項

而當找不到對應的方法時就會來到攔截呼叫,在找不到呼叫的方法程式崩潰之前呼叫的方法。
當呼叫了沒有實現的物件方法的時,就會呼叫**+(BOOL)resolveInstanceMethod:(SEL)sel方法。
當呼叫了沒有實現的類方法的時候,就會呼叫
+(BOOL)resolveClassMethod:(SEL)sel**方法。

首先我們來到API中看一下蘋果的說明,搜尋 Dynamic Method Resolution 來到動態方法解析。

Dynamic Method Resolution

Dynamic Method Resolution的API中已經講解的很清晰,我們可以實現方法resolveInstanceMethod:或者resolveClassMethod:方法,動態的給例項方法或者類方法新增方法和方法實現。

所以通過這兩個方法就可以知道哪些方法沒有實現,從而動態新增方法。引數sel即表示沒有實現的方法。

一個objective – C方法最終都是一個C函式,預設任何一個方法都有兩個引數。
self : 方法呼叫者 _cmd : 呼叫方法編號。我們可以使用函式class_addMethod為類新增一個方法以及實現。

這裡仿照API給的例子,動態的為P例項新增eat物件

+(BOOL)resolveInstanceMethod:(SEL)sel
{
    // 動態新增eat方法
    // 首先判斷sel是不是eat方法 也可以轉化成字串進行比較。    
    if (sel == @selector(eat)) {
    /** 
     第一個引數: cls:給哪個類新增方法
     第二個引數: SEL name:新增方法的編號
     第三個引數: IMP imp: 方法的實現,函式入口,函式名可與方法名不同(建議與方法名相同)
     第四個引數: types :方法型別,需要用特定符號,參考API
     */
      class_addMethod(self, sel, (IMP)eat , "v@:");
        // 處理完返回YES
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}
複製程式碼

重點來看一下class_addMethod方法

class_addMethod(__unsafe_unretained Class cls, SEL name, IMP imp, const char *types)
複製程式碼

class_addMethod中的四個引數。第一,二個引數比較好理解,重點是第三,四個引數。

  1. cls : 表示給哪個類新增方法,這裡要給Person類新增方法,self即代表Person。
  2. SEL name : 表示新增方法的編號。因為這裡只有一個方法需要動態新增,並且之前通過判斷確定sel就是eat方法,所以這裡可以使用sel。
  3. IMP imp : 表示方法的實現,函式入口,函式名可與方法名不同(建議與方法名相同)需要自己來實現這個函式。每一個方法都預設帶有兩個隱式引數
    self : 方法呼叫者 _cmd : 呼叫方法的標號,可以寫也可以不寫。
void eat(id self ,SEL _cmd)
{
      // 實現內容
      NSLog(@"%@的%@方法動態實現了",self,NSStringFromSelector(_cmd));
}
複製程式碼
  1. types : 表示方法型別,需要用特定符號。系統提供的例子中使用的是**"v@:",我們來到API中看看"v@:"**指定的方法是什麼型別的。
    Objective-C type encodings

    從圖中可以看出

v -> void 表示無返回值
@ -> object 表示id引數
: -> method selector 表示SEL

至此已經完成了P例項eat方法的動態新增。當P呼叫eat方法時輸出

p呼叫eat方法時輸出

動態新增有引數的方法
如果是有引數的方法,需要對方法的實現和class_addMethod方法內方法型別引數做一些修改。
方法實現:因為在C語言函式中,所以物件引數型別只能用id代替。
方法型別引數:因為新增了一個id引數,所以方法型別應該為**"v@:@"**
來看一下程式碼

+(BOOL)resolveInstanceMethod:(SEL)sel
{
    if (sel == @selector(eat:)) {
        class_addMethod(self, sel, (IMP)aaaa , "v@:@");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}
void aaaa(id self ,SEL _cmd,id Num)
{
    // 實現內容
    NSLog(@"%@的%@方法動態實現了,引數為%@",self,NSStringFromSelector(_cmd),Num);
}
複製程式碼

呼叫eat:函式

Person *p = [[Person alloc]init];
[p performSelector:@selector(eat:)withObject:@"xx_cc"];
複製程式碼

輸出為

p呼叫eat:方法時輸出

五. RunTime動態新增屬性

使用RunTime給系統的類新增屬性,首先需要了解物件與屬性的關係。

物件與屬性的關係

物件一開始初始化的時候其屬性name為nil,給屬性賦值其實就是讓name屬性指向一塊儲存字串的記憶體,使這個物件的屬性跟這塊記憶體產生一種關聯,個人理解物件的屬性就是一個指標,指向一塊記憶體區域。

那麼如果想動態的新增屬性,其實就是動態的產生某種關聯就好了。而想要給系統的類新增屬性,只能通過分類。

這裡給NSObject新增name屬性,建立NSObject的分類
我們可以使用@property給分類新增屬性

@property(nonatomic,strong)NSString *name;
複製程式碼

雖然在分類中可以寫@property
新增屬性,但是不會自動生成私有屬性,也不會生成set,get方法的實現,只會生成set,get的宣告,需要我們自己去實現。

方法一:我們可以通過使用靜態全域性變數給分類新增屬性

static NSString *_name;
-(void)setName:(NSString *)name
{
    _name = name;
}
-(NSString *)name
{
    return _name;
}
複製程式碼

但是這樣_name靜態全域性變數與類並沒有關聯,無論物件建立與銷燬,只要程式在執行_name變數就存在,並不是真正意義上的屬性。

方法二:使用RunTime動態新增屬性
RunTime提供了動態新增屬性和獲得屬性的方法。

-(void)setName:(NSString *)name
{
    objc_setAssociatedObject(self, @"name",name, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
-(NSString *)name
{
    return objc_getAssociatedObject(self, @"name");    
}
複製程式碼
  1. 動態新增屬性
objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy);
複製程式碼

引數一:id object : 給哪個物件新增屬性,這裡要給自己新增屬性,用self。
引數二:void * == id key : 屬性名,根據key獲取關聯物件的屬性的值,在**objc_getAssociatedObject中通過次key獲得屬性的值並返回。
引數三:
id value** : 關聯的值,也就是set方法傳入的值給屬性去儲存。
引數四:objc_AssociationPolicy policy : 策略,屬性以什麼形式儲存。
有以下幾種

typedef OBJC_ENUM(uintptr_t, objc_AssociationPolicy) {
    OBJC_ASSOCIATION_ASSIGN = 0,  // 指定一個弱引用相關聯的物件
    OBJC_ASSOCIATION_RETAIN_NONATOMIC = 1, // 指定相關物件的強引用,非原子性
    OBJC_ASSOCIATION_COPY_NONATOMIC = 3,  // 指定相關的物件被複制,非原子性
    OBJC_ASSOCIATION_RETAIN = 01401,  // 指定相關物件的強引用,原子性
    OBJC_ASSOCIATION_COPY = 01403     // 指定相關的物件被複制,原子性   
};
複製程式碼
  1. 獲得屬性
objc_getAssociatedObject(id object, const void *key);
複製程式碼

引數一:id object : 獲取哪個物件裡面的關聯的屬性。
引數二:void * == id key : 什麼屬性,與**objc_setAssociatedObject**中的key相對應,即通過key值取出value。

此時已經成功給NSObject新增name屬性,並且NSObject物件可以通過點語法為屬性賦值。

NSObject *objc = [[NSObject alloc]init];
objc.name = @"xx_cc";
NSLog(@"%@",objc.name);
複製程式碼

六. RunTime字典轉模型

為了方便以後重用,這裡通過給NSObject新增分類,宣告並實現使用RunTime字典轉模型的類方法。

+ (instancetype)modelWithDict:(NSDictionary *)dict
複製程式碼

首先來看一下KVC字典轉模型和RunTime字典轉模型的區別

KVC:KVC字典轉模型實現原理是遍歷字典中所有Key,然後去模型中查詢相對應的屬性名,要求屬性名與Key必須一一對應,字典中所有key必須在模型中存在。
RunTime:RunTime字典轉模型實現原理是遍歷模型中的所有屬性名,然後去字典查詢相對應的Key,也就是以模型為準,模型中有哪些屬性,就去字典中找那些屬性。

RunTime字典轉模型的優點:當伺服器返回的資料過多,而我們只使用其中很少一部分時,沒有用的屬性就沒有必要定義成屬性浪費不必要的資源。只儲存最有用的屬性即可。

RunTime字典轉模型過程
首先需要了解,屬性定義在類裡面,那麼類裡面就有一個屬性列表,屬性列表以陣列的形式存在,根據屬性列表就可以獲得類裡面的所有屬性名,所以遍歷屬性列表,也就可以遍歷模型中的所有屬性名。
所以RunTime字典轉模型過程就很清晰了。

  1. 建立模型物件
id objc = [[self alloc] init];
複製程式碼
  1. 使用**class_copyIvarList**方法拷貝成員屬性列表
unsigned int count = 0;
Ivar *ivarList = class_copyIvarList(self, &count);
複製程式碼

引數一:__unsafe_unretained Class cls : 獲取哪個類的成員屬性列表。這裡是self,因為誰呼叫分類中類方法,誰就是self。
引數二:unsigned int *outCount : 無符號int型指標,這裡建立unsigned int型count,&count就是他的地址,保證在方法中可以拿到count的地址為count賦值。傳出來的值為成員屬性總數。
返回值:Ivar * : 返回的是一個Ivar型別的指標 。指標預設指向的是陣列的第0個元素,指標+1會向高地址移動一個Ivar單位的位元組,也就是指向第一個元素。Ivar表示成員屬性。
3. 遍歷成員屬性列表,獲得屬性列表

for (int i = 0 ; i < count; i++) {
        // 獲取成員屬性
        Ivar ivar = ivarList[i];
}
複製程式碼
  1. 使用**ivar_getName(ivar)**獲得成員屬性名,因為成員屬性名返回的是C語言字串,將其轉化成OC字串
NSString *propertyName = [NSString stringWithUTF8String:ivar_getName(ivar)];
複製程式碼

通過**ivar_getTypeEncoding(ivar)**也可以獲得成員屬性型別。
5. 因為獲得的是成員屬性名,是帶_的成員屬性,所以需要將下劃線去掉,獲得屬性名,也就是字典的key。

// 獲取key
NSString *key = [propertyName substringFromIndex:1];
複製程式碼
  1. 獲取字典中key對應的Value。
// 獲取字典的value
id value = dict[key];
複製程式碼
  1. 給模型屬性賦值,並將模型返回
if (value) {
 // KVC賦值:不能傳空
[objc setValue:value forKey:key];
}
return objc;
複製程式碼

至此已成功將字典轉為模型。

七. RunTime字典轉模型的二級轉換

在開發過程中經常用到模型巢狀,也就是模型中還有一個模型,這裡嘗試用RunTime進行模型的二級轉換,實現思路其實比較簡單清晰。

  1. 首先獲得一級模型中的成員屬性的型別
// 成員屬性型別
NSString *propertyType = [NSString stringWithUTF8String:ivar_getTypeEncoding(ivar)];
複製程式碼
  1. 判斷當一級字典中的value是字典,並且一級模型中的成員屬性型別不是NSDictionary的時候才需要進行二級轉化。
    首先value是字典才進行轉化是必須的,因為我們通常將字典轉化為模型,其次,成員屬性型別不是系統類,說明成員屬性是我們自定義的類,也就是要轉化的二級模型。而當成員屬性型別就是NSDictionary的話就表明,我們本就想讓成員屬性是一個字典,不需要進行模型的轉換。
id value = dict[key];
if ([value isKindOfClass:[NSDictionary class]] && ![propertyType containsString:@"NS"]) 
{ 
      // 進行二級轉換。
}
複製程式碼
  1. 獲取要轉換的模型型別,這裡需要對propertyType成員屬性型別做一些處理,因為propertyType返回給我們成員屬性型別的是**@"Mode",我們需要對他進行擷取為Mode**。這裡需要注意的是隻是轉義符,不佔位。
// @"Mode"去掉前面的@"
NSRange range = [propertyType rangeOfString:@"""];
propertyType = [propertyType substringFromIndex:range.location + range.length];
// Mode"去掉後面的"
range = [propertyType rangeOfString:@"""];
propertyType = [propertyType substringToIndex:range.location];
複製程式碼
  1. 獲取需要轉換類的類物件,將字串轉化為類名。
Class modelClass =  NSClassFromString(propertyType);
複製程式碼
  1. 判斷如果類名不為空則呼叫分類的modelWithDict方法,傳value字典,進行二級模型轉換,返回二級模型在賦值給value。
if (modelClass) {
      value =  [modelClass modelWithDict:value];
}  
複製程式碼

這裡可能有些繞,重新理一下,我們通過判斷value是字典並且需要進行二級轉換,然後將value字典轉化為模型返回,並重新賦值給value,最後給一級模型中相對應的key賦值模型value即可完成二級字典對模型的轉換。

最後附上二級轉換的完整方法

+ (instancetype)modelWithDict:(NSDictionary *)dict{
    // 1.建立對應類的物件
    id objc = [[self alloc] init];
    // count:成員屬性總數
    unsigned int count = 0;
   // 獲得成員屬性列表和成員屬性數量
    Ivar *ivarList = class_copyIvarList(self, &count);
    for (int i = 0 ; i < count; i++) {
        // 獲取成員屬性
        Ivar ivar = ivarList[i];
        // 獲取成員名
       NSString *propertyName = [NSString stringWithUTF8String:ivar_getName(ivar)];
        // 獲取key
        NSString *key = [propertyName substringFromIndex:1];
        // 獲取字典的value key:屬性名 value:字典的值
        id value = dict[key];
        // 獲取成員屬性型別
        NSString *propertyType = [NSString stringWithUTF8String:ivar_getTypeEncoding(ivar)];
        // 二級轉換
        // value值是字典並且成員屬性的型別不是字典,才需要轉換成模型
        if ([value isKindOfClass:[NSDictionary class]] && ![propertyType containsString:@"NS"]) {
            // 進行二級轉換
            // 獲取二級模型型別進行字串擷取,轉換為類名
            NSRange range = [propertyType rangeOfString:@"""];
            propertyType = [propertyType substringFromIndex:range.location + range.length];
            range = [propertyType rangeOfString:@"""];
            propertyType = [propertyType substringToIndex:range.location];
            // 獲取需要轉換類的類物件
           Class modelClass =  NSClassFromString(propertyType);
           // 如果類名不為空則進行二級轉換
            if (modelClass) {
                // 返回二級模型賦值給value
                value =  [modelClass modelWithDict:value];
            }
        }
        if (value) {
            // KVC賦值:不能傳空
            [objc setValue:value forKey:key];
        }
    }
    // 返回模型
    return objc;
}
複製程式碼

以上只是對RunTime淺顯的理解,足以應付iOS面試過程中Runtime的一些問題。


我將iOS的一些學習視訊書籍資料總結在“碼農Style”公眾號裡,需要的小夥伴可以自行獲取。

掃碼關注

相關文章