• 【Unity记录】如何优雅地在Unity中订阅与退订C#事件


    本文内容

    阅读须知:

    • 阅读本文建议提前了解C#事件系统
    • 本文侧重介绍在Unity中事件退订的做法

    本文将介绍:

    • 简单介绍何为事件
    • 简单介绍如何使用C#事件
    • 为何需要退订C#事件
    • 何时需要退订C#事件
    • 如何在Unity中优雅地退订C#事件

    事件订阅模型

    简介

    事件模型是面向对象编程中常用的一种模块间通信的模型,其通过事件通知取代传统低效的轮询进行模块间逻辑交互。
    在编写程序的过程中,经常会有如下情景:模块A需要在模块B的某些时刻执行相应代码,那应该如何如何获悉模块B处于这些特定时刻呢?

    传统做法:A主动轮询:
    模块A按一定时间(一帧?一毫秒?)对模块B进行状态检测,如果达到该状态,则执行对应逻辑。
    这样的做法有两个很大的缺陷:

    1. 无法在状态达成的瞬间触发
    2. 占用大量无效处理时间

    更佳做法: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);
                }
            }
        }
        /// 
        /// 定义了名为ConditionChangeHandler的委托,其签名需要一个float类型的参数
        /// 相当于定义了一个参数为float类型的“函数指针类型”
        /// 
        /// 传入的数值
        public delegate void ConditionChangeHandler(float value);
    
        /// 
        /// 定义了上述委托的一个实际函数指针
        /// 但在C#中更为强大,其可以同时指向0~N个函数逻辑
        /// 
        public ConditionChangeHandler HpChanged;
    
    }
    
    public class A
    {
        /// 
        /// A这个函数用于监控B的Hp
        /// 
        /// 
        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;
        }
    }
    
    • 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
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60

    输出:

    哈哈!这个B只剩86血了
    
    • 1

    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;
    
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    输出:

    哈哈!这个B只剩114514血了
    哈哈!这个B只剩86血了
    
    • 1
    • 2

    可见,其中误用的代码:
    b.HpChanged(114514); 带来的输出是与B真实血量完全无关的“哈哈!这个B只剩114514血了”,这样就完全违背了设立这个委托的初衷。

    实际应用中,这种错误屌用造成的影响会非常诡异,Debug的时候,经常就是啪的一下很快啊,一天过去了😅。
    所以有没有能避免这种情况的做法涅?答案就是事件啦。

    事件定义

    事件事实上只对委托的访问性进行了封装,因此改为事件十分简单,只需要在委托的基础上加入event关键词:

        /// 
        /// 加入了event关键词,其余代码均保持原样
        /// 
    	public event ConditionChangeHandler HpChanged;
    
    • 1
    • 2
    • 3
    • 4

    此时在类外仍可照常订阅退订事件,但只有在事件订阅的类内才可以触发事件,如果企图在类外的任意位置调用事件(比如上面提及到的误用),则会出现编译错误:

    error CS0070: The event 'B.HpChanged' can only appear on the left hand side of += or -= (except when used from within the type 'B')
    
    • 1

    C#内置委托类型

    C#给我们提供了多个泛型的委托类型,我们可以使用它们,而无须自定义委托类型。它们分别是Action以及Func,有兴趣的朋友可以了解一下。

    事件的退订

    为什么需要退订事件

    事件爽归爽,但是爽完是需要负责任的涅。

    内存泄漏风险

    在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;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    总之只要你确保能够取得该订阅的引用,就可以在适当的地方退订了。

    何时退订事件?

    在订阅方不再需要接收广播方事件推送时退订,你所需要做的只是写一句-=
    ……废话,有这种时机那肯定好啊,那要是没有呢?比如,订阅方需要在整个生命周期内订阅事件

    那就在它寄的时候退订吧! 具体做法是:

    在一般C#类中(非继承MonoBehavior)

    跟C++释放内存的做法类似,可以在C#中的终结器(Finalizers,类似于C++析构函数)中处理退订逻辑。具体做法可以参考下面这个例子:

    public class B { /*...B的逻辑不变...*/ }
    
    public class A
    {
        B cachedB;
        
        /// 
        /// 定义一个A的初始化函数
        /// 为了可以退订,我们需要保存广播方的引用
        /// 顺便把事件订阅也一并封装进来
        /// 
        public void Initialize(B b)
        {
            cachedB = b;
            cachedB.HpChanged += ObserveHp;
        }
        
        /// 
        /// A这个函数用于监控B的Hp
        /// 
        /// 
        public void ObserveHp(float hp)
        {
            Console.WriteLine($"哈哈!这个B只剩{hp}血了");
        }
        
        /// 
        /// 终结器退订事件
        /// 
        ~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;
    
        }
    }
    
    • 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
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50

    在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);
            }
    
            /// 
            /// 处理第一次启用时的特殊情况
            /// 
            /// 
            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;
                }
            }
        }
    
    • 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
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43

    注意: 如果要操作A脚本所在物体的父物体的启用,请确保先调用A.Initialize(B)初始化后,再启用其父物体,否则可能会在错误的cachedB引用下调用OnEnable()方法

    在OnEnable与OnDisable进行事件订阅退订是Unity中的常用做法,希望这个例子可以给你一些启示。如果有其它逻辑需要实现,那就需要具体问题具体分析了。

    参考

    1. Is OnDestroy reliable in Unity?
    2. Will loading a new scene automatically destroy/unsubscribe event handlers?
  • 相关阅读:
    prometheus初窥
    【Docker】五分钟完成Docker部署Java应用,你也可以的!!!
    初识 SpringMVC,运行配置第一个Spring MVC 程序
    Python 学习 第二册 第11章 文件
    快速上手Linux核心命令(十):Linux安装软件
    go写webasmbly
    五、C语言判断语句
    【云原生】Docker操作命令大全
    Windows10安装MySQL5.7.43
    加密与安全_探索对称加密算法
  • 原文地址:https://blog.csdn.net/mkr67n/article/details/126277253