iOS 如何實現 Aspect Oriented Programming (上)

一縷殤流化隱半邊冰霜發表於2016-10-22
111194012-ba39dff8c951dd0e

前言

在“Runtime病院”住院的後兩天,分析了一下AOP的實現原理。“出院”後,發現Aspect庫還沒有詳細分析,於是就有了這篇文章,今天就來說說iOS 是如何實現Aspect Oriented Programming。

目錄

  • 1.Aspect Oriented Programming簡介
  • 2.什麼是Aspects
  • 3.Aspects 中4個基本類 解析
  • 4.Aspects hook前的準備工作
  • 5.Aspects hook過程詳解
  • 6.關於Aspects的一些 “坑”

一.Aspect Oriented Programming簡介

面向切面的程式設計(aspect-oriented programming,AOP,又譯作面向方面的程式設計觀點導向程式設計剖面導向程式設計)是電腦科學中的一個術語,指一種程式設計範型。該範型以一種稱為側面(aspect,又譯作方面)的語言構造為基礎,側面是一種新的模組化機制,用來描述分散在物件函式中的橫切關注點(crosscutting concern)。

側面的概念源於對物件導向的程式設計的改進,但並不只限於此,它還可以用來改進傳統的函式。與側面相關的程式設計概念還包括元物件協議、主題(subject)、混入(mixin)和委託。

AOP通過預編譯方式和執行期動態代理實現程式功能的統一維護的一種技術。

OOP(物件導向程式設計)針對業務處理過程的實體及其屬性行為進行抽象封裝,以獲得更加清晰高效的邏輯單元劃分。

AOP則是針對業務處理過程中的切面進行提取,它所面對的是處理過程中的某個步驟階段,以獲得邏輯過程中各部分之間低耦合性的隔離效果

OOP和AOP屬於兩個不同的“思考方式”。OOP專注於物件的屬性和行為的封裝,AOP專注於處理某個步驟和階段的,從中進行切面的提取。

舉個例子,如果有一個判斷許可權的需求,OOP的做法肯定是在每個操作前都加入許可權判斷。那日誌記錄怎麼辦?在每個方法的開始結束的地方都加上日誌記錄。AOP就是把這些重複的邏輯和操作,提取出來,運用動態代理,實現這些模組的解耦。OOP和AOP不是互斥,而是相互配合。

在iOS裡面使用AOP進行程式設計,可以實現非侵入。不需要更改之前的程式碼邏輯,就能加入新的功能。主要用來處理一些具有橫切性質的系統性服務,如日誌記錄、許可權管理、快取、物件池管理等。

二. 什麼是Aspects

121194012-a31568e70017325f

Aspects是一個輕量級的面向切面程式設計的庫。它能允許你在每一個類和每一個例項中存在的方法裡面加入任何程式碼。可以在以下切入點插入程式碼:before(在原始的方法前執行) / instead(替換原始的方法執行) / after(在原始的方法後執行,預設)。通過Runtime訊息轉發實現Hook。Aspects會自動的呼叫super方法,使用method swizzling起來會更加方便。

這個庫很穩定,目前用在數百款app上了。它也是PSPDFKit的一部分,PSPDFKit是一個iOS 看PDF的framework庫。作者最終決定把它開源出來。

三.Aspects 中4個基本類 解析

我們從標頭檔案開始看起。

1.Aspects.h

在標頭檔案中定義了一個列舉。這個列舉裡面是呼叫切片方法的時機。預設是AspectPositionAfter在原方法執行完之後呼叫。AspectPositionInstead是替換原方法。AspectPositionBefore是在原方法之前呼叫切片方法。AspectOptionAutomaticRemoval是在hook執行完自動移除。

定義了一個AspectToken的協議,這裡的Aspect Token是隱式的,允許我們呼叫remove去撤銷一個hook。remove方法返回YES代表撤銷成功,返回NO就撤銷失敗。

又定義了一個AspectInfo協議。AspectInfo protocol是我們block語法裡面的第一個引數。

