【游戏引擎 - C#脚本系统】3、程序集加载和测试

翻译 from peter1745的《mono-guide》
强烈建议阅读Mono官方文档

1、加载程序集(Loading Assemblies)

在本节中,我们将介绍如何使用Mono加载C#程序集。首先,你应该知道在C#(以及.NET通常情况下),程序集可以是DLL或EXE文件。在我们开始编写加载代码之前,我们必须有一个要加载的程序集。

在本例中,我们将将C#项目构建为一个动态链接库(DLL)。在Visual Studio中,DLL的项目类型被称为“Class Library”,所以确保你的C#项目的“输出类型”设置为“Class Library”,如下图所示:
请添加图片描述

一开始,我们将简单地编写一些基本的C#代码,以确保我们的代码实际上能够运行。首先,我将创建一个名为 “CSharpTesting” 的C#类,并向其添加一些基本的数据和方法。

using System;

public class CSharpTesting
{
    public float MyPublicFloatVar = 5.0f;

    public void PrintFloatVar()
    {
        Console.WriteLine("MyPublicFloatVar = {0:F}", MyPublicFloatVar);
    }

    private void IncrementFloatVar(float value)
    {
        MyPublicFloatVar += value;
    }
}

目前我们不会实际运行这些代码,但我们将在加载程序集后检查该类是否存在。此代码还将用于演示在使用Mono时需要记住的一些事项。

但现在,继续构建你的项目,你应该得到一个DLL文件,该文件的名称与你的项目名称相对应,可以在项目文件夹中的名为 “bin” 的文件夹中找到它。记住文件路径,因为我们加载DLL时会需要它。

C++代码

现在是时候编写实际将C# DLL加载到Mono中的代码了。通常在游戏引擎中,你需要加载两个DLL,一个包含游戏代码,另一个由引擎提供。

通常,引擎会提供一个C# DLL,游戏代码将与其链接,这是因为引擎开发人员可以为用户提供安全的API来进行交互。因此,我们将编写一个通用函数,它将简单地加载任何DLL,该函数将以DLL文件的路径作为参数,并返回指向MonoAssembly的指针。

游戏代码DLL包含了具体的游戏实现,而引擎DLL提供了引擎所需的基础设施。

  • 游戏代码 DLL: 这是包含实际游戏逻辑和功能的代码的库。开发者编写的游戏逻辑通常存储在这个DLL中。游戏引擎加载这个DLL以运行游戏。
  • 引擎提供的 DLL: 这是由游戏引擎开发人员提供的库,其中包含引擎的核心功能和API。这个DLL为游戏提供了与游戏引擎进行交互的接口。游戏代码DLL可能会链接到这个引擎DLL,以利用引擎提供的功能和服务。

现在,有很多方法可以使用Mono加载程序集,但首选的方法将文件加载到字节数组中,然后将该字节数组直接传递给Mono

我还将提供一个将文件加载到字节数组的函数:

char* ReadBytes(const std::string& filepath, uint32_t* outSize)
{
    std::ifstream stream(filepath, std::ios::binary | std::ios::ate);
    
    if (!stream)
    {
        // 打开文件失败
        return nullptr;
    }

    std::streampos end = stream.tellg();
    stream.seekg(0, std::ios::beg);
    uint32_t size = end - stream.tellg();
    
    if (size == 0)
    {
        // 文件为空
        return nullptr;
    }

    char* buffer = new char[size];
    stream.read((char*)buffer, size);
    stream.close();

    *outSize = size;
    return buffer;
}

加载C#程序集的代码:

MonoAssembly* LoadCSharpAssembly(const std::string& assemblyPath)
{
    uint32_t fileSize = 0;
    char* fileData = ReadBytes(assemblyPath, &fileSize);

    // 注意:我们不能对这个图像执行除了加载程序集以外的任何操作,因为这个图像没有对程序集的引用
    MonoImageOpenStatus status;
    MonoImage* image = mono_image_open_from_data_full(fileData, fileSize, 1, &status, 0);

    if (status != MONO_IMAGE_OK)
    {
        const char* errorMessage = mono_image_strerror(status);
        // 使用 errorMessage 数据记录一些错误消息
        return nullptr;
    }

    MonoAssembly* assembly = mono_assembly_load_from_full(image, assemblyPath.c_str(), &status, 0);
    mono_image_close(image);
    
    // 不要忘记释放文件数据
    delete[] fileData;

    return assembly;
}

代码解释

