Unity学习-委托代理Delegate

Unity 学习总结

学习背景

	距离上一篇文章已经过去8个月了-_-,其实上次的2D早就做完了,之后又做了很多东西,不过一直懒得写博客,
这个假期来把前面的都补上顺便复习^_^。

上一篇文章在这里

问题解决

	先来解决上一篇文章作为初学者遗留的问题,回看上篇文章发现当时对游戏制作方法的理解还是可以的,关键在
于对c#认知不足导致协程和代理完全没搞懂,代码一部分也是纯模仿没有本质理解。

代理(delegate)

代理的语法

    public delegate void A(int param);//声明一个代理类型
    class Program
    {
        static void Main(string[] args)
        {
            A a1 = new A(DelegateA);//创建代理对象
            A a2 = DelegateA;//通过重载后的赋值运算符创建代理对象
            TestDelegate(a2);//直接传入委托对象作为实参(地址传递因为是引用类型)
            TestDelegate(DelegateA);//传入委托函数重新实例化一个委托对象作为实参
            a1 += DelegateB;//通过重载后的+运算符添加代理对象中委托的函数
            a1.Invoke(3);//委托的调用
        }
        public static void DelegateA(int _param)
        {
            Console.WriteLine("123");
        }
        public static void DelegateB(int _param)
        {
            Console.WriteLine("456");
        }
        public static void TestDelegate(A _a)
        {
            _a.Invoke(5);//委托的调用
        }
    }

首先要强调代理不是函数,先不要将它和函数联系起来,我们常说代理是函数指针,规定了函数形式,这只是我们对他的理解,从定义上讲代理是一个独立的类型,采用delegate关键字,它更像用class声明的类,用delegate声明的代理类型继承自System.Delegate抽象类,是引用类型,可以通过new创建对应的实例或者使用重载后的=运算符创建实例,通过重载后的+=运算符添加委托函数。
委托常见有两种使用
1.作为函数的参数
这时你即可以创建一个像a1一样的委托对象传入,也可以传入一个参数相当于直接实例化一个新的委托对象作为实参。
2.作为函数的抽象或者集合
这时主要是使用+=添加委托函数,和使用委托对象的Invoke成员函数调用所有委托函数

总结
在我初学的时候,总是因为代理类型可以直接传入函数或者用函数实例化就把代理和函数混为一谈,不能总用c++的函数指针眼光去企图完整理解它,现在看来,只有把代理看作一个独立的自定义引用类型,以看待class类类型的眼光去看待它才能正确理解代理,代理并不只是函数指针,它的作用远远多于函数指针

代理是一个非常好用的工具,先放几个代理的使用例子来感受一下
(谈的会比较细,不想看的话可以直接跳到后面看结论)

1.异步加载场景中使用代理提供回调函数服务

    public delegate void LoadSceneOverCallback();
    public IEnumerator LoadScene(string sceneName, float fadeInTime, float fadeOutTime, LoadSceneOverCallback loadSceneOver)

异步加载场景实际上在逻辑上分为多个步骤,开始异步加载→场景淡出画布(场景过渡画面)→场景加载完成→场景淡入画布→开始恢复接收输入。

场景的过渡函数一般是由单独的单例类SceneController提供的,它是DontDestroyOnLoad的。我们有时面临这样的需求:我们需要在加载场景时做一些动态加载人物和物体等并非DontDestroyOnLoad或者已经存在于下个场景中的物体,他们需要在加载完下一个场景物体后(或者之前,这取决于脚本的依赖关系)进行加载,将他们放在SceneController或者其他跨场景存在(DontDestroyOnLoad)的脚本中显然不合适,因为这些类都是工具类不应该直接和某一关的游戏逻辑相关,最好的方法是在加载下一关的前一关中的某个逻辑脚本中处理这件事,但矛盾也就显现出来。

假设我们要从场景1去到场景2,场景1的脚本A中有初始化下一个场景的函数,当下一个场景物体被加载出来的时候脚本A就已经被销毁,即你企图在LoadScene协程结束后初始化你的下一个场景物体是无意义的,因为这时候脚本A已经不存在后续代码也不会被执行,所以我们让SceneController这个跨场景单例替A完成它未完成的事,但A要告诉SceneController这件事是什么(也就是通过参数传入的那个回调函数),在加载完下一个场景后SceneController就会Invoke回调函数

    public IEnumerator LoadScene(string sceneName, float fadeInTime, float fadeOutTime, LoadSceneOverCallback loadSceneOver)
    {
        fadeCanvas = Instantiate(fadeCanvasSkin);
        SceneFader sceneFader = fadeCanvas.GetComponent<SceneFader>();
        AsyncOperation ao = SceneManager.LoadSceneAsync(sceneName);
        ao.allowSceneActivation = false;
        yield return sceneFader.FadeIn(fadeInTime);
        while (ao.progress < 0.9f)
        {
            yield return null;
        }
        ao.allowSceneActivation = true;
        while (SceneManager.GetActiveScene().name != sceneName)
        {
            yield return null;
        }
        loadSceneOver.Invoke();
        yield return sceneFader.FadeOut(fadeOutTime);
        Destroy(fadeCanvas);
    }

