本文内容
阅读须知:
- 阅读本文建议提前了解C#事件系统
- 本文侧重介绍在Unity中事件退订的做法
本文将介绍:
- 简单介绍何为事件
- 简单介绍如何使用C#事件
- 为何需要退订C#事件
- 何时需要退订C#事件
- 如何在Unity中优雅地退订C#事件
事件订阅模型
简介
事件模型是面向对象编程中常用的一种模块间通信的模型,其通过事件通知取代传统低效的轮询进行模块间逻辑交互。
在编写程序的过程中,经常会有如下情景:模块A需要在模块B的某些时刻执行相应代码,那应该如何如何获悉模块B处于这些特定时刻呢?
传统做法:A主动轮询:
模块A按一定时间(一帧?一毫秒?)对模块B进行状态检测,如果达到该状态,则执行对应逻辑。
这样的做法有两个很大的缺陷:
- 无法在状态达成的瞬间触发
- 占用大量无效处理时间
更佳做法:A等待通知:
A提前向B订阅该事件,通俗来讲,就是A对B说:“你在达成XX条件的时候通知我一哈”。而B在达成该条件时,主动告知A,使A获得一个执行逻辑的机会。
这个做法就完全解决了上述轮询的两个缺陷;此外。事件模型还有利于模块间的解耦,增加了逻辑之间的合理性。
C#委托与事件
如果你已经对事件的概念很熟悉可以跳过本节
扼要
委托与事件是C#中为数不多的新人杀手,但其实它们的实质并没有那么复杂。本文不会展开详细解释它们的原理与区别,只扼要介绍它们的概念。如果需要详细解释,请阅读其它文章。
简单来讲:
- C#中的委托的作用可类比于其它语言的“函数指针”
- 事件是封装后的委托,除了访问级别与委托不同以外,作用基本相同。
委托
下面这个例子展示了委托的作用。其含义是A希望监测B的血量,当B血量变化时告知A。
using System;
public class B
{
private float hp;
public float Hp
{
get => hp;
set
{
if (hp != value)
{
hp = value;
//?.写法相当于null检测,如果HpChanged为空则不执行
//B此处触发委托
HpChanged?.Invoke(hp);
}
}
}
/// <summary>
/// 定义了名为ConditionChangeHandler的委托,其签名需要一个float类型的参数
/// 相当于定义了一个参数为float类型的“函数指针类型”
/// </summary>
/// <param name="value">传入的数值</param>
public delegate void ConditionChangeHandler(float value);
/// <summary>
/// 定义了上述委托的一个实际函数指针
/// 但在C#中更为强大,其可以同时指向0~N个函数逻辑
/// </summary>
public ConditionChangeHandler HpChanged;
}
public class A
{
/// <summary>
/// A这个函数用于监控B的Hp
/// </summary>
/// <param name="hp"></param>
public void ObserveHp(float hp)
{
Console.WriteLine($"哈哈!这个B只剩{hp}血了");
}
}
public class Test
{
public static void Main()
{
A a = new A();
B b = new B() { Hp = 10086}; //B初始化10086血量
//此处a订阅了b的
b.HpChanged += a.ObserveHp;
//B受到一万点伤害
b.Hp -= 10000;
}
}
输出:
哈哈!这个B只剩86血了
A :
其中ConditionChangeHandler是一个委托类型,其规定了委托处理方法的签名(所谓函数指针的类型)
HpChanged是该类型具体的一个委托(所谓函数指针威力加强版)
B :
ObserveHp是欲检测A的处理方法
**Main: **
b.HpChanged += a.ObserveHp; 该句“订阅”了委托,建立了A、B之间的沟通关系。
事件
委托的缺陷
上面的委托已经足够好用了,但其存在一个问题:谁都可以在任意时刻调用HpChanged,这样的结果是容易造成代码的误用,考虑某个与你合作的程序员,他并不清楚B的具体逻辑,但他阴差阳错写出了这样的玩意:
public class Test
{
public static void Main()
{
A a = new A();
B b = new B() { Hp = 10086}; //B初始化10086血量
//此处a订阅了b的
b.HpChanged += a.ObserveHp;
//其它人,或者自己误用的代码
b.HpChanged(114514);
//B受到一万点伤害
b.Hp -= 10000;
}
}
输出:
哈哈!这个B只剩114514血了
哈哈!这个B只剩86血了
可见,其中误用的代码:
b.HpChanged(114514); 带来的输出是与B真实血量完全无关的“哈哈!这个B只剩114514血了”,这样就完全违背了设立这个委托的初衷。
实际应用中,这种错误屌用造成的影响会非常诡异,Debug的时候,经常就是啪的一下很快啊,一天过去了😅。
所以有没有能避免这种情况的做法涅?答案就是事件啦。
事件定义
事件事实上只对委托的访问性进行了封装,因此改为事件十分简单,只需要在委托的基础上加入event关键词:
/// <summary>
/// 加入了event关键词,其余代码均保持原样
/// </summary>
public event ConditionChangeHandler HpChanged;
此时在类外仍可照常订阅退订事件,但只有在事件订阅的类内才可以触发事件,如果企图在类外的任意位置调用事件(比如上面提及到的误用),则会出现编译错误:
error CS0070: The event 'B.HpChanged' can only appear on the left hand side of += or -= (except when used from within the type 'B')
C#内置委托类型
C#给我们提供了多个泛型的委托类型,我们可以使用它们,而无须自定义委托类型。它们分别是Action<T1, T2, …>以及Func<T1, T2, …, Out>,有兴趣的朋友可以了解一下。
事件的退订
为什么需要退订事件
事件爽归爽,但是爽完是需要负责任的涅。
内存泄漏风险
在C++中,我们知道手动分配的内存需要使用完毕后归还,在C#中得益于GC(垃圾回收)的机制,使我们无须再关注内存的分配问题,直接用就完事儿了反正不会内存泄漏。
但是!事件如果不退订,存在内存泄漏的风险:
如果事件广播方是一个生命周期极长的对象,而订阅方是一大组生命周期极短的对象。在订阅方生命周期结束后,其GC并不会回收其存储于广播方的事件订阅,也就是广播方会一直存储这些没用的订阅,因此便存在内存泄漏甚至溢出的风险。
残余错误风险
上面的例子提及,广播方会一直存储没用的事件,这还不算最要命的,要命的是,它在广播时仍然会触发这些事件订阅!这就麻烦了,尝试触发一些超出生命周期的逻辑往往造成错误,运气不好可能啪的一下又一天过去了。
所以请务必重视时间退订!(血的教训家人们)
什么情况需要退订事件
显然,当事件广播方生命周期极长时需要订阅方在完事后退订事件;反之,如果事件广播方生命周期短,甚至不及订阅方,则无须退订啦,因为在广播方终结后的GC中会清理掉所有事件订阅的。
如何退订事件?
退订事件需要借助订阅时的引用,所以如果需要退订事件,请将事件处理函数的引用缓存至合适的位置:
//Lambda表达式,无法退订
b.HpChanged += (hp) => {Console.WriteLine(hp);};
//该类型的委托,可以退订
B.ConditionChangeHandler exp = (hp) => {Console.WriteLine(hp);};
b.HpChanged += exp;
b.HpChanged -= exp;
//签名吻合的方法:可以退订
b.HpChanged += a.ObserveHp;
b.HpChanged -= a.ObserveHp;
总之只要你确保能够取得该订阅的引用,就可以在适当的地方退订了。
何时退订事件?
在订阅方不再需要接收广播方事件推送时退订,你所需要做的只是写一句-=
……废话,有这种时机那肯定好啊,那要是没有呢?比如,订阅方需要在整个生命周期内订阅事件
那就在它寄的时候退订吧! 具体做法是:
在一般C#类中(非继承MonoBehavior)
跟C++释放内存的做法类似,可以在C#中的终结器(Finalizers,类似于C++析构函数)中处理退订逻辑。具体做法可以参考下面这个例子:
public class B { /*...B的逻辑不变...*/ }
public class A
{
B cachedB;
/// <summary>
/// 定义一个A的初始化函数
/// 为了可以退订,我们需要保存广播方的引用
/// 顺便把事件订阅也一并封装进来
/// </summary>
public void Initialize(B b)
{
cachedB = b;
cachedB.HpChanged += ObserveHp;
}
/// <summary>
/// A这个函数用于监控B的Hp
/// </summary>
/// <param name="hp"></param>
public void ObserveHp(float hp)
{
Console.WriteLine($"哈哈!这个B只剩{hp}血了");
}
/// <summary>
/// 终结器退订事件
/// </summary>
~A()
{
Console.WriteLine($"执行到A.Finalizer");
cachedB.HpChanged -= ObserveHp;
}
}
public class Test
{
public static void Main()
{
A a = new A();
B b = new B() { Hp = 10086}; //B初始化10086血量
a.Initialize(b);
//B受到一万点伤害
b.Hp -= 10000;
}
}
在Unity的MonoBehavior类中
不推荐做法:终结器 (Finalizer)
众所周知Unity喜欢搞特殊,在MonoBehavior类中,构造函数与终结器会不可控地被Unity调用若干次,具体可以查看我之前写的一篇文章,因此不建议在终结器中退订事件
深坑做法:MonoBehavior.OnDestroy
好在Unity给我们留下了许多实用的生命周期方法……以及一堆坑!
一个错误的(至少是有隐患的)做法是在MonoBehavior.OnDestroy方法中退订事件。因为当某个物体在场上处于未启用状态时被销毁,它的OnDestroy是不会被调用的!!!
具体而言是在以下几种操作时OnDestroy不会调用:
- 对该物体或其父物体调用SetActive(false);
- 对该脚本设置了enabled = false;
- 在运行时的Inspector中禁用了组件或其附属物体(或父物体)
我遇到的问题是,我订阅事件的物体处于未启用状态,此时加载切换至另一场景(自然销毁了所有订阅方,但OnDestroy未被调用,即未正常退订事件)。另一方面,我的广播方是存在于全局跨场景的对象,因此当它再广播该事件时,残余的事件订阅引发了一连串的错误,查错的时候啪的一下很快啊,一天又过去了😅
推荐做法:MonoBehavior.OnDisable
为了避免上述问题,一种常用的做法是在组件禁用时就退订事件,当组件启用时再重新订阅事件。
OnDisable恰好在上述的任意OnDestroy不会调用的情况下,均可照常调用,覆盖范围更大更省心,虽然如果频繁禁用启用物体会导致开销增大,但就这一点点开销换来自己阳寿,肯定是值得的。
但这种做法会在第一次启用前有些小问题(因为由Instantiate生成的物体可能还没初始化完毕),因此如果需要初始化,可以考虑在物体生成时保持禁用状态,当初始化结束后再启用物体,具体请看以下示意:
public class A : MonoBehaviour
{
B cachedB;
private void Awake()
{
// 先禁用该物体
gameObject.SetActive(false);
}
/// <summary>
/// 处理第一次启用时的特殊情况
/// </summary>
/// <param name="b"></param>
public void Initialize(B b)
{
// ...此处做初始化工作
// 赋值
cachedB = b;
// 启用该物体
gameObject.SetActive(true);
}
private void ObserveHp(float hp)
{
Console.WriteLine($"哈哈!这个B只剩{hp}血了");
}
private void OnEnable()
{
cachedB.HpChanged += ObserveHp;
}
private void OnDisable()
{
if (cachedB != null)
{
cachedB.HpChanged -= ObserveHp;
}
}
}
注意: 如果要操作A脚本所在物体的父物体的启用,请确保先调用A.Initialize(B)初始化后,再启用其父物体,否则可能会在错误的cachedB引用下调用OnEnable()方法文章来源:https://www.toymoban.com/news/detail-448903.html
在OnEnable与OnDisable进行事件订阅退订是Unity中的常用做法,希望这个例子可以给你一些启示。如果有其它逻辑需要实现,那就需要具体问题具体分析了。文章来源地址https://www.toymoban.com/news/detail-448903.html
参考
- Is OnDestroy reliable in Unity?
- Will loading a new scene automatically destroy/unsubscribe event handlers?
到了这里,关于【Unity记录】如何优雅地在Unity中订阅与退订C#事件的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!