C# 学习笔记:反射

这篇文章重新写反射,在很久之前的那一篇博客中曾经非常粗劣的描述了一下反射的基础用法(感觉跟没讲一样),这一篇博客我们重新归纳一下反射,就像彩虹六号中重置赫里福基地一样。

反射这个功能来自于代码中的加载需求,因为我们代码中一个程序往往会有很多配套的程序集,这些程序集往往不是自己熟悉甚至自己见过的,它们可能来自于公司的另一个部门或者其他公司,也有可能在我们程序本体发布以后才发布的这些程序集,所以我们需要在运行时将它们加载到程序当中。

加载程序集:

当JIT将方法的IL的代码编译成本地代码时,将会查看IL代码引用了哪些类型。JIT会利用程序集的TypeRef(类型引用)AssemblyRef(程序集引用)元数据表来确定哪一个程序集定义了所引用的类型。 

在AssemblyRef元数据表的定义项中,包含了程序集强命名的各个部分:名称,版本,语言文化,公钥标记——JIT将会获取这些部分,然后将它们连成一个字符串标示,再尝试将与该标示匹配的程序集加载到AppDomain中。如果程序集是弱命名。那么JIT只会获取这个程序集的名称。

在Assembly类中,有两个最常用的加载程序集的方法:

public static Assembly Load(AssemblyName assemblyRef);
public static Assembly Load(string assemblyString);

这两种方法首先会导致CLR向程序集应用一个版本绑定重定向策略,然后在GAC(全局程序集缓存)中查找程序集。如果没找到,就会向应用程序的基目录、私有路径的子目录和codebase中查找。如果Load方法的传递弱命名程序集,Load就不会向程序集应用一个版本绑定重定向策略,并且CLR也不会去GAC中查找程序集。

如果Load方法找到了对应的程序集,那么将会返回对应已加载程序集的Assembly对象的引用。如果没有找到程序集,将会抛出一个System.IO.FileNotFoundException的错误。

Load方法在加载程序集时非常常用,但是它要求用户事先就掌握程序集标示的各个部分,我们也可以直接获取一个引用了程序集文件的路径名(包括扩展名)的命令行实参,这种方式就比较无脑了,实参直接调用路径名就好:

public static Assembly LoadFrom(string assemblyFile);

这种方法表面上很简单,但实际上内部做了很多工作:

  • LoadForm首先会调用System.Reflection.AssemblyName类的静态方法GetAssemblyName。这个方法打开指定文件,查找AssemblyRef元数据表的记录项,提取程序集标示的信息。以AssemblyName对象的形式返回这些信息。
  • 然后,LoadForm在内部调用Assembly的Load方法,将AssemblyName对象实参传递给它。
  • Load方法遵照自己的执行逻辑,CLR为器应用版本绑定重定向策略,并在各个位置查找匹配的程序集。
  • 如果Load方法找到了匹配的程序集就会加载并返回对应的Assembly的值,如果Load函数没有找到匹配的程序集,就会加载LoadForm函数实参传递路径的程序集。
  • 如果此时已经加载了一个具有相同标示的程序集,LoadForm函数就会简单的返回一个已加载程序集的对象。

我们需要注意的是,由于一个程序集可能存在多种版本,如果它们的版本号没有发生变化,那么对应的标示也可能相同,由于LoadForm方法内部调用了Load方法,所以CLR可能不会加载指定的文件而是加载一个不同的文件,进而发生非预期的行为。所以在程序集中必须确认每一个版本都要有自己的版本号来确定这个版本唯一的标示。

反射

我们上面的几个方法中,都需要查询元数据表,而元数据表则由一系列表来存储。当生成一个程序集或者模块时,编译器会创建一个类型定义表,一个字段定义表,一个方法定义表以及其他的表。当我们使用反射时,可以通过代码来解析这些元数据表。但实际上,反射为这些元数据创建了一个对象模型

这个对象模型可以枚举一个类型定义元数据表中的所有类型,然后针对每个类型获取它们的基类型,接口,以及类型关联标志(flag)。并且还可以解析对应的元数据表来查询类型的字段、方法、属性、事件。

反射并不是一个常用的类型,一般由很多类库来使用它,它们需要清楚的理解类型的定义才能提供丰富的功能。例如在序列化中就通过反射类判断一个类型定义了哪些字段。然后再通过序列化格式器来获取这些字段的值,并将它们写入字节流。

