起因是,笔者在调研如何实现一套高效的UI换皮框架的时候,接触到了依赖注入的概念。所谓UI换皮,就是在UI的交互和显示文本内容信息等功能模块不变的情况下,仅仅是需要做美术资源的替换或者动画的修改。在笔者过往的开发过程中,UI的交互模块和动效模块并没有分割。那么就导致了一个问题:当项目进入一个大量美术风格测试的阶段时,比如,有十套美术风格要上线测试,按照之前的方案,笔者就要复制十分类似的预制体,然后为这十套预制体各自编写一套控制脚本,很麻烦的一点是,页面中的按钮、文本信息都是一样的,而我需要在十份不同的脚本中编写十次,并且,当其中某一个按钮的点击功能或者文本的内容发生改变时,我就要去改十份脚本中的内容,维护成本实在是太高了,因此笔者希望找到一种解决方案,能够将UI中的功能模块与动效模块隔离开,只需要编写一次功能模块,表现模块由于动画实现方式等原因必定是要定制的。 由此,笔者了解到了Zenject或许可以解决我们的问题。
我们的问题在于,在不同的页面中,按钮、文本等交互组件的节点是无法保证的,每一种美术风格如何向UI的交互模块的代码中传入正确的Button或Text组件呢?这就是我们的问题

依赖注入

依赖注入(Dependency Inject)本质上是一种设计模式,笔者按照个人的理解描绘一个场景帮助各位读者形成对依赖注入的初步认知:程序功能的实现依赖于各个类之间的协同配合,我们通过一些设计原则保证这些类的可维护性与可拓展性,设计原则中有一条原则名为单一职责原则(Single Responsibility Principle),现在有如下一个例子:

1
2
3
4
5
6
7
8
9
10
11
public class A{
private B _b;

public A(){
_b = new B();
}

public void DoSomeThings(){
_b.Func();
}
}

这样的写法虽然能够实现功能,但是该类的可拓展性被降低了,这是因为类A的单一职责原则被一定程度上破坏了,类A有两个职责,一是实例化成员_b,二是执行DoSomeThings方法。间接导致了这段代码的耦合变高、可拓展性变差。要解耦合也很简单,将类A的职责拆分,我们可以把成员b的定义_b的工作转移到该类外面去,然后通过构造方法传参的方式,将类B的实例化对象传入进来:
1
2
3
4
5
6
7
8
9
10
11
public class A{
private B _b;

public A(B b){
_b = b;
}

public void DoSomeThings(){
_b.Func();
}
}

按照这样的写法,只要任何继承自B的类,都可以复用这段脚本。想象一下,如果类A是一个页面相关逻辑交互的类,类B是组织页面上一些动画表现相关的类,对同一个页面来说,页面上的按钮交互功能、显示的文本信息内容基本上都是一样的只有一些表现形式上的不同,那么我们就可以使用这种方法,对于不同表现形式的页面,他们的逻辑交互是可以复用的,只需要在这个维护交互逻辑的类被实例化的时候将具体的表现形式的类传进来就可以了。

对依赖注入的误解

一开始接触依赖注入的概念时会感到很困惑,要完全理解依赖注入的思想需要时间和经验。

在上面的例子中,使用依赖注入的方式可以轻松切换一个给定接口的不同实现。然而这只是依赖注入所能提供的众多好处之一。更重要的是依赖注入的框架允许我们更容易遵循单一职责原则。通过让依赖注入框架来关心将类连接起来,每个类本身可以只关心履行自己的职责。

另一个对于刚刚接触DI的人所犯普遍的错误是,他们会从每个类里面提取接口,然后到处使用接口,而不是直接使用类。我们使用依赖注入的目标是让我们的代码解耦合,所以我们认为将一个类与接口绑定比将一个类与另一个类绑定是更加合理的。然而,在大多数情况下,程序中的各种功能都是由一个具体的类来实现他们的,提取接口的行为仅仅会是增加了不必要的维护成本。而且,具体类的公共成员已经通过它的公共成员定义了一个接口。一个好的经验法则是,只有在一个类有超过一个实现或者在未来会有多个实现的情况下才创建接口。顺带一提,这种原则叫作复用抽象原则(Reused Abstraction Principle)。

