Unity3d架构之-Unity MVC框架 StrangeIoC

 

由于工作原因最近在看unity的一个IOC框架:StrangeIOC,官方的文档都不是很好理解,找到了一篇比较好的GetStart文章,顺手翻译一下,一来方便自己加深理解,二来还是想共享出来,

Strange是一个unity3d中用于控制反转的第三方框架,控制反转(IOC-Inversion of Control)思想是类间解耦的一个重要方法,对于我来说,任何解耦技术都值得去学习。什么是IOC?这里有详细解答。IOC框架已经在企业级开发和其他非游戏软件的开发中成为了主流,并且可以说已经非常成熟。我觉得它可以帮助游戏开发变得更加容易测试,更好的进行协作开发。我非常想尝试它看看到底可以在游戏开发过程中起到多大的帮助程度。

Strange使用起来真的像他的名字一样,非常”奇怪”。我发现它对于初学者来说,使用起来真的非常”闹心”,比如你想试着去写一个”Hello World”都非常不容易。这里是StrangeIOC框架的说明页面,但是这上面并没有一个真正意义上的”新手引导”来帮助我们了解Strange的工作机制,这就是你现在看到现在这篇文章的意义-用StrangeIOC框架写一个HelloWorld。

一些提醒:

  • 在阅读本篇文章之前,最好先去上面提到的官方说明页面了解一下Strange框架的架构(看看它的每个部分的功能以及怎么整合到一块工作的)。
  • 这篇文档使用的是signal(消息)而非event(事件)(因为相比event我更喜欢signal)
  • 我不会把文档中的Unity项目提供出来,因为我希望大家自己动手去做,这样肯定会学到更多:)
  • 这个Hello World示例只是简单的提供注入绑定(injection binding)、命令绑定(command binding)、调解绑定(mediation binding)的示例。

Signal
建立一个空Unity项目,下载并且解压Strange框架到Assets文件夹中,我们只需要框架的脚本,把”examples”和”.doc”文件夹去除,在Unity的的结构应该是这样的:
Assets    
StrangeIoC
scripts

在Assets文件夹下创建”Game”文件夹,即用来创建Hello World示例的文件夹。文件夹的的结构应该是这样的:

Assets    
Game       
Scenes
Scripts在Scripts文件夹下新建名为HelloWorldSignals.cs的c#脚本,这个类将包含所有用到的signal,让我们coding起来:


using System;

using strange.extensions.signal.impl;

namespace Game {

public class StartSignal : Signal {}

}

在Strange中,这个signal的概念非常像观察者模式(observer pattern)中的事件(events)。在这里,它以命名类的方式实现了继承Strange的Signal类.别急,我们马上会看到怎么去使用它。

Strange采用”Contexts”的概念来识别不同的问题域或者子模块。在实际的游戏项目中,你可以有多个”Contexts”,比如游戏逻辑、资源、持久层、统计分析、社交模块等等。我们在这个实例中只用了一个”Context”。
一个预构建的context在Strange中称为MVCSContext,MVCSContext默认使用event机制,我们来创建另外一种context父类,改造成使用signal机制,我们其他的context要继承这个SignalContext。
在Scripts下创建名为SignalContext.cs的脚本:


using System;

using UnityEngine;

using strange.extensions.context.impl;
using strange.extensions.command.api;
using strange.extensions.command.impl;
using strange.extensions.signal.impl;

namespace Game {
public class SignalContext : MVCSContext {

/**
* Constructor
*/
public SignalContext (MonoBehaviour contextView) : base(contextView) {
}

protected override void addCoreComponents() {
base.addCoreComponents();

// bind signal command binder
injectionBinder.Unbind<ICommandBinder>();
injectionBinder.Bind<ICommandBinder>().To<SignalCommandBinder>().ToSingleton();
}

public override void Launch() {
base.Launch();
Signal startSignal = injectionBinder.GetInstance<StartSignal>();
startSignal.Dispatch();
}

}
}

在”Scripts”文件夹下创建一个新文件夹”Controller”,到这里有了一点MVC模式的特征。Strange作者建议我们应该以指令类(Command Class)的形式实现各个Controller接口,这个文件夹将包含所有的Command类,现在我们创建一个在StartSignal指令调用时执行的指令。在Controller文件夹下创建名为HelloWorldStartCommand.cs的类:


using System;

using UnityEngine;

using strange.extensions.context.api;
using strange.extensions.command.impl;

