iOS 開發—探秘 Block 原理
1.概述
在iOS開發中,block大家用的都很熟悉了,是iOS開發中閉包的一種實現方式,可以對一段代碼邏輯進行封裝,使其可以像數據一樣被傳遞、存儲、調用,并且可以保存相關的上下文狀態。
很多block原理性的文章都比較老,里面講的一些知識已經過時,這里用新版的iOS SDK再梳理一遍block原理,也是和大家一起對已有知識做一次復習。
2.內存布局
block本質上可以理解為結構體,對于結構體的內存布局,先用一張圖來表示一下,圖中字段順序按照布局的先后順序:
- isa:block也有isa,從內存結構上也屬于對象,isa指向的是block的類對象,類對象例如__NSMallocBlock__,后續文章會講到;
- flags:用于存儲一些標志位信息,例如是否捕獲外部變量;
- reserved:系統保留字段,后續可能會用于一些編譯優化標志位,或者存儲一些臨時變量的處理;
- invoke:函數指針,指向了block要執行的函數地址,也就是block代碼塊對應的函數地址;
- descriptor(現在叫desc):指向block_desc_0,包含block大小、捕獲的外部變量布局信息、增加引用計數和銷毀的相關函數指針;
- variables:block捕獲的外部變量。
圖片
3.類型
由于block也是對象,可以通過class方法獲取到其類型,也就是類對象。block有下面三種類型:
- __NSGlobalBlock__,沒有訪問auto變量的block,訪問static變量是沒問題的。這種類型的變量并沒有什么意義,如果不需要用到auto變量,寫成方法就可以滿足需求;
- __NSStackBlock__,在MRC環境下,訪問了auto變量,會默認被放在棧區。需要手動copy到堆區,ARC環境下會在訪問auto變量后,會自動拷貝到堆區;
- __NSMallocBlock__,由開發者自己管理內存,不會由系統來釋放。
block的分配主要是在三個區域,堆區、棧區、全局區,全局區的數據存儲在數據段。
block在不同的場景會存在不同的內存區域中,在MRC中創建一個block首先是在__NSStackBlock__內存中的,然后我們使用copy方法將block拷貝到__NSMallocBlock__內存中進行內存管理。后來在ARC中系統已經幫我們做好了copy的操作,創建的block會自動copy到__NSMallocBlock__內存中,堆區的block也有引用計數的概念。如果這個block中沒有用到任何外部參數,系統會將這個block存放在__NSGlobalBlock__內存中。
圖片
并且block也有繼承關系,以下面TestBlock的實例來說,其父類是__NSGlobalBlock__,所有block的父類是NSBlock,并且NSBlock繼承自NSObject類。在更早一些的iOS系統中,__NSGlobalBlock__和NSBlock之間,還會有一層__NSGlobalBlock的關系(后面沒有下劃線)。
圖片
4.轉換C++
下面,我們通過clang命令將block轉為結構體,來分析下其具體實現。雖然這并不是最終運行在iOS系統上的代碼,其等于一種中間表現形式,后續編譯鏈接優化才會形成運行在手機上的ipa包,但對于我們了解block的實現原理有很大幫助。
4.1轉換命令
xcrun是Xcode用于查找和執行相關命令行的工具集,可以更好的執行clang命令,減少報錯。
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc -fobjc-arc [源文件路徑] -o [目標文件路徑]
clang命令有下面這些關鍵參數:
- -fobjc-arc:如果項目是ARC或者ARC和MRC混編的環境,需要通過此參數修飾,表示按ARC的方式進行轉換,如果不需要ARC環境可以忽略;
- -x objective-c++:此參數上面沒用,如果包含Objective++源文件的時候,需要用到此參數,以確保clang可以區分OC和C++代碼;
- -rewrite-objc:告訴clang以C++的方式重寫出來,包含的上層代碼,clang會以底層代碼的方式進行展現;
- [目標文件路徑]:非必傳參數,不傳的話默認在當前目錄生成一個同名的cpp文件,例如main.m對應main.cpp。
4.2轉換示例
下面在main.m中實現了一個很簡單的block,并且沒有捕獲任何外部變量,通過clang命令查看C++代碼,觀察block的具體實現原理。
圖片
轉換后將C++源文件拉到最下面,可以看到main函數以及TestBlock的實現,main函數中有很多轉義代碼,刪掉后梳理邏輯會更清晰。
圖片
5.結構體
5.1基礎結構
轉換后的代碼看著比較復雜,但我們只看關鍵信息,__main_block_impl_0構造函數也可以去掉,整理后就是下面三個結構體。在不包含外部變量和__block的前提下,block結構體各個字段就這么簡單,關鍵就是isa、Block_size、FuncPtr這三個。
圖片
我們也可以打印block結構體相關字段,但由于block的結構體并沒有聲明在某個.h文件中,所以需要我們講clang轉換后的結構體粘到對應的文件中,做顯示聲明。隨后用__bridge的方式,將block對象橋接為自己聲明的結構體,即可打印對應字段。
圖片
結構體中impl.FuncPtr存儲的就是回調函數地址,從地址可以看出是一個虛擬地址,block結構體都存儲在堆區。
圖片
5.2調用部分
看完block結構體的定義,我們來到main函數中,看block的實現和調用轉換后是什么樣的。將main函數中block相關的轉換都去掉,結果如紅圈部分。本質上就是兩步,第一步是調用__main_block_impl_0的結構體構造函數,第二步是調用結構體的函數指針。
圖片
第一行main函數中調用的構造方法,是__main_block_impl_0結構體聲明的C++構造函數,因為我們創建的是一個最簡單block,可以看到block的存儲區域是在stack棧區的。即main函數調用完,block生命周期就會結束。
圖片
__main_block_impl_0構造函數有兩個參數,第一個紅圈部分就是傳入函數指針地址,函數對應的就是block內部的實現代碼。第二個參數是__main_block_desc_0_DATA結構體,其定義為__main_block_desc_0,并且默認實現第一個參數傳0,第二個參數是block結構體的大小,結構體為__main_block_impl_0 block自身的結構體大小。第三個參數有默認值,可以不傳。
圖片
__main_block_desc_0結構體是一種緊湊型的寫法,在聲明__main_block_desc_0結構體后,緊接著聲明了一個名為__main_block_desc_0_DATA的變量,變量類型為靜態變量,并且實現了初始化相關代碼。
圖片
在執行block的代碼位置,可以看到并不是block->impl.FuncPtr的方式調用,而是直接block->FuncPtr的方式調用,中間少了一步。
嚴謹些來說應該加上impl,但不加也不會出問題。這是因為,如果看未刪除轉換代碼的原始clang代碼,可以看到block是被轉換為__block_impl的,也就是說被當做__block_impl看待的。如果再結合__main_block_impl_0的結構體定義來看,__block_impl在成員變量的第一位,所以訪問FuncPtr是沒有問題的,只要不訪問Desc就是可以的。
6.外部變量
6.1值類型
如果在block的調用中加一個外部變量,那結構體將會是怎樣的?
圖片
通過clang命令可以可以看到,轉換后的__main_block_impl_0中增加了一個同名字段,這很簡單沒必要過多解釋。在__main_block_impl_0構造函數中傳入,通過冒號后的初始化列表對value參數進行初始化。
圖片
后面傳參和使用,就都是結構體賦值和取值邏輯,很簡單。
圖片
6.2值傳遞
下面這種寫法,在block的使用中很容易踩坑。在block中使用value參數,并且打印value參數,發現結果為1,而不是2。
圖片
通過C++源碼我們可以看到,這是因為如果block引用的外部變量是值類型,會采取直接復制值的方式,而不是指針引用。
圖片
想解決這個問題也很簡單,通過__block修飾一下值類型,即可實現block內value的值和外部value參數統一。
圖片
6.3靜態變量
我們看一下,如果捕獲的是一個static修飾的靜態變量,其結構體會是什么實現。
圖片
轉換為C++代碼后,可以看到原來的值傳遞變成了地址傳遞,__main_block_impl_0中value的引用是指針引用,在main函數中將value的地址傳入。如果被static修飾的本身就是一個對象,對象是通過指針引用的,在block的結構體中就是兩個星號引用。也就是NSObject **obj。
圖片
正是由于靜態變量地址傳遞的實現,在block內可以對靜態變量直接進行更改,而無需用__block進行修飾。
圖片
6.4全局變量
如果把value改為全局變量,結構體會有什么變化呢?
圖片
因為全局變量的作用域很大,所以并不需要block進行單獨持有即可訪問,結構體并不會新增字段。
圖片
6.5對象類型變量
如果block中引用的是對象,而不是基礎數據類型,結構體會是什么定義呢?
圖片
執行clang命令,執行完成后結構體是下圖的,下面代碼去掉了轉換,以及整理過代碼。可以看到多了兩個函數指針,__main_block_copy_0和__main_block_dispose_0。
以copy的實現__main_block_copy_0為例,執行后會調用Block_object_assign的實現,在實現中系統會根據person的引用方式,__strong、__weak、__unsafe_unretained,是強引用還是弱引用,調用對應的內存管理方法。
__main_block_dispose_0函數在block從堆區移除的時候被調用,調用dispose時會調用實現Block_object_dispose函數,函數中會根據person的引用方式,進行對應的減少引用計數或釋放操作。
copy和dispose兩個函數都有一個3的參數,這個參數是一個標志位,表示外部變量類型。這里是BLOCK_FIELD_IS_OBJECT表示一個對象類型,也有BLOCK_FIELD_IS_WEAK表示weak引用的變量,BLOCK_FIELD_IS_BLOCK表示block類型的變量等。
圖片