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

这篇具有很好参考价值的文章主要介绍了【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);
            }
        }
    }
    /// <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()方法

在OnEnable与OnDisable进行事件订阅退订是Unity中的常用做法,希望这个例子可以给你一些启示。如果有其它逻辑需要实现,那就需要具体问题具体分析了。文章来源地址https://www.toymoban.com/news/detail-448903.html

参考

  1. Is OnDestroy reliable in Unity?
  2. Will loading a new scene automatically destroy/unsubscribe event handlers?

到了这里,关于【Unity记录】如何优雅地在Unity中订阅与退订C#事件的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 记录--前端如何优雅导出多表头xlsx

    xlsx导出是比较前后端开发过程中都比较常见的一个功能。但传统的二维表格可能很难能满足我们对业务的需求,因为当数据的维度和层次比较多时,二维表格很难以清晰和压缩的方式展现所有的信息,所以我们也就经常能碰到多级表头开发了。 每当我们新使用一个插件的时候

    2024年02月09日
    浏览(41)
  • SpringBoot项目如何优雅的实现操作日志记录

    在实际开发当中,对于某些关键业务,我们通常需要记录该操作的内容,一个操作调一次记录方法,每次还得去收集参数等等,会造成大量代码重复。 我们希望代码中只有业务相关的操作,在项目中使用注解来完成此项功能。 通常就是使用Spring中的AOP特性来实现的,那么在

    2024年01月18日
    浏览(44)
  • 掌握C#中异步魔法:同步方法如何优雅调用异步方法

      概述: 上述C#示例演示了如何在同步方法中调用异步方法。通过使用`async`和`await`,实现了同步方法对异步方法的调用。建议使用`await`而不是`Result`来避免潜在的死锁问题。这种模式在处理异步任务时能够提高代码的可读性和性能。 在C#中,从同步方法调用异步方法

    2024年03月25日
    浏览(39)
  • Unity 如何在Unity中优雅的画线

    内容将会持续更新,有错误的地方欢迎指正,谢谢!   Unity 如何在Unity中优雅的画线       TechX 坚持将创新的科技带给世界! 拥有更好的学习体验 —— 不断努力,不断进步,不断探索 TechX —— 心探索、心进取! 助力快速掌握 画线 为初学者节省宝贵的学习时间,避免困惑!

    2024年02月02日
    浏览(54)
  • 【Unity】如何优雅地移动物体-8个方法

    在游戏开发中,如何移动物体?是我们需要思考的事情。 Unity 引擎也提供了众多的方法,每个开发者的使用习惯也各不相同,所以往往不是很清楚在这种场景下哪种方式最好的或者最有效的。 那么,这篇文章,我想分享一下移动物体的一些方法和优缺点。 仓库地址 如何优雅

    2023年04月09日
    浏览(42)
  • Unity学习笔记--如何优雅简便地利用对象池生成游戏对象(进阶版)LRU + 对象池

    之前写过一篇关于对象池的文章,现在来看写的并不是很好,所以来考虑优化下。 现在来看一年前写的代码,越看越不能入目hhh Unity学习笔记–如何优雅简便地利用对象池生成游戏对象 Unity学习笔记–使用 C# 开发一个 LRU PoolManager.cs BaseFactory.cs 创建 Factory 创建 object 创建 Bu

    2024年02月13日
    浏览(45)
  • 【微信小程序】使用WxNotificationCenter实现复杂的事件通信功能,在任意页面中订阅事件、发送事件和取消订阅事件

    在微信小程序中,如果需要实现复杂的事件通信功能,可以使用第三方库来辅助实现。以下是一些常用的第三方库示例: WxNotificationCenter Github地址(https://github.com/icindy/WxNotificationCenter) WxNotificationCenter是一个基于发布/订阅模式的事件通知库,可以在微信小程序中实现跨页面

    2024年02月16日
    浏览(41)
  • C#控制台应用程序如何添加窗口关闭事件?

    公司有一个控制台应用程序,在关闭控制台应用程序窗口前,想处理一下业务逻辑。还有比如误操作关闭了,或者像消息队列启动了发送消息提醒,那关闭了窗口代表控制台应用销毁了,也需要发送消息通知。那这个时候添加关闭窗口事件就派上用场了。 很多小伙伴问,有没

    2024年02月13日
    浏览(47)
  • CMake String函数:如何巧妙地在cmake中操作字符串

    在CMake中,我们可以通过多种方式创建和赋值字符串。下面是两种常见的方法: 使用 set 命令:这是创建和赋值字符串的最直接方式。例如,我们可以创建一个名为 VAR 的变量,并赋值为 Hello, CMake! 。 使用 string 命令:除了 set 命令,我们还可以使用 string 命令的 APPEND 子命令来

    2024年02月08日
    浏览(47)
  • C# / Unity关于 世界时间、本地时间、UNIX Timestamps,以及相互转化的记录

    C# / Unity关于 世界时间、本地时间、UNIX Timestamps,以及相互转化的记录 世界时间(UtcTime ) :DateTime.UtcNow 获取的是世界时间,如果开发一些全球国际性质的产品,可以用这个时间来进行。 本地时间(LocalTime) : DateTime.Now 获取的是当前用户所在时区的时间,也就是本地时间。 时间

    2024年02月12日
    浏览(52)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包