首先,我们将C#程序集的字节读入char*缓冲区中。之后,我们需要将加载的数据提供给Mono,我们可以通过调用mono_image_open_from_data_full来实现。前两个参数应该是不言而喻的,它们只是数据和数据的大小。第三个参数告诉Mono我们是否希望它复制数据,还是我们负责存储它,这里我们传递1,表示Mono将数据复制到内部缓冲区中。第四个参数是指向MonoImageOpenStatus枚举的指针,我们可以使用此值确定Mono是否能够读取该数据,或者是否有问题。

最后一个参数也是一个布尔值,如果设置为true或1,表示Mono将以“反射模式”加载我们的图像,这意味着我们可以检查类型,但不能运行任何代码。如果你正在构建类似JetBrains dotPeek程序的应用程序,你很可能希望将此参数设置为true,但由于我们想要运行代码,我们将其设置为false或0。

mono_image_open_from_data_full如果成功解释(因为是二进制的)我们的数据,将返回指向MonoImage结构的有效指针,否则将返回nullptr。在我们将数据加载到Mono之后,我们将检查status变量是否设置为MONO_IMAGE_OK,如果没有,我们将查询Mono获取描述发生了什么问题的错误消息,我们使用mono_image_strerror来实现,它会将我们的status变量转换为更友好的错误消息。

现在我们有一个有效的图像加载了,我们必须通过它创建一个MonoAssembly,幸运的是,这非常容易,我们只需调用mono_assembly_load_from_full并给它图像。如果此函数成功,我们将获得指向MonoAssembly结构的指针,否则它将返回nullptr

此函数的第一个参数是我们从Mono获得的图像,第二个参数实际上只是一个名称,Mono可以在打印错误时使用,第三个参数是我们的status变量,此函数将在发生错误时写入我们的status变量,但在这一点上真的不应该生成错误,所以我们不会检查它。

最后一个参数与mono_image_open_from_data_full中的最后一个参数相同,因此如果在那里指定了1,你应该在此函数中也这样做,但在我们的情况下,我们将其设置为0。

在从Mono获得一个MonoAssembly指针后,我们可以(也应该)关闭该图像,因为它仅用于获取MonoAssembly指针,对于其他任何事情都是无用的。我会注意到MonoImages在Mono中用于其他一些事情,我们稍后会涵盖这些内容,但这个图像是无用的,所以我们需要关闭它以减少引用计数。

就是这样!但因为我们是好的程序员,我们会确保释放我们加载的缓冲区,使用delete[] fileData;。之后我们只需返回我们的assembly指针!

现在你可能想要确保要加载的文件实际上存在,但在这个示例中,我们假设我们永远不会尝试加载磁盘上不存在的文件。

好了,现在我们有一个能够将C#程序集加载到Mono运行时的函数,所以现在是时候实际加载我们的程序集并验证我们的代码是否有效

2、程序集加载测试

现在我们有一个能够加载 C# 程序集的函数。现在我们所要做的就是确保它能正常工作。我们应该怎么做呢?当然,我们可以简单地检查我们是否得到了有效的 MonoAssembly 指针,但这并不严格意味着一切都按预期工作。

为了正确测试它,我们将遍历程序集中定义的所有类类型,这样我们就可以确切地看到其中有哪些类、结构体和枚举。我们通过迭代程序集元数据metadata)来实现这一点,通过获取对类型定义表的访问权限来实现。

2.1 测试代码

这段代码展示了如何迭代程序集中的所有类型定义

void PrintAssemblyTypes(MonoAssembly* assembly)
{
    // 获取程序集图像
    MonoImage* image = mono_assembly_get_image(assembly);

    // 获取类型定义表信息
    const MonoTableInfo* typeDefinitionsTable = mono_image_get_table_info(image, MONO_TABLE_TYPEDEF);

    // 获取类型的数量
    int32_t numTypes = mono_table_info_get_rows(typeDefinitionsTable);
	// 迭代类型定义表中的每一行
    for (int32_t i = 0; i < numTypes; i++)
    {
		// 当前行的列数据
        uint32_t cols[MONO_TYPEDEF_SIZE];
        mono_metadata_decode_row(typeDefinitionsTable, i, cols, MONO_TYPEDEF_SIZE);
	
		// 从图像中获取命名空间和类型名称
        const char* nameSpace = mono_metadata_string_heap(image, cols[MONO_TYPEDEF_NAMESPACE]);
        const char* name = mono_metadata_string_heap(image, cols[MONO_TYPEDEF_NAME]);
		
		// 打印命名空间和类型名称
        printf("%s.%s\n", nameSpace, name);
    }
}

解释一下:

