动手点关注 干货不迷路 👆
近三年,抖音直播业务实现了爆发式增长,直播间的功能也增添了许多的可玩性。为了高效满足业务快速迭代的诉求,抖音直播非常深度的使用了依赖注入架构。
在软件工程中,依赖注入(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
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 (Inversion of Control)容器等等该上场了……
一个类的实现需要依赖其他的类,那么其他类就是该类的依赖。依赖分两部分:
上面依赖反转的代码示例中,使用对象时的依赖,实质上已经通过依赖反转得到了解决(self.components 类型声明的是 id< ComponentInterface >的对象,而不是依赖具体类的对象)。
但创建对象时的依赖的问题仍然存在,ComponentLoader 内部直接创建了对应类的实例,因此依赖于 ComponentA,ComponentB,ComponentC 等具体的类。
如何解决创建对象时的依赖?把这个任务交给专业的人去做,由第三方进行创建:如工厂,IoC 容器...
这里创建逻辑就发生了反转,即将「对象的创建」这一逻辑转移到了第三方身上。
就好像 Apple 的 Homekit 不负责生产具体的产品,也不负责将这些产品接入到 Homekit 的系统中。谁来做呢?生产产品是由具体产品的工厂来做,接入是由具体产品的工程师来做。
使用更通用的结构图表述:
这样做有什么好处呢?
依赖反转(DIP)是设计原则,控制反转(IoC)只是原则或模式,并没有提供具体的实现措施。控制反转与依赖反转没有直接关系。
IoC 是 DIP 的实现吗?我认为不是的。它们分别描述了两个方面的原则
使用 IoC 原则,并不意味着一定会使用 DIP:
同样,使用 DIP 原则也不一定会使用 IoC:
ComponentA
,ComponentB
,ComponentC
都使用 DIP 原则依赖了接口,但 a,b,c 的实例仍然是由 ComponentLoader 来创建的,因此并没有控制反转发生但 IoC 可以和 DIP 一起使用,即,使用 IoC 来解决 DIP 中底层组件的创建和与高层组件的注入、绑定等问题。这样可以最大程度解决类耦合的问题,得到一个纯净无污染的类。
依赖注入是控制反转( IoC)原则的一种具体实现方式,具体来说,是创建依赖对象反转的实现方式之一。
依赖注入的目的,是为了将「依赖对象的创建」与「依赖对象的使用」分离,通俗讲就是使用方不负责服务的创建。
依赖注入将对象的创建逻辑,转移到了依赖注入框架中。一个类只需要定义自己的依赖,然后直接使用该依赖就可以,依赖注入框架负责创建、绑定、维护被依赖对象的生命周期。
一个 DI 框架一般需要具备这些能力:
依赖关系的配置
被依赖的对象与其实现协议之间的映射关系 依赖对象生命周期的管理
注入对象的创建与销毁 依赖对象的获取
通过依赖对象绑定的协议,获取到对应的对象 依赖对象的注入
即被依赖的对象如何注入到使用者内
下面就这四种能力分别展开讨论。
依赖关系的配置常见的有以下几种方式:
既然是只需要一份配置关系,那么可以将该配置关系在编译时写到 Mach-O 的 __DATA 段中,运行时需要用到的时候进行懒加载获取即可。
写配置关系也有多种方法:
相关原理可以参考《一种延迟 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方法中进行注册,将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
会先创建一个ComponentLoader
,ComponentLoader
负责创建直播间中需要的组件。
如果一进直播间就一股脑加载几百个组件,一方面会因为设备性能瓶颈导致首屏体验慢,另一方面每个组件加载的耗时存在差异,展示的优先级也有差别,同时加载必然带来不好的用户观感体验。
因此针对这几百个组件,设计了优先级的划分,按优先级分批次进行组件的创建与加载,来保障丝滑的首屏秒开体验。
DI 容器隔离
依赖注入框架的本质是一个单例来维护协议与实现协议的对象之间的映射关系,单例也就意味着全局独一份。如果业务相对比较清晰,处理好注入对象的生命周期管理,使用单例来管理,清晰明了简单易用,也没什么大问题。但是在抖音直播这种大型的业务上面,业务场景过于复杂,单例带来的维护成本也会显著上升。
单例最致命的问题是在于:所有服务都会注册到同一个的 DI 容器中,若存在多个直播间,多直播间之间的服务很难做到优雅的隔离。
例如直播间上下滑场景,滑动过程中会同时存在两个直播间,两个直播间都存在礼物组件,这两个礼物组件需要在同一个 DI 容器中被管理。
同一容器中多直播间之间同类对象的区分管理,会带来比较大的复杂度与维护成本。
由于抖音直播过早地、很深地依赖了依赖注入框架,当发现它本身的限制性时,已经很难把原有框架替换掉,只能在原有功能基础上进行能力迭代。
最终的解决方案是:分层与隔离。我们设计了多层的 DI 容器来实现隔离
直播通用的服务,注册到 LiveDI 容器中,如配置下发服务、用户信息服务等;
单个房间级别的服务,注册到 RoomDI 容器中,如一般的直播间内组件(礼物、红包等)。
通常情况下,同时只存在一个 LiveDI 容器跟一个 RoomDI 容器。
在直播间上下滑场景中,会同时存在两个 RoomDI 容器,这两个容器之间实现互相隔离。如上一个直播间中的礼物组件与下一个新直播间的礼物组件是两个独立的对象,分别注册在两个独立的 RoomDI 容器中,当新直播间完全展示时,消失直播间的 RoomDI 容器就会被销毁,其内维护的组件便也一并跟着释放。
通过这种多容器的设计,实现了不同直播间的隔离。
使用 IOC 框架产品能够给我们的开发过程带来很大的好处,但是也要充分认识引入 IOC 框架的缺点,做到心中有数,杜绝滥用框架。
通过对优缺点的分析,我们大体可以得出这样的结论:
一些工作量不大的项目或者产品,不太适合使用 IOC 框架产品。另外,如果团队成员的知识能力欠缺,对于 IOC 框架产品缺乏深入的理解,也不要贸然引入,可能会带来额外的风险与成本。
但如果你经历的是一个复杂度较高的项目,需要通过组件化、模块化等形式来降低耦合,提高开发效率,那么依赖注入就值得被纳入考虑范围,或许你会得到不一样的开发体验。