namespace Game {
public class HelloWorldStartCommand : Command {

public override void Execute() {
// perform all game start setup here
Debug.Log("Hello World");
}

}
}

现在我们为这个HelloWorld示例创建一个自定义的context类HelloWorldContext.cs:


[/color]
using System;

using UnityEngine;

using strange.extensions.context.impl;

namespace Game {
public class HelloWorldContext : SignalContext {

/**
* Constructor
*/
public HelloWorldContext(MonoBehaviour contextView) : base(contextView) {
}

protected override void mapBindings() {
base.mapBindings();

// we bind a command to StartSignal since it is invoked by SignalContext (the parent class) on Launch()
commandBinder.Bind<StartSignal>().To<HelloWorldStartCommand>().Once();
}

}
}

在这里,我们把StartSignal类绑定(bind)给了HelloWorldStartCommand类。这样在StartSignal的实例被调用时,HelloWorldStartCommand会进行实例化(instantiated)和执行(executed),注意在我们的示例中StartSignal信号会在SignalContext.Launch()方法中调用发出。

最后一步就是创建一个MonoBehaviour来在Unity中管理context,在Scripts文件夹下创建HelloWorldBootstrap.cs:

using System;

using UnityEngine;

using strange.extensions.context.impl;

namespace Game {
public class HelloWorldBootstrap : ContextView {

void Awake() {
this.context = new HelloWorldContext(this);
}

}
}

用于在Unity中管理Strange context的接口类通常命名为“xxxBootstrap”,当然这只是一个建议,如果你乐意你可以随意起名字。这里唯一需要注意的是继承Strange框架的ContextView类的类需要是一个MonoBehaviour,我们在Awake()里分配了一个我们自定义好的context实例给继承的变量”context”。
创建一个空场景命名为”HelloStrange”,创建一个EmptyObject命名为Bootstrap,把我们之前创建的HelloWorldBootstrap add上来。可以跑一下这个场景,之前程序正确的话,你应该看到控制台的”Hello World”输出了。

Injection in Mediator
到目前为止写这么一大堆东西只是输出一句“HelloWorld”,是不是被Strange搞得头都大了?其实做到现在这一步已经大致为你梳理出来Strange的一些机制了。首先我们有了一个能跑的context,从这一步开始,我们就可以添加view和相应的mediator,还可以使用injection binder把一个实例映射到一些可注入controllers/commands和mediators的接口中,而这些接口并不需要关心这个实例是怎么来的。接下来就是见证奇迹的时刻了!
一般我们做游戏编程的时候,会有一堆单例管理器(singleton managers)比如EnemyManager、AsteroidManager、CombatManager等等,假如需要同一个实例给任意一个管理器去调用有很多解决方案,比如我们可以使用GameObject.Find() 或者为这个类添加一个静态单例(GetInstance() static method),OK,让我们看看有了Strange以后,这样的情形可以怎么去解决:创建一个名为”ISomeManager”的接口,模拟一个上面说的那种manager,在Scripts文件夹创建ISomeManager.cs脚本


[/color]
namespace Game {
public interface ISomeManager {

/**
* Perform some management
*/
void DoManagement();

}
}

这就是我们示例当中的manager接口,注意:Strange的作者建议我们总是使用一个接口然后通过injectionBinder将它映射到一个真正的实现类,当然,你也可以使用多对多的映射。接下来我们创建一个具体实现类,在Scripts文件夹下创建ManagerAsNormalClass.cs脚本:

using System;

using UnityEngine;

namespace Game {
public class ManagerAsNormalClass : ISomeManager {

public ManagerAsNormalClass() {
}

#region ISomeManager implementation
public void DoManagement() {
Debug.Log("Manager implemented as a normal class");
}
#endregion

}
}

如果你仔细在看你可能会发现这是一个没有MonoBehaviour的manager,别急,一会再介绍怎么bind有MonoBehaviour的

现在我们来创建一个简单的交互场景,效果是当一个Button按下时,ISomeManager的DoManagement函数执行,这里我们有一个要求:用MVC思想—对controll层(ISomeManager)和view层(控制Button触发事件的脚本)完全解耦,view层只需要通知controll层:”hey!button被点击了”,至于接下来发生什么交由controll层进行逻辑处理。

现在缺一个view层,把它创建出来吧—在Game文件夹下创建”View”文件夹,创建HelloWorldView.cs脚本:

using System;

using UnityEngine;

using strange.extensions.mediation.impl;
using strange.extensions.signal.impl;