依赖注入的其他好处包括:

  • 可重构性(Refactorability)当代码是松耦合的时候,正确使用DI的时候就是这样,整个代码库会更加有弹性,我们可以完全修个代码库的某一个部分而不会对其他部分产生任何影响。
  • 鼓励模块化代码(Encourage modular code)使用DI框架时,你会很自然地遵循更好的设计实践,因为DI会迫使你去思考类之间的接口依赖关系。
  • 可测试性(Testability) 编写单元测试或者用户驱动测试变得十分容易,因为这只是编写不同组合根(composition root)的事情,通过编写不同的组合根就能组合不同的依赖。想要只测试一个子系统?只需要创建一个新的组合根就可以了。

Zenject概念

当我们在编写一个单独的类来实现某个功能的时候,它大概率需要

1
2
3
4
5
public class Foo
{
ISomeS

}

HelloWorld

本小节将使用Zenject框架编写一个HelloWorld,
按照Zenject的HelloWorld教程编写之后,确实能输出HelloWorld,不过笔者有几个疑问,首先是按照Zenject的官方说法,Zenject内部维护了一个Container,所有由Zenject创建的实例都会存放在这个Container中,那么我该如何访问到Container类中的实例呢?
下面再来一个例子:
在场景中添加一个空物体并挂载下面这个脚本,这个脚本中使用[Inject]特性标记了Construct方法,如果在Awake、Start和Construct方法上打断点的话,Construct会先于Awake执行,请注意,这是物体在游戏启动时就在场景中的情况,但是如果物体是在运行了一段时间后被动态加载上来的,执行下面这个脚本就会报错了,还需要看一下是什么原因。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
using System;
using UnityEngine;
using Zenject;

public class Test : MonoBehaviour
{
private Greeter _greeter;

private float _timer;
[Inject]
private void Construct(Greeter greeter)
{
_greeter = greeter;
}

private void Awake()
{
}

private void Start()
{

}

private void Update()
{
_timer += Time.deltaTime;
if (_timer >= 1f)
{
_greeter.Log();
}
}
}

几种注入方式

构造器注入

1
2
3
4
5
6
7
8
public class Foo{
IBar _bar;

public Foo(IBar bar){
_bar = bar;
}

}

字段注入

1
2
3
4
public class Foo{
[Inject]
IBar _bar;
}

字段的注入发生在构造函数调用之后。所有被标记了[Inject]特性的字段都会在容器中查找并给定一个值。无论这个字段是private还是public方法,注入都会发生

Property Injection

1
2
3
4
5
6
7
public class Foo{
[Inject]
public IBar Bar{
get;
private set;
}
}

PropertyInjection和字段的情况一样,同样的,无论setter/getter是public还是private注入都会发生

Method Injection

1
2
3
4
5
6
7
8
9
10
public class Foo{
IBar _bar;
Qux _qux;

[Inject]
public void Init(IBar bar, Qux qux){
_bar = bar;
_qux = qux;
}
}

Method Inject和构造函数注入十分相似

一些需要说明的:

  • 对于继承自MonoBehaviour的脚本来说,更推荐使用Method注入的方式,因为我们无法操作MonoBehaviour的构造函数。
  • 可以将任意数量的Method标记为Inject,不过这些Method被Inject的顺序是从基类到子类的。这种设计有利于避免,同时确保了基类中的方法首先完成注入,就跟构造方法的执行顺序一样。
  • InjectMethod在所有其他注入类型注入完成后完成注入
  • 你可以安全地假定(一种情况除外:你的代码中存在循环依赖)。
  • 需要注意的是,使用注入方法来执行初始化逻辑通常不是一个好的方法。更推荐的方式是使用IInitializable.Initialize或者Start()方法,因为这样允许你首先创建整个初始对象图。

