作者|周吾昆
前言
近三年,抖音直播业务实现了爆发式增长,直播间的功能也增添了许多的可玩性。为了高效满足业务快速迭代的诉求,抖音直播非常深度的使用了依赖注入架构。
在软件工程中,依赖注入(dependency injection)的意思为:给予调用方它所需要的事物。
“依赖”是指可被方法调用的事物。依赖注入形式下,调用方不再直接使用“依赖”,取而代之是“注入” 。
“注入”是指将“依赖”传递给调用方的过程。在“注入”之后,调用方才会调用该“依赖”。
传递依赖给调用方,而不是让让调用方直接获得依赖,这个是该设计的根本需求。该设计的目的是为了分离调用方和依赖方,从而实现代码的高内聚低耦合,提高可读性以及重用性。
本文试图从原理入手,讲清楚什么是依赖,什么是反转,依赖反转与控制反转的关系又是什么?一个依赖注入框架应该具备哪些能力?抖音直播又是如何通过依赖注入优雅的实现模块间的解耦?通过对依赖注入架构优缺点的分析,能对其能有更全面的了解,为后续的架构设计工作带来更多的灵感。
什么是依赖
对象间依赖
面向对象设计及编程的基本思想,简单来说就是把复杂系统分解成相互合作的对象,这些对象类通过封装以后,内部实现对外部是透明的,从而降低了解决问题的复杂度,而且服务可以灵活地被重用和扩展。而面向对象设计带来的最直接的问题,就是对象间的依赖。
我们举一个开发中最常见的例子:
在 A 类里用到 B 类的实例化构造,就可以说 A 依赖于 B。软件系统在没有引入 IOC 容器之前,对象 A 依赖于对象 B,那么对象 A 在初始化或者运行到某一点的时候,自己必须主动去创建对象 B 或者使用已经创建的对象 B。无论是创建还是使用对象 B,控制权都在 A 自己手上。
这个直接依赖会导致什么问题?
过渡暴露细节
- A 只关心 B 提供的接口服务,并不关心 B 的内部实现细节,A 因为依赖而引入 B 类,间接的关心了 B 的实现细节
对象间强耦合
- B 发生任何变化都会影响到 A,开发 A 和开发 B 的人可能不是一个人,B 把一个 A 需要用到的方法参数改了,B 的修改能编译通过,能继续用,但是 A 就跑不起来了
扩展性差
- A 是服务使用者,B 是提供一个具体服务的,假如 C 也能提供类似服务,但是 A 已经严重依赖于 B 了,想换成 C 非常之困难
学过面向对象的同学马上会知道可以使用接口来解决上面几个问题。如果早期实现类 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< ComponentInterface >的对象,而不是依赖具体类的对象)。
但创建对象时的依赖的问题仍然存在,ComponentLoader 内部直接创建了对应类的实例,因此依赖于 ComponentA,ComponentB,ComponentC 等具体的类。
如何解决创建对象时的依赖?把这个任务交给专业的人去做,由第三方进行创建:如工厂,IoC 容器...
这里创建逻辑就发生了反转,即将「对象的创建」这一逻辑转移到了第三方身上。
就好像 Apple 的 Homekit 不负责生产具体的产品,也不负责将这些产品接入到 Homekit 的系统中。谁来做呢?生产产品是由具体产品的工厂来做,接入是由具体产品的工程师来做。
使用更通用的结构图表述:
这样做有什么好处呢?
符合单一原则
- 该类只处理自己的本职工作,不负责其依赖对象的创建
高可测试性
- 通过接口,可以方便的进行单元测试
提高类的稳定性
- 减少了依赖的类,依赖的类出错不影响本类的正常编译
依赖反转与控制反转的关系
依赖反转(DIP)是设计原则,控制反转(IoC)只是原则或模式,并没有提供具体的实现措施。控制反转与依赖反转没有直接关系。
IoC 是 DIP 的实现吗?我认为不是的。它们分别描述了两个方面的原则
- DIP 原则是「架构设计方面」的原则,给不同模块(高层模块,底层模块)应当如何设计提供了范式(对应上图中橙色的箭头)
- IoC 原则是指导「代码如何编写」的原则,是控制流(业务逻辑)如何实现方面的原则(对应上图中蓝色的箭头)
使用 IoC 原则,并不意味着一定会使用 DIP:
- 可以在任意地方使用其他方式(如工厂模式)进行对象的创建,而不一定是在依赖对象与被依赖对象之间。
- IoC 原则也没有规范创建对象的类型。通过 IoC 容器创建出来的也可以是一个具体的类的实例,并不是依赖抽象接口的实现,因而也不一定满足 DIP 所要求的的「依赖于抽象」
同样,使用 DIP 原则也不一定会使用 IoC:
- 参考依赖反转章节中的代码中,虽然 ComponentA,ComponentB,ComponentC 都使用 DIP 原则依赖了接口,但 a,b,c 的实例仍然是由 ComponentLoader 来创建的,因此并没有控制反转发生
但 IoC 可以和 DIP 一起使用,即,使用 IoC 来解决 DIP 中底层组件的创建和与高层组件的注入、绑定等问题。这样可以最大程度解决类耦合的问题,得到一个纯净无污染的类。
依赖注入框架原理
依赖注入框架的能力
依赖注入是控制反转( IoC)原则的一种具体实现方式,具体来说,是创建依赖对象反转的实现方式之一。
依赖注入的目的,是为了将「依赖对象的创建」与「依赖对象的使用」分离,通俗讲就是使用方不负责服务的创建。
依赖注入将对象的创建逻辑,转移到了依赖注入框架中。一个类只需要定义自己的依赖,然后直接使用该依赖就可以,依赖注入框架负责创建、绑定、维护被依赖对象的生命周期。
一个 DI 框架一般需要具备这些能力:
依赖关系的配置
- 被依赖的对象与其实现协议之间的映射关系
依赖对象生命周期的管理
- 注入对象的创建与销毁
依赖对象的获取
- 通过依赖对象绑定的协议,获取到对应的对象
依赖对象的注入
- 即被依赖的对象如何注入到使用者内
下面就这四种能力分别展开讨论。
依赖关系配置
依赖关系的配置常见的有以下几种方式:
- 编译时配置
- 链接时配置
- +load 方法配置
- 运行时配置
- 代码配置
编译时配置
既然是只需要一份配置关系,那么可以将该配置关系在编译时写到 Mach-O 的 __DATA 段中,运行时需要用到的时候进行懒加载获取即可。
写配置关系也有多种方法:
- 写入 protocol 和 Class 的映射关系,需要用的时候,根据 protocol 读出来 Class,再进行对象的创建
- 定义一个函数,负责创建对象。写入的是 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();
优点
- 轻量,没有注册,没有运行时的映射表
- 编译期检查比较完善,如果 Service 没有实现创建对象的方法,那么在链接的时候因为 C 方法会找不到而报错
缺点
- 调用 C 方法之前必须 extern,所以在.m 的最开始需要写对应的宏
- 每个 service 都需要实现对应的创建对象方法,使用不友好
+load 方法配置
即在类的+load方法中进行注册,将protocol与imp绑定。
+ (void)load {
BIND(protocol, imp);
}
缺点
- load 在 main 函数之前执行,影响启动速度,且崩溃后自研 Crash 监控平台捕获不到
- load 方法执行顺序依赖于该类的链接顺序,假如有其他使用者在 load 里获取 Service,很可能获取的时候 Service 还没有注册
- load 方法只执行一次,在服务被销毁后,无法提供重新注入能力
开源 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 容器创建对象有两种时机
- 隐式创建,DI 容器在某个特定时机创建对象,比如 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 容器就会被销毁,其内维护的组件便也一并跟着释放。
通过这种多容器的设计,实现了不同直播间的隔离。
依赖注入的优缺点
优点
稳定性好
- 两个存在依赖关系的对象,只有在真正使用时,两者才发生联系。所以,无论两者中的任何一方出现什么的问题,都可以做到不影响另一方的开发与调试。例如心愿单模块依赖了手势模块,在手势模块存在代码问题无法运行时,可以通过修改配置去掉手势模块的加载,心愿模块依然能正常运行,只是涉及到手势相关功能存在问题而已,不会影响到整体的开发调试。
可维护性好
- 代码中的每一个 Class 都可以单独测试,彼此之间互不影响,只要保证自身的功能无误即可。我们可以通过 mock 依赖对象来代替需要注入的对象,进行单元测试。
耦合性低,开发提效
- 各个模块间解耦后,只需要定义并遵守相应的协议,无需关心其他模块的细节。每个开发团队的成员都只需要关心实现自身的业务逻辑,完全不用去关心其它的人工作进展,因为你的任务跟别人没有任何关系,你的任务可以单独测试,你的任务也不用依赖于别人的组件,再也不用扯不清责任了。所以,在一个大中型项目中,团队成员分工明确、责任明晰,很容易将一个大的任务划分为细小的任务,开发效率和产品质量必将得到大幅度的提高。
复用性好
- 我们可以把具有普遍性的常用组件独立出来,反复利用到项目中的其它部分,或者是其它项目,当然这也是面向对象的基本特征。显然,IOC 不仅更好地贯彻了这个原则,提高了模块的可复用性。符合接口标准的实现,都可以插接到支持此标准的模块中。
支持热插拔
- IOC 生成对象的方式转为外置方式,也就是把对象生成放在配置文件里进行定义,这样,当我们增删一个模块,或者更换一个实现子类,将会变得很简单,只要修改配置文件就可以了,完全具有热插拨的特性。
缺点
使用 IOC 框架产品能够给我们的开发过程带来很大的好处,但是也要充分认识引入 IOC 框架的缺点,做到心中有数,杜绝滥用框架。
提高了上手成本
- 软件系统中由于引入了第三方 IOC 容器,生成对象的步骤变得有些复杂,本来是两者之间的事情,又凭空多出一道手续,所以,我们在刚开始使用 IOC 框架的时候,会感觉系统变得不太直观。所以,引入了一个全新的框架,就会增加团队成员学习和认识的培训成本,并且在以后的运行维护中,还得让新同学具备同样的知识体系。
引入不成熟框架带来风险
- IOC 框架产品本身的成熟度需要进行评估,如果引入一个不成熟的 IOC 框架产品,那么会影响到整个项目,所以这也是一个隐性的风险。例如直播中台很早就引入了 IESDI 库,但是 IESDI 存在一些能力缺陷,比如做不到不同 DI 容器之间的隔离。因为 IESDI 在项目中被深度使用,如果换 DI 框架会带来涉及面很广的改动,风险不可控,所以只能基于现有框架进行修补,因此也会带来一些比较 trick 的逻辑。
对运行效率带来一定影响
- 由于 IOC 容器生成对象有些是通过反射的方式,在运行效率上有一定的损耗。如果要追求运行效率的话,就必须对此进行权衡。
通过对优缺点的分析,我们大体可以得出这样的结论:
一些工作量不大的项目或者产品,不太适合使用 IOC 框架产品。另外,如果团队成员的知识能力欠缺,对于 IOC 框架产品缺乏深入的理解,也不要贸然引入,可能会带来额外的风险与成本。
但如果你经历的是一个复杂度较高的项目,需要通过组件化、模块化等形式来降低耦合,提高开发效率,那么依赖注入就值得被纳入考虑范围,或许你会得到不一样的开发体验。
结语
得益于依赖注入框 架强大的解耦能力,在实现抖音直播间这种复杂的功能聚合型页面时,仍然能保持高效的组织协作与模块分工,为高质量的业务迭代与追求极致的用户体验提供稳固的基础技术基石。在抖音如此庞大的 APP 里做架构层面的重构,任何风吹草动都可能伤筋动骨,这就要求我们在做架构设计时,多抬头看看前方的路。我们不提倡过度设计,但是时刻保持思考,始终创业,才能让架构伴随业务一起成长,共述华章。