namespace Game {
public class HelloWorldView : View {

public Signal buttonClicked = new Signal();

private Rect buttonRect = new Rect(0, 0, 200, 50);

public void OnGUI() {
if(GUI.Button(buttonRect, "Manage")) {
buttonClicked.Dispatch();
}
}

}
}

这里继承的Strange框架中的View类已经包含了MonoBehaviour。所有使用Strange context的View层类都必须继承这个Strange的View类,我们刚刚创建的View类只有一个交互功能:在点击名为”Manage”的Button后,调用一个 generic signal(通用信号) 。

Strange作者建议对每个View创建对应的Mediator。Mediator是一个薄层,他的作用是让与之对应的View和整个程序进行交互。mediation binder的作用是把View映射到它对应的mediator上。所以接下来为View层创建对应的mediator—在”view”文件夹下创建HelloWorldMediator.cs脚本:

using System;

using UnityEngine;

using strange.extensions.mediation.impl;

namespace Game {
public class HelloWorldMediator : Mediator {

[Inject]
public HelloWorldView view {get; set;}

[Inject]
public ISomeManager manager {get; set;}

public override void OnRegister() {
view.buttonClicked.AddListener(delegate() {
manager.DoManagement();
});
}

}
}

在这段代码里我们可以看到神奇的”Inject”标注(Inject attribute)。这个”Inject”标注只能和变量搭配使用,当一个变量上面有”Inject”标注时,意味着Strange会把这个变量的一个实例自动注入到它对应映射的context中。据此从我们上面的代码来分析,在这里我们获取到了”view”和”manager”的实例,并且不用去关心这些个实例是怎么来的。

OnRegister()是一个可以被重写的方法,它用来标记实例注入完成已经可以使用了,它的意义主要是进行初始化,或者说做准备。在上面的类中,OnRegister方法中为HellowWorldView.buttonClicked signal添加了一个监听器,这个监听器的逻辑是按下就执行manager.DoManagement方法。

接下来就是最后的工作,我们需要把待绑的类映射到Strange Context中。打开我们之前写的HelloWorldContext脚本,在mapBindings()方法中添加代码:

protected override void mapBindings() {
base.mapBindings();

// we bind a command to StartSignal since it is invoked by SignalContext (the parent class) during on Launch()
commandBinder.Bind<StartSignal>().To<HelloWorldStartCommand>().Once();

// bind our view to its mediator
mediationBinder.Bind<HelloWorldView>().To<HelloWorldMediator>();

// bind our interface to a concrete implementation
injectionBinder.Bind<ISomeManager>().To<ManagerAsNormalClass>().ToSingleton();
}

在HelloWorld scene中,添加一个名为”View”的GameObject,add HelloWorldView 脚本,运行场景,你应该能看到当我们按下”Manage”按钮时,控制台输出”Manager implemented as a normal class”。
ISomeManager in action
你会发现Strange自动把HelloWorldMediator脚本挂载到了”View”GameObject上面。注意我们之前并没有手动把HelloWorldMediator脚本挂载到”View”GameObject上。
Where did the mediator came from?

MonoBehaviour Manager
大部分时候,我们需要类似于上面的manager但是实现类是一个MonoBehaviour,这样我们才能使用例如协程、序列化的Unity特性。
接下来创建实现MonoBehaviour接口的manager实例,看看怎么在Strange中进行bind。
创建一个实现MonoBehaviour接口的manager,在Script文件夹下,命名为ManagerAsMonobehaviour.cs

using System;

using UnityEngine;

namespace Game {
public class ManagerAsMonoBehaviour : MonoBehaviour, ISomeManager {

#region ISomeManager implementation
public void DoManagement() {
Debug.Log("Manager implemented as MonoBehaviour");
}
#endregion

}
}

在HelloStrangeScene中,创建一个新的GameObject名为”Manager”,add 上面创建好的 ManagerAsMonobehaviour脚本
编辑HelloWorldContext脚本的mapBindings()方法:

protected override void mapBindings() {
base.mapBindings();

// we bind a command to StartSignal since it is invoked by SignalContext (the parent class) during on Launch()
commandBinder.Bind<StartSignal>().To<HelloWorldStartCommand>().Once();

// bind our view to its mediator
mediationBinder.Bind<HelloWorldView>().To<HelloWorldMediator>();

// REMOVED!!!
//injectionBinder.Bind<ISomeManager>().To<ManagerAsNormalClass>().ToSingleton();

// bind the manager implemented as a MonoBehaviour
ManagerAsMonoBehaviour manager = GameObject.Find("Manager").GetComponent<ManagerAsMonoBehaviour>();
injectionBinder.Bind<ISomeManager>().ToValue(manager);
}

