多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

揭秘Unity反射背后的六大类型层次

揭秘Unity反射背后的六大类型层次 这六个概念看起来都在描述“类型信息”但实际上处在不同层次。我们先用一个统一案例publicclassEnemy{publicintHealth;publicEnemy(inthealth){Healthhealth;}}通过反射创建它Typetypetypeof(Enemy);ConstructorInfoctortype.GetConstructor(new[]{typeof(int)})!;object[]args{100};Enemyenemy(Enemy)ctor.Invoke(args);把这段代码想象成拿着产品目录找到 Enemy 型号的装配说明准备参数然后让工厂制造一个 Enemy。对应关系如下概念工厂比喻类型元数据产品的原始设计档案IL2CPP 运行时类型结构工厂内部使用的生产管理卡托管System.Type对象C# 程序拿到的产品查询卡构造函数的反射表示某一道装配工序的查询卡参数数组及其他临时对象本次订单的参数单和临时包装运行时反射缓存工厂为避免重复查找建立的索引柜下面分别展开。一、类型元数据描述“这个类型是什么”的档案1. 它记录什么对于Enemy元数据可以描述类型名称Enemy 命名空间…… 所在程序集…… 基类System.Object 字段 Health 类型System.Int32 可见性public 构造函数 名称.ctor 参数System.Int32 可见性public还可能包含实现的接口属性和事件方法签名泛型参数自定义特性相关信息类型和成员的各种标记。所以类型元数据回答的是这个类型叫什么包含哪些成员这些成员是什么形式它不是某个Enemy实例的数据。元数据 Enemy 有一个 int 类型的 Health 字段。 实例数据 这只 Enemy 的 Health 等于 100。两者完全不同。2. 在 IL2CPP 中在哪里Unity 的构建流程会将托管程序集转换为 IL2CPP 所需的代码和数据。元数据的一部分通常存在于global-metadata.dat此外还有生成到本机程序中的注册信息及其他相关数据。因此不能理解成IL2CPP 所需的一切类型信息都只保存在这一个文件里。3. 元数据不是方法的机器码例如publicEnemy(inthealth){Healthhealth;}可以拆成元数据 有一个构造函数接受一个 int。 本机代码 实际执行初始化和赋值操作。元数据描述“它是什么”代码实现“它做什么”。内存优化对应点主要关注是否保留了不需要的类型和成员是否过度使用preserveall是否可以通过裁剪减少元数据与相关代码。不调用反射不代表构建中就完全不需要元数据。正常运行时功能也会使用类型信息。二、IL2CPP 运行时类型结构引擎内部的“工作状态表”原始档案适合保存信息但运行时不能每做一次类型判断都从文件头重新查找。它需要更适合执行的内部结构。IL2CPP 中常见的相关原生结构包括Il2CppClass Il2CppType注意这两个结构不是同一个概念。1. Il2CppClass更接近运行时的类型管理信息它通常关联或维护类型名称和所属映像父类型字段、方法和接口信息实例大小及布局相关信息虚方法表静态字段存储相关信息类型初始化状态泛型相关上下文。具体字段和布局随 Unity 版本变化。可以把它理解为Enemy 的运行时管理卡 生产一个实例需要多大空间 父类型是谁 虚方法如何分派 静态初始化是否完成 哪些方法可以调用它不仅服务反射也服务于创建对象类型检查虚函数调用静态字段访问类型初始化。2. Il2CppType更接近“某种类型的描述”它用于描述一个类型表达或类型形式例如int Enemy Enemy[] 某个泛型参数 按引用传递的类型它和Il2CppClass相互关联但不能简单一一等同。3. 为什么运行时结构会增加内存因为文件中的紧凑记录与运行时方便使用的结构不是一回事。运行时可能需要把索引转换成可直接访问的关联建立某些表准备泛型实例相关信息按需补齐类型的部分运行时状态。有些数据由生成代码直接提供有些数据或状态会在运行时建立、补齐。所以不要理解成每执行一次typeof(Enemy)就重新创建一整套Il2CppClass。实际存在复用及初始化机制。内存优化对应点减少不必要的类型遍历、泛型组合和成员访问可能缩小运行时实际触及的数据范围。但这部分是原生运行时数据不等于托管堆里的对象不能指望普通托管 GC 自动清理所有相关结构。三、托管 System.Type 对象C# 能拿到的“类型查询入口”现在来到 C# 层Typetypetypeof(Enemy);这里的type是一个托管对象引用。它的职责是向 C# 程序提供类型查询接口type.Nametype.BaseTypetype.GetFields()type.GetMethods()type.GetConstructors()1. 它不是元数据本身可以理解为System.Type 对象 ↓ 通过运行时关联 IL2CPP 内部类型信息 ↓ 所需元数据和执行信息System.Type像一张查询卡。你拿着卡问“Enemy 有哪些构造函数”运行时再根据内部类型信息回答。并不是System.Type对象里面复制了一份完整的Enemy元数据文件。2. 它也不是 Enemy 实例Typetypetypeof(Enemy);不会因此调用newEnemy(100);它表示Enemy 这种类型而不是某一只 Enemy3. 多次获取不意味着不断创建新包装Typeatypeof(Enemy);Typebtypeof(Enemy);运行时通常会复用相应的类型对象或相关表示不应把它理解为每次都分配全新的类型包装。内存优化对应点单独使用几个typeof(...)通常不是重点。真正值得关注的是assembly.GetTypes();这种批量发现操作返回一个数组暴露大量类型对象可能扩大运行时已访问和缓存的类型范围。四、构造函数的反射表示描述“如何创建”的查询对象代码ConstructorInfoctortypeof(Enemy).GetConstructor(new[]{typeof(int)})!;这里的ctor描述Enemy(int health)这一特定构造函数。它可以告诉你ctor.DeclaringTypector.IsPublicctor.GetParameters()也能作为调用入口ctor.Invoke(newobject[]{100});1. ConstructorInfo 不等于构造函数机器码ConstructorInfo是托管反射对象。它通过运行时内部信息关联到方法签名参数信息所属类型实际执行所需的入口与调用支持。可以画成托管 ConstructorInfo ↓ IL2CPP 原生方法信息 ↓ 构造函数实现及调用支持调用过程还可能涉及参数检查、装箱/拆箱及异常处理并不是直接把一个托管对象当作函数指针跳过去。2. 小心一个源码命名陷阱IL2CPP 原生源码里可能出现MethodInfo这与 C# 中的System.Reflection.MethodInfo不是同一种对象。前者是原生运行时方法信息结构后者是托管反射 API 类型。而且在 C# 继承关系中MethodBase ├── MethodInfo └── ConstructorInfo因此ConstructorInfo也不是托管MethodInfo的子类。3. Activator 不保证先创建一个 ConstructorInfo理解概念时可以说 Activator 要确定构造路径。但不能由此推断每次 Activator.CreateInstance 必定先 new 一个 ConstructorInfo具体运行时和重载可能使用专门的创建路径。内存优化对应点如果只需要一个构造函数就不要先枚举所有构造函数再筛选。但缓存ConstructorInfo主要是在减少重复查找不代表能够消除运行时内部反射缓存。五、参数数组及其他临时对象本次操作的“耗材”看这句object[]args{100};通常涉及至少两类托管分配。1. object[] 数组一个长度为 1 的引用数组数组存放的是对象引用。2. 整数 100 的装箱对象100是值类型int。要把它放进object[]需要装箱object[] args │ └── 引用 → 装箱后的 int100因此ctor.Invoke(newobject[]{100});除了反射调用还包含了参数准备成本。3. 查询签名时也可能有数组例如new[]{typeof(int)}这里创建的是Type[]它用于告诉GetConstructor我要找参数类型为int的构造函数。它不是构造函数执行时的实参数组。两类数组不要混淆Type[] 用于查找构造函数签名。 object[] 用于传递本次调用的实参。4. 其他可能的临时对象例如GetMethods()返回的数组GetConstructors()返回的数组查询过程中应用创建的ListLINQ 产生的中间对象特性实例异常对象。它们是否分配、如何复用要看具体 API 和实现。内存优化对应点主要针对每帧 GC Alloc 频繁装箱 大量短命数组可以考虑改成直接调用使用合适的委托避免重复批量查询在安全的生命周期内复用参数容器。但复用object[]并不会自动消除后续赋入值类型时的装箱。六、运行时维护的反射缓存为什么清空自己的字典还不够假设你写了MethodInfo[]methodstypeof(Enemy).GetMethods();运行时为了避免重复建立相同信息可能维护类型信息 → 托管 Type 对象 方法信息 → 托管反射对象 内部索引 → 已解析的运行时表示具体缓存键、存储位置、锁和生命周期随实现变化并不是所有版本都有一模一样的几张表。1. 缓存不是一种独立的“反射信息”前面几个概念描述的是数据是什么而缓存更多描述这些数据如何被复用和长期持有所以这些概念存在交叉不能机械相加Type 对象内存 反射缓存内存如果“反射缓存内存”的统计已经包含它持有的 Type 对象再相加就可能重复计算。缓存自身的字典、表也占内存被它持有的对象则是另一部分。2. 运行时缓存与业务缓存不同运行时缓存 IL2CPP 自己维护。 业务缓存 你自己的 Dictionary、List、静态字段。例如myCache.Clear();只表示释放业务缓存里的引用。如果运行时仍然持有对象运行时缓存 ↓ MethodInfo 或其他反射对象对象就仍然可达不会因为业务清空而被 GC 回收。3. 为什么“扫描一次”也可能留下长期影响因为首次扫描 ↓ 触及大量类型与成员 ↓ 建立部分运行时数据和反射包装 ↓ 相关缓存长期存活所以“一次性扫描”可以没有持续的每帧分配却仍然增加长期工作集。这也是 Unity 项目常把扫描移到编辑器或构建阶段的原因。七、把这六个概念串起来回到最初的代码Typetypetypeof(Enemy);ConstructorInfoctortype.GetConstructor(new[]{typeof(int)})!;object[]args{100};Enemyenemy(Enemy)ctor.Invoke(args);对应的概念关系可以画成类型元数据 描述 Enemy 及其构造函数 │ ▼ IL2CPP 运行时类型/方法结构 支持类型查询、布局和执行 ▲ │ 关联 │ 托管 System.Type │ GetConstructor(...) ▼ 托管 ConstructorInfo │ │ Invoke(args) ▼ 参数数组 装箱参数 │ ▼ 运行时执行创建与构造 │ ▼ Enemy 实例同时运行时缓存 └── 可能复用、持有其中部分运行时表示和反射对象这张图表达的是关系不是保证每次调用都按顺序新建所有这些对象。八、最后用三个问题判断内存属于哪一类1. 它是描述信息还是实际实例数据Enemy 有 Health 字段 → 元数据/类型信息 某只 Enemy 的 Health 为 100 → 实例数据2. 它在托管堆还是原生运行时侧System.Type、ConstructorInfo、object[] → 托管对象 Il2CppClass 等内部结构 → 原生运行时侧3. 它是一次性耗材还是被长期持有临时参数数组 → 无引用后通常可回收 运行时缓存中的反射对象 → 可能长期存活一句话总结元数据是档案运行时结构是内部工作表Type 和 ConstructorInfo 是托管查询入口参数数组是本次调用的耗材而缓存决定其中哪些信息会被长期留用。
返回列表