關于Runtime的那些事

Runtime的那些事

前言

今天我們一起來聊聊Runtime的那些事。作為一名iOS開發者,如果不知道Runtime,就如同Java工程師不知道JVM、C工程師不知道指針一樣尷尬。

當然,掌握Runtime,也是一名UI工程師進階為高級工程師的必經之路。魯迅曰,不想當廚師的司機不是一名好工程師(魯迅:我說的?黑人問號)。哈哈開個玩笑,接下來咱們正式進入主題,跟上步伐,玩的愉快!

切入點

研究一件事情,我比較喜歡先尋找切入點,一方面這樣會比較符合人的認知心理,更容易找到問題本源,另一方面,這樣也是為了避免盲目探究,最后偏離了方向,忘記了初心。
那么,尖銳的問題來了,Runtime知識看起來是一個非常龐雜的體系,哪里入手呢?
切入之前,我們得先了解下,Runtime是什么?一起來看看官方怎么說:

// 下面一段來自官方文檔
The Objective-C runtime is a runtime library that provides support for the dynamic properties of the Objective-C language, 
and as such is linked to by all Objective-C apps.

// 下面一段來自官方編程指南
The Objective-C language defers as many decisions as it can from compile time and link time to runtime. 
Whenever possible, it does things dynamically. 
This means that the language requires not just a compiler, 
but also a runtime system to execute the compiled code. 
The runtime system acts as a kind of operating system for the Objective-C language; it’s what makes the language work.

上述資料分別摘自官方文檔與官方編程指南,文章底部有鏈接。

按照官方的解釋,Runtime是一個運行時庫,為OC語言提供動態屬性的支持,OC語言為了保證其強大的動態能力,所以把編譯期、鏈接期的盡可能多的決議推遲了到了運行時,所以除了編譯器,還需要一個運行時系統。而Runtime就是這個運行時系統,相當于是OC語言的的操作系統,這也是OC語言的運行機制。所以到這里,我們對Runtime的認識明晰了很多。

我們知道,OC語言是由Smalltalk語言演化而來【可以閱讀下面的'擴展知識'小節】,是一種消息型的語言,即該語言采用“消息結構”而非“函數調用”來實現的。“消息結構”的語言運行時所執行的代碼是由運行環境決定的,而“函數調用”的語言則有編譯器決定。而結合前面官方對Runtime的定義,我們可以很明確,是Runtime運行時系統支撐起了OC語言的動態性的。

所以我們的研究切入點一下就找到了,那么就從OC語言的消息機制說起。

OC語言的消息機制

我們可以通過OC語言的消息機制為切入點,窺探一下Runtime的一些細節。
我們通過clang命令,將OC代碼重寫為C++,可以看到更底層一些的實現細節。

// clang命令
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m
// OC代碼
ZKPerson *person = [[ZKPerson alloc] init];
[person run];

