Unity C# 爆破计划(十四):事件

15 篇文章 1 订阅


十四、事件

Covers:委托与事件

概念

事件 在真实世界中无处不在,你可能不知道 C# 中事件的概念,但你一定了解这个词的本义。我们抛开编程不谈,先考察一下“事件”的逻辑性质。

你的朋友发来一条信息,约你去打球。关于这一事件,我们能总结出事件 3 个要素:

  • 你的朋友是 事件的发起者,这件事是由他引起的,换言之,在事件发生之前,你的朋友就是一个潜在的事件发生器;
  • 你是事件的 接收者,这个事件对你产生了作用;
  • 事件的内容 是“下午约你去打球”,换言之,这个事件 携带的数据 是这个消息。

这个事件还隐含着两个未在字面上体现的要素:

  • 你在收到信息之后,可能接受邀请收拾行装,或拒绝邀请 —— 但总之你会有一定的后续行为,换言之,你会对事件作出 响应
  • 如果手机没有开机,或者你在睡觉,或者家里 WiFi 坏了……如果没有 一定条件确保消息能够到达接收者,那么事件即使发生了(朋友把消息发出去了),该事件也是无效事件。

小到水龙头中落下一滴水,大到洲际导弹发射、太阳熄灭、超新星爆发,任何事件要发生并有意义,至少要满足 5 个要素(当然还有更多,但至少是这 5 个):

  1. 发起者 —— 我们称为 事件源,虽然潜在的事件发生者可以有多个,但对于某一个正在发生的事件而言,其事件源总是唯一的;
  2. 接收者 —— 我们称为 订阅者,显然可以有多个;
  3. 事件的内容 —— 我们称为 事件参数
  4. 对事件的响应 —— 我们称为 事件处理器
  5. 确保事件有效传达的先决条件 —— 我们称为 订阅关系

C# 的事件机制就建立在这 5 个要素上,我们要学习的是将这五要素有效建模的一种“八股文”。

事件定义语法

事件参数

我们首先定义的是事件参数,因为它依赖的其它类最少,只要从 System.EventArgs 类派生一个就行了:

public class EVENT_ARGS : EventArgs {BODY}

这里定义了一个派生类 EVENT_ARGS,命名惯例是“事件种类+EventArgs”,如“ComputerBlowUpEventArgs”、“HeroSlayEventArgs”、“SunExtinguishEventArgs”(我怎么不想点好的);BODY 是其带有的字段,我们一般不在事件参数中定义方法。

订阅关系

隆重介绍 System.EventHandler,它是一个委托类型,接受一个任意 Object 和一个 EventArgs 类型的数据“包”。你可以猜到订阅关系是如何传播的 —— 利用委托的多播性质,当 EventHandler 被执行时,所有绑定到它的事件处理器就都被调用。

我们可以从自己定义一个“事件种类+EventHandler”,也可以直接使用原生的 System.EventHandler,自己定义的格式是:

public delegate void EVENT_HNDL(object sender, EVENT_ARGS e);

这里第一个参数名约定俗成为 sender,第二个参数的类型是刚才定义的 EVENT_ARGS,参数名约定为 e

事件源

事件源是一个类(不需要继承自某个系统类),它必须具有 3 个成员:

  1. 一个 EventHandler(或自定义)实例字段;
  2. 一个事件成员;
  3. 一个调用(是指“函数调用”)了前述 EventHandler 字段的方法。

所以一个事件源类大概是这样的:

public class EVENT_SOURCE {
    EVENT_HNDL _eventHandler;
    
    public event EVENT_HNDL EVENT {
        add {ADD_BODY}
        remove {REMOVE_BODY}
    }
    
    void EVENT_EMIT(PARAMS) {BODY}
    
    public void ENTRY...
}

上面的 event 关键字用于定义一个事件,其后紧接着的是 EVENT_HNDL 类名(这不是在声明字段或属性!只是一种约定的语法),然后是事件名 EVENT,最后是两个访问器组成的定义体,类似于属性的访问器,但使用了不同的关键字。

add 后接语句块定义该事件的 订阅器,用于将一个事件处理器绑定到事件委托对象 _eventHandler;remove 后接语句块定义该事件的 解除器,用于将一个事件处理器从事件委托对象中解除绑定。一般这样写:

public event EVENT_HNDL EVENT {
    add {_eventHandler += value;}
    remove {_eventHandler -= value;}
}

EVENT_EMIT 方法的编写、参数都是自由的,只要在其中有这么一句就行:

if (_eventHandler != null) {
    _eventHandler(this, EVENT_ARGS_OBJ);
}

或其等效形式(这里的 ?. 其实就是 .,只是多一层逻辑:如果委托为空绑定,则什么都不做):

_eventHandler?.Invoke(this, EVENT_ARGS_OBJ);