但是反射也具有一些缺点:

  • 反射会造成编译时无法保证安全类型。因为反射严重依赖字符串,所以在编译时会丧失安全性。如果字符串出错那么代码也会出错。
  • 反射速度慢,因为需要不断的执行字符串搜索。使用反射时,类型以及成员的名称在编译时处于未知,所以这些类型以及成员我们需要用字符串来标识它们。在反射搜索类型的元数据时,这样的字符串搜索会拖慢运行速度。
  • 使用反射调用成员时,如果涉及到参数,就需要将实参打包成一个Object数组,然反射内部将这个数组解包到线程栈上,并且在调用方法前,CLR必须检查Object数组是否对应形参有正确的数据类型。最后,CLR必须确保调用者有正确的安全权限来访问被调用的成员。

综上,反射最好避免用于调用方法或者属性。如果我们需要使用反射来动态发现和构造类型实例,那么常规可以这样写:

  • 迎合里式替换原则,让类型从一个编译时已知的接口或者基类型中派生。在运行时,构造派生类型的实例,然后将引用传递给基类型(或者接口)的对象中,让这个反射出来的派生类型对象变成这个抽象基类或者虚基类、接口的实例。然后调用抽象基类的抽象方法(同样也可以是虚方法或者接口定义的方法)。

我们常常需要在基类或者接口的选用中做出选择,在版本控制的情况下,使用基类要比使用接口更为恰当,因为基类可以随时添加新的成员而不需要派生类去迎合这种变化,而使用接口的话一旦接口发生变化那么实现接口的子类都需要重新编译。

我们可以使用Assembly的GetExportedTypes来获得一个程序集的信息。例如我们最开始的那个Games程序集,我们在里面写上一些方法和类型,然后在另外一个程序中查看该程序集的一些调用,例如Games下有如下代码:

namespace Games
{
    public interface IAddGames
    {
        string ShowGameCode(int x);
    }

    public class AddGames1 : IAddGames
    {
        public string ShowGameCode(int x)
        {
            Console.WriteLine("A游戏序列号是" + x);
            return "A游戏序列号是" + x.ToString();
        }
    }

    public class AddGames2 : IAddGames
    {
        public string ShowGameCode(int x)
        {
            Console.WriteLine("B游戏序列号是" + x);
            return "B游戏序列号是" + x.ToString();
        }
    }
    public class Game1
    {
        public void Shout<T>(T t)
        {
            Console.WriteLine(typeof(T));
        }
    }
}

那么我们在调用时,将这个程序集生成解决方案后放在我们需要调用它的根目录下面,然后我们统一遍历这个根目录下面所有的程序集并输出这些类型:

            string path = Assembly.GetEntryAssembly().Location;
            Console.WriteLine(path);
            string AssemblyDataLocation = Path.GetDirectoryName(path);
            string[] Assemblies = Directory.GetFiles(AssemblyDataLocation, "*.dll");
            foreach(string file in Assemblies)
            {
                Assembly assembly = Assembly.LoadFile(file);
                foreach(Type t in assembly.GetExportedTypes())
                {
                    Console.WriteLine(t.FullName);
                }
            }

注意,我们这里使用了Dictionary下的GetFile方法来获得一个路径下所有的对应格式的文件,由于我们需要查看程序集,所以我们需要将对应的格式设置成dll,即动态链接库。

可以看到这个里面有许多类型,我们可以看到我们Games程序集的对应类型的输出,它里面包含一个接口IAddGames以及两个派生自它的子类AddGames1和2,另有一个类Game1,这个都是我们自己手动定义的。

类型对象Type

我们的上述代码中,GetExportedTypes返回值即为一个Type类型的数组。System.Type类是执行类型与对象操作的起点。它是一个由System.Reflection.MemberInfo派生的抽象基类。Type类型下面派生了例如RuntimeType、ReflectionOnlyType等等。

RuntimeType类型在C#中仅用于内部使用,当一个类型在C#中首次访问时,CLR会为之构造一个RuntimeType的实例并初始化对应的字段。在使用Object类型的公共方法GetType时,CLR会判断指定对象的类型,并为之返回该类型对应的RuntimeType对象的引用。由于一个类型仅有一个唯一的RuntimeType对象,所以我们最终可以通过GetType函数来判断两个对象是否属于同一个类型。

GetType方法:

System.Type类的本身也存在静态方法GetType以及它们的重载版本。这个函数接受一个字符串,字符串必须为指定类型的全名(包括命名空间)。如果传递的字符串只是一个类型名称,GetType将检查调用程序集查看是否定义了指定名称的类型。如果存在这个类型,就返回RuntimeType的引用。

如果对应的程序集中没有找到相应的类型,将会检查MSCCorLib.dll对应的类型,如果最后实在没有找到这种类型,将会返回null或者抛出System.TypeLoadException(这取决于GetType方法的重载版本)。

typeof操作符:

对于晚绑定的类型对象我们可以通过GetType获得,如果我们需要通过一个已知的类型名称来获得对应的类型对象的引用,就需要使用typeof关键字来生成早绑定的对象的类型对象引用。这句话:

            if(o.GetType()==typeof(object))
            {
                //
            }
            

获得一个类型的Type对象引用之后,就可以查询这种类型的各种属性,例如IsClass、IsSealed、IsAbstract这种关于这个类型的方方面面。

BindFlags查找枚举:

这个参数为查找方式的枚举,它的枚举值非常易懂,有Instance(查找实例成员),Static(查找静态成员),Public(返回公共成员),NotPublic(返回非公共成员),FlattenHierarchy(返回基类型定义的静态成员),IgnoreCase(返回指定字符串匹配的成员)等。

其中Ignore适用于Type类型的GetField、GetMethod这些方法的重载版本,这些只返回一个MemberInfo的方法往往依赖于字符串的查找,所以这里的BindFlag也最好是IgnoreCase。如果我们不传递BindFlags参数,那么将只返回公共成员,即默认值是:BindFlags.Public|BindFlags.Static|BingFlags.Instace

同样的,BindFlags也存在其他的用于InvokerMember的枚举(这个在下文中会讲到),例如CreateInstance(创建实例),SetField/GetField(获取或者设置一个字段的值),InvokerMethod(调用某个方法)。

构造类型Activator

我们知道一个类型之后就可以非常方便的构造这个类型的实例了。在System.Activator类中,提供了构造类型的方式:

  • System.Activator的CreateInstance方法:传递一个Type对象的引用或者传递一个字符串。如果使用字符串的话,字符串首先要标示定义类型的程序集,并且这种情况下并不会返回新对象的引用,而是返回一个System.Runtime.Remoting.ObjectHandle对象。
  • System.Activator的CreateInstanceForm方法:传递一个字符串来指定类型以及程序集。程序集本身则通过LoadForm方法来获得。并且由于这种方法不接受Type类型的形参,所以返回一个ObjectHandle引用,我们需要调用ObjectHandle类型的Unwarp方法来将它具体化。
  • System.Type的InvokerMember方法:使用Type对象的引用来调用一个InvokerMember方法,这个方法会查找一个传递的实参的构造器并构造类型。
  • System.Array的CreateInstance方法:专用于创建数组类型,它和它的重载版本都需要调用Type类型的引用。
  • System.Delegate的CreateDelegate方法:用于创建程序集中的委托类型,它的第一个参数为Type对象引用,其他的参数为指定在委托中包装的方法,这个可以是某个实例的方法也可以是静态方法。

如果我们创建泛型类型对象,那么我们还需要注意类型的开放与封闭的情况,我们需要首先获取这个类型的开放类型引用,然后再为其泛型赋上具体的封闭类型,最终调用CreateInstance来生成一个类型,这与我们直接创建泛型类实例是一致的,我们可以写个代码来测试上述描述的类型构造方法。

Type类型的基本使用:

我们首先先创建两个类型,分别对应两个常用的C#集合:Dictionary和ArrayList,只有ArrayList我们写了一个粗略的构造函数:

    class TestDictionary<Tkey, TValue> { }
    class TestArrayList
    {
        public TestArrayList(int i)
        {
            Console.WriteLine("构造函数输出" + i);
        }
    }

那么我们的代码中对它们有如下的调用:

        static void Main()
        {
            Type listArrayType = typeof(TestArrayList);
            Object getArrayList = Activator.CreateInstance(listArrayType, new object[] { 2 });

            Type testDictionaryOpenType = typeof(TestDictionary<,>);
            Type testDictionaryCloseType = testDictionaryOpenType.MakeGenericType(typeof(string), typeof(int));
            Object getDictionary = Activator.CreateInstance(testDictionaryCloseType);

            TestDictionary<string, int> dictionary = new TestDictionary<string, int>();
            if (getDictionary.GetType() == dictionary.GetType())
            {
                Console.WriteLine("二者是同一类型");
            }
            else
            {
                Console.WriteLine("二者并不是同一类型");
            }
        }

