什么是委托?
从现实世界来看:
委托就是让别人来完成自己本来应该做的事,委托第三方间接地帮自己完成一些事情。
例:让舍友帮忙拿外卖。拿外卖本身是自己要做的事,但是为了偷懒也可以委托舍友这个第三方来帮自己实现拿外卖这件事。
从程序的世界来看:
1)委托是一种类,引用类型的数据类型
2)可以把委托看作一个集合,存的是对应的方法。 委托类型的实例(之后我们说使用委托,用完整的话来说就是使用委托类型的实例)可以引用/封装一到多个方法。
当然也不是什么样的方法都能与委托进行绑定,我们要保证:
这些方法的签名/参数列表(相同的参数数目,并且类型相同,顺序相同,参数的修饰符)和返回值与委托类型定义的参数类型和返回值保持一致。 之后就可以通过委托的实例间接的调用里面的方法。(封装方法,类型兼容)
之前使用类的时候,首先要声明一个类,告诉编译器这个类由哪些成员变量,方法组成。然后实例化这个类就可以使用它。
//声明
public class Person{
private string name;
private int age;
}
//实例化
Person p=new Person();
委托也是类,使用委托之前也是要经历这两个阶段:
1)声明委托,告诉编译器这个委托可以封装什么类型的方法
2)实例化委托
委托是种特殊的类,我们知道通过类的声明可以显而易见地看出这个类的组成部分。而委托是和方法绑定的,那么我们就可以通过声明委托来显而易见地知道这个委托能和什么样的方法绑定。
因此委托的声明有点像方法的声明
声明格式:
访问修饰符 delegate 方法返回值 委托名字(方法参数列表);
例如:
public delegate void MyDelegate1();
//无参无返回值的方法 =>匹配的是: public void fun(){}
public delegate int MyDelegateWithArg(int a, string b);
//1个参数int,第2个参数string,返回值int的方法 => 匹配的是 public int fun(int a,string b){return 1;}
先定义一个委托类型的变量(顺着上面的例子,为声明的无参无返回值的委托创建一个委托类型的变量):
private MyDelegate1 myDelegate1;
假设我们有两个无参无返回值的方法 ChangeColor 和 Log,我们希望调用委托 myDelegate1 的时候能够间接调用 ChangeColor 和 Log
委托的实例化可以有两种方式:
myDelegate1 = new MyDelegate1(ChangeColor);
myDelegate1 = ChangeColor;
需要注意的是,一个委托可以传入多个与之类型兼容的方法,然后调用委托时就会依次调用引用的方法(顺序和将方法绑定给委托的顺序是一样的),这叫多播委托。
我们通过 += 的方式为赋值后的委托变量添加多个方法(实例化算赋值,将委托变量=null也算赋值),有种“附加”的意思。
也可以用 -= 的方式为委托移除方法。
假设我们将方法绑定到委托的顺序如下:
myDelegate1 = ChangeColor;
myDelegate1 += Log;
那么之后在调用 myDelegate1 时会先调用 ChangeColor 方法,再调用 Log 方法。
注:
1) 将第二个方法绑定到委托时如果使用了"=",比如
myDelegate1 = ChangeColor;
myDelegate1 = Log;
那么之前所绑定的 ChangeColor 方法将会被覆盖掉,也就是说,以后如果调用 myDelegate1,最后只会调用 Log 方法而不会调用 ChangeColor 方法!因此这也是委托的缺点之一。如果一不小心将“+=“写成了”=",会把这个委托之前所封装的所有方法全部覆盖掉,造成严重的后果!
并且第一次给委托变量赋值时用的是 “=”,之后给委托附加方法时用的是 “+=”,但不管是 “=” 还是 “+=” ,本质上都是将方法绑定给委托,只是调用先后顺序不同罢了。这样不是用着有一点别扭吗?
不过,在实际开发中像这种类似用一个包装器来封装多个方法的场景,很少用委托去实现,取而代之的是用更加强大的事件(比如事件有一些约束,不会让程序员不小心犯下将"+=“写成”="的错误)。
2) 多播委托只能得到封装的最后一个方法的返回值,一般我们把多播委托的返回类型声明为void
比如我声明了这样一个委托:
public delegate int MyDelegateWithArg(int a, int b);
private MyDelegateWithArg myDelegate;
然后我准备两个与之匹配的方法,一个是加法,一个是乘法:
public int AddNum(int a,int b)
{
return a + b;
}
public int Multiply(int a,int b)
{
return a * b;
}
然后对委托进行实例化,将方法绑定到委托上:
myDelegate = AddNum;
myDelegate += Multiply;
那么最后调用委托时得到的返回值是 a*b 的结果,a+b 的结果被覆盖掉了。
接下来介绍一下如何使用/调用委托:
使用委托时就会依次调用该委托所封装的方法,有两种常见的调用方式:
1)委托变量名(传给方法的参数)
2)委托变量名.Invoke(传给方法的参数)
myDelegate1();
myDelegate1.Invoke();
int result=myDelegate2(1,"a"); //相当于把1和a传给这个委托封装的方法
补充说明:
myDelegate1?.Invoke()
?.相当于下面的代码:
if(myDelegate1!=null){
myDelegate1.Invoke();
}
那么现在用一个 Unity 的小 demo 来串一遍使用委托需要经历的三个步骤(声明,实例化,调用),我们需要用委托实现当鼠标点击场景中的方块时会将方块变成绿色,并且输出当前的时间和一个加法运算的结果。我们直接创建一个脚本挂在场景中的方块上,因为场景的搭建很简单,这里就不展示了,我们直接看脚本的代码:
public class DelegateDemo : MonoBehaviour
{
//step1:声明委托类型,确定委托存储的方法类型
public delegate void MyDelegate1(); //无参无返回值的方法
//step2:通过委托类型,创建委托类型的变量
private MyDelegate1 myDelegate1;
public delegate int MyDelegateWithArg(int a, int b);
private MyDelegateWithArg myDelegate2;
private void Awake()
{
myDelegate1 = new MyDelegate1(ChangeColor); //因为委托是种类,所以创建实例方式和类一样
//myDelegate1 = ChangeColor; //直接把方法赋值给委托变量也行
myDelegate1 += Log; //把第二个方法赋加给同一个委托,这个叫做多播委托
myDelegate2 += AddNum;
}
private void OnMouseDown()
{
//使用委托间接调用方法
myDelegate1();
//第二种调用形式
//myDelegate1.Invoke();
print(myDelegate2(1, 2));
}
public void ChangeColor()
{
gameObject.GetComponent<MeshRenderer>().material.color = Color.green;
}
private void Log()
{
print("Current time is:" + DateTime.UtcNow);
}
public int AddNum(int a,int b)
{
return a + b;
}
}
运行结果:
点击的是左边的这个方块。可以看到产生了我们想要的结果。
⭐补充说明:
有些小伙伴在测试这段程序时,会发现如果在 Awake 方法里这么为委托绑定方法,并不会报错:
public delegate void MyDelegate1();
private MyDelegate1 myDelegate1;
public delegate int MyDelegateWithArg(int a, int b);
private MyDelegateWithArg myDelegate2;
private void Awake()
{
myDelegate1 += ChangeColor; //注意看这行
myDelegate1 += Log;
myDelegate2 += AddNum;
}
为什么这里在定义了 MyDelegate1 委托类型的变量 myDelegate1 后,在第一次把方法绑定给 myDelegate1 时能直接使用 “+=”?不是说第一次给委托变量赋值要用 “=”吗?
这里重新回顾一下我之前对 “+=” 的总结:
我们通过 += 的方式为赋值后的委托变量添加多个方法(实例化算赋值,将委托变量=null也算赋值)
既然用 += 不报错,说明 myDelegate1 这个委托变量是已经赋值过了。而我们没给它实例化,那么只剩下一种情况:这个委托变量已经被赋值成了 null
可是我们之前也没有显式地让 myDelegate1=null 呀。这里需要注意的是,myDelegate1 这个委托变量是类中的一个成员。而类中的成员变量会被默认初始化。因为引用类型的成员变量会被默认初始化 null,而委托是引用类型,所以这个 myDelegate1被默认赋了个 null 值,我们之后可以对已赋值的委托变量使用 “+=”
类中的成员变量会被默认初始化,可是方法中的局部变量就不会被默认初始化了,比如下面这段 C#程序:
public delegate void MyDelegate();
class Program{
public static void Test(){}
static void Main(string[] args)
{
MyDelegate myDelegate;
myDelegate += Test; //这行会报错
}
}
这个时候编译器会报错:
因为此时这个委托变量是在方法中定义,是个局部变量,它并不会被默认初始化,也就是还未被赋值。那么我们这时也就不能用 += 为委托变量绑定方法了。除非把 myDelegate 显式地赋值成 null:
这样就不报错了。
虽然委托的功能很强大,但是也不建议滥用,否则可能适得其反。
缺点 1: 把多个方法绑定给委托时如果不小心把 “+=” 写成 “=”,会把委托之前绑定的方法全覆盖掉,这样会使程序的安全性降低。
缺点 2: 过度使用委托可能会导致内存泄漏。因为委托会引用一个方法,如果这个方法是实例方法(非静态),那么这个方法必定隶属于一个对象。拿一个委托绑定这个方法,那么这个方法所属的对象就必定存在于内存中。即使没有其他引用变量引用这个对象了,这个对象的内存也不能被释放,因为一旦释放了对象的内存,委托就不能间接调用对象的方法了。除非这个实例方法和委托解绑,方法所属的对象才会在没用的时候被释放掉。随着泄露的内存越来越多,程序的性能会下降。
缺点3: 过度使用委托会使可读性下降。比如一个委托绑定了一个方法,这个方法又会传入另一个委托,这种写法会使代码理解起来比较困难,并且 Debug 的难度也会增加。可能你过一阵子再回看这种嵌套的委托,就完全不知道自己当初写的代码是什么意思了。
大多数情况下,我们不需要去自定义委托,因为C#提供了一些内置的委托:
注:引入 System 命名空间
Action委托可以封装void返回类型的方法,先看Action委托有哪些:(T表示方法参数的类型)
Action //可封装无参无返回值类型的方法
Action<T> //可封装有一个参数,无返回值类型的方法
Action<T1,T2>
Action<T1,T2 .... T16>
(共有16种重载形式)
之前我们自定义委托时要先声明再实例化,但Action可以直接实例化。因为Action可指向的方法类型系统已经定义好了,我们可通过泛型来限定方法传入的参数类型
public delegate void Mydelegate();
Mydelegate myDelegate;
//等同于
Action myDelegate;
Func可以封装带有一个返回值的方法,它可以传递0或者多到16个参数类型(参数类型要和指定的方法的参数列表顺序一致),和一个返回类型(泛型中的最后一个类型)
Func<out TResult>
Func<T,out TResult>
Func<T1,T2....T16,out TResult>
//例如
Func<int,float,string> func;
//可封装第一个参数是int,第二个参数是float,返回值是string的方法
引入命名空间:using UnityEngine.Events;
定义方式类似:public UnityAction action;
和 C# 的 Action 一样,UnityAction 可以引用带有一个 void 返回类型的方法,但是它最多只有4个参数的重载
注意点:
Unity中的AddListener功能需要传入一个UnityAction类型的委托,为一个监听器添加监听事件,当监听事件触发后,会去调用传给监听器的委托,从而去间接调用此委托所封装的方法。
以 UGUI 的 Button 为例,为按钮添加监听事件,点击按钮时触发:
可以看到 AddListener 方法规定了只能传入 UnityAction 类型的参数
通过委托的方式把方法当作参数传给另一个方法
好处:我们在方法中使用传进来的委托,间接地调用委托封装的方法,形成了一种动态调用方法的代码结构。
相当于写了一个方法作为模板。这个模板里有一处是不确定的,其余部分是确定好的。这个不确定的部分就靠传进来的委托参数所包含的方法来填补。
就像作文模板,有一些句子是固定的,但是中间会留出一些空。我们要做的就是根据不同的主题填上符合写作主题的句子,最后拼成一篇还算不错的作文。
例:
Nowadays,there is a growing awareness of the necessity to______.There may be a combination of factors which contribute to . To begin with,____. In addition,. Furthermore,______.
对于一些方法来说,它们可能有些相同的逻辑代码,但是中间有一部分的逻辑代码是不同的。如果我们仅为了中间这段不同的逻辑去编写这些方法,那么就会不得不重复编写那些相同的逻辑。
而我们的目的是提高代码复用性,可扩展性。
复用性:把重复使用的代码封装成方法
可扩展性:遵循 “开闭原则”,即面向修改关闭,面向扩展开放。尽量保证面对新的需求时不去修改原有的代码,而是在原有代码的基础上进行扩展。
举个切换游戏场景的例子,需求是在切换场景时要加载新场景的游戏资源,然后等加载完毕后去触发一些事情,比如说:
//方法一:
切换场景1:
每一帧加载场景资源(循环)
加载完毕后:
开启对话UI
//方法二:
切换场景2:
每一帧加载场景(循环)
加载完毕后:
播放某一段动画
重点把握相同的逻辑和不同的逻辑:
每一帧加载场景资源是每个切换场景方法的相同逻辑,场景加载完毕后做的事情是不同的逻辑。
我们这里为切换到不同的场景编写对应的切换场景方法。
但是如果场景有多个,或者将来新增游戏场景,在编写切换场景方法时就不得不重复编写“每一帧加载场景”这一段代码,这是很麻烦的事。
可能有的童鞋想偷偷懒,直接用 CV 大法:复制粘贴!
但是复制粘贴相同代码也有缺点:
1)代码量变多了以后不方便找到要复制的代码
2)相同的逻辑万一也要进行修改。比如说现在游戏需要在切换场景时加个进度条,在每一帧加载资源时显示当前加载进度,那么还要找到每一个切换场景的方法进行修改
可能也有的童鞋会想把资源加载完毕后做的事封装成不同的方法,然后只要写一个统一的切换场景方法,用 if 语句去判断当前是哪一个场景或者说下一个跳转的场景是哪一个,再决定应该调用哪一个封装的方法。但是同样的,将来如果新增场景,还要修改原有的切换场景方法,即补上一个新的条件判断,显然破坏了可扩展性。
用委托改进:
切换场景: (委托A作为方法参数)
每一帧加载场景,更新进度条(循环)
加载完毕后:
调用委托A
将“开启对话UI”封装成方法 => 切换场景1时将此方法传给“切换场景”方法
将“播放某一段动画”封装成方法 => 切换场景2时将此方法传给“切换场景”方法
…
将切换不同场景时的不同代码逻辑封装成方法,传给原本切换场景的方法
但其实更新进度条这一操作也能以委托的方式传给原本的方法,如果把它也当作新增的需求,那么还要对原本相同的逻辑代码进行修改。虽然此时只用改动一个方法(最早要对每一个切换场景方法进行修改,现在切换场景的方法只有一个),但还是破坏了可扩展性。
用委托可以改进,即在每一帧加载场景时调用这个委托,将“更新进度条”封装成方法传给原本的切换场景方法。
最终改法:
切换场景: (委托A,委托B作为方法参数)
每一帧加载场景,调用委托B(循环)
加载完毕后:
调用委托A
将“更新进度条”封装成方法 ->切换场景1时将此方法传给“切换场景”方法的委托B
在 Unity 中用代码实现异步加载场景的方法:(展示的只是场景切换脚本的部分代码)
//加载场景
public void LoadScene(int index,Action<float>onProgressChange,Action onFinish)
{
StartCoroutine(I_LoadScene(index,onProgressChange,onFinish));
}
private IEnumerator I_LoadScene(int index,Action<float>onProgressChange,Action onFinish)
{
AsyncOperation asyncOperation = SceneManager.LoadSceneAsync(index); //异步方法
while (!asyncOperation.isDone)
{
yield return null;
//加载进度变化时调用的委托B
onProgressChange?.Invoke(asyncOperation.progress);
}
//加载完等一秒
yield return new WaitForSeconds(1f);
//加载完毕调用的委托A
onFinish?.Invoke();
}
这样,我只需准备一个切换场景的方法 (复用性提高),以后不论新增多少个场景,我只要对委托A和委托B处做的事情封装成相应的方法,在调用切换场景方法的时候传给原有的切换场景方法就行了 (动态调用)。我要做的只是为新的需求扩展新的方法,而原来的切换场景方法无需做任何改动 (可扩展性提高)。
如此可见,我们可以利用委托的模板思想来优化代码结构。
特别鸣谢:B站的BeaverJoe大佬,他的视频让我对委托有了更深刻的认识。如果你是 Unity 的学习者,我也非常推荐大家看一看这位宝藏 up 主制作的视频!质量扛扛滴!
委托与事件系列:
C#委托(结合 Unity)
C#事件(结合 Unity)
观察者模式(结合C# Unity)
Unity 事件管理中心
事件番外篇:UnityEvent
Unity 事件番外篇:事件管理中心(另一种版本)