一般来说我们要把 EVENT_EMIT 方法写成私有的,这样做是出于安全考虑,不让不清楚情况的人随便直接 Invoke 造成混乱。因此我们一般还要再写一个暴露出来的方法 ENTRY,用来给别人调用。

订阅者和事件处理器

订阅者也是一个类,它是事件的归宿,负责在事件到来时(前面的 _eventHandler 被 Invoke 时)做出一定的动作)。其定义是自由的,只要在其中包括一个事件处理器方法即可。

事件处理器是一个方法,必须满足 EVENT_HNDL 委托类型对函数签名的定义,比如用 System.EventHandler 时,其签名就应该是:

public void EVENT_PROC(object sender, EVENT_ARGS e)

事件处理器函数体根据功能需求写即可。

调用代码

调用代码(比如 Main)中需要 初始化事件源实例(虽然你可以把事件源声明为静态的,但我们一般不这么做),如果订阅者类 EVENT_SUBSCRIBER 是非静态的,在调用处的代码中还需要初始化这个类的实例;如果是静态类则不需要;接着,我们需要为事件绑定处理器方法,事件是事件源的一个成员,它和委托一样可以用 += 运算符;最后,我们调用一下事件源类暴露出的入口方法,开始等待事件发生即可。

综合一下写出来大概是这样的:

...
    var source = new EVENT_SOURCE();
    source.EVENT += EVENT_SUBSCRIBER.EVENT_PROC;
    source.ENTRY;
事件编写八股

现在五要素备齐,我们总结一下编写一个完整的事件发生-处理逻辑需要的关键环节:

  1. 事件定义(design)阶段:
    1. 编写事件参数类(不携带任何自定义数据时可省略,用 System.EventArgs);
    2. 编写事件委托类(可省略,用 System.EventHandler);
    3. 编写事件源,需要编写前述“3 个成员”(如有必要,可编写多个,一般没必要)
  2. 事件应用(use,这个词好 low,我应该换个 apply 什么的?)阶段:
    1. 编写事件订阅者类,为其编写事件处理器方法(如有必要,可编写多个);
    2. 编写调用代码。

看晕了不要紧,我们写一个例子来用上上面所有的八股套路,它实现的功能是每当用户按下键盘任意键(除 ESC)时,在控制台上打印一个感叹号:

using System;

namespace LearnEvents
{
    // Design-1. Define what message is carried when event is invoked:
    public class KeyPressEventArgs : EventArgs
    {
        public ConsoleKeyInfo Key { get; set; }
    }

    // Design-2. Define event handler delegate type:
    public delegate void KeyPressEventHandler(object sender, KeyPressEventArgs e);

    // Design-3. Define an event source:
    public class ConsoleSession
    {
        // Design-3a. Event handler delegate field:
        KeyPressEventHandler _keyPressEventHandler;

        // Design-3b. Define the event:
        public event KeyPressEventHandler KeyPress
        {
            add { _keyPressEventHandler += value; }
            remove { _keyPressEventHandler -= value; }
        }
        
        // Design-3c. Define a PRIVATE (for safety) invoker of the event handler:
        void OnKeyPress(KeyPressEventArgs args)
        {
            _keyPressEventHandler?.Invoke(this, args);
        }

        // Design-3d. Expose an "entry" method that is will call the invoker:
        public void Start()
        {
            var args = new KeyPressEventArgs();
            for (;;)
            {
                args.Key = Console.ReadKey(true);

                // If ESC key is pressed, exit the session:
                if (args.Key.GetHashCode() == 27) return;
                
                OnKeyPress(args);
            }
        }
    }

    // Use-1. Define event subscriber, who has an event processor method:
    public static class ConsoleKeyListener
    {
        public static void KeyPressEventProcessor(object sender, KeyPressEventArgs e)
        {
            Console.Write("!");
        }
    }

    // Use-2. Application code:
    class Program
    {
        static void Main()
        {
            // Use-2a. Initialize an event source object:
            var session = new ConsoleSession();

            // Use-2b. Add a processor to the event handler:
            session.KeyPress += ConsoleKeyListener.KeyPressEventProcessor;

            // Use-2c. Finish, call the entry and wait for an event:
            session.Start();
        }
    }
}
事件源的简写

实际编程中,事件源的事件定义部分几乎总是一样的,而委托字段又实在是没什么好写的。因此 C# 为我们提供了一个语法糖,我们可以只定义事件成员,不写访问器,也不写委托实例:

public event EVENT_HNDL EVENT;

上面的一行相当于隐式定义了一个委托字段,又定义了一个事件 EVENT,它具有默认的访问器。