我们可以看到,对于泛型类型TestDictionary来说,需要首先创建开放类型,然后创建其封闭类型,最后调用Activator的方法才能创建出一个具体的对象实例。而对于存在有参构造函数的类型TestArrayList来说,则需要在重载版本中创建构造函数的形参列表才能正确的创建这个类型的实例。

访问子类定义的接口函数:

同时我们可以使用Type类型来访问最开始的代码Games的程序集,并且使用IAddGames接口来迎合里式替换,来保证得到正确的对象类型,我们这里重复贴一下我们Games类型所定义的内容:

namespace Games
{
    public interface IAddGames
    {
        string ShowGameCode(int x);
    }

    public class AddGames1 : IAddGames
    {
        public string ShowGameCode(int x)
        {
            Console.WriteLine("A游戏序列号是" + x);
            return "A游戏序列号是" + x.ToString();
        }
    }

    public class AddGames2 : IAddGames
    {
        public string ShowGameCode(int x)
        {
            Console.WriteLine("B游戏序列号是" + x);
            return "B游戏序列号是" + x.ToString();
        }
    }
}

然后在代码中,我们需要首先引入Games的引用,然后通过搜索本地文件中的dll库查实现IAddGames的类型。,我们这里将类型放入一个类型数组List<Type>中,如果找到了相关的类型就将这个类型放入类型数组,最后将类型数组遍历,创建类型实例并调用接口定义方法:

using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using Games;

namespace CSharp学习
{
    class Program
    {
        static void Main()
        {
            String AddinDir = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location);
            Console.WriteLine(AddinDir); 

            string[] AddInAssenblies = Directory.GetFiles(AddinDir, "*.dll");

            List<Type> AddinTypes = new List<Type>();
            foreach (string file in AddInAssenblies)
            {
                Assembly AddinAssembly = Assembly.LoadFile(file);

                foreach(Type t in AddinAssembly.GetExportedTypes())
                {
                    if(t.IsClass&&typeof(IAddGames).IsAssignableFrom(t))
                    {
                        AddinTypes.Add(t);
                    }
                }
            }

            foreach(Type t in AddinTypes)
            {
                IAddGames iadd = (IAddGames)Activator.CreateInstance(t);
                iadd.ShowGameCode(5);
            }
        }
    }
}

最终我们可以输出这个接口定义的方法在子类的实现,我们这里没有很复杂的逻辑,只是单纯的输出一个语句,最终我们输出:

类型成员类MemberInfo:

一个类中的字段、构造函数、成员函数、属性、事件、嵌套类型都可以认为是这个类的成员。为了获得这些成员,我们可以通过MemberInfo类来统一接收并使用这些成员。MemberInfo是一个封装所有成员通用属性的抽象基类。《CLR via C#》中的这个图片非常简洁的描述了成员通用属性和MemberInfo的关系:

通过MemberInfo类型,可以非常轻松的获得一个类型中的所有成员的信息,例如我们创建一个自己的类叫做MyType,那么可以通过这个类型的RuntimeType来获得这个类型的所有成员信息:

    public class MyType
    {
        public override string ToString()
        {
            return base.ToString();
        }
        public int AAA;
        public int getProperty
        {
            get { return AAA; }
            set { AAA = value; }
        }
    }
    class Program
    {
        static bool Filter(Type m, Object a)
        {
            return m.Assembly == a as Assembly;
        }
        static void Main()
        {
            MemberInfo[] members = typeof(MyType).GetMembers();
            PropertyInfo[] propertys = typeof(MyType).GetProperties();
            FieldInfo[] fields = typeof(MyType).GetFields();
            foreach (MemberInfo member in members)
            {
                Console.WriteLine("成员名是" + member.Name + "对应的类是" + member.DeclaringType.ToString());
            }
            foreach (PropertyInfo proerty in propertys)
            {
                Console.WriteLine("属性是" + proerty.Name + "对应的类是" + proerty.DeclaringType.ToString());
            }
            foreach(FieldInfo field in fields)
            {
                Console.WriteLine("字段是" + field.Name + "对应的类是" + field.DeclaringType.ToString());
            }
        }
    }

