深入淺出依賴注入及其在抖音直播中的應用

前言

近三年,抖音直播業務實現了爆發式增長,直播間的功能也增添了許多的可玩性。爲了高效滿足業務快速迭代的訴求,抖音直播非常深度的使用了依賴注入架構。

在軟件工程中,依賴注入(dependency injection)的意思爲:給予調用方它所需要的事物。

“依賴” 是指可被方法調用的事物。依賴注入形式下,調用方不再直接使用 “依賴”,取而代之是 “注入” 。

“注入”是指將 “依賴” 傳遞給調用方的過程。在 “注入” 之後,調用方纔會調用該“依賴”。

傳遞依賴給調用方,而不是讓讓調用方直接獲得依賴,這個是該設計的根本需求。該設計的目的是爲了分離調用方和依賴方,從而實現代碼的高內聚低耦合,提高可讀性以及重用性。

本文試圖從原理入手,講清楚什麼是依賴,什麼是反轉,依賴反轉與控制反轉的關係又是什麼?一個依賴注入框架應該具備哪些能力?抖音直播又是如何通過依賴注入優雅的實現模塊間的解耦?通過對依賴注入架構優缺點的分析,能對其能有更全面的瞭解,爲後續的架構設計工作帶來更多的靈感。

什麼是依賴

對象間依賴

面向對象設計及編程的基本思想,簡單來說就是把複雜系統分解成相互合作的對象,這些對象類通過封裝以後,內部實現對外部是透明的,從而降低了解決問題的複雜度,而且服務可以靈活地被重用和擴展。而面向對象設計帶來的最直接的問題,就是對象間的依賴。

我們舉一個開發中最常見的例子:

在 A 類裏用到 B 類的實例化構造,就可以說 A 依賴於 B。軟件系統在沒有引入 IOC 容器之前,對象 A 依賴於對象 B,那麼對象 A 在初始化或者運行到某一點的時候,自己必須主動去創建對象 B 或者使用已經創建的對象 B。無論是創建還是使用對象 B,控制權都在 A 自己手上。

這個直接依賴會導致什麼問題?

學過面向對象的同學馬上會知道可以使用接口來解決上面幾個問題。如果早期實現類 B 的時候就定義了一個接口,B 和 C 都實現這個接口裏的方法,這樣從 B 切換到 C 是不是就只需很小的改動就可以完成。

A 對 B 或 C 的依賴變成對抽象接口的依賴了,上面說的幾個問題都解決了。但是目前還是得實例化 B 或者 C,因爲 new 只能 new 對象,不能 new 一個接口,還不能說 A 徹底只依賴於接口了。從 B 切換到 C 還是需要修改代碼,能做到更少的依賴嗎?能做到 A 在運行的時候想切換 B 就 B,想切換 C 就 C,不用改任何代碼甚至還能支持以後切換成 D 嗎?

通過反射可以簡單實現上面的訴求。例如常用的接口NSClassFromString,通過字符串可以轉換成同名的類。通過讀取本地的配置文件,或者服務端下發的數據,通過 OC 的提供的反射接口得到對應的類,就可以做到運行時動態控制依賴對象的引入。

軟件系統的依賴

讓我們把視角放到更大的軟件系統中,這種依賴問題會更加突出。

在面向對象設計的軟件系統中,它的底層通常都是由 N 個對象構成的,各個對象或模塊之間通過相互合作,最終實現系統地業務邏輯。

如果我們打開機械式手錶的後蓋,就會看到與上面類似的情形,各個齒輪分別帶動時針、分針和秒針順時針旋轉,從而在錶盤上產生正確的時間。

上圖描述的就是這樣的一個齒輪組,它擁有多個獨立的齒輪,這些齒輪相互齧合在一起,協同工作,共同完成某項任務。我們可以看到,在這樣的齒輪組中,如果有一個齒輪出了問題,就可能會影響到整個齒輪組的正常運轉。

齒輪組中齒輪之間的齧合關係, 與軟件系統中對象之間的耦合關係非常相似。