// C++代碼
ZKPerson *person = ((ZKPerson *(*)(id, SEL))(void *)objc_msgSend)((id)((ZKPerson *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("ZKPerson"), sel_registerName("alloc")), sel_registerName("init"));
((void (*)(id, SEL))(void *)objc_msgSend)((id)person, sel_registerName("run"));

通過上面的探索,我們看到了一個有趣的現象:OC中的方法調用,其實都被轉換為了objc_msgSend的調用。
蘋果的Runtime相關代碼是開源的,我們可以通過閱讀源碼,追蹤objc_msgSend的調用流程,一步一步了解消息機制的本質。
當我們在源碼工程中搜索objc_msgSend的時候,發現并不像上次研究RunLoop那么順利。原來這個方法沒有在出現在objc-runtime-new.mm文件中,而是在匯編文件中。我們選擇與真機設備arm64架構匹配的匯編文件研究:objc-msg-arm64.s。由于匯編語言的函數會默認將高級語言的函數名加上_前綴,所以我們在這個文件中搜索_objc_msgSend。找到了函數實現:

ENTRY _objc_msgSend
    UNWIND _objc_msgSend, NoFrame

    cmp p0, #0          // nil check and tagged pointer check
#if SUPPORT_TAGGED_POINTERS
    b.le    LNilOrTagged        //  (MSB tagged pointer looks negative)
#else
    b.eq    LReturnZero
#endif
    ldr p13, [x0]       // p13 = isa
    GetClassFromIsa_p16 p13     // p16 = class
LGetIsaDone:
    CacheLookup NORMAL      // calls imp or objc_msgSend_uncached

#if SUPPORT_TAGGED_POINTERS
LNilOrTagged:
    b.eq    LReturnZero     // nil check

    // tagged
    adrp    x10, _objc_debug_taggedpointer_classes@PAGE
    add x10, x10, _objc_debug_taggedpointer_classes@PAGEOFF
    ubfx    x11, x0, #60, #4
    ldr x16, [x10, x11, LSL #3]
    adrp    x10, _OBJC_CLASS_$___NSUnrecognizedTaggedPointer@PAGE
    add x10, x10, _OBJC_CLASS_$___NSUnrecognizedTaggedPointer@PAGEOFF
    cmp x10, x16
    b.ne    LGetIsaDone

    // ext tagged
    adrp    x10, _objc_debug_taggedpointer_ext_classes@PAGE
    add x10, x10, _objc_debug_taggedpointer_ext_classes@PAGEOFF
    ubfx    x11, x0, #52, #8
    ldr x16, [x10, x11, LSL #3]
    b   LGetIsaDone
// SUPPORT_TAGGED_POINTERS
#endif

LReturnZero:
    // x0 is already zero
    mov x1, #0
    movi    d0, #0
    movi    d1, #0
    movi    d2, #0
    movi    d3, #0
    ret

    END_ENTRY _objc_msgSend

這個函數不算長,即使不太懂匯編,我們也大概能明白這個函數的意思:

  • 首先做一些邊界檢測,然后通過GetClassFromIsa_p16 p13獲取isa指針,我們知道,通過isa指針可以找到類對象或元類對象,后面小節會對isa詳細介紹。
  • 找到isa后,調用CacheLookup NORMAL,這個方法是個.macro宏,具體代碼我就不貼了,畢竟不是重點,我介紹下這個方法中做的事情:
    • 首先是在這個類的方法緩存列表中查找是否有這個方法,如果命中直接調用。
    • 如果沒有命中緩存,則調用CheckMiss ==> __objc_msgSend_uncached ==> MethodTableLookup ==> __class_lookupMethodAndLoadCache3,最后這個方法的實現是在objc-runtime-new.mm中,而這個方法直接做個參數透傳,調用了lookUpImpOrForward函數,而這個函數,就是OC語言的消息機制的核心實現。我們一起來看下:
消息發送機制:

這個函數主要做了如下幾件事情

  • 對該類和其父類的內存結構進行重新修正組裝,為class_rw_t數據開辟內存空間(因為需要在class_rw_t這個數據結構中存儲分類動態添加的方法、屬性和協議等數據),通過調用methodizeClass對該類的方法列表、屬性列表和屬性列表進行修正,即把該類的分類中的這些內容添加到該類的內存結構中。
if (!cls->isRealized()) {
    realizeClass(cls);
}

經過上一步,該類和其父類的內存結構進行修正,其方法列表、屬性列表和協議列表都是最新最全的了。接下來就可以真正進入消息發送流程了。

  • 首先在該類中查找。

    • 查找該類的方法緩存列表。如果命中則直接done,也即是將IMP返回。
    // Try this class's cache.
    imp = cache_getImp(cls, sel);
    if (imp) goto done;
    
    • 在該類的方法列表中查找。如果查找到了,則把該方法保存在該類的方法緩存列表中,再將IMP返回給外部。
    // Try this class's method lists.
    {
        Method meth = getMethodNoSuper_nolock(cls, sel);
        if (meth) {
            log_and_fill_cache(cls, meth->imp, sel, inst, cls);
            imp = meth->imp;
            goto done;
        }
    }
    
  • 如果在該類中沒有找到,則通過superClass指針向父類查找。
    查找邏輯同上,先從緩存中查找,沒找到再從方法列表中查找。這里有個細節要注意,如果在父類中找到了該方法,不管是在父類的方法緩存列表中找到的還是在父類的方法列表中找到的,找到后都要把該方法緩存在該類cls中的方法緩存列表中去,方便下次調用。

    // Try superclass caches and method lists.
      {
          unsigned attempts = unreasonableClassCount();
          for (Class curClass = cls->superclass;
               curClass != nil;
               curClass = curClass->superclass) {
              
              // Superclass cache.
              imp = cache_getImp(curClass, sel);
              if (imp) {
                  if (imp != (IMP)_objc_msgForward_impcache) {
                      // Found the method in a superclass. Cache it in this class.
                      log_and_fill_cache(cls, imp, sel, inst, curClass);
                      goto done;
                  }
                  else {
                      // Found a forward:: entry in a superclass.
                      // Stop searching, but don't cache yet; call method 
                      // resolver for this class first.
                      break;
                  }
              }
              
              // Superclass method list.
              Method meth = getMethodNoSuper_nolock(curClass, sel);
              if (meth) {
                  log_and_fill_cache(cls, meth->imp, sel, inst, curClass);
                  imp = meth->imp;
                  goto done;
              }
          }
      }
    
  • 以上的是常規的消息發送流程。如果最終找到NSObjectRootClass還是沒有找到,則啟動了消息轉發流程。

Tea time
消息轉發機制:
(1)解鈴還須系鈴人,先征詢接收者能不能動態添加方法。即+resolveInstanceMethod

運行期組件會首先問該方法所屬的類:是否能動態添加方法來處理當前這個未知的選擇子,即動態方法解析。當這個對象收到無法解讀的消息后,會調用其所屬類的下列類方法:

+ (BOOL)resolveInstanceMethod:(SEL)selector

其返回值是Boolean類型,表示這個類是否能新增一個實例方法來處理未知消息。如果未知方法為類方法,會詢問resolveClassMethod來處理未知類消息。

使用這個方法的前提是:相關方法的實現代碼已經寫好,只等著運行的時候動態插在類里面就行了。比如CoreData框架中的NSManagedObjects對象的屬性用@dynamic修飾,表示這些屬性的存取方法會在編譯期自動添加,在編譯期會以ORM的方式模型映射到數據庫進行數值存取生成Setter和Getter。

這個階段非常重要,我們在實際編程中,runtime動態添加方法也多是在這個階段進行的。常見的寫法如下:

+ (BOOL)resolveInstanceMethod:(SEL)selector {
    if (...) {
        class_addMethod(self, selector, IMP, ...);
    }
}
(2)如果接收消息的類沒有通過階段1來動態增添方法來處理未知消息,那么運行時組件就會問當前接收者:你既然不想處理這個消息,那能不能把這條消息轉發給其他接收者來處理。這時當前接收者靈機一動,哦隔壁老王應該可以響應這個消息,那就轉發給他吧。于是,通過下面代碼,將未知消息成功甩鍋出去:
- (id)forwardingTargetForSelector:(SEL)selector

該方法的返回值即是把未知消息甩向的對象。如果成功甩出去了,那么消息轉發至此結束,皆大歡喜。如果沒有找到背鍋俠(備援響應者),則返回nil。那這時候,運行期組件就很絕望:你自己不想動態添加方法來響應這個未知消息,也不想甩給其他人來響應,有些絕望,于是,運行期組件只好求助消息派發系統,進入了下一步:

(3)運行期組件被消息接收者兩次拒絕后,生無可戀,于是向消息派發系統求助。于是把未知消息的所有細節封裝包裹在NSInvocation對象中,包括選擇子SEL、接收者Target和參數等,觸發后消息派發系統將親自出馬,把消息指派給目標對象。
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {
    return [NSMethodSignature signatureWithObjCTypes:@"v@:"];
}

- (void)forwardInvocation:(NSInvocation *)anInvocation{
    [anInvocation setTarget:realObject];
    [anInvocation invoke];
}

注意,由于這個步驟跟上面的步驟二類似,可以指定新的接收者來處理未知消息。他可以先改變這條未知消息的內容再轉發出去,如更換選擇子,追加參數等。實現此方法時,若發現某調用操作不應由本類處理,則會調用超類的同名方法,即繼承體系中的每個類都有機會處理此請求,直至NSObject。如果最后調用了NSObject類的方法還是沒能處理,那么該方法會繼而調用老少皆知的doesNotRecognizeSelector以拋出異常,至此,未知消息以最終未能處理來正式告終。

擴展知識

為了保證上面章節的整體行文的流暢性,下面我把一些我認為比較重要但是跟這篇文章的主題不太強相關的知識寫在這個小節中,后面如果有時間的話(研究表明:說出"后面如果有時間的話"這句話的人通常不會有時間,攤~)我可能會單獨成一篇文章。

關于 OC 語言的一些歷史

OC是Brad Cox和Tom Love在1980年初發明出的一種程序設計語言,跟同時代的C++一樣,都是在C的基礎上加入面向對象特性擴充而成的。

他是一門面向對象、消息型語言,由消息型語言的鼻祖Smalltalk演進而來,即該語言采用“消息結構”而非“函數調用”來實現的。(事實上,SmallTalk 可以說是世界上第一個真正的面向對象的語言,第一個具備垃圾回收的語言,第一個擁有真正的集成開發環境的語言,第一次引入了MVC的概念來開發軟件的語言)。

我們知道,C++一直是如日中天,霸榜編程語言排名前列30年之久,而作為同樣是基于C語言擴充而來的語言-OC,為什么表現的不盡如人意,直到2010年才被眾多世人知曉呢?

造成這種結果的原因,跟蘋果公司有著密切的關系。當年(1988年)喬布斯創辦的NeXT公司選用OC作為其御用程序開發編程語言,并買下了版權, 并且擴展了著名的開源編譯器GCC,使之支持 Objective-C 的編譯。種種的一切,給OC帶來了生機,但同時也由于版權的限制和蘋果公司的的孤傲,導致OC不能像Java、Swift、C++等發展這么迅速。

當然,OC對蘋果公司的軟硬一體化的戰略部署,起到了舉足輕重的作用,想想如今的谷歌深陷跟甲骨文關于Java版權之爭難以自拔,不禁對當年老喬的決定心生敬畏。

參考

  1. Objective-C Runtime 編程指南
  2. Objective-C 編程指南
  3. Objective-C Runtime
  4. OC Runtime 源碼
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 173,264評論 25 708
  • 寫在前面 本文原標題《以iPhone 6 為例介紹手機內置傳感器 》,是我的《傳感器》課程的課后大作業。說來之所以...
    繼續海闊天空閱讀 29,764評論 2 17
  • 發現 關注 消息 iOS 第三方庫、插件、知名博客總結 作者大灰狼的小綿羊哥哥關注 2017.06.26 09:4...
    肇東周閱讀 12,229評論 4 61
  • 使用濾鏡調節照片的飽和度、亮度和對比度值,再調整曝光度實現效果: CIFilter的CIColorControls...
    LJ的ios開發閱讀 2,741評論 0 0
  • 都說偏見和欲望害死人,許強可以作證。這些年,他霉到了極點,干什么都倒霉,人稱“霉神”。 前年九月,一天上午,上班途...
    ZHANG頑石點頭閱讀 1,124評論 3 26