本文旨在为 Unity 开发的程序框架设计阶段提供指导作用,适合有一定 Unity 开发基础的读者。
不管是要实现策划给的需求、与其他开发者协作,还是将需求拆分成若干规模可维护的模块,开发者都需要把功能留成可调用的接口以供别方使用。 要留一个接口,往往有很多种方法。 有的是 C# 原生的,有的是依赖于 Unity 的,它们各有利弊。 本文就来列举、讲评一些常用接口设计方法的特性。
接口的 N 种写法
实例接口
公有方法
最直接、也最符合字面的「接口」当然是类的公有方法,可以通过 C# 脚本直接调用。
消息约定
Unity 官方提供了一套“标准”的类型无关的逻辑触发机制——消息(message)。
所谓类型无关,就是说在调用接口的时候不需要知道目标具体是什么类,只需要预期对方有某个方法(即 message handler),通过 GameObject.SendMessage()
将方法名和参数(如果有)填进去即可触发。
消息总是作用在 active 的 game object 上,inactive 的 game object 会忽视其收到的消息。 这一特性可以用来避免意外在 inactive game object 上触发只适用于 active game object 的逻辑。
此系统只适用于场景层级里的对象(game object 和 component),其他的继承自 UnityEngine.Object
或是野生 C# 类无法作为消息的对象。
当消息被 game object 接收时,会一视同仁地触发其上挂载的所有 component 的 handler,无法特殊指定某一个。 倘若有这种需求,则不应该使用消息系统。
消息只能携带最多一个参数,并且应保证所有 component 的同名 handler 的参数相互兼容,否则 Unity 会报错。
UnityEvent
消息系统成也萧何,败也萧何;类型无关的设计使得 handler 方法在发生变化时,触发对应消息的逻辑处并不会随之改变。 这导致一旦最开始决定了消息名,后面就很难再做出任何改动,否则一定会陷入手动地毯式排查的困境。
所幸,Unity 还提供了另一套“更规范”的逻辑触发机制——Unity event。
Unity event 并非编译时确定的代码,而是一种可以被序列化的数据,可以作为类的成员变量一并储存。 这意味着用它指定的逻辑可以随意修改而不必重新编译 C# 脚本。
被序列化的 Unity event 可以在 inspector 里修改其逻辑:
用 Unity event,你可以明确指定调用的目标是哪个 game object 上的哪个 component
(这也是为什么 MonoBehaviour
的名字里带有 "mono" 的原因,如果同一个 game object 上有多个同类组件,就没法通过类型区分它们了)。
但值得注意的是,Unity event 中调用的函数必须有在场景中可以引用得到的实例,并且只能有最多一个可序列化的参数。
不满足这些条件的方法无法被序列化,也就不能在 inspector 里选择。
除了可以被序列化,Unity event 还支持在运行时通过 UnityEvent.AddListener()
动态绑定新的逻辑;
运行是绑定的逻辑不会被序列化,因此也不会显示在 inspector 里。
若需手动触发其中逻辑,可以调用 UnityEvent.Invoke()
方法。
当 Unity event 作为本身会被序列化的类(如 MonoBehaviour
)的字段时,其值会被自动赋予一个实例,反之则不会。
在后面的情况下调用 Invoke()
方法时,应注意判 null
。
有趣的是,仅凭 Unity event 自己不足以触发别的 Unity event,因为它只能引用到 UnityEngine.Object
的直接成员。
Delegate
类比 Unity event,C# 语言本身也有类似的特性:delegate(委托)。
所谓 delegate,其实就是函数头,可以用它将执行逻辑存成变量,颇有函数式编程的味道。
System
命名空间里提供了一些常用的 delegate 类型如 Action
和 Func<T>
,你也可以自己声明需要的 delegate 类型。
然后,就可以在实例类里添加该 delegate 类型的字段。
可以用 +=
、-=
、=
等运算符来修改 delegate 实例里绑定的逻辑,以及 Invoke()
方法来触发其中逻辑。
与 Unity event 不同的是,delegate 无法被序列化,也不能被 Unity event 调用。 因此,除非你这部分的逻辑是只面向 C# 脚本的(比如 inspector 代码),否则不要用 delegate。
全局接口
静态类
要设计全局皆可访问的接口,最简单直接的方法显然是静态类。 这样做有两个缺点:
- 不能被序列化,进而不能被 Unity event 所引用,只能在 C# 脚本里调用。
- 无法控制初始化时机。
野生单例类
也可以采用单例模式,由别的 MonoBehaviour
来控制初始化时机。
这样并没有解决第一条问题。
Manager 单例
通过将承载单例的类改为实际的游戏 manager(它自己是个 MonoBehaviour
),可以解决序列化的问题。
不仅可以从 C# 脚本里调用其公有方法,也可以在场景里的 Unity event 里引用之。
然而这样做引入了两个新的问题:
- 寄生于组件里的接口是场景相关的,场景外以及别的场景里无法引用。
- 在切换场景时,如何交接 manager 的存活周期/控制权也是个问题。
单例 + Agent asset
为了解决场景相关的问题,一个很常见的 trick 是额外写一个继承自 ScriptableObject
的类作为 agent(代理)。
由于 scriptable object 可以存成 asset,asset 一定是场景无关的。
用它隐藏掉 C# 的代码,可以实现场景无关的接口逻辑序列化。
例如下面的例子:
using UnityEngine;
[CreateAssetMenu(menuName = "DebugAgent")]
public class DebugAgent : ScriptableObject {
public void Log(string message) => Debug.Log(message);
}
创建完 asset 后就可以:
表格总结
方法 | 环境 | 从 Unity event 调用 | 动态修改逻辑 | 控制初始化 | 适用情况 |
---|---|---|---|---|---|
公有方法 | C# | 不可 | 不可 | - | 固定逻辑 |
消息约定 | Unity | 可(无法指定目标组件) | 不可 | - | 跨模块触发 |
Unity event | Unity | 不可 | 可 | - | 不可复用逻辑 |
Delegate | C# | 不可 | 可(仅脚本) | - | Inspector 逻辑 |
静态类 | C# | 不可 | - | 不可 | 工具类 |
野生单例类 | C# | 不可 | - | 可 | 后端类 |
Manager 单例 | Unity | 可(场景相关) | - | 可 | 场景特异 manager |
单例 + Agent asset | Unity | 可 | - | 可 | 跨场景 manager |