對象之間的耦合關係是無法避免的,也是必要的,這是協同工作的基礎。功能越複雜的應用,對象之間的依賴關係一般也越複雜,經常會出現對象之間的多重依賴性關係,因此,架構師對於系統的分析和設計,將面臨更大的挑戰。對象之間耦合度過高的系統,必然會出現牽一髮而動全身的情形。

耦合關係不僅會出現在對象與對象之間,也會出現在軟件系統的各模塊之間。如何降低系統之間、模塊之間和對象之間的耦合度,是軟件工程永遠追求的目標之一。

控制反轉

爲了解決對象之間的耦合度過高的問題,軟件專家 Michael Mattson 1996 年提出了 IOC 理論,用來實現對象之間的 “解耦”,目前這個理論已經被成功地應用到實踐當中。

1996 年,Michael Mattson 在一篇有關探討面向對象框架的文章中,首先提出了 IOC (Inversion of Control / 控制反轉)這個概念。

IOC 理論提出的觀點大體爲:**藉助於 “第三方” 實現具有依賴關係的對象之間的解耦。**如下圖:

由於引進了中間位置的 “第三方”,也就是 IOC 容器,使得 A、B、C、D 這 4 個對象沒有了耦合關係,齒輪之間的傳動全部依靠“第三方” 了,全部對象的控制權全部上繳給 “第三方”IOC 容器,所以,IOC 容器成了整個系統的關鍵核心,它起到了一種類似“粘合劑” 的作用,把系統中的所有對象粘合在一起發揮作用,如果沒有這個 “粘合劑”,對象與對象之間會彼此失去聯繫,這就是有人把 IOC 容器比喻成“粘合劑” 的由來。

我們再來做個試驗:把上圖中間的 IOC 容器拿掉,然後再來看看這套系統:

我們現在看到的畫面,就是我們要實現整個系統所需要完成的全部內容。這時候,A、B、C、D 這 4 個對象之間已經沒有了耦合關係,彼此毫無聯繫,這樣的話,當你在實現 A 的時候,根本無須再去考慮 B、C 和 D 了,對象之間的依賴關係已經降低到了最低程度。所以,如果真能實現 IOC 容器,對於系統開發而言,這將是一件多麼美好的事情,參與開發的每一成員只要實現自己的類就可以了,跟別人沒有任何關係!

軟件系統在引入 IOC 容器之後,對象間依賴的情況就完全改變了,由於 IOC 容器的加入,對象 A 與對象 B 之間失去了直接聯繫,所以,當對象 A 運行到需要對象 B 的時候,IOC 容器會主動創建一個對象 B 注入到對象 A 需要的地方

通過前後的對比,我們不難看出來:對象 A 獲得依賴對象 B 的過程, 由主動行爲變爲了被動行爲,控制權顛倒過來了,這就是 “控制反轉” 這個名稱的由來。

依賴反轉與控制反轉

沒有反轉

當我們考慮如何去解決一個高層次的問題的時候,我們會將其拆解成一系列更細節的較低層次的問題,再將每個較低層次的問題拆解爲一系列更低層次的問題,這就是業務邏輯(控制流)的走向,是「自頂向下」的設計。

如果按照這樣的拆解問題的思路去組織我們的代碼,那麼代碼架構的走向也就和業務邏輯的走向一致了,也就是沒有反轉的情況。

沒有依賴反轉的情況下,系統行爲決定了控制流,控制流決定了代碼的依賴關係

以抖音直播爲例:直播有房間的概念,房間內包含多個功能組件。對應的,代碼裏有一個房間服務的控制器類(如RoomController),一個組件管理的類(ComponentLoader),以及若干組件類(如紅包組件 RedEnvelopeComponent 、禮物組件 GiftComponent)。

進入直播房間時,先創建房間控制器,控制器會創建組件管理類,接着組件管理類會初始化房間內所有組件。這裏的描述就是業務邏輯(控制流)的方向。

如果按照沒有反轉的情況,控制流和代碼依賴的示意圖如下:

無反轉僞代碼示例如下:

@implementation RoomController

- (void)viewDidLoad {
    // 初始化房間服務
    self.componentLoader = [[ComponentLoader alloc] init];
    [self.componentLoader setupComponents];
}

