iOS面向切面編程AOP實踐

什么是AOP

AOP:Aspect Oriented Programming,譯為面向切面編程。

在不修改源代碼的情況下,通過運行時給程序添加統一功能的技術。

我覺得其中有兩層涵義:

  • 第一:不修改源代碼,即盡可能的解耦。
  • 第二:添加統一的功能,即我們能實現的是添加統一的單一的功能,在某處使用AOP,我們只能實現一項單一的功能。如:日志記錄。當然你可以添加多個AOP的模塊到項目中,每一個實現不同功能,但是每一個功能必須是單一的。

主要功能:日志記錄,性能統計等。

iOS中如何實現AOP

有心的讀者可能會發現,我在上面的AOP簡介中并沒有原話搬用百度百科的AOP簡介,因為這是一篇iOS的AOP教程,在OC中我們就是用運行時來給實現AOP的。(我們基本不會使用預編譯方式來實現AOP)

在iOS中實現AOP的核心技術是Runtime,使用Runtime的Method Swizzling黑魔法,我們可以移花接木,在運行時將方法的具體實現添油加醋、偷梁換柱。

點此移步了解Method Swizzling

AOP技術實現

越是底層的框架越是難用,任何語言皆是如此,同樣Method Swizzling也不例外。那是否有一個第三庫,可以讓我們輕松駕馭Method Swizzling黑魔法呢?

當然有,而且不止一個,其中最著名的要數Aspects,Aspects的使用非常簡單,整個庫封裝為兩個方法:

+ (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;

實際為同一個方法,這兩個方法是同名不同類型的方法,一個是靜態類方法,一個是成員方法。
使用這個方法可以給類的實例方法添加一個Block,并且對這個類的所有對象都會起作用。

所有的調用,都會是線程安全的。Aspects 使用了Objective-C 的消息轉發機會,會有一定的性能消耗。所有對于過于頻繁的調用,不建議使用 Aspects。Aspects更適用于視圖/控制器相關的等每秒調用不超過1000次的代碼。

代碼示例

在調試應用時,使用Aspects動態添加日志記錄功能。

[UIViewController aspect_hookSelector:@selector(viewWillAppear:) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo) {
    //NSLog(@"??????Appear:--> %@", aspectInfo.instance);(為什么不使用此方式,請查看評論)
    NSLog(@"??????Appear:--> %@", NSStringFromClass([aspectInfo.instance class]));
} error:NULL];

通過這段代碼,我們給UIViewController的viewWillAppear:方法添加了一個鉤子,每當在調用viewWillAppear:后就會執行block中的代碼。在此我們打印了一段Log(加上emoji表情就更好找log啦),通過log我們可以看到當前顯示的頁面的VC名稱,從而快速定位到該類。還可以在ViewController的Dealloc時打印log:

[UIViewController aspect_hookSelector:NSSelectorFromString(@"dealloc") withOptions:AspectPositionBefore usingBlock:^(id<AspectInfo> aspectInfo) {
        //NSLog(@"??????Dealloc:---->: %@", aspectInfo.instance);(為什么不使用此方式,請查看評論)
        NSLog(@"??????Dealloc:---->: %@", NSStringFromClass([aspectInfo.instance class]));
    } error:NULL];

與上一段代碼的微小差別是Selector換成了NSSelectorFromString(@"dealloc"),而不是@selector(dealloc),這是因為在ARC下面是不能直接手動調用Dealloc的,@selector(dealloc)會被編譯器直接報錯。

通過這個log,我們可以知道ViewController是否釋放,如果沒有釋放很可能就是有循環引用,這時你務必仔細檢查你的代碼,這在性能調試和debug中非常有用。

AOP實戰

在實際的項目開發中,事件統計是很多APP都會添加一項重要功能,它能統計用戶的行為、商品的銷售狀況、商品查看數據等,今天的AOP實戰是利用AOP實現APP事件統計。

這樣統計?

假設產品有這么個需求:當用戶在詳情頁點擊添加到購物車按鈕時,記錄一下事件。我們實現起來大概會是這樣

- (void)onBuyButtonClicked:(id)sender
{
    [XXXAnalytics track:eventName properties:properties];
}

這個需求就這樣輕松搞定了,但細細想想還是有不少問題的:

  • 頁面上會有其他的 Button,可能每個 Button 都要放上這么一段代碼。
  • 這些統計其實跟具體的業務無關,沒必要跟業務代碼混雜在一起,不優雅。
  • 當改版或者重構時,有可能忘了把相應的事件統計代碼遷移過去。