小结
可以看到,在这个例子中代理的作用就是对外提供了一个阶段的自定义(也可以是多个阶段),如果你想在这个阶段做什么事就把它作为参数传进来,我会在对应的时候Invoke它。
代理的使用增强了函数的灵活性和功能,一个函数的代码是死的,但传入的参数是无穷多的,不同函数参数使LoadScene这个函数似乎也能满足无穷多关卡各种各样的要求

变量作为参数指定的是值,而函数作为参数指定的是某件事某个方法,可以说代理就是为了函数作为参数而存在的

2.网络游戏中客户端注册消息回调函数

  我们知道网络游戏中客户端需要像服务器发送不同种类的消息搭载相应的信息交给服务器去处理逻辑,消息中一
般都至少包含两部分Header(MsgName)和Param,Header是用于区分消息类型的,Param是具体的消息参数。比如
Move消息,Header可能直接就是字符串“Move”,Param就包括原Position,Rotation和移动后的Position和
Rotation(状态同步)或者是玩家的输入(前进后退等)。

  往往网络通信的模块是与游戏逻辑分离的,客户端和服务端都有一个NetManager静态类来负责通信。那么我们
需要一个抽象的方法来去规定各种各样的消息(一个游戏可能有成百上千条),让NetManager类能够在收到消息时
自动将消息分发给对应的处理函数。

  目前我使用过的有两种方法,第一种是利用c#的反射建立一个由消息头到函数的反射表,一个简单的例子就是直接
以消息头字符串作为函数名去分配消息(Move消息对应的处理函数名就是Move);另一种就是利用代理,提供一个
注册方法,将对应的函数放在一个字典中实现查询表。

  个人感觉第一种更适合在服务器,直接规定对应关系,由一个函数即可处理一类消息的对应逻辑。
  第二种更适合在客户端,因为一个服务端处理结果消息产生的影响可能涉及到多个物体,并且有的物体可能在这个
时候接收消息,别的时候不接收消息,消息的影响会根据游戏的进度和物体的生命周期而不同,所以应该由各个游戏
物体脚本动态的去注册和取消注册消息接受函数

  以下是一个客户端的NetManager部分代码(上述第二种情况)
    private static Dictionary<string, MsgListener> msgListeners = new Dictionary<string, MsgListener>();
    private static Dictionary<NetEvent, EventListener> eventListeners = new Dictionary<NetEvent, EventListener>();
    
    public delegate void MsgListener(MsgBase msgBase);
    public delegate void EventListener(string err);
    
    public static void AddMsgListener(string msgName,MsgListener listener)
    public static void RemoveMsgListener(string msgName,MsgListener msgListener)
    public static void AddEventListener(NetEvent netEvent,EventListener eventListener)
    public static void RemoveEventListener(NetEvent netEvent, EventListener eventListener)
    
    public static void FireMsg(string MsgName,MsgBase msg)
    public static void FireEvent(NetEvent netEvent,string err)

MsgListener代理规定了能注册为接收消息的函数必须接收一个MsgBase参数(也就是消息本身,MsgBase是所有消息的基类)并且返回值为void,Event是一类特殊的消息,它属于游戏逻辑之外,比如登录登出,异地登录等特殊事件,EventListener规定了该类接收函数必须接受一个字符串参数(错误原因等)。

当服务器返回的消息到来时,比如MoveMsg就会影响到对应的玩家角色,NetManager可以很方便的通过FireMsg和msgListeners遍历所有需要监听MoveMsg结果的游戏物体

    public static void FireMsg(string MsgName,MsgBase msg)
    {
        if(msgListeners.ContainsKey(MsgName))
        {
            msgListeners[MsgName](msg);
        }
    }

小结
在这个例子中代理的作用并不是像上个例子中代表一个阶段,而是一类函数的抽象,即满足MsgListener代理格式的函数就可以作为服务器返回消息的监听者,统一由NetManager的FireMsg函数调用。帮助实现网络模块和游戏逻辑模块解耦