@end

@implementation ComponentLoader

- (void)setupComponents {
    // 初始化所有房間組件
    ComponentA *a = [[ComponentA alloc] init];
    ComponentB *b = [[ComponentB alloc] init];
    ComponentC *c = [[ComponentC alloc] init];
    self.components = @[a, b, c];

    [a setup];
    [b setup];
    [c setup];
}

@end

@implementation ComponentA
- (void)setup {
}
@end

@implementation ComponentB
- (void)setup {
}
@end

@implementation ComponentC
- (void)setup {
}
@end

依賴反轉(DIP)

SOLID 原則之一:DIP(Dependency Inversion Principle)。這裏的依賴指的是代碼層面的依賴,上層模塊不應該依賴底層模塊,它們都應該依賴於抽象(上層模塊定義並依賴抽象接口,底層模塊實現該接口)。

反轉指的是:反轉源代碼的依賴方向,使其與控制流的方向相反

依賴反轉代碼示例如下:

@protocol ComponentInterface
- (void)setup;
@end

@interface ComponentA <ComponentInterface>
@end

@interface ComponentB <ComponentInterface>
@end

@interface ComponentC <ComponentInterface>
@end


@implementation ComponentLoader

- (void)setModules {
    // 初始化組件
    ComponentA *a = [[ComponentA alloc] init];
    ComponentB *b = [[ComponentB alloc] init];
    ComponentC *c = [[ComponentC alloc] init];
    self.components = @[a, b, c];

    for (NSObject<ComponentInterface> *aComponent in self.components) {
        [aComponent setup];
    }
}

@end

這樣做有什麼好處呢?

舉個例子:Apple 的智能家居系統定義了 Homekit 接口,但沒有依賴於任何一款具體的 Homekit 產品。任何滿足 Homekit 接口的產品,都可以自由接入智能家居的系統中。

但 DIP 原則只是提供了架構設計的原則,並沒有提供具體的實現措施。底層模塊由誰來創建?如何創建?如何與高層模塊進行注入和綁定?上面 👆🏻 藍色的箭頭如何處理?這就是 IoC 想要解決的問題。

控制反轉(IoC )

這裏的控制是指:**一個類除了自己的本職工作以外的邏輯。**典型的如創建其依賴的對象的邏輯。將這些控制邏輯移出這個類中,就稱爲控制反轉。

那麼這些邏輯由誰來實現呢?各種框架、工廠類、IoC (Inversion of Control)容器等等該上場了……

一個類的實現需要依賴其他的類,那麼其他類就是該類的依賴。依賴分兩部分:

上面依賴反轉的代碼示例中,使用對象時的依賴,實質上已經通過依賴反轉得到了解決(self.components 類型聲明的是 id 的對象,而不是依賴具體類的對象)。

但創建對象時的依賴的問題仍然存在,ComponentLoader 內部直接創建了對應類的實例,因此依賴於 ComponentA,ComponentB,ComponentC 等具體的類。

如何解決創建對象時的依賴?把這個任務交給專業的人去做,由第三方進行創建:如工廠,IoC 容器...

這裏創建邏輯就發生了反轉,即將「對象的創建」這一邏輯轉移到了第三方身上。

就好像 Apple 的 Homekit 不負責生產具體的產品,也不負責將這些產品接入到 Homekit 的系統中。誰來做呢?生產產品是由具體產品的工廠來做,接入是由具體產品的工程師來做。

使用更通用的結構圖表述:

這樣做有什麼好處呢?

依賴反轉與控制反轉的關係

依賴反轉(DIP)是設計原則,控制反轉(IoC)只是原則或模式,並沒有提供具體的實現措施。控制反轉與依賴反轉沒有直接關係。

IoC 是 DIP 的實現嗎? 我認爲不是的。它們分別描述了兩個方面的原則

使用 IoC 原則,並不意味着一定會使用 DIP:

同樣,使用 DIP 原則也不一定會使用 IoC:

但 IoC 可以和 DIP 一起使用,即,使用 IoC 來解決 DIP 中底層組件的創建和與高層組件的注入、綁定等問題。這樣可以最大程度解決類耦合的問題,得到一個純淨無污染的類。