使用AOP實現統計

基于上面的問題,需要將事件統計這段代碼抽離,與具體點擊事件邏輯代碼解耦。通過AOP在運行時將事件統計的代碼加入到方法中正是這個問題的最佳解。代碼大概如下:

[PBAGoodsDetailViewController aspect_hookSelector:@selector(onBuyButtonClicked:) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo) {
        [XXXAnalytics track:eventName properties:properties];
    } error:NULL];
多個事件?

當然事件統計往往需要統計多個事件,這時我們只要對該方法稍微抽象一下就可以了,代碼如下:

- (void)setupAnalytics
{
    [self trackEventWithClass:aViewController selector:@seletor(onBuyButtonTapped:) event:kSomeEventYouDefined];
    [self trackEventWithClass:bViewController selector:@seletor(followButtonTapped:) event:kAnotherEventYouDefined];
    // ...
}
- (void)trackEventWithClass:(Class)klass selector:(SEL)selector event:(NSString *)event
{
[klass aspect_hookSelector:@selector(selector) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo) {
    [XXXAnalytics track:eventName properties:properties];
    } error:NULL];
}
使用plist文件配置事件統計

當事件非常多時,你的setupAnalytics方法將會變得越來越長,而且不好維護。如果我們可以利用一張表格來配置事件統計,看起來會更加直觀簡潔。
使用Xcode創建一個plist文件,其文件結構如圖:


EventList.plish

使用類名作為字典的鍵,值為一個數組,數組內存放該類下的事件列表,每個事件包含事件ID(EventId)和觸發事件的方法名稱(MethodName)。
在AppDelegate.m中,添加事件統計的代碼如下:

- (void)setupAnalytics
{
    //設置事件統計
    //放到異步線程去執行
    __weak typeof(self) ws = self;
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        //讀取配置文件,獲取需要統計的事件列表
        NSString *path = [[NSBundle mainBundle] pathForResource:@"EventList" ofType:@"plist"];
        NSDictionary *eventStatisticsDict = [[NSDictionary alloc] initWithContentsOfFile:path];
        for (NSString *classNameString in eventStatisticsDict.allKeys) {
            //使用運行時創建類對象
            const char * className = [classNameString UTF8String];
            //從一個字串返回一個類
            Class newClass = objc_getClass(className);
            NSArray *pageEventList = [eventStatisticsDict objectForKey:classNameString];
            for (NSDictionary *eventDict in pageEventList) {
                //事件方法名稱
                NSString *eventMethodName = eventDict[@"MethodName"];
                SEL seletor = NSSelectorFromString(eventMethodName);
                
                NSString *eventId = eventDict[@"EventId"];
                
                [self trackEventWithClass:newClass selector:seletor event:eventId];
            }
        }
    });
}

至此,一切好像都好像完美了,但人生總是充滿了變數。

事件需要傳遞參數

一個陽光明媚的上午,產品跑過來和我說事件統計需要傳遞一些參數,比如點擊查看商品詳情事件需要傳遞商品ID和商品名稱。我當時心中就一萬只草泥馬在奔騰,但是沒辦法呀!我們只是搬磚的程序猿,只能低頭默默的改。好不容易設計好的架構,眼看就要打回原形。后來仔細研究一番發現,其實Aspects是可以通過Block獲取到方法傳遞的參數的,馬上心情好了許多,修改思路馬上再腦海形成。

首先,將Block改為^(id<AspectInfo> aspectInfo, NSDictionary *dict),第一個參數一定要為id<AspectInfo> aspectInfo,后面接方法傳遞的對應類型的參數,這樣便可以接收到方法調用傳遞的參數。但是每一個事件需要傳遞的參數都各不相同,那我們要如何配置呢?
我的方案是:在plist的事件字典中加入一個鍵為Params,值為數組的鍵值對。修改后配置文件如下:

EventListV2

統計代碼:

