Unity下的开发框架--适应web和微端游戏异步资源请求的框架
一、 内容简介:
Web和微端游戏最重要的特性是,资源是持续从服务器上即时下载下来的。而保证体验流畅的关键就是保证资源下载分散到持续的体验过程中,并保证每次下载的等待时间在可承受的范围内。《XXXX》项目广泛的利用了C#与Unity对协程的支持。优雅地实现这样的设计目标。
对于Unity这样带有一体化编辑环境的引擎来说,模块化还意味着能更方便的在引擎中开发编辑模式供美术等资源制作者使用。框架通过一个配置选择性的加载所需模块,便能够在不同的编辑模式下选择必要的模块功能。同时好的框架能够更好的提供编辑功能的支持,如通过统一的流程递交编辑后的数据。
Unity下代码混淆是一个难度较大的工作,笔者在之前的文章《Unity下代码混淆方案》中提到了代码混淆的思路和原理。其中提到了游戏框架支持将游戏分为上下两层,从而使代码混淆能够独自地执行在上层代码上。本文就将详细解释框架是如果通过“依赖注入”的方式,实现分层目标的。
在《Unity可视化原理》一文中,介绍了反射,元数据,内置序列化这几种语言高级特性在Unity编辑器扩展上的用处。这里要介绍的是C#和Unity对于协程的支持。
协程(Coroutine)的概念也出现在Lua,Python等动态语言中,C++的boost库中也有对协程的实现。协程的出现,简单来说就是为了更自然的实现相互协作的并行逻辑。(协程在分类上也分为不对称协程(asymmetric coroutine)与对称协程(symmetric coroutine)。本文并不打算探讨这其中过于理论的内容。)笔者之前了解过Lua中的协程实现方式:它每开始一个协程,都将创建一段栈空间,而协同过程就是在多个栈上进行跳转,直到协程的流程退出。这意味着每创建一个协程,都将分配一块栈内存(默认大小1M)。个人以为这点很大程度上制约了协程的运用。相比而言,C#对协程的支持显得更实用主义,尽管理论上说,它的功能是最弱的。但正因为如此,它的性能消耗也是极低的。
C#中并没有很明确地支持协程。但它的一项语法特性使它具备对协程的支持能力。那就是迭代器函数:
1. public static IEnumerator Power(int number, int exponent)
2. {
3. int counter = 0;
4. int result = 1;
5. while (counter++ < exponent)
6. {
7. result = result * number;
8. yield return result;
9. }
10. }
11.
12. static void Main()
13. {
14. foreach (int i in Power(2, 8))
15. {
16. Console.Write("{0} ", i);
17. }
18. }
当Power函数执行到yield return处时,他会返回result值,Power函数成为挂起状态,恢复执行时会从上次yield return处的下一条指令执行。这个机制看似十分神奇,但一旦了解了实现机制,也还是很好理解。使用反编译工具(如.NET Reflector)查看Power函数编译后的样子,如下:
1. public static IEnumerator Power(int number, int exponent)
2. {
3. <Power>d__0 d__ = new <Power>d__0(-2);
4. d__.<>3__number = number;
5. d__.<>3__exponent = exponent;
6. return d__;
7. }
会发现编译器在处理这个函数的时候,自动生成了一个<Power>d__0的类型。调用Power函数的时候,实际上是创建了这个类型的一个实例,设置初始化参数,并返回了这个实例对象。再来看看这个类型是如何定义的:
1. private sealed class <Power>d__0 :
2. IEnumerable<object>, IEnumerable,
3. IEnumerator<object>, IEnumerator, IDisposable
4. {
5. private int <>1__state;
6. private object <>2__current;
7. public int <>3__exponent;
8. public int <>3__number;
9. public int <counter>5__1;
10. public int <result>5__2;
11. public int exponent;
12. public int number;
13. public <Power>d__0(int <>1__state);
14. private bool MoveNext();
15. void IEnumerator.Reset();
16. ……
17. }
这个类型中除了之前的传入参数外,还有几个表示状态的变量。比较重要的,这里类型中有一个MoveNext函数:
1. private bool MoveNext()
2. {
3. switch (this.<>1__state)
4. {
5. case 0: this.<>1__state = -1;
6. this.<counter>5__1 = 0;
7. this.<result>5__2 = 1;
8. while (this.<counter>5__1++ < this.exponent)
9. {
10. this.<result>5__2 *= this.number;
11. this.<>2__current = this.<result>5__2;
12. this.<>1__state = 1;
13. return true;
14. Label_0065:
15. this.<>1__state = -1;
16. }
17. break;
18. case 1: goto Label_0065;
19. }
20. return false;
21. }
仔细看下,不难发现MoveNext函数是根据<>1__state,也就是上次退出时的状态决定下一次被调用时的执行位置的。而各个状态之间的界限,正是yield return的地方。也就是说,编译器在编译时期,就将Power这个迭代器函数进行了转换,得到一个执行实际流程的迭代器。而之前Main函数的foreach流程,就是循环调用这个状态机的MoveNext方法。
为什么说这样的机制能够实现异步的协程函数呢?这取决于迭代器调用方的调用流程。在Main函数中,在一个foreach循环下走完了所有的迭代,而Unity下提供的StartCoroutine函数则是将迭代的调用分散到每帧中的Update当中。在调用StartCoroutine(Power())之后,每过一帧,都会调用且仅调用一次MoveNext。这就构成了一个基本的异步过程。
接下来看Unity下Coroutine的一个典型应用:
1. public override IEnumerator SysInitialCo()
2. {
3. //提交资源申请
4. CLoadRequestList loadRequestList = new CLoadRequestList("");
5. loadRequestList.AddLoadRequest(new CLoadRequest(cUIInfoBarRes));
6. loadRequestList.AddLoadRequest(new CLoadRequest(cUIMsgBoxRes));
7. loadRequestList.AddLoadRequest(new CLoadRequest(CRecordMgr.GetTablePath(CRecordMgr.cIdxErrno)));
8. ResourceMgr.AddLoadRequestList(loadRequestList, ELoadPriority.KeyPath);
9.
10. //等待资源下载完成
11. while (loadRequestList.AllDone == false) yield return 0;
12.
13. //各种初始化流程
14. ……
15. }
这个流程中,我们会等待资源的下载。直到资源下载完成才进行后面的初始化操作。这相比设置回调函数的方式,表达上更加自然了。但它的好处不仅仅如此。由于协程函数支持嵌套。(在迭代过程中yield return一个子迭代器,Unity会在执行完子迭代器之后才继续父迭代器的调用。)因此能够安排多个异步过程的顺次执行。这是使用回调函数的方式难以做到的。
由于C#将迭代函数转化为迭代器的过程发生在编译期,因此在运行时,它与手动编写迭代器相比并没有任何性能差异。而在Unity中,协程在执行过程中,会承担每帧调用一次MoveNext的开销。我们只要保证在这个开销在可接受范围内即可。实际上,多数协程挂起的情况是在等待资源,这其中的MoveNext只是检查一个标识资源是否下载完成的变量。这样微乎其微的性能消耗相对于其对流程清晰化的作用相比,是完全可以接受的。
利用协程的嵌套,我们能够更好地组织模块初始化过程。加载一批模块后,我们依次调用他们的异步初始化,以避免产生相互依赖模块的初始化顺序错误。同时也保证了在一个模块初始化结束后,其功能就能正常使用。框架初始化模块的具体实现如下:
1. private IEnumerator _SwitchToStateCo(string newState)
2. {
3. //获得需要新加入的模块和需要卸载的模块
4. ……
5. //调用需要卸载模块的SysFinalize函数
6. ……
7. //执行新模块初始化流程
8. foreach (KeyValuePair<Type, string> pair in addSystems)
9. {
10. Type addSystem = pair.Key;
11. mSystems[addSystem].WithInState = pair.Value;
12. mSystems[addSystem].SysInitial();
13. if (mSystems[addSystem].HaveCoInitial())
14. {
15. yield return StartCoroutine(mSystems[addSystem].SysInitialCo());
16. }
17. mSystems[addSystem].InitialFinish();
18. }
19. //由于状态切换是异步过程,在新模块初始化完成后再调用要删除模块的SysLastFinalize函数,以保证切换过程中合适的表现。并最终删除旧模块。
20. ……
21. }
框架加载并初始化模块发生在框架的状态切换时。如从登录界面切换到世界地图界面,既是框架状态从Login状态切换到WorldMap状态。由于模块的初始化是异步过程,那么框架的状态切换自然也是异步过程。为了要避免状态切换的过程中再次切换状态,需要维护一个状态切换的队列。只有在当前状态切换完成之后,才会进行下一次切换:
1. private void _SwitchToState(string newState)
2. {
3. switchQueue.Enqueue(newState);
4. if (runing == false)
5. StartCoroutine(HandleSwitchQueue());
6. }
7.
8. bool runing = false;
9.
10. private IEnumerator HandleSwitchQueue()
11. {
12. runing = true;
13. while (switchQueue.Count != 0)
14. {
15. yield return StartCoroutine(_SwitchToStateCo(switchQueue.Dequeue()));
16. }
17. runing = false;
18. }
这样就实现了支持异步的模块初始化的状态流程。这样的特性使得各个系统能够很好的请求和管理自己所需的资源,同时保证了架构的灵活。
并非所有的系统都适合在初始化的时候请求资源,例如各种弹出UI。通常在进入游戏之后会有一个菜单上排布了各种二级界面的入口。我们希望做到的是在第一次点击入口的时候才加载资源。这种情况下我们使用协程来实现显示界面的流程。
1. public void ShowQuestUI(bool show)
2. {
3. if (!mUIShow && show)
4. {
5. mUIShow = true;
6. StartCoroutine(OpenQuestUICo());
7. }
8. else if (mUIShow && !show)
9. {
10. mUIShow = false;
11. StartCoroutine(CloseQuestUICo());
12. }
13. }
14.
15. //一个互斥量保证开启和关闭流程不会相互穿插。
16. private bool mUIOpening = false;
17.
18. private IEnumerator OpenQuestUICo()
19. {
20. while (mUIOpening) yield return 0;
21. mUIOpening = true;
22. //请求资源,等待,并初始化界面显示
23. ……
24. mUIOpening = false;
25. }
26.
27. private IEnumerator CloseQuestUICo()
28. {
29. while (mUIOpening) yield return 0;
30.
31. if (mUIObj != null)
32. mUIObj.SetActiveRecursively(false);
33. }
这样就实现了“惰性”资源下载的目标,使之前菜单界面的进入更加流畅。类似的,《XXXX》项目也利用协程函数实现了Avatar延时加载的机制,优化了进房间的流程。可以说,这种编程技巧很好的适应了Web游戏和微端游戏对资源细粒度和体验流畅度的要求。
ACG与RPG之间的一个不同之处是:RPG更多是场景切换,界面通常都是弹出的形式;而ACG有更多的界面切换。因为这个原因,《XXXX》项目设计了树状的状态层次。一个状态能够包含多个子状态。当前状态在子状态之间切换的时候,父状态中的系统依旧保留。这个设计使框架的灵活性更大。更好的适应了ACG游戏的系统管理,同时也为编辑模式下的系统重用提供了良好的支持。
以下是《XXXX》项目中状态与模块的实际配置信息节选:
1. class CGameRootCfg
2. {
3. public static CGameRootCfg[] mCfgs = new CGameRootCfg[]
4. {
5. //0.主游戏逻辑
6. new CGameRootCfg(
7. new CGameState("Basic",
8. new Type[]
9. {
10. typeof(CNetEngine),
11. typeof(CResourceMgr),
12. typeof(CUIInitialer),
13. typeof(CRecordMgr),
14. typeof(CEventMgr),
15. ......
16. },
17. new CGameState[]
18. {
19. new CGameState("Login",
20. new Type[]
21. {
22. typeof(CLogin),
23. },
24. new CGameState[]{}
25. ),
26. new CGameState("CreateRole",
27. new Type[]
28. {
29. typeof(CCreateCharacterScene),
30. },
31. new CGameState[]{}
32. ),
33. new CGameState("MainFrame",
34. new Type[]
35. {
36. typeof(CSettingSystem),
37. typeof(CRoomCtrl),
38. typeof(CChatScene),
39. typeof(CRelationManager),
40. typeof(CBagManager),
41. ......
42. },
43. new CGameState[]
44. {
45. ……
46. }
47. ),
48. }
49. )
50. )
51. //其他编辑模式配置
52. ……
53. }
54. }
可以看到,游戏状态(CGameState)是嵌套组成树结构的。游戏状态初始化参数中的类型列表(Type[]{})实际就是这个状态所包含的系统模块。与很多项目实现方式不同的是,这里并没有采用XML配置文件来管理状态和模块的配置。而是在一个静态成员中通过实际类型而不是系统名称来定义,这能够便于代码混淆的实施。
之前代码中省略的其他编辑模式配置的那部分如下:
1. #if UNITY_EDITOR
2. // 1.MainGame编辑器
3. new CGameRootCfg(
4. new CGameState("Always",
5. new Type[]
6. {
7. typeof(CResourceMgr),
8. typeof(CRecordMgr),
9. typeof(CUIInitialer),
10. typeof(CLoadingEditorMode),
11. ......
12. },
13. new CGameState[]
14. {
15. new CGameState("MultiGame",
16. new Type[]{},
17. new CGameState[]
18. {
19. new CGameState("Begin",
20. new Type[]
21. {
22. typeof(CNoteScroller),
23. typeof(CMainGame),
24. typeof(CCameraDirector),
25. typeof(CCameraFocusMgr),
26. typeof(CCameraPlay),
27. typeof(CTimerMgr),
28. typeof(CSectionSelect),
29. },
30. new CGameState[]{}
31. )
32. }
33. ),
34. }
35. )
36. ),// End 1.MainGame编辑器
37.
38. // 2.Avatar编辑器
39. new CGameRootCfg(
40. new CGameState("Always",
41. new Type[]
42. {
43. typeof(CMainLoading),
44. typeof(CResourceMgr),
45. typeof(CRecordMgr),
46. typeof(CEventMgr),
47. typeof(CAvatarSceneManager),
48. },
49. new CGameState[]
50. {
51. new CGameState("AvatarEditor",
52. new Type[]
53. {
54. typeof(CAvatarEditor),
55. },
56. new CGameState[]{}
57. ),
58. }
59. )
60. ),// End 2.Avatar编辑器
61. #endif
可以看到《XXXX》项目在实现编辑器扩展的时候重用了实际游戏过程中的大量模块。只加入了少数专门处理编辑逻辑的系统模块。这种方式保证了代码重用率,具备很好的灵活性,很有利于Unity下编辑器扩展的实施。更多关于Unity可视化相关的内容,敬请期待将要发表的《Unity可视化原理》一文。
与框架配套的有几个基础的系统模块。如资源管理模块,网络模块,事件管理模块。事件管理对框架的意义早已毋庸置疑。它能够很好的降低模块之间的依赖。提升各个模块的独立性与可重用性。《XXXX》项目的事件管理模块实现了同步事件和异步事件。并划分了多种不同的事件类型,如网络收包消息事件,系统事件,负责游戏效果触发的触发事件等。一个系统接受事件的方法很简单,重载基类CGameSystem的GetEventMap函数即可。如以下示例:
1. public override SEventData[] GetEventMap()
2. {
3. return new SEventData[]
4. {
5. new SEventData(MsgID.MsgID_QuestFetch_Response, OnQuestFetchResponse),
6. new SEventData((int)EEvent.LevelChange, OnLevelChange),
7. ……
8. };
9. }
10.
11. //处理网络消息
12. private bool OnQuestFetchResponse(IEvent evt)
13. {
14. CNetEvent netEvt = (CNetEvent)evt;
15. QuestFetch_Response response = netEvt.MsgBody.stM_stQuestFetchResponse;
16. ……
17. }
触发消息也极其简单:
1. //触发异步消息,消息将在下一帧被处理
2. EventMgr.QueueEvent(new CGameEvent((int)EEvent.QuestAccept,(ushort)mCurDesc.dwM_uiRelateID));
1. //触发同步消息,消息被立刻处理
2. EventMgr.TriggerEvent(new CGameEvent((int)EEvent.MemberDisconneted,inGameMember.Uin, null))
由于代码混淆方案的要求。我们必须将项目分为上下两层,上层是核心的系统逻辑,下层包括所有的被资源引用的代码。上层需要分离到Unity项目之外单独进行编译。那么游戏的主入口还是处在下层。按一般的程序集依赖逻辑,下层的程序集需要引用到上层,才能够初始化上层的逻辑。但由于上层程序集需要调用下层中资源引用到的代码,不可避免的会引用到下层程序集。这样的循环引用看似是绝对无法通过编译的。然而,借助C#语言强大的反射功能,有一个办法可使下层程序集不依赖于上层程序集,便能够初始化上层。这个技术被称为依赖注入(Dependence Injection)。
本质上,依赖注入就是下层通过反射获得上层程序集中的类型,并将其实例化的过程。这样,引用关系是在运行时动态确定的,而不是在编译时。而获得上层类型的方式也有多种,可以直接从一个配置文件中获得,也可以像以下例子中那样,通过搜索属性元数据得到。
首先使用C#的高级特性“自定义属性“来定义一个标识上层框架类型的属性:
1. public class ARootAttribute : System.Attribute
2. {
3. }
接着用这个属性来标识上层的框架类:
1. [ARootAttribute]
2. public class CGameRoot : MonoBehaviour, IgameRoot
3. {
4. ……
5. }
接着下层通过以下反射过程获得框架类并创建实例:(其中CodeGameLogic是上层程序集的名称)
1. System.Reflection.Assembly[] assemblies = System.AppDomain.CurrentDomain.GetAssemblies();
2. for (int i = 0; i < assemblies.Length; ++i)
3. {
4. string name = assemblies[i].FullName.Split(',')[0];
5. if (name == "CodeGameLogic")
6. {
7. Type[] types = assemblies[i].GetTypes();
8. for (int j = 0; j < types.Length; ++j)
9. {
10. object[] attris = types[j].GetCustomAttributes(typeof(ARootAttribute), false);
11. if (attris.Length > 0)
12. {
13. //将框架类注册为根物体的组件,从而开始框架的初始化流程
14. gameObject.AddComponent(types[j]);
15. break;
16. }
17. }
18. break;
19. }
20. }
下层有些情况也需要利用上层系统提供的方法。由于下层不能直接引用上层的系统类型,因此需要通过接口实现。下面的代码中,IGameRoot定义了框架类需要实现的方法——通过接口类型获得系统实例:
1. public interface IGameRoot
2. {
3. IGameSys GetGameSystemByInterface(Type type);
4. }
5.
6. public interface IGameSys
7. {
8. }
具体某个系统类,要实现供下层使用的接口。并使用另一个自定义属性标示两者的关系,如:
1. public interface IResourceMgr
2. {
3. GameObject TransPrefabObj(GameObject gameObject);
4.
5. Object Load(string path);
6. }
7.
8. [ASystemInterface(typeof(IResourceMgr))]
9. public class CResourceMgr : CGameSystem, IResourceMgr
10. {
11. ……
12. }
13.
14. class ASystemInterface : Attribute
15. {
16. public Type mInterfaceType;
17. public ASystemInterface(Type interfaceType)
18. {
19. mInterfaceType = interfaceType;
20. }
21. }
剩下的工作就是框架类CGameRoot实现IGameRoot接口。原理是这样:在CGameRoot加载一个系统模块时,根据AsystemInterface中声明的接口,保存一个索引。这样,底层通过接口请求系统模块是就能够返回实际的模块实例:
1. private Dictionary<Type, Type> mInterfaceMap = new Dictionary<Type, Type>();
2.
3. private IEnumerator _SwitchToStateCo(string newState)
4. {
5. ……
6. object[] objs = addSystem.GetCustomAttributes(typeof(ASystemInterface), true);
7. if (objs != null && objs.Length > 0)
8. {
9. ASystemInterface interfaceInfo = (ASystemInterface)objs[0];
10. if (!mInterfaceMap.ContainsKey(interfaceInfo.mInterfaceType))
11. mInterfaceMap.Add(interfaceInfo.mInterfaceType, addSystem);
12. }
13. ……
14. }
15.
16. public IGameSys GetGameSystemByInterface(Type type)
17. {
18. if (mInterfaceMap.ContainsKey(type))
19. {
20. if (mSystems.ContainsKey(mInterfaceMap[type]))
21. return mSystems[mInterfaceMap[type]];
22. else
23. return null;
24. }
25. else
26. {
27. return null;
28. }
29. }
至此,前文提到的循环引用问题被完整的解决了,下层在编译过程中完全不会引用到上层。保证了代码混淆方案的实施。
对于Unity下的Web与微端项目来说,细粒度的异步资源加载,编辑器扩展的支持,安全性都对框架提出了新的挑战。文中介绍的方案显示了如何利用C#语言的高级特性达成这些挑战。也显示出,随着语言的发展,框架的设计也获得了更多的灵活性和能力。
在Unity下,框架可能能够实现更多的优秀特性。例如实现代码的热加载。Unity提供资源和代码热加载的机制。也就是说,在运行阶段下,修改了资源和代码,不需要重新启动游戏便能看到修改。这样的机制能很大程度上提升开发的效率。(《幻想世界》项目就实现了Lua脚本的重新加载,使界面的修改的生效能够不必重启游戏。)或是进一步与可视化开发相融合,提供对编辑器扩展更友好的框架环境。这些都需要在将来的日子中继续深入研究。借助C#的新能力,或许这一切都并不是空谈。