[貝聊科技]一次高效的依賴注入

貝聊科技發表於2018-09-10

作者介紹:NewPan,貝聊科技高階 iOS 工程師。

文章涉及依賴注入方案基於 EXTConcreteProtocol 實現,GitHub連結在這裡

01. 問題場景

如果基於 Cocopods 和 Git Submodules 來做元件化的時候,我們的依賴關係是這樣的:

[貝聊科技]一次高效的依賴注入

這裡依賴路徑有兩條:

    1. 最簡單的主專案依賴第三方 pods。
    1. 元件依賴第三方 pods,主專案再依賴元件。

這種單向的依賴關係,決定了從元件到專案的通訊是單向的,即主專案可以主動向元件發起通訊,但是元件卻沒有辦法主動和主專案通訊。

你可能說不對,可以發通知啊?是的,是可以發通知,但是這一點都不優雅,也不好維護和擴充。

有沒有一種更加優雅、更加方便日常開發的擴充和維護的方式呢?答案是有的,名字叫做“依賴注入”。

02. 依賴注入

依賴注入有另外一個名字,叫做“控制反轉”,像上面的元件化的例子,主專案依賴元件,現在有一個需求,元件需要依賴主專案,這種情況就叫做“控制反轉”。

能把這部分“控制反轉”的程式碼統一起來解耦維護,方便日後擴充和維護的服務,我們就可以叫做依賴注入。

所以依賴注入有兩個比較重要的點:

  • 第一,要實現這種反轉控制的功能。
  • 第二,要解耦。

不是我自身的,卻是我需要的,都是我所依賴的。一切需要外部提供的,都是需要進行依賴注入的。

這句話出自這篇文章:理解依賴注入與控制反轉 | Laravel China 社群 - 高品質的 Laravel 開發者社群

如果對概念性的東西有更加深入的理解,歡迎谷歌搜尋“依賴注入”。

03. iOS 依賴注入調查

iOS 平臺實現依賴注入功能的開源專案有兩個大頭:

詳細對比發現這兩個框架都是嚴格遵循依賴注入的概念來實現的,並沒有將 Objective-C 的 runtime 特性發揮到極致,所以使用起來很麻煩。

還有一點,這兩個框架使用繼承的方式實現注入功能,對專案的侵入性不容小視。如果你覺得這個侵入性不算什麼,那等到你專案大到一定程度,發現之前選擇的技術方案有考慮不周,你想切換到其他方案的時候,你一定會後悔當時沒選擇那個不侵入專案的方案。

那有沒有其他沒那麼方案呢?

GitHub - jspahrsummers/libextobjc: A Cocoa library to extend the Objective-C programming language. 裡有一個 EXTConcreteProtocol 雖然沒有直接叫做依賴注入,而是叫做混合協議,但是充分使用了 OC 動態語言的特性,不侵入專案,高度自動化,框架十分輕量,使用非常簡單。

輕量到什麼地步?就只有一個 .h 一個 .m 檔案。 簡單到什麼地步?就只需要一個 @conreteprotocol關鍵字,你就已經注入好了。

從一個評價開源框架的方方面面都甩開上面兩個框架好幾條街。

但是他也有致命的缺點,魚和熊掌不可兼得,這個我們等會說。

04. EXTConcreteProtocol 實現原理

有兩個比較重要的概念需要提前明白才能繼續往下將。

    1. 容器。這裡的容器是指,我們注入的方法需要有類(class)來裝,而裝這些方法的器皿就統稱為容器。
    1. __attribute__()這是一個 GNU 編譯器語法,被 constructor 這個關鍵字修飾的方法會在所有類的 +load 方法之後,在 main 函式之前被呼叫。詳見:Clang Attributes 黑魔法小記 · sunnyxx的技術部落格

[貝聊科技]一次高效的依賴注入

如上圖,用一句話來描述注入的過程:將待注入的容器中的方法在 load 方法之後 main 函式之前注入指定的類中。

04.1. EXTConcreteProtocol 的使用

比方說有一個協議 ObjectProtocol。我們只要這樣寫就已經實現了依賴注入。

@protocol ObjectProtocol<NSObject>

+ (void)sayHello;

- (int)age;

@end

@concreteprotocol(ObjectProtocol)

+ (void)sayHello {
    NSLog(@"Hello");
}

- (int)age {
    return 18;
}

@end
複製程式碼

之後比方說一個 Person 類想要擁有這個注入方法,就只需要遵守這個協議就可以了。

@interface Person : NSObject<ObjectProtocol>

@end
複製程式碼

我們接下來就可以對 Person 呼叫注入的方法。

int main(int argc, char * argv[]) {
     Person *p = [Person new];
	 NSLog(@"%@", [p age]);
	 [p.class sayHello];
}

