转载

匹夫细说C#:庖丁解牛聊委托,那些编译器藏的和U3D给的

0x00 前言

由于工作繁忙所以距离上一篇博客已经过去一个多月的时间了,因此决心这个周末无论如何也得写点东西出来,既是总结也是分享。那么本文主要的内容集中在了委托的使用以及内部结构(当然还有事件了,但是受制于篇幅故分为两篇文章)以及结合一部分Unity3D的设计思考。当然由于时间仓促,文中难免有一些疏漏和不准确,也欢迎各位指出,共同进步。

0x01 从观察者模式说起

在设计模式中,有一种我们常常会用到的设计模式——观察者模式。那么这种设计模式和我们的主题“如何在Unity3D中使用委托”有什么关系呢?别急,先让我们来聊一聊什么是观察者模式。

首先让我们来看看报纸和杂志的订阅是怎么一回事:

  1. 报社的任务便是出版报纸。
  2. 向某家报社订阅他们的报纸,只要他们有新的报纸出版便会向你发放。也就是说,只要你是他们的订阅客户,便可以一直收到新的报纸。
  3. 如果不再需要这份报纸,则可以取消订阅。取消之后,报社便不会再送新的报纸过来。
  4. 报社和订阅者是两个不同的主体,只要报社还一直存在着,不同的订阅者便可以来订阅或取消订阅。

如果各位读者能看明白我上面所说的报纸和杂志是如何订阅的,那么各位也就了解了观察者模式到底是怎么一回事。除了名称不大一样,在观察者模式中,报社或者说出版者被称为“主题”(Subject),而订阅者则被称为“观察者”(Observer)。将上面的报社和订阅者的关系移植到观察者模式中,就变成了如下这样:主题(Subject)对象管理某些数据,当主题内的数据改变时,便会通知已经订阅(注册)的观察者,而已经注册主题的观察者此时便会收到主题数据改变的通知并更新,而没有注册的对象则不会被通知。

当我们试图去勾勒观察者模式时,可以使用报纸订阅服务,或者出版者和订阅者来比拟。而在实际的开发中,观察者模式被定义为了如下这样:

观察者模式:定义了对象之间的一对多依赖,这样一来,当一个对象改变状态时,它的所有依赖者都会收到通知并自动更新。

那么介绍了这么多观察者模式,是不是也该说一说委托了呢?是的,C#语言通过委托来实现回调函数的机制,而回调函数是一种很有用的编程机制,可以被广泛的用在观察者模式中。

那么Unity3D本身是否有提供这种机制呢?答案也是肯定的,那么和委托又有什么区别呢?下面就让我们来聊一聊这个话题。

0x02 向Unity3D中的SendMessage和BroadcastMessage说拜拜

当然,不可否认Unity3D游戏引擎的出现是游戏开发者的一大福音。但不得不说的是,Unity3D的游戏脚本的架构中是存在一些缺陷的。一个很好的例子就是本节要说的围绕SendMessage和BroadcastMessage而构建的消息系统。之所以说Unity3D的这套消息系统存在缺陷,主要是由于SendMessage和BroadcastMessage过于依赖反射机制(reflection)来查找消息对应的回调函数。频繁的使用反射自然会影响性能,但是性能的损耗还并非最为严重的问题,更加严重的问题是使用这种机制之后代码的维护成本。为什么说这样做是一个很糟糕的事情呢?因为使用字符串来标识一个方法可能会导致很多隐患的出现。举一个例子:假如开发团队中某个开发者决定要重构某些代码,很不巧,这部分代码便是那些可能要被这些消息调用的方法定义的代码,那么如果方法被重新命名甚至被删除,是否会导致很严重的隐患呢?答案是yes。这种隐患的可怕之处并不在于可能引发的编译时错误,恰恰相反,这种隐患的可怕之处在于编译器可能都不会报错来提醒开发者某些方法已经被改名甚至是不存在了,面对一个能够正常的运行程序而没有警觉是最可怕的,而什么时候这个隐患会爆发呢?就是触发了特定的消息而找不到对应的方法的时候 ,但这时候发现问题所在往往已经太迟了。

