前言
游戏开发中,我们会创建各种各样的管理器,可以说每一个模块我们就得创建一个管理器,例如ResourceManager、UIManager、DataManager、LogManager等等,作者之前项目是各种Manager继承字MonoBehaviour,然后挂在Hierarchy上,这样有一个弊端,我们每添加或者删除一个manager就要保存一次场景,做Unity开发的都知道git或者svn协同开发,唯独scene是不好合并的,所以如果涉及到频繁修改场景是非常不好的设计,其次是场景树中一堆Manager的节点,感觉不太雅观。
老的Manager继承的代码: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/// <summary>
/// 直接挂在UI上的MonoBehaviour单例
/// </summary>
/// <typeparam name="T"></typeparam>
public class SingletonMonoBehaviour<T> : MonoBehaviour where T : MonoBehaviour
{
private static T _instance;
public static T Instance
{
get
{
if (_instance == null)
{
GameObject singleton = new GameObject("(singleton)" + typeof(T));
_instance = singleton.AddComponent<T>();
DontDestroyOnLoad(singleton);
Debug.Debugger.Log("[Singleton] An instance of " + typeof(T) +
" is needed in the scene, so '" + singleton +
"' was created with DontDestroyOnLoad.");
}
else
{
Debug.Debugger.Log("[Singleton] Using instance already created:" + _instance.gameObject.name);
}
return _instance;
}
}
}
框架启动
一般框架启动就是实例化各种Manager,下载或者加载数据表这些操作。拿加载Manager举例,笔者之前公司项目在游戏启动的时候好多好多行Manager的加载实例
并没有截全,项目越大,Manager加载的行数越多,当你看到项目中甚至好几十行这种类似的代码,总会觉得有一点点“恶心”从框架层面来讲,我觉得框架应该将耦合性降到最低,如果我想实现一个新的Manager,我只要写一个C#脚本,程序启动的时候就自动加载并且创建了实例,甚至不用去刻意写添加方法,这就用到C#的利器特性加反射来实现。
优化加载方案
老早之前笔者一直有个疑惑,我们都知道继承接口是为了让类具备接口里面的功能,为啥我们实现了接口方法,这个类就具备了这个方法的功能呢?最常见的就是Unity中我们要实现一个卡牌拖动,那我们让这个卡牌继承IDragHandler接口实现接口方法OnBeginDrag、OnDrag、OnEndDrag等方法,那么这张卡就可以被拖动了,感觉很神奇,有一种想看源码实现的冲动,但Unity不开源,我们想看却看不到,这里指的看不了是针对小白程序,下面我就来举例GFFramework加载Manager也有类似的原理实现,介绍之后你就明白Unity底层是怎么实现的了。
需求:我们想实现自动加载程序自启动模块。
定义IGameStart接口
顾名思义,这接口就是模块启动接口,凡是继承改接口的模块(类)都具备启动功能(实现接口的Start方法)。1
2
3
4
5
6
7
8
9namespace GFFramework.GameStart
{
public interface IGameStart
{
void Start();
void Update();
void LateUpdate();
}
}
Start方法就是启动方法,至于Update和LateUpdate方法这个我是想放在Unity的MonoBehaviour生命周期中去管理,也就是说整个工程中只有一个类继承自MonoBehaviour,在这一个类里各个生命周期的方法去调用各个模块的生命周期的方法,一方面是因为我们下面要介绍的C#热更方案ILRuntime不支持继承MonoBehaviour的类的更新,还有一方面是好多好多脚本都继承MonoBehaviour可控性差。
继承IGameStart接口的类
1 | using Game.UI; |
这个类就做了一些准备工作和加载第一个Window。
如何加载这些继承自IGameStart接口的类呢?
1 | var types = Assembly.GetExecutingAssembly().GetTypes(); |
上面的代码写在程序启动的入口代码上,反射获取本地所有程序集,然后找到继承自IGameStart的类,然后创建这些类的实例,并且调用了他们的初始化Start方法,看到这里我们回头想想刚刚抛出的疑惑?为啥继承自接口的类实现了接口方法,那么这个类就具备了接口功能,底层就是这样通过反射实现的。
特性
看看上面的程序,其实还有一个通过特性来查找的判断,为何要这样设计呢,我们反射查找到继承自某个接口的类,如果还要细分,可以通过特性来判断查找,上面的逻辑就是当满足继承自IGameStart接口的类并且有我们自定义的GameStartAttribute特性标记,我们才创建该类的实例并且调用它的Start方法。如果特性不了解的同学可以看下特性介绍。
iOS JIT和反射
笔者刚做Unity开发的时候也是有这个观念,Unity在iOS平台上不允许用反射代码,这是iOS的限制,后来发现并不是这样,iOS限制的是JIT, just in time, 即时编译。虽然iOS平台允许用反射,但涉及到业务逻辑的时候并不推荐用反射代码,因为会有一定的效率问题。
我们写的Unity程序在安卓上运行的好好的,导出到iOS平台就会有报错,这是Unity开发者会经常碰到的问题,最典型的的报错就是:ExecutionEngineException: Attempting to JIT compile method 'XXXX' while running with --aot-only.
笔者上个项目才碰到这样的报错,原因是引用了Reflection.Emit命名空间下的代码,重写了类的ToString方法,在打印这个类的对象的时候iOS会卡死报错,就是因为触发了jit。所以如果你们项目中有Reflection.Emit这样的代码,要小心谨慎!
至于详细的JIT方面的知识,可以阅读小匹夫的谁偷了我的热更新?Mono,JIT,iOS
反射的原理
.net所编写的程序集包含两个重要部分IL和metadata,我们写好的代码很多类很多成员在编译的时候都把这些信息记录在元数据表里面,反射就是将这个过程反过来,通过元数据记录的关于类的信息找到该类的成员,并能使它“复活”(因为元数据里所记录的信息足够详细,以至于可以根据metadata里的信息找到该类的IL code加以利用)。
举例:
1 | Type t = typeof(System.string); |
以上代码就会获的关于String类的所有信息。
管理器
管理器基类
1 | /// <summary> |
主要流程:
1.框架启动会拿到所有的程序集Assembly里面的所有Type
2.检查所有管理器找到继承自ManagerBase的管理器
3.实例化继承自ManagerBase的管理器
4.对应的管理器会根据V的类型标签中的Type去自动拿到Type信息
5.对应的管理器自己实现自己的业务逻辑
这样自动注册流程就完成了注册
UIManager & SceneViewManager
UIAttribute & ScreenViewAttribute & ManagerAttribute
这样定义特性标签之后,我们UI资源就可以根据设置标签来指定类型和位置,例如GameWindow界面
就通过UI标签来指定类型和指定资源路径
GFFramework地址
https://github.com/dingxiaowei/GFFrameWork