instance方法返回當前被hook的例項。originalInvocation方法返回被hooked方法的原始的invocation。arguments方法返回所有方法的引數。它的實現是懶載入。

標頭檔案中還特意給了一段註釋來說明Aspects的用法和注意點,值得我們關注。

Aspects利用的OC的訊息轉發機制,hook訊息。這樣會有一些效能開銷。不要把Aspects加到經常被使用的方法裡面。Aspects是用來設計給view/controller 程式碼使用的,而不是用來hook每秒呼叫1000次的方法的。

新增Aspects之後,會返回一個隱式的token,這個token會被用來登出hook方法的。所有的呼叫都是執行緒安全的。

關於執行緒安全,下面會詳細分析。現在至少我們知道Aspects不應該被用在for迴圈這些方法裡面,會造成很大的效能損耗。

Aspects整個庫裡面就只有這兩個方法。這裡可以看到,Aspects是NSobject的一個extension,只要是NSObject,都可以使用這兩個方法。這兩個方法名字都是同一個,入參和返回值也一樣,唯一不同的是一個是加號方法一個是減號方法。一個是用來hook類方法,一個是用來hook例項方法。

方法裡面有4個入參。第一個selector是要給它增加切面的原方法。第二個引數是AspectOptions型別,是代表這個切片增加在原方法的before / instead / after。第4個引數是返回的錯誤。

重點的就是第三個入參block。這個block複製了正在被hook的方法的簽名signature型別。block遵循AspectInfo協議。我們甚至可以使用一個空的block。AspectInfo協議裡面的引數是可選的,主要是用來匹配block簽名的。

返回值是一個token,可以被用來登出這個Aspects。

注意,Aspects是不支援hook 靜態static方法的

這裡定義了錯誤碼的型別。出錯的時候方便我們除錯。

2.Aspects.m

#import 匯入這個標頭檔案是為了下面用到的自旋鎖。#import 和 #import 是使用Runtime的必備標頭檔案。

定義了AspectBlockFlags,這是一個flag,用來標記兩種情況,是否需要Copy和Dispose的Helpers,是否需要方法簽名Signature 。

在Aspects中定義的4個類,分別是AspectInfo,AspectIdentifier,AspectsContainer,AspectTracker。接下來就分別看看這4個類是怎麼定義的。

3. AspectInfo

AspectInfo對應的實現

AspectInfo是繼承於NSObject,並且遵循了AspectInfo協議。在其 – (id)initWithInstance: invocation:方法中,把外面傳進來的例項instance,和原始的invocation儲存到AspectInfo類對應的成員變數中。- (NSArray *)arguments方法是一個懶載入,返回的是原始的invocation裡面的aspects引數陣列。

aspects_arguments這個getter方法是怎麼實現的呢?作者是通過一個為NSInvocation新增一個分類來實現的。

為原始的NSInvocation類新增一個Aspects分類,這個分類中只增加一個方法,aspects_arguments,返回值是一個陣列,陣列裡麵包含了當前invocation的所有引數。

對應的實現

– (NSArray *)aspects_arguments實現很簡單,就是一層for迴圈,把methodSignature方法簽名裡面的引數,都加入到陣列裡,最後把陣列返回。

關於獲取方法所有引數的這個- (NSArray *)aspects_arguments方法的實現,有2個地方需要詳細說明。一是為什麼迴圈從2開始,二是[self aspect_argumentAtIndex:idx]內部是怎麼實現的。

131194012-970e6eab65c407ae

先來說說為啥迴圈從2開始。

Type Encodings作為對Runtime的補充,編譯器將每個方法的返回值和引數型別編碼為一個字串,並將其與方法的selector關聯在一起。這種編碼方案在其它情況下也是非常有用的,因此我們可以使用@encode編譯器指令來獲取它。當給定一個型別時,@encode返回這個型別的字串編碼。這些型別可以是諸如int、指標這樣的基本型別,也可以是結構體、類等型別。事實上,任何可以作為sizeof()操作引數的型別都可以用於@encode()。

