Nyaggin'

Unity 接口设计讲评

本文旨在为 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 里修改其逻辑:

Calling a public instance function via Unity event

用 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 类型如 ActionFunc<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 后就可以:

Calling an Agent Interface

表格总结

方法 环境 从 Unity event 调用 动态修改逻辑 控制初始化 适用情况
公有方法 C# 不可 不可 - 固定逻辑
消息约定 Unity 可(无法指定目标组件) 不可 - 跨模块触发
Unity event Unity 不可 - 不可复用逻辑
Delegate C# 不可 可(仅脚本) - Inspector 逻辑
静态类 C# 不可 - 不可 工具类
野生单例类 C# 不可 - 后端类
Manager 单例 Unity 可(场景相关) - 场景特异 manager
单例 + Agent asset Unity - 跨场景 manager