3.带了面具的代理

搞清楚c#代理的基本语法和使用后你会发现在unity官方代码中却很少见到delegate,就算是c#的官方命名空间中也几乎没有delegate,对于底层抽象代码来说代理的重要性不言而喻,那为什么找不到delegate呢,其实它只是变了很多名字,举几个最常见的例子

public int RemoveAll(Predicate<T> match);//List的RemoveAll函数
public void AddListener(UnityAction call);//Unity Button的添加监听函数
public UnityEvent();// Unity Event,没找到例子拉个构造函数充个数

他们的delegate原型

public bool Predicate<T>(T param);
public void UnityAction<T>(T param1);//通过重载最多支持4个参数
public void UnityEvent();

Predicate就是接受一个参数返回值为bool类型的代理,用于筛选条件的判断
UnityAction和UnityEvent是Unity提供的两个代理类型,Action就是一个动作,动作当然是接受输入(有参数)然后在函数体中执行(无返回值)的。Event就是一个事件,没有参数也没有返回值。

我们来看看Unity安排这两个代理有什么用。游戏逻辑就是由一个接着一个的事件组成的,比如玩家死了这是一个事件,动作1:队友收到消息更新对应UI或者产生反应 ,动作2:击杀它的NPC播放庆祝动画 动作3:NPC升级或者被强化,动作4:游戏中的Manager类更新玩家列表等信息,动作5:更新该玩家的生涯战绩等。。。。。
在PlayerDie这个事件中你可以规定好各个动作的发生顺序,当玩家死了你只需要PlayerDie.Invoke()即可执行所有玩家死亡后应该发生的动作逻辑。你可以很方便的用这两个代理构建你自己的游戏逻辑
因为每个代理都是要有名字的(匿名情况后面再讨论),Unity和C#官方实际上都是把最常用的代理给出了一个规范的名称,避免你一次定义一个名字把项目的代理结构搞得很乱。

代理与匿名函数

  其实这部分和代理的知识内容关系不大,这主要涉及一些与匿名有关的内容,但在代理的使用中无比常见并且我
初学的时候很容易搞混,不清楚具体每种语法是什么意思,所以在此解释一下。先看几种常见的表示。
	equipments.RemoveAll((EquipmentData_SO ed) => { return true; });
	createButton.onClick.AddListener(CreateRoom);
	joinButton.onClick.AddListener(delegate { JoinRoom(info.roomId); });
	joinButton.onClick.AddListener(() => { JoinRoom(info.roomId); });

第一种是最常见的,传入的是一个函数,只不过是匿名函数采用lambda表达式语法,适用于函数体不太长并且仅使用这一次的情况。
第二种是常规情况,传入具体的实名函数。
第三个是什么鬼,我们知道当代理作为函数参数时我们有两类传参选择,第一是传入一个函数(指针)用它初始化一个代理实例,第二是直接传入一个代理实例。我们看到了delegate关键字,所以这是第二类吗?并不是,这实际上是lambda表达式的另一种写法,仍然属于第一类情况,它和第四种写法等价。它看起来很奇怪是因为它使用了函数的嵌套,当我们想要执行的函数类型与代理规定的类型不相符时,就需要将函数包含在另一个符合代理规定的匿名函数(或者也可以是实名函数)的函数体中传入,它的参数一部分由代理规定的提供,多出来的部分需要我们自己规定。最典型的例子就是脚本为Button添加含参监听函数,Button的Addlistener仅有无参UnityAction一种,我们只能采取上述方法实现伪含参调用。

实际上如果代理参数只需要调用一个函数的话,几乎不会用到第二类传参情况(传入代理实例),第二类情况是为一次传入多个同类型函数调用做准备的。

总结

1.代理是自定义引用类型,按照类型的眼光去看待而不是函数指针,代理与函数密切相关,但函数只是代理的一部分,代理要高于函数
2.代理主要有两大用法,函数参数和抽象调用,实际上函数参数也是为了对传入的函数进行调用,本质都是对一类函数的回调,“回调”是代理存在的意义所在。
3.如果说泛型和泛型约束是对类型的抽象,代理就是对函数的抽象,代理包含了确定一个函数所需的所有特征,代理规定了其接受的函数的模板。
4.日常使用中更多的是一些已经被官方规定名字的代理,匿名函数和lambda表达式与代理的定义无关但与其使用息息相关。

上一篇:iOS基于AVFoundation实现朗读文字


下一篇:浏览器加载模式:window.onload和$(document).ready()的区别(详解)