在Objective-C Runtime Programming Guide中的Type Encoding一節中,列出了Objective-C中所有的型別編碼。需要注意的是這些型別很多是與我們用於存檔和分發的編碼型別是相同的。但有一些不能在存檔時使用。

注:Objective-C不支援long double型別。@encode(long double)返回d,與double是一樣的。

141194012-5db676475c03c0be

OC為支援訊息的轉發和動態呼叫,Objective-C Method 的 Type 資訊以 “返回值 Type + 引數 Types” 的形式組合編碼,還需要考慮到 self
和 _cmd 這兩個隱含引數:

按照上面的表,我們可以知道,編碼出來的字串,前3位分別是返回值Type,self隱含引數Type @,_cmd隱含引數Type :。

所以從第3位開始,是入參。

假設我們以- (void)tapView:(UIView *)view atIndex:(NSInteger)index為例,列印一下methodSignature

number of arguments = 4,因為有2個隱含引數self和_cmd,加上入參view和index。

ARGUMENT RETURN VALUE 0 1 2 3
methodSignature v @ : @ q

第一個argument的frame {offset = 0, offset adjust = 0, size = 0, size adjust = 0}memory {offset = 0, size = 0},返回值在這裡不佔size。第二個argument是self,frame {offset = 0, offset adjust = 0, size = 8, size adjust = 0}memory {offset = 0, size = 8}。由於size = 8,下一個frame的offset就是8,之後是16,以此類推。

至於為何這裡要傳遞2,還跟aspect_argumentAtIndex具體實現有關係。

再來看看aspect_argumentAtIndex的具體實現。這個方法還要感謝ReactiveCocoa團隊,為獲取方法簽名的引數提供了一種優雅的實現方式。

getArgumentTypeAtIndex:這個方法是用來獲取到methodSignature方法簽名指定index的type encoding的字串。這個方法傳出來的字串直接就是我們傳進去的index值。比如我們傳進去的是2,其實傳出來的字串是methodSignature對應的字串的第3位。

由於第0位是函式返回值return value對應的type encoding,所以傳進來的2,對應的是argument2。所以我們這裡傳遞index = 2進來,就是過濾掉了前3個type encoding的字串,從argument2開始比較。這就是為何迴圈從2開始的原因。

151194012-fd4ebb88f6917c23

_C_CONST是一個常量,用來判斷encoding的字串是不是CONST常量。

這裡的Type和OC的Type 是完全一樣的,只不過這裡是一個C的char型別。

WRAP_AND_RETURN是一個巨集定義。這個巨集定義裡面呼叫的getArgument:atIndex:方法是用來在NSInvocation中根據index得到對應的Argument,最後return的時候把val包裝成物件,返回出去。

在下面大段的if – else判斷中,有很多字串比較的函式strcmp。

比如說strcmp(argType, @encode(id)) == 0,argType是一個char,內容是methodSignature取出來對應的type encoding,和@encode(id)是一樣的type encoding。通過strcmp比較之後,如果是0,代表型別是相同的。

下面的大段的判斷就是把入參都返回的過程,依次判斷了id,class,SEL,接著是一大推基本型別,char,int,short,long,long long,unsigned char,unsigned int,unsigned short,unsigned long,unsigned long long,float,double,BOOL,bool,char *這些基本型別都會利用WRAP_AND_RETURN打包成物件返回。最後判斷block和struct結構體,也會返回對應的物件。

這樣入參就都返回到陣列裡面被接收了。假設還是上面- (void)tapView:(UIView *)view atIndex:(NSInteger)index為例子,執行完aspects_arguments,陣列裡面裝的的是:

總結,AspectInfo裡面主要是 NSInvocation 資訊。將NSInvocation包裝一層,比如引數資訊等。

4. AspectIdentifier

161194012-06c0591a32e26b52

對應實現

在instancetype方法中呼叫了aspect_blockMethodSignature方法。

這個aspect_blockMethodSignature的目的是把傳遞進來的AspectBlock轉換成NSMethodSignature的方法簽名。

