上一篇介紹了 Objective-C Messaging。利用 Objective-C 的 Runtime 特性,我們可以給語言做擴充套件,幫助解決專案開發中的一些設計和技術問題。這一篇,我們來探索一些利用 Objective-C Runtime 的黑色技巧。這些技巧中最具爭議的或許就是 Method Swizzling 。
介紹一個技巧,最好的方式就是提出具體的需求,然後用它跟其他的解決方法做比較。
所以,先來看看我們的需求:對 App 的使用者行為進行追蹤和分析。簡單說,就是當使用者看到某個 View
或者點選某個 Button
的時候,就把這個事件記下來。
手動新增
最直接粗暴的方式就是在每個 viewDidAppear
裡新增記錄事件的程式碼。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
@implementation MyViewController () - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; // Custom code // Logging [Logging logWithEventName:@“my view did appear”]; } - (void)myButtonClicked:(id)sender { // Custom code // Logging [Logging logWithEventName:@“my button clicked”]; } |
這種方式的缺點也很明顯:它破壞了程式碼的乾淨整潔。因為 Logging
的程式碼本身並不屬於 ViewController
裡的主要邏輯。隨著專案擴大、程式碼量增加,你的 ViewController
裡會到處散佈著 Logging
的程式碼。這時,要找到一段事件記錄的程式碼會變得困難,也很容易忘記新增事件記錄的程式碼。
你可能會想到用繼承或者類別,在重寫的方法裡新增事件記錄的程式碼。比如用類別的程式碼大概長這個樣子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
@implementation UIViewController (Logging) - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; // Custom code // Logging [Logging logWithEventName:NSStringFromClass([self class])]; } - (void)myButtonClicked:(id)sender { // Custom code // Logging NSString *name = [NSString stringWithFormat:@“my button in %@ is clicked”, NSStringFromClass([self class])]; [Logging logWithEventName:name]; } |
Logging
的程式碼都很相似,通過繼承或類別重寫相關方法是可以把它從主要邏輯中剝離出來。但同時也帶來新的問題:
- 你需要繼承
UIViewController
,UITableViewController
,UICollectionViewController
所有這些 ViewController ,或者給他們新增類別; - 每個 ViewController 裡的 ButtonClick 方法命名不可能都一樣;
- 你不能控制別人如何去例項化你的子類;
- 對於類別,你沒辦法呼叫到原來的方法實現。大多時候,我們重寫一個方法只是為了新增一些程式碼,而不是完全取代它。
- 如果有兩個類別都實現了相同的方法,執行時沒法保證哪一個類別的方法會給呼叫。
Method Swizzling
Method Swizzling 利用 Runtime 特性把一個方法的實現與另一個方法的實現進行替換。
上一篇文章 有講到每個類裡都有一個 Dispatch Table ,將方法的名字(SEL)跟方法的實現(IMP,指向 C 函式的指標)一一對應。Swizzle 一個方法其實就是在程式執行時在 Dispatch Table 裡做點改動,讓這個方法的名字(SEL)對應到另個 IMP 。
首先定義一個類別,新增將要 Swizzled 的方法:
1 2 3 4 5 6 7 8 9 10 |
@implementation UIViewController (Logging) - (void)swizzled_viewDidAppear:(BOOL)animated { // call original implementation [self swizzled_viewDidAppear:animated]; // Logging [Logging logWithEventName:NSStringFromClass([self class])]; } |
程式碼看起來可能有點奇怪,像遞迴不是麼。當然不會是遞迴,因為在 runtime 的時候,函式實現已經被交換了。呼叫 viewDidAppear:
會呼叫你實現的 swizzled_viewDidAppear:
,而在 swizzled_viewDidAppear:
裡呼叫 swizzled_viewDidAppear:
實際上呼叫的是原來的 viewDidAppear:
。
接下來實現 swizzle 的方法 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
@implementation UIViewController (Logging) void swizzleMethod(Class class, SEL originalSelector, SEL swizzledSelector) { // the method might not exist in the class, but in its superclass Method originalMethod = class_getInstanceMethod(class, originalSelector); Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector); // class_addMethod will fail if original method already exists BOOL didAddMethod = class_addMethod(class, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod)); // the method doesn’t exist and we just added one if (didAddMethod) { class_replaceMethod(class, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod)); } else { method_exchangeImplementations(originalMethod, swizzledMethod); } } |
這裡唯一可能需要解釋的是 class_addMethod
。要先嚐試新增原 selector 是為了做一層保護,因為如果這個類沒有實現 originalSelector
,但其父類實現了,那 class_getInstanceMethod
會返回父類的方法。這樣 method_exchangeImplementations
替換的是父類的那個方法,這當然不是你想要的。所以我們先嚐試新增 orginalSelector
,如果已經存在,再用 method_exchangeImplementations
把原方法的實現跟新的方法實現給交換掉。
最後,我們只需要確保在程式啟動的時候呼叫 swizzleMethod
方法。比如,我們可以在之前 UIViewController
的 Logging 類別裡新增 +load:
方法,然後在 +load:
裡把 viewDidAppear
給替換掉:
1 2 3 4 5 6 |
@implementation UIViewController (Logging) + (void)load { swizzleMethod([self class], @selector(viewDidAppear:), @selector(swizzled_viewDidAppear:)); } |
一般情況下,類別裡的方法會重寫掉主類裡相同命名的方法。如果有兩個類別實現了相同命名的方法,只有一個方法會被呼叫。但 +load:
是個特例,當一個類被讀到記憶體的時候, runtime 會給這個類及它的每一個類別都傳送一個 +load:
訊息。
其實,這裡還可以更簡化點:直接用新的 IMP 取代原 IMP ,而不是替換。只需要有全域性的函式指標指向原 IMP 就可以。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
void (gOriginalViewDidAppear)(id, SEL, BOOL); void newViewDidAppear(UIViewController *self, SEL _cmd, BOOL animated) { // call original implementation gOriginalViewDidAppear(self, _cmd, animated); // Logging [Logging logWithEventName:NSStringFromClass([self class])]; } + (void)load { Method originalMethod = class_getInstanceMethod(self, @selector(viewDidAppear:)); gOriginalViewDidAppear = (void *)method_getImplementation(originalMethod); if(!class_addMethod(self, @selector(viewDidAppear:), (IMP) newViewDidAppear, method_getTypeEncoding(originalMethod))) { method_setImplementation(originalMethod, (IMP) newViewDidAppear); } } |
通過 Method Swizzling ,我們成功把邏輯程式碼跟處理事件記錄的程式碼解耦。當然除了 Logging ,還有很多類似的事務,如 Authentication 和 Caching。這些事務瑣碎,跟主要業務邏輯無關,在很多地方都有,又很難抽象出來單獨的模組。這種程式設計問題,業界也給了他們一個名字 – Cross Cutting Concerns。
而像上面例子用 Method Swizzling 動態給指定的方法新增程式碼,以解決 Cross Cutting Concerns 的程式設計方式叫:Aspect Oriented Programming
Aspect Oriented Programming (面向切面程式設計)
Wikipedia 裡對 AOP 是這麼介紹的:
An aspect can alter the behavior of the base code by applying advice (additional behavior) at various join points (points in a program) specified in a quantification or query called a pointcut (that detects whether a given join point matches).
在 Objective-C 的世界裡,這句話意思就是利用 Runtime 特性給指定的方法新增自定義程式碼。有很多方式可以實現 AOP ,Method Swizzling 就是其中之一。而且幸運的是,目前已經有一些第三方庫可以讓你不需要了解 Runtime ,就能直接開始使用 AOP 。
Aspects 就是一個不錯的 AOP 庫,封裝了 Runtime , Method Swizzling 這些黑色技巧,只提供兩個簡單的API:
1 2 3 4 5 6 7 8 |
+ (id<AspectToken>)aspect_hookSelector:(SEL)selector withOptions:(AspectOptions)options usingBlock:(id)block error:(NSError **)error; - (id<AspectToken>)aspect_hookSelector:(SEL)selector withOptions:(AspectOptions)options usingBlock:(id)block error:(NSError **)error; |
使用 Aspects 提供的 API,我們之前的例子會進化成這個樣子:
1 2 3 4 5 6 7 8 9 10 11 |
@implementation UIViewController (Logging) + (void)load { [UIViewController aspect_hookSelector:@selector(viewDidAppear:) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo) { NSString *className = NSStringFromClass([[aspectInfo instance] class]); [Logging logWithEventName:className]; } error:NULL]; } |
你可以用同樣的方式在任何你感興趣的方法裡新增自定義程式碼,比如 IBAction 的方法裡。更好的方式,你提供一個 Logging 的配置檔案作為唯一處理事件記錄的地方:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
@implementation AppDelegate (Logging) + (void)setupLogging { NSDictionary *config = @{ @"MainViewController": @{ GLLoggingPageImpression: @"page imp - main page", GLLoggingTrackedEvents: @[ @{ GLLoggingEventName: @"button one clicked", GLLoggingEventSelectorName: @"buttonOneClicked:", GLLoggingEventHandlerBlock: ^(id<AspectInfo> aspectInfo) { [Logging logWithEventName:@"button one clicked"]; }, }, @{ GLLoggingEventName: @"button two clicked", GLLoggingEventSelectorName: @"buttonTwoClicked:", GLLoggingEventHandlerBlock: ^(id<AspectInfo> aspectInfo) { [Logging logWithEventName:@"button two clicked"]; }, }, ], }, @"DetailViewController": @{ GLLoggingPageImpression: @"page imp - detail page", } }; [AppDelegate setupWithConfiguration:config]; } + (void)setupWithConfiguration:(NSDictionary *)configs { // Hook Page Impression [UIViewController aspect_hookSelector:@selector(viewDidAppear:) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo) { NSString *className = NSStringFromClass([[aspectInfo instance] class]); [Logging logWithEventName:className]; } error:NULL]; // Hook Events for (NSString *className in configs) { Class clazz = NSClassFromString(className); NSDictionary *config = configs[className]; if (config[GLLoggingTrackedEvents]) { for (NSDictionary *event in config[GLLoggingTrackedEvents]) { SEL selekor = NSSelectorFromString(event[GLLoggingEventSelectorName]); AspectHandlerBlock block = event[GLLoggingEventHandlerBlock]; [clazz aspect_hookSelector:selekor withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo) { block(aspectInfo); } error:NULL]; } } } } |
然後在 -application:didFinishLaunchingWithOptions:
裡呼叫 setupLogging
:
1 2 3 4 5 6 |
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { // Override point for customization after application launch. [self setupLogging]; return YES; } |
最後的話
利用 objective-C Runtime 特性和 Aspect Oriented Programming ,我們可以把瑣碎事務的邏輯從主邏輯中分離出來,作為單獨的模組。它是對物件導向程式設計模式的一個補充。Logging 是個經典的應用,這裡做個拋磚引玉,發揮想象力,可以做出其他有趣的應用。
使用 Aspects 完整的例子可以從這裡獲得:AspectsDemo。
如果你有什麼問題和想法,歡迎留言或者發郵件給我 peng@glowing.com 進行討論。