与把ISomeManager映射为一个类型相反,我们把这个ManagerAsMonobehaviour映射为一个实例值(instance value)。
Manager is now a MonoBehaviour

Injection in Command
到目前为止我们为HelloWorldMediator注入了一个ISomeManager的一个实例,并且可以直接使用它。这样做其实并不是很理想,一个Mediator应该是在view层和controller层之间的一个薄层。我们需要尽量使Mediator层不去关心应该在Manager类去做的那部分复杂的逻辑处理代码。虽然这么做也可以,我们还是用signal把这部分映射到command层吧。
编辑HelloWorldSignals.cs脚本,添加一个DoManagementSignal:

using System;

using strange.extensions.signal.impl;

namespace Game {

public class StartSignal : Signal {}

public class DoManagementSignal : Signal {} // A new signal!

}

我们创建command映射到signal:在Controller文件夹下创建一个脚本DoManagementCommand.cs

using System;

using UnityEngine;

using strange.extensions.context.api;
using strange.extensions.command.impl;

namespace Game {
public class DoManagementCommand : Command {

[Inject]
public ISomeManager manager {get; set;}

public override void Execute() {
manager.DoManagement();
}

}
}

在这个类,我们把ISomeManager注入到command类,并且在Execute方法中让它的DoManagement方法执行。
修改HelloWorldMediator类:

using System;

using UnityEngine;

using strange.extensions.mediation.impl;

namespace Game {
public class HelloWorldMediator : Mediator {

[Inject]
public HelloWorldView view {get; set;}

[Inject]
public DoManagementSignal doManagement {get; set;}

public override void OnRegister() {
view.buttonClicked.AddListener(doManagement.Dispatch);
}

}
}

现在我们的mediator类中已经没有任何对ISomeManager接口的调用了。取而代之的是要在mediator类获取到DoManagementSignal的实例,当button点击时,这个类会发出DoManagementSignal。mediator层不需要知道任何manager的事情,它只管发送信号(signal)出去。
最后,在HelloWorldContext.mapBindings()方法中添加这个signal-command映射。

protected override void mapBindings() {
base.mapBindings();

// we bind a command to StartSignal since it is invoked by SignalContext (the parent class) during on Launch()
commandBinder.Bind<StartSignal>().To<HelloWorldStartCommand>().Once();
commandBinder.Bind<DoManagementSignal>().To<DoManagementCommand>().Pooled(); // THIS IS THE NEW MAPPING!!!

// bind our view to its mediator
mediationBinder.Bind<HelloWorldView>().To<HelloWorldMediator>();

// bind the manager implemented as a MonoBehaviour
ManagerAsMonoBehaviour manager = GameObject.Find("Manager").GetComponent<ManagerAsMonoBehaviour>();
injectionBinder.Bind<ISomeManager>().ToValue(manager);
}

运行场景,效果和之前一样,但是我们在代码层面把这块代码重构了。

最后
你会注意到这篇文章动不动就提到”作者建议”这样的话,这是因为作者的建议确实是一个比较重要的选择。比如说你可以在你的view层中直接注入各种实例,可能你压根不想去创建什么mediator层!我想说的是你可以根据你的实际需要来决定使用Strange的方法,但是我选择了根据作者的建议来使用它因为对于我来说这样还不错。
如果你把这篇文章看到了这里,你可能会很疑惑:”我为什么要在我的项目里搞这么多复杂又多余的层?”其实在我自己的项目中应用这个框架也是处于探索研究阶段,现在的感受Strange有一个好处是强行让我把每个模块划分了。比如这样一个情形:用Strange Context处理SimpleSQL数据持久化,对于现有的游戏逻辑代码,没有使用Strange的部分,他们间的通信通过signal来进行。
我们还在另外一个RPG项目用了Strange,我希望用了它以后可以像它的口号一样,确实有助于代码间的协作。到目前来看我没法跟你宣称它在我的项目中有多好,因为我们也只是在起步阶段,但是至少到目前为止对于我们来说它工作的还不错。我们的模式是使用多个Context,一个程序员负责一个Context,然后通过signal来与其他人的Context通信。

需要StrangeIoC框架源码的童鞋可以前往unity3d assetstore官方商店自行下载