AspectBlock的結構如下

這裡定義了一個Aspects內部使用的block型別。對系統的Block很熟悉的同學一眼就會感覺兩者很像。不熟悉的可以看看我之前分析Block的文章。文章裡,用Clang把Block轉換成結構體,結構和這裡定義的block很相似。

171194012-0a6448a6c928dded

瞭解了AspectBlock的結構之後,再看aspect_blockMethodSignature函式就比較清楚了。

AspectBlockRef layout = (__bridge void *)block,由於兩者block實現類似,所以這裡先把入參block強制轉換成AspectBlockRef型別,然後判斷是否有AspectBlockFlagsHasSignature的標誌位,如果沒有,報不包含方法簽名的error。

注意,傳入的block是全域性型別的

desc就是原來block裡面對應的descriptor指標。descriptor指標往下偏移2個unsigned long int的位置就指向了copy函式的地址,如果包含Copy和Dispose函式,那麼繼續往下偏移2個(void )的大小。這時指標肯定移動到了const char signature的位置。如果desc不存在,那麼也會報錯,該block不包含方法簽名。

到了這裡,就保證有方法簽名,且存在。最後呼叫NSMethodSignature的signatureWithObjCTypes方法,返回方法簽名。

舉例說明aspect_blockMethodSignature最終生成的方法簽名是什麼樣子的。

const char *signature最終獲得的字串是這樣

v32@?0@””8@”UIView”16q24是Block

對應的Type。void返回值的Type是v,32是offset,@?是block對應的Type,@“”是第一個引數,@”UIView”是第二個引數,NSInteger對應的Type就是q了。

每個Type後面跟的數字都是它們各自對應的offset。把最終轉換好的NSMethodSignature列印出來。

回到AspectIdentifier中繼續看instancetype方法,獲取到了傳入的block的方法簽名之後,又呼叫了aspect_isCompatibleBlockSignature方法。

這個函式的作用是把我們要替換的方法block和要替換的原方法,進行對比。如何對比呢?對比兩者的方法簽名。

入參selector是原方法。

先比較方法簽名的引數個數是否相等,不等肯定是不匹配,signaturesMatch = NO。如果引數個數相等,再比較我們要替換的方法裡面第一個引數是不是_cmd,對應的Type就是@,如果不是,也是不匹配,所以signaturesMatch = NO。如果上面兩條都滿足,signaturesMatch = YES,那麼就進入下面更加嚴格的對比。

這裡迴圈也是從2開始的。舉個例子來說明為什麼從第二位開始比較。還是用之前的例子。

這裡我要替換的原方法是UIView:atIndex:,那麼對應的Type是v@:@q。根據上面的分析,這裡的blockSignature是之前呼叫轉換出來的Type,應該是v@?@””@”UIView”q。

ARGUMENT RETURN VALUE 0 1 2 3
methodSignature v @ : @ q
blockSignature v @? @”” @”UIView” q

methodSignature 和 blockSignature 的return value都是void,所以對應的都是v。methodSignature的argument 0 是隱含引數 self,所以對應的是@。blockSignature的argument 0 是block,所以對應的是@?。methodSignature的argument 1 是隱含引數 _cmd,所以對應的是:。blockSignature的argument 1 是,所以對應的是@””。從argument 2開始才是方法簽名後面的對應可能出現差異,需要比較的引數列表。

最後

如果經過上面的比較signaturesMatch都為NO,那麼就丟擲error,Block無法匹配方法簽名。

如果這裡匹配成功了,就會blockSignature全部都賦值給AspectIdentifier。這也就是為何AspectIdentifier裡面有一個單獨的屬性NSMethodSignature的原因。

AspectIdentifier還有另外一個方法invokeWithInfo。

註釋也寫清楚了,這個判斷是強迫症患者寫的,到了這裡block裡面的引數是不會大於原始方法的方法簽名裡面引數的個數的。

把AspectInfo存入到blockInvocation中。