用这种写法简写上例 design 的第三步:

    ...
    
    // Design-3. Define an event source:
    public class ConsoleSession
    {
        // [!!] Simplified:
        public event KeyPressEventHandler KeyPress;

        void OnKeyPress(KeyPressEventArgs args)
        {
            KeyPress?.Invoke(this, args);
        }

        public void Start()
        {
            var args = new KeyPressEventArgs();
            for (;;)
            {
                args.Key = Console.ReadKey(true);
                if (args.Key.GetHashCode() == 27) return;
                OnKeyPress(args);
            }
        }
    }
    
    ...

进一步,我们还可以直接使用 System.EventHandler 简化 design 阶段。

上面的例子使用默认 EventHandler 简写:

    ...

    class KeyPressEventArgs : EventArgs
    {
        public ConsoleKeyInfo Key { get; set; }
    }

    class ConsoleSession
    {
        public event EventHandler KeyPress;

        void OnKeyPress(EventArgs args)
        {
            KeyPress?.Invoke(this, args);
        }

        public void Start()
        {
            var args = new KeyPressEventArgs();
            for (;;)
            {
                args.Key = Console.ReadKey(true);
                if (args.Key.GetHashCode() == 27) return;
                OnKeyPress(args);
            }
        }
    }

    public static class ConsoleKeyListener
    {
        public static void KeyPressEventProcessor(object sender, EventArgs e)
        {
            Console.Write("!");
        }
    }

    ...

你可能会问,我们定义了 KeyPressEventArgs,却在事件处理器中将其作为 EventArgs 接受,要怎么再拿出其中的时间参数呢?

还记得我们之前学过的拆箱吗:

...
    public static void KeyPressEventProcessor(object sender, EventArgs e)
    {
        var key = e as KeyPressEventArgs;
        Console.WriteLine(key?.Key.GetHashCode());
    }
...

这里我们将 e 拆箱,“升级”成 KeyPressEventArgs 对象,由于 KeyPressEventArgs 派生自 EventArgs,且我们能够确定传入的 e 一定是 KeyPressEventArgs 对象在 OnKeyPress 被调用时装箱而成的,所以这个操作不会抛出异常;我们在访问 key 之前还对其进行了“防 null 检查”。

事件驱动

至此我们已经掌握了 C# 事件的大部分必要知识,也是整部笔记中最难的知识。

其实在实际开发中,我们更多的是用这些知识来调用平台已经为我们写好的事件,我们要做的只是编写合适的事件处理器,并绑定到事件上(相当于平台的开发者已经为我们完成了 design 阶段,我们只需要做 use 阶段的事情)。

我们程序员经常与“黑窗户”控制台打交道,但游戏是一个图形界面。图形界面是复杂的,没办法保证用户总是“先点击这个,再点击那个”,因此像控制台一样做“流程式”交互的思路是行不通的;我们必须定义“如果用户点这个,我们怎样响应”,再把实际的交互工作交给 .NET 中生生不息的“事件泵”去完成,我们称之为 事件驱动 的程序开发理念。

鉴于有的朋友还压根儿没下载好 Unity,只是来打语言基础的,我们在这里不涉及具体的 Unity API。但我想写一个小例子来简单体验一下事件驱动编程,我对 Winform 开发不感兴趣,因此也不会太过认真地去写了。

首先我们添加对 Windows.Forms 的依赖,Rider 中是这样操作的:

添加依赖

接着搜索“Fonts”,勾选并添加 System.Windows.Forms 依赖:

添加 Forms 依赖

然后敲代码:

using System.Windows.Forms;

namespace LearnEvents
{
    class ClickMe : Form
    {
        Label _lbl;
        Button _btn;
        int _remainingClicks;
        
        public ClickMe()
        {
            _lbl = new Label();
            _btn = new Button();
            Controls.Add(_lbl);
            Controls.Add(_btn);

            Text = "Click Game";
            Height = 180;
            Width = 250;
            FormBorderStyle = FormBorderStyle.FixedSingle;
            _lbl.Top = 40;
            _lbl.Left = 75;
            _lbl.Height = 20;
            _lbl.Width = 100;
            _lbl.Text = "↓ Click it!";
            _btn.Top = 60;
            _btn.Left = 75;
            _btn.Height = 50;
            _btn.Text = "No, don't!";

            _remainingClicks = 10;
            _btn.Click += (sender, e) =>
            {
                --_remainingClicks;
                if (_remainingClicks <= 0)
                {
                    _btn.Enabled = false;
                    _btn.Text = $"I'm dead";
                    _lbl.Text = " Good job!!";
                }
                else
                {
                    _btn.Text = $"Remaining clicks: {_remainingClicks}";
                }
            };
        }
    }
    
    class Program
    {
        static void Main()
        {
            var form = new ClickMe();
            form.ShowDialog();
        }
    }
}

这是一个无聊的点按钮小程序:

Click Game
本节就先到这吧。


T.B.C.

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值