这段代码定义了一个函数 PrintAssemblyTypes,该函数接受一个 MonoAssembly* 类型的参数,表示一个Mono程序集。函数首先通过 mono_assembly_get_image 获取了程序集的图像(image)。然后,通过 mono_image_get_table_info 获取了类型定义表(typeDefinitionsTable)的信息。接着,通过 mono_table_info_get_rows 获取了类型的数量。

接下来,通过一个简单的循环,该函数迭代了类型定义表中的每一行。通过调用 mono_metadata_decode_row 获取每一行的列数据,并从图像中获取命名空间和类型名称。最后,将命名空间和类型名称打印到控制台。

这样,函数就完成了遍历并打印程序集中所有类型定义的任务。这种方法可以让你查看程序集中包含的所有类、结构体和枚举。

2.2 表的行和列


在我们得到要迭代的后,我们需要获取表中的行数,这种情况下是类型定义的数量。我们可以通过调用 mono_table_info_get_rows 并传递表信息指针来完成这个任务。

然后,我们遍历所有行,现在我们必须获取每一行的所有列值。所有列都将它们的数据存储为uint32_t即无符号32位整数,因此我们首先分配一个名为 cols 的数组,并将数组的大小设置为我们正在迭代的表的最大列数。Mono提供了代表每个表所需的常量,所以在这种情况下,我们将数组的大小设置为 MONO_TYPEDEF_SIZE

为了填充数组,我们必须解码类型定义表中的当前行,我们可以通过调用 mono_metadata_decode_row 来完成这个操作,并传递一些参数。虽然我认为这些参数是不言自明的,但我意识到这对于每个人来说可能并非如此,因此我将逐一解释每个参数是什么。

第一个参数是我们要迭代的实际表。第二个参数是我们想要获取其列的行。第三个参数只是我们分配的列数组,最后一个参数是该数组的大小。

调用此函数后,我们的 cols 数组现在将包含一堆值,我们现在可以使用这些值来获取此类型的一些数据。

在我解释代码的其余部分之前,简要说明一下:根据列代表的是什么,应该以不同的方式使用此数组中存储的数据。在某些情况下,该值是我们想要的值,直接存储在数组中;在其他情况下,该值表示另一个内存中不同位置的数据结构的索引,在给定类型的命名空间和名称的情况下,列存储在字符串堆中的索引。

因此,有时你会像我们在这里做的一样,使用该值从字符串堆中获取一个字符串,有时你会直接使用该值,MONO_ASSEMBLYREF_MAJOR_VERSION 是一个很好的例子,如果你想要获取程序集的主版本号,你只需执行 uint32_t majorVersion = cols[MONO_ASSEMBLYREF_MAJOR_VERSION];,假设你有正确的表。

2.3 获取命名空间和类型名


我将解释代码中的后面两行,你可以看到它们几乎相同,我们对两行都调用了 mono_metadata_string_heap,并传递了图像列的某个值

首先,我们通过访问 MONO_TYPEDEF_NAMESPACE 列中存储的值来获取命名空间名称,再次强调,该值是字符串堆中索引,我们的命名空间名称就在那里。如果一个类型没有命名空间,意味着它在全局命名空间中,这个函数将简单地返回一个空字符串。

接下来,我们做了几乎完全相同的事情,只是这次我们获取的是 MONO_TYPEDEF_NAME 列中的值。

正如你从下面的图像中看到的,MONO_TABLE_TYPEDEF 表中仍然有一些其他列,我现在不打算在这里详细介绍它们,但我会确保在以后适当地介绍它们。
请添加图片描述
好的!如果你现在调用这个函数(在加载了程序集之后),你应该在控制台上看到打印出的存储在你的程序集中的所有类型。

2.4 模块类型


你可能注意到第一个打印的类型是<Module>,而你可能意识到你的项目中并没有这个名字的类型,所以发生了什么?实际上,这是由C#编译器提供的一种类型,所有的C# dll和exe文件都有这个类型。

实际上,这个类型代表了整个程序集,你的程序集总是至少有一个模块,尽管有可能创建一个多文件程序集,它是包含多个模块的程序集。但无论如何,在这里并不重要,因为在本指南中我们永远不会使用 <Module> 类,而且如果你正在制作一个脚本引擎,你很可能永远不会用到它。

至此,这一节就结束了!如果你在控制台上看到了你的类型打印出来,那么可以安全地假设一切都按预期工作,你已经对C#程序集如何存储数据有了一些了解,现在我们可以继续进行一些有趣的操作。在下一节中,我们将创建一个 CSharpTesting 类的实例。

  • 25
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

宗浩多捞

您的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值