這一段是迴圈把originalInvocation中取出引數,賦值到argBuf中,然後再賦值到blockInvocation裡面。迴圈從2開始的原因上面已經說過了,這裡不再贅述。最後把self.block賦值給blockInvocation的Target。

181194012-8df87d2f18b96857

總結,AspectIdentifier是一個切片Aspect的具體內容。裡面會包含了單個的 Aspect 的具體資訊,包括執行時機,要執行 block 所需要用到的具體資訊:包括方法簽名、引數等等。初始化AspectIdentifier的過程實質是把我們傳入的block打包成AspectIdentifier。

5. AspectsContainer

18-51194012-82f31215a595a89f

對應實現

AspectsContainer比較好理解。addAspect會按照切面的時機分別把切片Aspects放到對應的陣列裡面。removeAspects會迴圈移除所有的Aspects。hasAspects判斷是否有Aspects。

AspectsContainer是一個物件或者類的所有的 Aspects 的容器。所有會有兩種容器。

值得我們注意的是這裡陣列是通過Atomic修飾的。關於Atomic需要注意在預設情況下,由編譯器所合成的方法會通過鎖定機制確保其原子性(Atomicity)。如果屬性具備nonatomic特質,則不需要同步鎖。

6. AspectTracker
191194012-b942a638eef4b741

對應實現

AspectTracker這個類是用來跟蹤要被hook的類。trackedClass是被追蹤的類。trackedClassName是被追蹤類的類名。selectorNames是一個NSMutableSet,這裡會記錄要被hook替換的方法名,用NSMutableSet是為了防止重複替換方法。selectorNamesToSubclassTrackers是一個字典,key是hookingSelectorName,value是裝滿AspectTracker的NSMutableSet。

addSubclassTracker方法是把AspectTracker加入到對應selectorName的集合中。removeSubclassTracker方法是把AspectTracker從對應的selectorName的集合中移除。subclassTrackersHookingSelectorName方法是一個並查集,傳入一個selectorName,通過遞迴查詢,找到所有包含這個selectorName的set,最後把這些set合併在一起作為返回值返回。

四. Aspects hook前的準備工作

201194012-2795423fae552b19

Aspects 庫中就兩個函式,一個是針對類的,一個是針對例項的。

兩個方法的實現都是呼叫同一個方法aspect_add,只是傳入的引數不同罷了。所以我們只要從aspect_add開始研究即可。

這是函式呼叫棧。從aspect_add開始研究。

aspect_add函式一共5個入參,第一個引數是self,selector是外面傳進來需要hook的SEL,options是切片的時間,block是切片的執行方法,最後的error是錯誤。

aspect_performLocked是一個自旋鎖。自旋鎖是效率比較高的一種鎖,相比@synchronized來說效率高得多。

如果對iOS中8大鎖不瞭解的,可以看以下兩篇文章

iOS 常見知識點(三):Lock
深入理解 iOS 開發中的鎖

211194012-01132072ff194854

但是自旋鎖也是有可能出現問題的:
如果一個低優先順序的執行緒獲得鎖並訪問共享資源,這時一個高優先順序的執行緒也嘗試獲得這個鎖,它會處於 spin lock 的忙等(busy-wait)狀態從而佔用大量 CPU。此時低優先順序執行緒無法與高優先順序執行緒爭奪 CPU 時間,從而導致任務遲遲完不成、無法釋放 lock。不再安全的 OSSpinLock

OSSpinLock的問題在於,如果訪問這個所的執行緒不是同一優先順序的話,會有死鎖的潛在風險。

這裡暫時認為是相同優先順序的執行緒,所以OSSpinLock保證了執行緒安全。也就是說aspect_performLocked是保護了block的執行緒安全。

現在就剩下aspect_isSelectorAllowedAndTrack函式和aspect_prepareClassAndHookSelector函式了。

接下來先看看aspect_isSelectorAllowedAndTrack函式實現過程。

先定義了一個NSSet,這裡面是一個“黑名單”,是不允許hook的函式名。retain, release, autorelease, forwardInvocation:是不允許被hook的。