輸出:
>>>18
>>>Hello
複製程式碼

是不是很神奇?想不想探一下究竟?

04.2. 原始碼解析

先來看一下標頭檔案:

#define concreteprotocol(NAME) \
	  // 定義一個容器類.
    interface NAME ## _ProtocolMethodContainer : NSObject < NAME > {} \
    @end \
    \
    @implementation NAME ## _ProtocolMethodContainer \
    //  load 方法新增混合協議.
    + (void)load { \
        if (!ext_addConcreteProtocol(objc_getProtocol(metamacro_stringify(NAME)), self)) \
            fprintf(stderr, "ERROR: Could not load concrete protocol %s\n", metamacro_stringify(NAME)); \
    } \
    // load 之後, main 之前執行方法注入.
    __attribute__((constructor)) \
    static void ext_ ## NAME ## _inject (void) { \
ext_loadConcreteProtocol(objc_getProtocol(metamacro_stringify(NAME))); \
    }
//  load 方法新增混合協議.
BOOL ext_addConcreteProtocol (Protocol *protocol, Class methodContainer);
// load 之後, main 之前執行方法注入.
void ext_loadConcreteProtocol (Protocol *protocol);
複製程式碼

可以在原始碼中清楚看到 concreteprotocol 這個巨集定義為我們的協議新增了一個容器類,我們主要注入的比如 +sayHello-age 方法都被定義在這個容器類之中。

然後在 +load 方法中呼叫了 ext_addConcreteProtocol 方法。

typedef struct {
    // 使用者定義的協議.
    __unsafe_unretained Protocol *protocol;

    // 在 __attribute__((constructor)) 時往指定類裡注入方法的 block.
    void *injectionBlock;

    // 對應的協議是否已經準備好注入.
    BOOL ready;
} EXTSpecialProtocol;

BOOL ext_addConcreteProtocol (Protocol *protocol, Class containerClass) { 
    return ext_loadSpecialProtocol(protocol, ^(Class destinationClass){
        ext_injectConcreteProtocol(protocol, containerClass, destinationClass);
    });
}

BOOL ext_loadSpecialProtocol (Protocol *protocol, void (^injectionBehavior)(Class destinationClass)) {
    @autoreleasepool {
        NSCParameterAssert(protocol != nil);
        NSCParameterAssert(injectionBehavior != nil);
        
        // 加鎖
        if (pthread_mutex_lock(&specialProtocolsLock) != 0) {
            fprintf(stderr, "ERROR: Could not synchronize on special protocol data\n");
            return NO;
        }
        
        // specialProtocols 是一個連結串列,每個協議都會被組織成為一個 EXTSpecialProtocol,這個 specialProtocols 裡存放了了這些 specialProtocols.
        if (specialProtocolCount >= specialProtocolCapacity) {
           ...
        }

        #ifndef __clang_analyzer__
        ext_specialProtocolInjectionBlock copiedBlock = [injectionBehavior copy];

        // 將協議儲存為一個 EXTSpecialProtocol 結構體.
        specialProtocols[specialProtocolCount] = (EXTSpecialProtocol){
            .protocol = protocol,
            .injectionBlock = (__bridge_retained void *)copiedBlock,
            .ready = NO
        };
        #endif

        ++specialProtocolCount;
        pthread_mutex_unlock(&specialProtocolsLock);
    }
    return YES;
}
複製程式碼

我們的 ext_loadSpecialProtocol 方法裡傳進去一個 block,這個 block 裡呼叫了 ext_injectConcreteProtocol 這個方法。

ext_injectConcreteProtocol 這個方法接受三個引數,第一個是協議,就是我們要注入的方法的協議;第二個是容器類,就是框架為我們新增的那個容器;第三個引數是目標註入類,就是我們要把這個容器裡的方法注入到哪個類。