依賴注入框架原理

依賴注入框架的能力

依賴注入是控制反轉( IoC)原則的一種具體實現方式,具體來說,是創建依賴對象反轉的實現方式之一。

依賴注入的目的,是爲了將「依賴對象的創建」與「依賴對象的使用」分離,通俗講就是使用方不負責服務的創建。

依賴注入將對象的創建邏輯,轉移到了依賴注入框架中。一個類只需要定義自己的依賴,然後直接使用該依賴就可以,依賴注入框架負責創建、綁定、維護被依賴對象的生命週期。

一個 DI 框架一般需要具備這些能力:

  • 依賴關係的配置

  • 被依賴的對象與其實現協議之間的映射關係

  • 依賴對象生命週期的管理

  • 注入對象的創建與銷燬

  • 依賴對象的獲取

  • 通過依賴對象綁定的協議,獲取到對應的對象

  • 依賴對象的注入

  • 即被依賴的對象如何注入到使用者內

下面就這四種能力分別展開討論。

依賴關係配置

依賴關係的配置常見的有以下幾種方式:

編譯時配置

既然是隻需要一份配置關係,那麼可以將該配置關係在編譯時寫到 Mach-O 的 __DATA 段中,運行時需要用到的時候進行懶加載獲取即可。

寫配置關係也有多種方法:

  1. 寫入 protocol 和 Class 的映射關係,需要用的時候,根據 protocol 讀出來 Class,再進行對象的創建

  2. 定義一個函數,負責創建對象。寫入的是 protocol 和該函數指針的映射關係。需要用的時候,根據 protocol 讀出來函數指針,直接進行調用

相關原理可以參考《一種延遲 premain code 的方法》。

鏈接時配置

也是將 protocol 和一個負責創建對象的函數進行綁定。不同的是不需要綁定函數指針,只需要配置和使用的地方對齊函數名,再通過 extern 進行調用即可,其本質是使用鏈接器完成了綁定的過程。

static inline id creator_testProtocol_imp(void) {
  id<TestProtocol> imp = [[TestClass alloc] init];
  return imp;
}

//實現
FOUNDATION_EXPORT id _di_provider_testProtocol(void) {
  return creator_testProtocol_imp();
}

//使用
extern id _di_provider_testProtocol(void);
id<Protocol> obj = _di_provider_testProtocol();

+load 方法配置

即在類的 + load 方法中進行註冊,將 protocol 與 imp 綁定。

+ (void)load {
  BIND(protocol, imp);
}

開源 DI 框架 objection 就是使用的該原理實現的綁定。

運行時配置

定義一個 DIContainer,創建與 protocol 同名的分類,利用 category,將 protocol 與實現類的綁定關係寫到 DIContainer 的方法列表裏(分類方法裏)。

TestProtocol爲例,當使用者通過調用 DIContainer 的prototypeObjectWithProtocol:方法將 Protocol 作爲參數傳入時,會通過約定的provideTestProtocol方法,獲取對應的實例對象。僞代碼如下:

@implementation DIContainer(TestProtocol)

//這裏將TestProtocol與創建的TestClass的對象imp進行了綁定
- (id<TestProtocol>)provideTestProtocol {
  id<TestProtocol> imp = [[TestClass alloc] init];
  return imp;
}

@end

@implementation DIContainer
//通過DIContainer的該方法獲取protocol對應綁定的實例對象
- (id)prototypeObjectWithProtocol:(Protocol *)protocol {
    id bean = [super prototypeObjectWithProtocol:protocol];
    if (bean) {
        return bean;
    } else {
        NSString *factoryMethodName = [NSString stringWithFormat:@"provide%@", NSStringFromProtocol(protocol)];
        SEL factorySEL = NSSelectorFromString(factoryMethodName);
        if ([self respondsToSelector:factorySEL]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
            return [self performSelector:factorySEL];
#pragma clang diagnostic pop
        } else {
            return nil;
        }
    }
}

@end