當檢測到selector的函式名是黑名單裡面的函式名,立即報錯。

再次檢查如果要切片dealloc,切片時間只能在dealloc之前,如果不是AspectPositionBefore,也要報錯。

當selector不在黑名單裡面了,如果切片是dealloc,且selector在其之前了。這時候就該判斷該方法是否存在。如果self和self.class裡面都找不到該selector,會報錯找不到該方法。

class_isMetaClass 先判斷是不是元類。接下來的判斷都是判斷元類裡面能否允許被替換方法。

subclassHasHookedSelectorName會判斷當前tracker的subclass裡面是否包含selectorName。因為一個方法在一個類的層級裡面只能被hook一次。如果已經tracker裡面已經包含了一次,那麼會報錯。

在這個do-while迴圈中,currentClass = class_getSuperclass(currentClass)這個判斷會從currentClass的superclass開始,一直往上找,直到這個類為根類NSObject。

經過上面合法性hook判斷和類方法不允許重複替換的檢查後,到此,就可以把要hook的資訊記錄下來,用AspectTracker標記。在標記過程中,一旦子類被更改,父類也需要跟著一起被標記。do-while的終止條件還是currentClass = class_getSuperclass(currentClass)。

以上是元類的類方法hook判斷合法性的程式碼。

如果不是元類,只要不是hook這”retain”, “release”, “autorelease”, “forwardInvocation:”4種方法,而且hook “dealloc”方法的時機必須是before,並且selector能被找到,那麼方法就可以被hook。

通過了selector是否能被hook合法性的檢查之後,就要獲取或者建立AspectsContainer容器了。

在讀取或者建立AspectsContainer之前,第一步是先標記一下selector。

在全域性程式碼裡面定義了一個常量字串

用這個字串標記所有的selector,都加上字首”aspects_”。然後獲得其對應的AssociatedObject關聯物件,如果獲取不到,就建立一個關聯物件。最終得到selector有”aspects_”字首,對應的aspectContainer。

得到了aspectContainer之後,就可以開始準備我們要hook方法的一些資訊。這些資訊都裝在AspectIdentifier中,所以我們需要新建一個AspectIdentifier。

呼叫AspectIdentifier的instancetype方法,建立一個新的AspectIdentifier

這個instancetype方法,只有一種情況會建立失敗,那就是aspect_isCompatibleBlockSignature方法返回NO。返回NO就意味著,我們要替換的方法block和要替換的原方法,兩者的方法簽名是不相符的。(這個函式在上面詳解過了,這裡不再贅述)。方法簽名匹配成功之後,就會建立好一個AspectIdentifier。

aspectContainer容器會把它加入到容器中。完成了容器和AspectIdentifier初始化之後,就可以開始準備進行hook了。通過options選項分別新增到容器中的beforeAspects,insteadAspects,afterAspects這三個陣列

小結一下,aspect_add幹了一些什麼準備工作:

  1. 首先呼叫aspect_performLocked ,利用自旋鎖,保證整個操作的執行緒安全
  2. 接著呼叫aspect_isSelectorAllowedAndTrack對傳進來的引數進行強校驗,保證引數合法性。
  3. 接著建立AspectsContainer容器,利用AssociatedObject關聯物件動態新增到NSObject分類中作為屬性的。
  4. 再由入參selector,option,建立AspectIdentifier例項。AspectIdentifier主要包含了單個的 Aspect的具體資訊,包括執行時機,要執行block 所需要用到的具體資訊。
  5. 再將單個的 AspectIdentifier 的具體資訊加到屬性AspectsContainer容器中。通過options選項分別新增到容器中的beforeAspects,insteadAspects,afterAspects這三個陣列。
  6. 最後呼叫prepareClassAndHookSelector準備hook。

221194012-c4741aae2eb19e72

下部分見下篇。

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!

任選一種支付方式

iOS 如何實現 Aspect Oriented Programming (上) iOS 如何實現 Aspect Oriented Programming (上)

相關文章