另一个潜在的问题是由于使用了反射机制因而Unity3D的这套消息系统也能够调用声明为私有的方法的。但是如果一个私有方法在声明的类的内部没有被使用,那么正常的想法肯定都认为这是一段废代码,因为在这个类的外部不可能有人会调用它。那么对待废代码的态度是什么呢?我想很多开发者都会选择消灭这段废代码,那么同样的隐患又会出现,可能在编译时并没有问题,甚至程序也能正常运行一段时间,但是只要触发了特定的消息而没有对应的方法,那便是这种隐患爆发的时候。因而,是时候向Unity3D中的SendMessage和BroadcastMessage说拜拜了,让我们选择C#的委托来实现自己的消息机制吧。

0x03 认识回调函数机制----委托

在非托管代码C/C++中也存在类似的回调机制,但是这些非成员函数的地址仅仅是一个内存地址。而这个地址并不携带任何额外的信息,例如函数的参数个数、参数类型、函数的返回值类型,因而我们说非托管C/C++代码的回调函数不是类型安全的。而C#中提供的回调函数的机制便是委托,一种类型安全的机制。为了直观的了解委托,我们先来看一段代码:

using UnityEngine; using System.Collections; public class DelegateScript : MonoBehaviour {    //声明一个委托类型,它的实例引用一个方法  internal delegate void MyDelegate(int num);  MyDelegate myDelegate;  void Start ()  {   //委托类型MyDelegate的实例myDelegate引用的方法   //是PrintNum    myDelegate = PrintNum;   myDelegate(50);   //委托类型MyDelegate的实例myDelegate引用的方法   //DoubleNum        myDelegate = DoubleNum;   myDelegate(50);  }  void PrintNum(int num)  {   Debug.Log ("Print Num: " + num);  }  void DoubleNum(int num)  {   Debug.Log ("Double Num: " + num * 2);  } } 

下面我们来看看这段代码做的事情。在最开始,我们可以看到internal委托类型MyDelegate的声明。委托要确定一个回调方法签名,包括参数以及返回类型等等,在本例中MyDelegate委托制定的回调方法的参数类型是int型,同时返回类型为void。

DelegateScript类还定义了两个私有方法PrintNum和DoubleNum,它们的分别实现了打印传入的参数和打印传入的参数的两倍的功能。在Start方法中,MyDelegate类的实例myDelegate分别引用了这两个方法,并且分别调用了这两个方法。

看到这里,不知道各位读者是否会产生一些疑问,为什么一个方法能够像这样myDelegate = PrintNum; “赋值”给一个委托呢?这便不得不提C#2为委托提供的方法组转换。回溯C#1的委托机制,也就是十分原始的委托机制中,如果要创建一个委托实例就必须要同时指定委托类型和要调用的方法(执行的操作),因而刚刚的那行代码就要被改为:

new MyDelegate(PrintNum);

即便回到C#1的时代,这行创建新的委托实例的代码看上去似乎并没有让开发者产生什么不好的印象,但是如果是作为较长的一个表达式的一部分时,就会让人感觉很冗繁了。一个明显的例子是在启动一个新的线程时候的表达式:

Thread th = new Thread(new ThreadStart(Method));

这样看起来,C#1中的方式似乎并不简洁。因而C#2为委托引入了方法组转换机制,即支持从方法到兼容的委托类型的隐式转换。就如同我们一开始的例子中做的那样。

//使用方法组转换时,隐式转换会将  //一个方法组转换为具有兼容签名的  //任意委托类型  myDelegate = PrintNum;  Thread th = new Thread(Method);

而这套机制之所以叫方法组转换,一个重要的原因就是由于重载,可能不止一个方法适用。例如下面这段代码所演示的那样:

using UnityEngine; using System.Collections; public class DelegateScript : MonoBehaviour {    //声明一个委托类型,它的实例引用一个方法 delegate void MyDelegate(int num);  //声明一个委托类型,它的实例引用一个方法  delegate void MyDelegate2(int num, int num2);  MyDelegate myDelegate;  MyDelegate2 myDelegate2;  void Start ()  {   //委托类型MyDelegate的实例myDelegate引用的方法   //是PrintNum    myDelegate = PrintNum;   myDelegate(50);   //委托类型MyDelegate2的实例myDelegate2引用的方法   //PrintNum的重载版本        myDelegate2 = PrintNum;   myDelegate(50, 50);  }  void PrintNum(int num)  {   Debug.Log ("Print Num: " + num);  }  void PrintNum(int num1, int num2)  {   int result = num1 + num2;   Debug.Log ("result num is : " + result);  } } 

这段代码中有两个方法名相同的方法:

void PrintNum(int num)

void PrintNum(int num1, int num2)

那么根据方法组转换机制,在向一个MyDelegate或一个MyDelegate2赋值时,都可以使用PrintNum作为方法组(此时有2个PrintNum,因而是“组”),编译器会选择合适的重载版本。

当然,涉及到委托的还有它的另外一个特点——委托参数的逆变性和委托返回类型的协变性。这个特性在很多文章中也有过介绍,但是这里为了使读者更加加深印象,因而要具体的介绍一下委托的这种特性。

在为委托实例引用方法时,C#允许引用类型的协变性和逆变性。协变性是指方法的返回类型可以是从委托的返回类型派生的一个派生类,也就是说协变性描述的是委托返回类型。逆变性则是指方法获取的参数的类型可以是委托的参数的类型的基类,换言之逆变性描述的是委托的参数类型。

例如,我们的项目中存在的基础单位类(BaseUnitClass)、士兵类(SoldierClass)以及英雄类(HeroClass),其中基础单位类BaseUnitClass作为基类派生出了士兵类SoldierClass和英雄类HeroClass,那么我们可以定义一个委托,就像下面这样:

delegate Object TellMeYourName(SoldierClass soldier);

那么我们完全可以通过构造一个该委托类型的实例来引用具有以下原型的方法:

string TellMeYourNameMethod(BaseUnitClass base);

在这个例子中,TellMeYourNameMethod方法的参数类型是BaseUnitClass,它是TellMeYourName委托的参数类型SoldierClass的基类,这种参数的逆变性是允许的;而TellMeYourNameMethod方法的返回值类型为string,是派生自TellMeYourName委托的返回值类型Object的,因而这种返回类型的协变性也是允许的。但是有一点需要指出的是,协变性和逆变性仅仅支持引用类型,所以如果是值类型或void则不支持。下面我们接着举一个例子,如果将TellMeYourNameMethod方法的返回类型改为值类型int,如下:

int TellMeYourNameMethod(BaseUnitClass base);

这个方法除了返回类型从string(引用类型)变成了int(值类型)之外,什么都没有被改变,但是如果要将这个方法绑定到刚刚的委托实例上,编译器会报错。虽然int型和string型一样,都派生自Object类,但是int型是值类型,因而是不支持协变性的。这一点,各位读者在实际的开发中一定要注意。

好了,到此我们应该对委托有了一个初步的直观印象。在本节中我带领大家直观的认识了委托如何在代码中使用,以及通过C#2引入的方法组转换机制为委托实例引用合适的方法以及委托的协变性和逆变性。那么本节就到此结束,接下来让我们更进一步的探索委托。

0x04 委托是如何实现的

让我们重新定义一个委托并创建它的实例,之后再为该实例绑定一个方法并调用它:

internal delegate void MyDelegate(int number);  MyDelegate myDelegate = new MyDelegate(myMethod1);  myDelegate = myMethod2;  myDelegate(10);

从表面看,委托似乎十分简单,让我们拆分一下这段代码:用C#中的delegate关键字定义了一个委托类型MyDelegate;使用new操作符来构造一个MyDelegate委托的实例myDelegate,通过构造函数创建的委托实例myDelegate此时所引用的方法是myMethod1,之后我们通过方法组转换为myDelegate绑定另一个对应的方法myMethod2;最后,用调用方法的语法来调用回调函数。看上去一切都十分简单,但实际情况是这样吗?

事实上编译器和Mono运行时在幕后做了大量的工作来隐藏委托机制实现的复杂性。那么本节就要来揭开委托到底是如何实现的这个谜题。

下面让我们把目光重新聚焦在刚刚定义委托类型的那行代码上:

internal delegate void MyDelegate(int number);

这行对开发者们来说十分简单的代码背后,编译器为我们做了哪些幕后的工作呢?

让我们使用Refactor反编译C#程序,可以看到如下图的结果:

匹夫细说C#:庖丁解牛聊委托,那些编译器藏的和U3D给的

可以看到,编译器实际上为我们定义了一个完整的类MyDelegate:

internal class MyDelegate : System.MulticastDelegate {  //构造器   [MethodImpl(0, MethodCodeType=MethodCodeType.Runtime)]  public MyDelegate(object @object, IntPtr method);  // Invoke这个方法的原型和源代码指定的一样   [MethodImpl(0, MethodCodeType=MethodCodeType.Runtime)]  public virtual void Invoke(int number);  //以下的两个方法实现对绑定的回调函数的一步回调   [MethodImpl(0, MethodCodeType=MethodCodeType.Runtime)]  public virtual IAsyncResult BeginInvoke(int number, AsyncCallback callback, object @object);  [MethodImpl(0, MethodCodeType=MethodCodeType.Runtime)]  public virtual void EndInvoke(IAsyncResult result); } 

可以看到,编译器为我们的MyDelegate类定义了4个方法:一个构造器、Invoke、BeginInvoke以及EndInvoke。而MyDelegate类本身又派生自基础类库中定义的System.MulticastDelegate类型,所以这里需要说明的一点是所有的委托类型都派生自System.MulticastDelegate。但是各位读者可能也会了解到在C#的基础类库中还定义了另外一个委托类System.Delegate,甚至System.MulticastDelegate也是从System.Delegate派生而来,而System.Delegate则继承自System.Object类。那么为何会有两个委托类呢?这其实是C#的开发者留下的历史遗留问题,虽然所有我们自己创建的委托类型都继承自MulticastDelegate类,但是仍然会有一些Delegate类的方法会被用到。最典型的例子便是Delegate类的两个静态方法Combine和Remove,而这两个方法的参数都是Delegate类型的。

public static Delegate Combine(  Delegate a,  Delegate b ) public static Delegate Remove(  Delegate source,  Delegate value ) 

由于我们定义的委托类派生自MulticastDelegate而MulticastDelegate又派生自Delegate,因而我们定义的委托类型可以作为这两个方法的参数。

再回到我们的MyDelegate委托类,由于委托是类,因而凡是能够定义类的地方,都可以定义委托,所以委托类既可以在全局范围中定义,也可以嵌套在一个类型中定义。同样,委托类也有访问修饰符,既可以通过指定委托类的访问修饰符例如:private、internal、public等等来限定访问权限。

由于所有的委托类型都继承于MulticastDelegate类,因而它们也继承了MulticastDelegate类的字段、属性以及方法,下面列出三个最重要的非公有字段:

字段

类型

作用

_target

System.Object

当委托的实例包装一个静态方法时,该字段为null;当委托的实例包装的是一个实例方法时,这个字段引用的是回调方法要操作的对象。也就是说,这个字段的值是要传递给实例方法的隐式参数this。

_methodPtr

System.IntPtr

一个内部的整数值,运行时用该字段来标识要回调的方法。

_invocationList

System.Object

该字段的值通常为null。当构造委托链时它引用一个委托数组。

需要注意的一点是,所有的委托都有一个获取两个参数的构造方法,这两个参数分别是对对象的引用以及一个IntPtr类型的用来引用回调函数的句柄(IntPtr 类型被设计成整数,其大小适用于特定平台。 即是说,此类型的实例在 32 位硬件和操作系统中将是 32 位,在 64 位硬件和操作系统上将是 64 位。IntPtr 对象常可用于保持句柄。 例如,IntPtr 的实例广泛地用在 System.IO.FileStream 类中来保持文件句柄)。代码如下:

public MyDelegate(object @object, IntPtr method);

但是我们回去看一看我们构造委托类型新实例的代码:

MyDelegate myDelegate = new MyDelegate(myMethod1);

似乎和构造器的参数对不上呀?那为何编译器没有报错,而是让这段代码通过编译了呢?原来C#的编译器知道要创建的是委托的实例,因而会分析代码来确定引用的是哪个对象和哪个方法。分析之后,将对象的引用传递给object参数,而方法的引用被传递给了method参数。如果myMethod1是静态方法,那么object会传递为null。而这个两个方法实参被传入构造函数之后,会分别被_target和_methodPtr这两个私有字段保存,并且_ invocationList字段会被设为null。

从上面的分析,我们可以得出一个结论,即每个委托对象实际上都是一个包装了方法和调用该方法时要操作的对象的包装器。

假设myMethod1是一个MyClass类定义的实例方法。那么上面那行创建委托实例myDelegate的代码执行之后,myDelegate内部那三个字段的值如下:

_target

MyClass的实例

_methodPtr

myMethod1

_ invocationList

null

假设myMethod1是一个MyClass类定义的静态方法。那么上面那行创建委托实例myDelegate的代码执行之后,myDelegate内部那三个字段的值如下:

_target

null

_methodPtr

myMethod1

_ invocationList

null

这样,我们就了解了一个委托实例的创建过程以及其内部结构。那么接下来我们继续探索一下,是如何通过委托实例来调用回调方法的。首先我们还是通过一段代码来开启我们的讨论。

using UnityEngine; using System.Collections; public class DelegateScript : MonoBehaviour {       delegate void MyDelegate(int num);  MyDelegate myDelegate;  void Start ()  {     myDelegate = new MyDelegate(this.PrintNum);     this.Print(10, myDelegate);     myDelegate = new MyDelegate(this.PrintDoubleNum);     this.Print(10, myDelegate);     myDelegate = null;     this.Print(10, myDelegate);  }  void Print(int value, MyDelegate md)  {     if(md != null)     {      md(value);     }     else     {      Debug.Log("myDelegate is Null!!!");     }  }  void PrintNum(int num)  {   Debug.Log ("Print Num: " + num);  }  void PrintDoubleNum(int num)  {   int result = num + num;   Debug.Log ("result num is : " + result);  } } 

编译并且运行之后,输出的结果如下:

Print Num:10  result num is : 20  myDelegate is Null!!!

我们可以注意到,我们新定义的Print方法将委托实例作为了其中的一个参数。并且首先检查传入的委托实例md是否为null。那么这一步是否是多此一举的操作呢?答案是否定的,检查md是否为null是必不可少的,这是由于md仅仅是可能引用了MyDelegate类的实例,但它也有可能是null,就像代码中的第三种情况所演示的那样。经过检查,如果md不是null,则调用回调方法,不过代码看上去似乎是调用了一个名为md,参数为value的方法:md(value);但事实上并没有一个叫做md的方法存在,那么编译器是如何来调用正确的回调方法的呢?原来编译器知道md是引用了委托实例的变量,因而在幕后会生成代码来调用该委托实例的Invoke方法。换言之,上面刚刚调用回调函数的代码md(value);被编译成了如下的形式:

md.Invoke(value);

为了更深一步的观察编译器的行为,我们将编译后的代码反编译为CIL代码。并且截取其中Print方法部分的CIL代码:

// method line 4  .method private hidebysig         instance default void Print (int32 'value', class DelegateScript/MyDelegate md)  cil managed  {      // Method begins at RVA 0x20c8  // Code size 29 (0x1d)  .maxstack 8  IL_0000:  ldarg.2  IL_0001:  brfalse IL_0012     IL_0006:  ldarg.2  IL_0007:  ldarg.1  IL_0008:  callvirt instance void class DelegateScript/MyDelegate::Invoke(int32)  IL_000d:  br IL_001c     IL_0012:  ldstr "myDelegate is Null!!!"  IL_0017:  call void class [mscorlib]System.Console::WriteLine(string)  IL_001c:  ret  } // end of method DelegateScript::Print

分析这段代码,我们可以发现在IL_0008这行,编译器为我们调用了DelegateScript/MyDelegate::Invoke(int32)方法。那么我们是否可以显式的调用md的Invoke方法呢?答案是Yes。所以,Print方法完全可以改成如下的定义:

void Print(int value, MyDelegate md) {  if(md != null)  {    md.Invoke(value);  }  else  {    Debug.Log("myDelegate is Null!!!");  } } 

而一旦调用了委托实例的Invoke方法,那么之前在构造委托实例时被赋值的字段_target和_methodPtr在此时便派上了用场,它们会为Invoke方法提供对象和方法信息,使得Invoke能够在指定的对象上调用包装好的回调方法。OK,本节讨论了编译器如何在幕后为我们生成委托类、委托实例的内部结构以及如何利用委托实例的Invoke方法来调用一个回调函数,那么我们接下来继续来讨论一下如何使用委托来回调多个方法。

0x05 委托是如何调用多个方法的?

为了方便,我们将用委托调用多个方法简称为委托链。而委托链是委托对象的集合,可以利用委托链来调用集合中的委托所代表的全部方法。为了使各位能够更加直观的了解委托链,下面我们通过一段代码来作为演示:

using UnityEngine; using System; using System.Collections; public class DelegateScript : MonoBehaviour {          delegate void MyDelegate(int num);        void Start ()        {  //创建3个MyDelegate委托类的实例   MyDelegate myDelegate1 = new MyDelegate(this.PrintNum);  MyDelegate myDelegate2 = new MyDelegate(this.PrintDoubleNum);  MyDelegate myDelegate3 = new MyDelegate(this.PrintTripleNum);  MyDelegate myDelegates = null;  //使用Delegate类的静态方法Combine   myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate1);  myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate2);  myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate3);  //将myDelegates传入Print方法  this.Print(10, myDelegates);        }        void Print(int value, MyDelegate md)        {  if(md != null)  {         md(value);  }  else  {         Debug.Log("myDelegate is Null!!!");  }        }        void PrintNum(int num)        {  Debug.Log ("1 result Num: " + num);        }        void PrintDoubleNum(int num)        {  int result = num + num;  Debug.Log ("2 result num is : " + result);        }        void PrintTripleNum(int num)        {  int result = num + num + num;  Debug.Log ("3 result num is : " + result);        } } 

编译并且运行之后(将该脚本挂载在某个游戏物体上,运行Unity3D即可),可以看到Unity3D的调试窗口打印出了如下内容:

1 result Num: 10  2 result Num: 20  3 result Num: 30

换句话说,一个委托实例myDelegates中调用了三个回调方法PrintNum、PrintDoubleNum以及PrintTripleNum。下面,让我们来分析一下这段代码。我们首先构造了三个MyDelegate委托类的实例,并分别赋值给myDelegate1、myDelegate2、myDelegate3这三个变量。而之后的myDelegates初始化为null,即表明了此时没有要回调的方法,之后我们要用它来引用委托链,或者说是引用一些委托实例的集合,而这些实例中包装了要被回调的回调方法。那么应该如何将委托实例加入到委托链中呢?不错,前文提到过基础类库中的另一个委托类Delegate,它有一个公共静态方法Combine是专门来处理这种需求的,所以接下来我们就调用了Delegate.Combine方法将委托加入到委托链中。

myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate1);                myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate2);                myDelegates = (MyDelegate)Delegate.Combine(myDelegates, myDelegate3);

在第一行代码中,由于此时myDelegates是null,因而当Delegate.Combine方法发现要合并的是null和一个委托实例myDelegate1时,Delegate.Combine会直接返回myDelegate1的值,因而第一行代码执行完毕之后,myDelegates现在引用了myDelegate1所引用的委托实例。

当第二次调用Delegate.Combine方法,继续合并myDelegates和myDelegate2的时候,Delegate.Combine方法检测到myDelegates已经不再是null而是引用了一个委托实例,此时Delegate.Combine方法会构建一个不同于myDelegates和myDelegate2的新的委托实例。这个新的委托实例自然会对上文常常提起的_target和_methodPtr这两个私有字段进行初始化,但是此时需要注意的是,之前一直没有实际值的_invocationList字段此时被初始化为一个对委托实例数组的引用。该数组的第一个元素便是包装了第一个委托实例myDelegate1所引用的PrintNum方法的一个委托实例(即myDelegates此时所引用的委托实例),而数组的第二个元素则是包装了第二个委托实例myDelegate2所引用的PrintDoubleNum方法的委托实例(即myDelegate2所引用的委托实例)。之后,将这个新创建的委托实例的引用赋值给myDelegates变量,此时myDelegates指向了这个包装了两个回调方法的新的委托实例。

接下来,我们第三次调用了Delegate.Combine方法,继续将委托实例合并到一个委托链中。这次编译器内部发生的事情和上一次大同小异,Delegate.Combine方法检测到myDelegates已经引用了一个委托实例,同样地,这次仍然会创建一个新的委托实例,新委托实例中的那两个私有字段_target和_methodPtr同样会被初始化,而_invocationList字段此时同样被初始化为一个对委托实例数组的引用,只不过这次的元素多了一个包装了第三个委托实例myDelegate3中所引用的PrintDoubleNum方法的委托实例(即myDelegate3所引用的委托实例)。之后,将这个新创建的委托实例的引用赋值给myDelegates变量,此时myDelegates指向了这个包装了三个回调方法的新的委托实例。而上一次合并中_invocationList字段所引用的委托实例数组,此时不再需要,因而可以被垃圾回收。

当所有的委托实例都合并到一个委托链中,并且myDelegates变量引用了该委托链之后,我们将myDelegates变量作为参数传入Print方法中,正如前文所述,此时Print方法中的代码会隐式的调用MyDelegate委托类型的实例的Invoke方法,也就是调用myDelegates变量所引用的委托实例的Invoke方法。此时Invoke方法发现_invocationList字段已经不再是null而是引用了一个委托实例的数组,因此会执行一个循环来遍历该数组中的所有元素,并按照顺序调用每个元素(委托实例)中包装的回调方法。所以,PrintNum方法首先会被调用,紧跟着的是PrintDoubleNum方法,最后则是PrintTripleNum方法。

有合并,对应的自然就有拆解。因而Delegate除了提供了Combine方法用来合并委托实例之外,还提供了Remove方法用来移除委托实例。例如我们想移除包装了PrintDoubleNum方法的委托实例,那么使用Delegate.Remove的代码如下:

myDelegates = (MyDelegate)Delegate.Remove(myDelegates, new MyDelegate(PrintDoubleNum));

当Delegate.Remove方法被调用时,它会从后向前扫描myDelegates所引用的委托实例中的委托数组,并且对比委托数组中的元素的_target字段和_methodPtr字段的值是否与第二个参数即新建的MyDelegate委托类的实例中的_target字段和_methodPtr字段的值匹配。如果匹配,且删除该元素之后,委托实例数组中只剩余一个元素,则直接返回该元素(委托实例);如果删除该元素之后,委托实例数组中还有多个元素,那么就会创建一个新的委托实例,这个新创建的委托实例的_invocationList字段会引用一个由删除了目标元素之后剩余的元素所组成的委托实例数组,之后返回该委托实例的引用。当然,如果删除匹配实例之后,委托实例数组变为空,那么Remove就会返回null。需要注意的一点是,Remove方法每次仅仅移除一个匹配的委托实例,而不是删除所有和目标委托实例匹配的委托实例。

当然,如果每次合并委托和删除委托都要写Delegate.Combine和Delegate. Remove则未免显得太过繁琐,所以为了方便使用C#语言的开发者,C#编译器为委托类型的实例重载了+=和-+操作符来对应Delegate.Combine和Delegate. Remove。具体的例子,我们可以看看下面的这段代码。

using UnityEngine; using System.Collections; public class MulticastScript : MonoBehaviour {  delegate void MultiDelegate();  MultiDelegate myMultiDelegate;  void Start ()  {   myMultiDelegate += PowerUp;   myMultiDelegate += TurnRed;   if(myMultiDelegate != null)   {    myMultiDelegate();   }  }  void PowerUp()  {   print ("Orb is powering up!");  }  void TurnRed()  {   renderer.material.color = Color.red;  } } 

好,我想到此我已经回答了本小节题目中所提出的那个问题:委托是如何调用多个方法的。但是为了要实现观察者模式甚至是我们自己的消息系统,还有一个大人物不得不介绍,那就是和委托关系密切的事件,那么下一篇博客就让我们走进委托和事件的世界中吧。

正文到此结束
Loading...