static void ext_injectConcreteProtocol (Protocol *protocol, Class containerClass, Class class) {
    // 獲取容器類裡所有的例項方法.
    unsigned imethodCount = 0;
    Method *imethodList = class_copyMethodList(containerClass, &imethodCount);

    // 獲取容器類裡所有的類方法方法.
    unsigned cmethodCount = 0;
    Method *cmethodList = class_copyMethodList(object_getClass(containerClass), &cmethodCount);
            
    // 拿到要注入方法的類的元類.
    Class metaclass = object_getClass(class);

    // 注入例項方法.
    for (unsigned methodIndex = 0;methodIndex < imethodCount;++methodIndex) {
        Method method = imethodList[methodIndex];
        SEL selector = method_getName(method);

        // 如果該類已經實現了這個方法,就跳過注入,不至於覆蓋使用者自定義的實現.
        if (class_getInstanceMethod(class, selector)) {
            continue;
        }

        IMP imp = method_getImplementation(method);
        const char *types = method_getTypeEncoding(method);
        if (!class_addMethod(class, selector, imp, types)) {
            fprintf(stderr, "ERROR: Could not implement instance method -%s from concrete protocol %s on class %s\n",
                sel_getName(selector), protocol_getName(protocol), class_getName(class));
        }
    }

    // 注入類方法.
    for (unsigned methodIndex = 0;methodIndex < cmethodCount;++methodIndex) {
        Method method = cmethodList[methodIndex];
        SEL selector = method_getName(method);

        // +initialize 不能被注入.
        if (selector == @selector(initialize)) {
            continue;
        }

        // 如果該類已經實現了這個方法,就跳過注入,不至於覆蓋使用者自定義的實現.
        if (class_getInstanceMethod(metaclass, selector)) {
            continue;
        }

        IMP imp = method_getImplementation(method);
        const char *types = method_getTypeEncoding(method);
        if (!class_addMethod(metaclass, selector, imp, types)) {
            fprintf(stderr, "ERROR: Could not implement class method +%s from concrete protocol %s on class %s\n",
                sel_getName(selector), protocol_getName(protocol), class_getName(class));
        }
    }

    // 管理記憶體
    free(imethodList); imethodList = NULL;
    free(cmethodList); cmethodList = NULL;

    // 允許使用者在容器類裡複寫 +initialize 方法,這裡呼叫是保證使用者複寫的實現能夠被執行.
    (void)[containerClass class];
}
複製程式碼

我們再看一下在 +load 之後 main 之前呼叫的 ext_loadConcreteProtocol 方法。

void ext_loadConcreteProtocol (Protocol *protocol) {
    ext_specialProtocolReadyForInjection(protocol);
}

void ext_specialProtocolReadyForInjection (Protocol *protocol) {
    @autoreleasepool {
        NSCParameterAssert(protocol != nil);
        
        // 加鎖
        if (pthread_mutex_lock(&specialProtocolsLock) != 0) {
            fprintf(stderr, "ERROR: Could not synchronize on special protocol data\n");
            return;
        }

        // 檢查要對應的 protocol 是否已經載入進上面的連結串列中了,如果找到了,就將對應的 EXTSpecialProtocol 結構體的 ready 置為 YES.
        for (size_t i = 0;i < specialProtocolCount;++i) {
            if (specialProtocols[i].protocol == protocol) {
                if (!specialProtocols[i].ready) {
                    specialProtocols[i].ready = YES;
                    assert(specialProtocolsReady < specialProtocolCount);
                    if (++specialProtocolsReady == specialProtocolCount)
						   // 如果所有的 EXTSpecialProtocol 結構體都準備好了,就開始執行注入.
                        ext_injectSpecialProtocols();
                }

                break;
            }
        }
        pthread_mutex_unlock(&specialProtocolsLock);
    }
}
複製程式碼

上面都是準備工作,接下來開始進入核心方法進行注入。

static void ext_injectSpecialProtocols (void) {
    // 對協議進行排序.
	  // 比方說 A 協議繼承自 B 協議,但是不一定是 B 協議對應的容器類的  load 方法先執行,A 的後執行. 所以如果 B 協議的類方法中複寫了 A 協議中的方法,那麼應該保證 B 協議複寫的方法被注入,而不是 A 協議的容器方法的實現.
	  // 為了保證這個循序,所以要對協議進行排序,上面說的 A 繼承自 B,那麼循序應該是 A 在 B 前面.
    qsort_b(specialProtocols, specialProtocolCount, sizeof(EXTSpecialProtocol), ^(const void *a, const void *b){
        if (a == b)
            return 0;

        const EXTSpecialProtocol *protoA = a;
        const EXTSpecialProtocol *protoB = b;

        int (^protocolInjectionPriority)(const EXTSpecialProtocol *) = ^(const EXTSpecialProtocol *specialProtocol){
            int runningTotal = 0;

            for (size_t i = 0;i < specialProtocolCount;++i) {
                if (specialProtocol == specialProtocols + i)
                    continue;

                if (protocol_conformsToProtocol(specialProtocol->protocol, specialProtocols[i].protocol))
                    runningTotal++;
            }

            return runningTotal;
        };

        return protocolInjectionPriority(protoB) - protocolInjectionPriority(protoA);
    });

	  // 獲取專案中所有的類 ???.
    unsigned classCount = objc_getClassList(NULL, 0);
    if (!classCount) {
        fprintf(stderr, "ERROR: No classes registered with the runtime\n");
        return;
    }

	Class *allClasses = (Class *)malloc(sizeof(Class) * (classCount + 1));
    if (!allClasses) {
        fprintf(stderr, "ERROR: Could not allocate space for %u classes\n", classCount);
        return;
    }
	classCount = objc_getClassList(allClasses, classCount);

    @autoreleasepool {
        // 遍歷所有的要注入的協議結構體.
        for (size_t i = 0;i < specialProtocolCount;++i) {
            Protocol *protocol = specialProtocols[i].protocol;
            
            // 使用 __bridge_transfer 把物件的記憶體管理交給 ARC.
            ext_specialProtocolInjectionBlock injectionBlock = (__bridge_transfer id)specialProtocols[i].injectionBlock;
            specialProtocols[i].injectionBlock = NULL;

            // 遍歷所有的類 ???.
            for (unsigned classIndex = 0;classIndex < classCount;++classIndex) {
                Class class = allClasses[classIndex];
                
                // 如果這個類遵守了要注入的協議,那麼就執行注入.
				  // 注意: 這裡是 continue 不是 break,因為一個類可以注入多個協議的方法.
                if (!class_conformsToProtocol(class, protocol))
                    continue;
                
                injectionBlock(class);
            }
        }
    }

    // 管理記憶體.
    free(allClasses);
    free(specialProtocols); specialProtocols = NULL;
    specialProtocolCount = 0;
    specialProtocolCapacity = 0;
    specialProtocolsReady = 0;
}
複製程式碼