输出为:

 

由于ToString类型在MyType类型中重写过了,所以这里我们看到ToString类型对应的类型为MyType类型,但是其他的继承自Object类型的函数Equals这种则对应的类型为Object。并且这里还输出了默认的无参构造函数。

注意:如果我们使用member的ReflectedType则不管这个是否是Object类型定义,总是返回MyType,因为GetMember执行反射的时候的类型总是MyType。

Type类型除了使用GetMember类型返回所有MemberInfo类型的引用以外,还可以通过其他方法例如GetFields(获得公有字段)、GetMethods(获得方法)、GetProperties(获得属性)这样函数来返回特定的成员类型,同样的,由于这样的成员在一个类型中不止一个,所以返回的成员往往也是对应上面图片的类型的数组。例如,GetFields方法返回一个FieldInfo类型数组。GetPropertys方法返回一个PropertyInfo数组。

使用类型发现接口:

和上面的查找成员相似,我们也可以使用Type来查找这个类型继承接口,并通过InterfaceMapping类型检索接口和实现接口的类型的实际方法的映射。例如我们这里创建了两个接口:IGameAAA和IGameBBB,最终myGame实现了这两个接口,并设定实现特定接口的函数:

    interface IGameAAA:IDisposable
    {
        void Buy();
        void Play();
    }
    interface IGameBBB
    {
        void Buy();
    }

    class myGame : IGameAAA, IGameBBB, IDisposable
    {
        void IGameAAA.Buy()
        {
            Console.WriteLine("游戏A买了");
        }

        void IGameBBB.Buy()
        {
            Console.WriteLine("游戏B买了");
        }

        public void Dispose()
        {  }

        public void Play()
        {
            Console.WriteLine("开玩");
        }
    }

那么我们在主函数中,就可以通过GetInterfaces方法和InterfaceMapping来检索这个接口:

    class Program
    {
        static bool Filter(Type m,Object a)
        {
            Console.WriteLine(m.Assembly.FullName);
            return m.Assembly == a;
        }
        static void Main()
        {
            Type t = typeof(myGame);
            Type[] getInterfaces = t.FindInterfaces(Filter, typeof(Program).Assembly);

            foreach(Type i in getInterfaces)
            {
                Console.WriteLine("接口名称" + i);
                InterfaceMapping map = t.GetInterfaceMap(i);
                for (int m = 0; m < map.InterfaceMethods.Length; m++)
                {
                    Console.WriteLine(map.InterfaceMethods[m] + "被myGame类中的" + map.TargetMethods[m]);
                }
            }
        }
    }

 输出为:

我们需要注意的是,在调用GetInterface接口的时候第一个实参为TypeFilter委托,第二个实参为程序集对象的引用。实际上,筛选器的功能即为将我们第二个实参的程序集引用与接口的程序集进行比较(这个比较是通过我们自己手动委托进行的),例如我们这个例子中,实际上还存在IDisposable接口的实现,但是在我们筛选后却没有体现出来。这是因为IDisposable接口属于mecorlib程序集,所以在筛选器比较的时候,被剔除掉了。

如果我们这里让筛选器恒返回true,那么最终Type数组中将会存在IDisposable接口的类型对象引用。

使用反射调用类型的成员InvokerMember

实际上在上文中,我们已经可以通过Activator方法来获得反射出来的对象并调用反射的方法,但是如果我们没有获得程序集的情况下, 创建出来的对象实例只能通过Object对象接收,这不太方便。如果我们在不能调用程序集的情况下仅通过字符串调用程序集的成员时,就需要使用InvokerMember方法来通过字符串动态的调用晚绑定的程序集,这个方法的形参为:

  • name(string类型):成员的名称。
  • flag(BindFlags类型):成员查找方式。
  • binder(Binder类型):成员实参匹配方式。
  • target(Object类型):成员实参的调用实例对象。
  • args(Object类型数组):成员的实参。
  • culture(CultureInfo类型):绑定器所使用的语言文化。