推荐
跟字段/属性注入比起来,使用构造方法和属性注入的方式更加推荐

  • 构造方法注入强迫该构造方法的依赖只在该类被创建的时候指定一次,这通常是你想要的。在大多数的情况下,你并不想暴露一个公共的属性

向依赖注入容器中注册映射关系

依赖注入框架的核心就是DI container。 它最简单的形式就是一个持有所有注册项目的字典,本节我们将尝试使用,在Zenject中,这被叫做binding(绑定关系)。因为它在抽象类型和具体类型之间创建了绑定关系。

Binding

每个依赖注入框架的最终形态就是一个将类型和实例绑定起来的框架。

在Zenject中,依赖映射通过向一个名为container(容器)的结构中添加绑定关系来完成。添加完映射关系之后,这个容器就知道了如何在你的Application中创建所有的对象实例,通过递归地解决给定物体上的所有的依赖。

当这个容器被要求用一个给定的类型构建一个实例,这个容器使用反射来找到这个类的构造方法中的参数和所有被标记了[Inject]特性的字段/属性。然后这个容器会尝试解决每一个需要的它用来依赖,

因此每一个Zenject application必须告诉容器如何决定每一个依赖,通过Bind command的方式,比如下面这个例子:

1
2
3
4
5
6
7
8
9
public class Foo
{
IBar _bar;

public Foo(IBar bar){
_bar = bar;
}

}

我们可以使用下面的代码来连接这个类中的依赖项:

1
2
Container.Bind<Foo>.AsSingle();
Container.Bind<IBar>().To<Bar>.AsSingle();

这告诉Zenject每一个需要类型Foo的依赖应该使用同样的实例,这个实例将会在需要的时候自动创建。类似地,任何需要IBar和接口的类将会被分配同样的Bar类型的实例。

完整的Bind Command形式如下。需要指明大部分的情况我们将不回用到以下所有的方法,当没有指明调用某些方法的时候,将会使用默认值

1
2
3
4
5
6
7
8
9
10
11
Container.Bind<ContractType>()
.WithId(Identifier)
.To<ResultType>()
.FromConstructionMethod()
.AsScope()
.WithArguments(Arguments)
.OnInstantiated(InstantiatedCallback)
.When(Condition)
.(Copy|Move)Into(All|Direct)SubContainers()
.NonLazy()
.IfNotBound();

下面解释一下各个Method的含义:

  • ContractType: 那些在脚本中被标记了Inject的参数或者字段,其类型会表现为ContractType。
  • ResultType:
    默认值等于ContractType
    ResultType必须等价与ContractType或者派生自ContractType。
  • Identifier 该值可以给这个绑定操作一个独一无二的ID,不过在大部分情况下我们不需要进行给定ID的操作。此值将由指定的 ConstructionMethod 用于检索该类型的实例。
  • FromConstructionMethod
  • Scope

感悟

看了一段时间Zenject的代码,感受到了接口概念的引入带给面向对象开发带来的拓展性,比如下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class FooFactory : IFactory<Foo>
{
public Foo Create()
{
return new Foo();
}
}

public override void InstallBindings()
{
Container.Bind<Foo>().FromIFactory(x => x.To<FooFactory>().FromSubContainerResolve().ByMethod(InstallFooFactory)).AsSingle();
}

void InstallFooFactory(DiContainer subContainer)
{
subContainer.Bind<FooFactory>().AsSingle();
}

一个类只能够继承自一个类,对于像Zenject这种辅助性质的框架,它没必要知道每一个需要依赖注入的类到底是什么,以及它的具体细节,Zenject只关心这个类可以被依赖注入,如果为了引入Zenject框架而需要给所有要依赖注入的类都添加一个基类,那么代码的可拓展性就太差了,使用接口就没有这种问题了,Zenject可以通过持有接口的方式间接持有该类,同样的一个类可以实现多个接口,那么这个类就可以通过多种不同的接口来被间接地持有。

参考资料