- (void)setupAnalytics
{
    //設置事件統計
    //放到異步線程去執行
    __weak typeof(self) ws = self;
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        //讀取配置文件,獲取需要統計的事件列表
        NSString *path = [[NSBundle mainBundle] pathForResource:@"EventList" ofType:@"plist"];
        NSDictionary *eventStatisticsDict = [[NSDictionary alloc] initWithContentsOfFile:path];
        for (NSString *classNameString in eventStatisticsDict.allKeys) {
            //使用運行時創建類對象
            const char * className = [classNameString UTF8String];
            //從一個字串返回一個類
            Class newClass = objc_getClass(className);
            NSArray *pageEventList = [eventStatisticsDict objectForKey:classNameString];
            for (NSDictionary *eventDict in pageEventList) {
                //事件方法名稱
                NSString *eventMethodName = eventDict[@"MethodName"];
                SEL seletor = NSSelectorFromString(eventMethodName);
                NSString *eventId = eventDict[@"EventId"];
                NSArray *params = eventDict[@"Params"];
                [self trackEventWithClass:newClass selector:seletor event:eventId params:params];
            }
        }
    });
}

統計方法:

- (void)trackEventWithClass:(Class)klass selector:(SEL)selector event:(NSString *)event params:(NSArray *)paramNames
{
    [klass aspect_hookSelector:@selector(selector) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo, NSDictionary *dict) {
        //定義與事件相關的屬性信息
        NSMutableDictionary *properties = [NSMutableDictionary dictionary];
        //如果有參數,那么把參數名和參數值拼接在eventID之后
        if (paramNames.count > 0) {
            if ([dict isKindOfClass:[NSDictionary class]]) {
                //獲取dict
                for (NSString *paramName in paramNames) {
                    //添加所需參數
                    NSString *paramValue = [dict objectForKey:paramName];
                    properties[paramName] = paramValue;
                }
            }
        }

        [XXXAnalytics track:eventName properties:properties];
    } error:NULL];
}

將需要傳遞的參數以字典格式作為方法的第一個參數,Params中配置事件統計需要傳遞的參數的Key,通過此方法可以傳遞任何我們需要傳遞的參數,使用plist快速、靈活配置需要傳遞的參數。實戰內容到此基本結束,我們使用AOP已經實現了一個低耦合、可靈活配置的事件統計。

還有一些挑戰

在使用Aspects中我發現,如果方法為類方法時,并不會回調block。在調用aspect_hookSelector:withOptions:usingBlock:時,報Aspects: Block signature <NSMethodSignature: 0x7fa13345ce60> doesn't match (null).錯誤提示,意思是block不匹配,其根本原因在于無法使用Class獲取該Class的類方法,通過runtime只能獲取到成員方法,而類方法需要使用該Class的MetaClass獲取,MateClass可以使用object_getClass(newClass)得到。代碼如下:

[ws trackEventWithClass:object_getClass(newClass) selector:seletor event:eventId params:params];

修改后雖然不會報錯,但是依然不會觸發block。查看Aspects的github介紹發現,Aspects壓根就不支持類方法,這讓我很是苦惱。不過按道理應該是可以的,于是和同事討論了一下,就使用Method Swizzling做了交換兩個類方法的試驗,結果是成功了。

查看Aspects的源代碼發現,Aspects交換的是成員方法。無奈最后只能修改Aspects的源代碼,我在其中一方法中加入了Class類型判斷,如果是MetaClass,那么就初始化為類方法,而非成員方法。代碼如下:

static void aspect_prepareClassAndHookSelector(NSObject *self, SEL selector, NSError **error) {
    NSCParameterAssert(selector);
    Class klass = aspect_hookClass(self, error);
    //TODO:Edit bu JackYong
    Method targetMethod;
    IMP targetMethodIMP;
    if (class_isMetaClass(klass)) {
        targetMethod = class_getClassMethod(klass, selector);
        targetMethodIMP = method_getImplementation(targetMethod);
    } else {
        targetMethod = class_getInstanceMethod(klass, selector);
        targetMethodIMP = method_getImplementation(targetMethod);
    }

修改后block和往常一樣被調用了。暫時使用沒有遇到什么問題,不過目測應該是有bug的,不然Aspects的開發者早就加了這判斷。
Demo:https://github.com/yongca887/AOPDemo

Aspects的坑
  • 1.無法為類方法添加hooking(通過上面的方法暫時可以解決,不過還是不太建議使用)
  • 2.Block無法自動判斷參數個數,自動匹配。如果你添加一個無參的方法,而Block中有跟一個參數,那么你會收到Block不匹配的錯誤。

參考
iOS 統計打點那些事

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

推薦閱讀更多精彩內容