InvokerMember方法内部会首先根据字符串和绑定方式查找一个确定的成员。这种行为成为绑定,然后再根据实参数组的情况确定这个成员的重载版本。传给InvokerMember的实参除了target以外都有利于方法确定需要绑定的成员。

  • Binder类型:Binder类型对象表示InvokerMember方法筛选成员的规则,Binder类型定义了一些抽象方法,例如BindToField,BindToMethod,SelectMethod等等。在InvokerMember内部通过Binder对象来调用这些方法。
  • args实参数组:对于传入的object数组的数量和类型都讲协助InvokerMember来确定最终所绑定的成员。并且还会应用一些自动的类型转换类获得类型更大的灵活性,例如实参数组中存在一个Int32位的数值,但是形参要求为Int64位,这个时候就会将Int32位转换成Int64位的数值
  • target调用对象:target是需要调用成员参数的对象引用。如果我们需要调用类型中的静态成员,那么我们设置target的值为NULL就好。

InvokerMember方法的强大之处在于,通过BindFlags枚举,它可以调用一个方法,创造一个类型实例,获取或者设置一个字段或者属性。但是我们需要注意的是,BindFlags很多情况下只能选择一个枚举,但是可以将BindFlags枚举同时设置为GetProperty和GetField,在这种情况下,InvokerMember首先会查找匹配的字段,如果没有找到就去查找匹配的属性。类似的也有SetProperty和SetField两个枚举,InvokerMember会为它们做同样的事情。

我们尝试定义一个类型叫PlayerCount,我们为它加上各种类型的成员:

    public delegate void PlayEventHandler();
    public class playerCount
    {
        public playerCount(int level)
        {
            this.level = level;
        }
        private PlayEventHandler playEventHandler;
        private int coin;
        private string playerName;
        public int level;
        public void AddCoin(ref int x)
        {
            coin += x;
            x = coin;
        }

        public override string ToString()
        {
            return "角色的金币数量是" + coin.ToString();
        }
        
        public string PlayerName
        {
            get
            {
                return playerName;
            }
            set
            {
                if(value=="")
                {
                    throw new NullReferenceException();
                }
                playerName = value;
            }
        }
        public event PlayEventHandler playEvent
        {
            add
            {
                playEventHandler += value;
            }
            remove
            {
                playEventHandler -= value;
            }
        }
        public void StartGame()
        {
            playEventHandler.Invoke();
        }
    }

然后仅通过这个类型对象,再使用InvokerMember就可以来调用类型中的各种成员,包括了属性,字段,方法,事件,构造器等等等等,我觉得这个例子最能体现反射的强大:

    class Program
    {
        private const BindingFlags flags = BindingFlags.DeclaredOnly | BindingFlags.Public
                    | BindingFlags.NonPublic | BindingFlags.Instance;
        static void Main()
        {
            Type t = typeof(playerCount);
            NormalBingAndInvoke(t);
        }
        static void TestFunction(string str)
        {
            Console.WriteLine(str);
        }
        static void NormalBingAndInvoke(Type t)
        {
            object instance = t.InvokeMember(null, flags|BindingFlags.CreateInstance, null, null, new object[] { 12 });
            Console.WriteLine("此时获得的成员类型为" + instance.GetType().ToString());
    
            int level = (int)t.InvokeMember("level", flags | BindingFlags.GetField, null, instance, null);
    
            Console.WriteLine("玩家的等级为" + level);
    
            string s = t.InvokeMember("ToString", flags | BindingFlags.InvokeMethod, null, instance, null).ToString();
            Console.WriteLine(s);
    
            //读属性
            //try
            //{
            //    t.InvokeMember("Level", flags | BindingFlags.SetProperty, null, instance, new object[] { "" });
            //}
            //catch
            //{
            //    throw new ArgumentNullException();
            //}
            t.InvokeMember("PlayerName", flags | BindingFlags.SetProperty, null, instance, new object[] { "弟弟" });
            string PlayerName = (string)t.InvokeMember("PlayerName", flags | BindingFlags.GetProperty, null, instance, null);
            Console.WriteLine("玩家名字是" + PlayerName);
    
    
            PlayEventHandler playEvent = new PlayEventHandler(() => { Console.WriteLine("玩家" + PlayerName + "正在进行游戏"); });
            t.InvokeMember("add_playEvent", flags | BindingFlags.InvokeMethod, null, instance, new object[] { playEvent });
    
            t.InvokeMember("StartGame", flags | BindingFlags.InvokeMethod, null, instance, null);
        }
    }

最终我们输出的结果为:

猜你喜欢

转载自blog.csdn.net/qq_38601621/article/details/103578732