這一路看下來,原理看的明明白白,是不是也沒什麼特別的,都是 runtime 的知識。但是這個思路確實是 666。

04.3. 問題在哪?

這不挺好的嗎?別人也分析過這個框架的原始碼,我再寫一遍有什麼意義?

這問題挺好,確實是這樣,如果一切順利,我這篇文章沒有存在的意義。接下來看一下問題出現在哪?

看到我剛才的註釋了嗎?這個笑臉很燦爛。如果專案不大,比如專案只有幾百個類,這些都沒有問題的,但是我們專案有接近 30000 個類,沒錯,是三萬。我們使用注入的地方有幾十上百處,兩套 for 迴圈算下來是一個百萬級別的。而且 objc_getClassList 這個方法是非常耗時的而且沒有快取。

 // 獲取專案中所有的類 ???.
// 遍歷所有的類 ???.
複製程式碼

在貝聊專案上,這個方法在我的 iPhone 6s Plus 上要耗時一秒,在更老的 iPhone 6 上耗時要 3 秒,iPhone 5 可以想象要更久。而且隨著專案迭代,專案中的類會越來越多, 這個耗時也會越來越長。

這個耗時是 pre-main 耗時,就是使用者看那個白屏啟動圖的時候在做這個操作,嚴重影響使用者體驗。我們的產品就因為這個點導致閃屏廣告展示出現問題,直接影響業務。

05. 解決方案

從上面的分析可以知道,導致耗時的原因就是原框架獲取所有的類進行遍歷。其實這是一個自動化的牛逼思路,這也是這個框架高於前面兩個框架的核心原因。但是因為專案規模的原因導致這個點成為了實踐中的短板,這也是作者始料未及的。

那我們怎麼優化這個點呢?因為要注入方法的類沒有做其他的標記,只能掃描所有的類,找到那些遵守了這個協議的再進行注入,這是要注入的類和注入行為的唯一聯絡點。從設計的角度來說,如果要主動實現注入,確實是這樣的,沒有更好方案來實現相同的功能。

但是有一個下策,能顯著提高這部分效能,就是退回到上面兩個框架所做的那樣,讓使用者自己去標識哪些類需要注入。這樣我把這些需要注入的類放到一個集合裡,遍歷注入,這樣做效能是最好的。如果我從頭設計一個方案,這也是不錯的選擇。

但是我現在做不了這些,我專案裡有好幾百個地方用了注入,如果我採用上面的方式,我要改好幾百個地方。這樣做很低效,而且我也不能保證我眼睛不會花出個錯。我只能選擇自動化去做這個事。

如果換個思路,我不主動注入,我懶載入,等你呼叫注入的方法我再執行注入操作呢?如果能實現這個,那問題就解決了。

[貝聊科技]一次高效的依賴注入

    1. 開始我們仍然在 +load 方法中做準備工作,和原有的實現一樣,把所有的協議都存到連結串列中。
    1. __attribute__((constructor)) 中仍然做是否能執行注入的檢查。
    1. 現在我們 hook NSObject+resolveInstanceMethod:+resolveClassMethod:
    1. 在 hook 中進行檢查,如果該類有遵守了我們實現了注入的協議,那麼就給該類注入容器中的方法。

對了,程式碼和 demo 我放這裡了,需要的可以下載看下。

06. 最後插播一個招聘廣告

貝聊科技招聘 iOS 開發工程師,座標廣州。如果你想和我一起共事,機會不等人,趕緊動手吧!簡歷發到 13246884282@163.com

相關文章