抖音直播也使用了一樣的原理來實現依賴關係的配置,只不過 provideXXX 方法不是寫在分類裏,而是寫在對應的協議實現者裏面,不管是寫在 category 裏還是寫在協議實現者類裏,本質上沒有區別,只不過是選擇集中式還是分散式管理。

代碼配置

代碼配置是一種靜態註冊方式,將配置關係的邏輯寫到代碼中,運行時在合適的時機進行關係的配置。

在抖音直播的使用場景中,很多 Component 都是在特定時機將 self 與其實現的 protocol 進行綁定。這就是屬於代碼配置的形式。

依賴對象生命週期管理

生命週期管理主要包括依賴對象的創建與銷燬。

依賴對象的創建

依賴對象被注入的前提,是需要先創建被依賴的對象。在完成依賴關係配置之後,就需要在適當的時機進行依賴對象的創建。

按照上文的要將「依賴對象的創建」與「依賴對象的使用」分離的目的,那麼依賴對象的創建就不能是依賴對象的使用者。

那麼誰來負責依賴對象的創建呢?通常有以下選擇:

DI 容器負責創建

DI 容器創建對象有兩種時機

管理者負責創建

通過專門的管理者來創建對象,被創建的對象,可以通過 DI 容器提供的setObject:forKey方法,將對象存儲在 DI 容器的字典裏,其中 key 一般爲 protocol 對應的字符串,value 爲傳入的對象。

在抖音直播裏,是由 ComponentLoader 來創建所有的 Component,然後在特定時機將 Component 與 protocol 進行綁定,最終就是調用的 DI 容器的setObject:forKey方法。

依賴對象的銷燬

DI 容器一般需要提供銷燬某個協議對應的注入對象的接口,同時也應該提供銷燬容器本身的接口。

例如在抖音直播中,從一個直播間切換到另一個直播間,上一個直播間的容器就應該被銷燬。

依賴對象獲取

DI 容器一般維護了一個 map,來存儲 protocol 與 imp 之間的映射關係,並且會提供通過 key 來獲取綁定對象的接口,這裏的 key 一般就是 protocol 的字符串來充當。而想要通過 protocol 獲取到對應的對象,前提是已經創建了對應的依賴對象,並且完成與 protocol 的綁定。

在 DI 容器隱式創建的情況下,首次進行依賴對象獲取,會觸發對象的懶加載完成對象的創建。

依賴對象注入

假如對象 A 需要使用對象 B 的能力,如果實現這個過程?

一般有兩種方式,一種是直接在 A 裏直接創建對象 B 並且使用它能力,另一種是通過注入的方式,將依賴對象 B 引入到對象 A 中再使用。

依賴注入通常有三種方式:

構造方法注入

在一個類的構造函數中,增加該類依賴的其他對象。

@interface ComponentLoader

- (instancetype)initWithInjectComponent:(id<TestProtocol>)component;

@end

接口注入

通過定義一個注入依賴的接口,進行依賴對象的注入。

缺點: 對象的注入時機不太可控,且中途外部能修改,存在隱藏風險。

@interface ComponentLoader

- (void)injectComponent:(id<TestProtocol>)component;

@end

取值注入

在使用依賴對象的地方通過 DI 提供的接口,獲取依賴對象並直接使用。

通過 DI 容器提供的接口,配合包裝的宏定義,我們可以輕鬆的獲取到對應的依賴對象,但是如果一個類中在多處依賴了該對象,就會在多處存在 DI 的宏,代碼層面上增加了對 DI 的依賴,因此可以把依賴對象聲明爲屬性,並通過 getter 方法對依賴對象的屬性進行賦值。

其僞代碼如下:

@interface ComponentLoader

@property (nonatomic, strong) id<TestProtocol> component;

@end

@implementation ComponentLoader

//將屬性component與TestProtocol綁定的的依賴注入對象進行關聯
XLink(component,TestProtocol)

//宏定義展開後的代碼爲
- (id<TestProtocol>)component {
  return xlink_get_property(@protocol(TestProtocol), (NSObject *)_component, @component, (NSObject *)self);
}
@end

依賴注入在抖音直播中的應用

抖音直播間將每個細分功能設計爲一個組件,將功能相近或關聯較強的組件打包到同一個模塊,通過模塊化、組件化的設計,來讓業務得到合理的粒度拆分。目前抖音直播設計有幾十個模塊,數百個組件。

抖音直播裏的依賴主要指的是一個組件依賴另一個組件提供的能力,而依賴注入的使用主要也是解決組件間的耦合問題。

組件的創建

在打開抖音直播間時,RoomController會先創建一個ComponentLoaderComponentLoader負責創建直播間中需要的組件。

如果一進直播間就一股腦加載幾百個組件,一方面會因爲設備性能瓶頸導致首屏體驗慢,另一方面每個組件加載的耗時存在差異,展示的優先級也有差別,同時加載必然帶來不好的用戶觀感體驗。

因此針對這幾百個組件,設計了優先級的劃分,按優先級分批次進行組件的創建與加載,來保障絲滑的首屏秒開體驗。

DI 容器隔離

依賴注入框架的本質是一個單例來維護協議與實現協議的對象之間的映射關係,單例也就意味着全局獨一份。如果業務相對比較清晰,處理好注入對象的生命週期管理,使用單例來管理,清晰明瞭簡單易用,也沒什麼大問題。但是在抖音直播這種大型的業務上面,業務場景過於複雜,單例帶來的維護成本也會顯著上升。

單例最致命的問題是在於:所有服務都會註冊到同一個的 DI 容器中,若存在多個直播間,多直播間之間的服務很難做到優雅的隔離。

例如直播間上下滑場景,滑動過程中會同時存在兩個直播間,兩個直播間都存在禮物組件,這兩個禮物組件需要在同一個 DI 容器中被管理。

同一容器中多直播間之間同類對象的區分管理,會帶來比較大的複雜度與維護成本。

由於抖音直播過早地、很深地依賴了依賴注入框架,當發現它本身的限制性時,已經很難把原有框架替換掉,只能在原有功能基礎上進行能力迭代。

最終的解決方案是:分層與隔離。我們設計了多層的 DI 容器來實現隔離

直播通用的服務,註冊到 LiveDI 容器中,如配置下發服務、用戶信息服務等;

單個房間級別的服務,註冊到 RoomDI 容器中,如一般的直播間內組件(禮物、紅包等)。

通常情況下,同時只存在一個 LiveDI 容器跟一個 RoomDI 容器。

在直播間上下滑場景中,會同時存在兩個 RoomDI 容器,這兩個容器之間實現互相隔離。如上一個直播間中的禮物組件與下一個新直播間的禮物組件是兩個獨立的對象,分別註冊在兩個獨立的 RoomDI 容器中,當新直播間完全展示時,消失直播間的 RoomDI 容器就會被銷燬,其內維護的組件便也一併跟着釋放。

通過這種多容器的設計,實現了不同直播間的隔離。

依賴注入的優缺點

優點

缺點

使用 IOC 框架產品能夠給我們的開發過程帶來很大的好處,但是也要充分認識引入 IOC 框架的缺點,做到心中有數,杜絕濫用框架。

通過對優缺點的分析,我們大體可以得出這樣的結論:

一些工作量不大的項目或者產品,不太適合使用 IOC 框架產品。另外,如果團隊成員的知識能力欠缺,對於 IOC 框架產品缺乏深入的理解,也不要貿然引入,可能會帶來額外的風險與成本。

但如果你經歷的是一個複雜度較高的項目,需要通過組件化、模塊化等形式來降低耦合,提高開發效率,那麼依賴注入就值得被納入考慮範圍,或許你會得到不一樣的開發體驗。

結語

得益於依賴注入框架強大的解耦能力,在實現抖音直播間這種複雜的功能聚合型頁面時,仍然能保持高效的組織協作與模塊分工,爲高質量的業務迭代與追求極致的用戶體驗提供穩固的基礎技術基石。

在抖音如此龐大的 APP 裏做架構層面的重構,任何風吹草動都可能傷筋動骨,這就要求我們在做架構設計時,多抬頭看看前方的路。我們不提倡過度設計,但是時刻保持思考,始終創業,才能讓架構伴隨業務一起成長,共述華章。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/W3A-VEbOfCgSiCdmcz6yCg