Skip to content

反射与特性

反射是什么?有什么代价?

一句话讲清楚

反射就是:程序在运行时读取程序集里的元数据,动态知道“有哪些类、字段、方法、特性”,甚至可以动态创建对象、访问字段、调用方法。

reflection-costs

反射能做什么

正常写代码时,我们是编译期就知道类型:

Player player = new Player(); // 编译期就知道要创建 Player

反射是运行时才决定:

c
using System; // 引入 Type 和 Activator
using System.Reflection; // 引入 MethodInfo 反射类型
public class Player // 定义玩家类
{ // 类开始
    public void Attack() // 定义攻击方法
    { // 方法开始
        Console.WriteLine("Attack"); // 输出攻击文本
    } // 方法结束
} // 类结束
public static class ReflectionExample // 定义反射示例类
{ // 类开始
    public static void Run() // 定义运行方法
    { // 方法开始
        Type type = typeof(Player); // 获取 Player 类型的元数据
        object obj = Activator.CreateInstance(type); // 根据 Type 动态创建 Player 对象
        MethodInfo method = type.GetMethod("Attack"); // 根据方法名查找 Attack 方法
        method.Invoke(obj, null); // 动态调用 obj 对象上的 Attack 方法
    } // 方法结束
} // 类结束

反射的常见用途

编辑器工具:Unity 里扫描字段、生成面板、读取特性。 序列化框架:根据字段名和属性自动读写数据。 配置系统:通过类名、字段名把表格数据映射成对象。 依赖注入:运行时扫描类型并自动创建对象。 插件系统:运行时加载程序集,找到某些接口实现类。

反射有什么代价

第一,性能更慢。

直接调用方法时,编译器和运行时都很清楚要调用谁。反射调用要先查元数据,找到 MethodInfo,再走 Invoke,过程更绕,所以比直接调用慢很多。

第二,可能产生 GC。

比如 Invoke 的参数经常是 object[],返回值也是 object,值类型可能发生装箱,读取 Attribute 也可能创建数组和对象。在 Unity 里,如果你每帧反射,很容易产生 GC Alloc。

第三,编译期安全变弱。

直接调用方法,方法名写错编译器会报错。反射用字符串找方法,比如 "Attack",如果你改名了但字符串没改,编译期可能不报错,运行时才炸。

第四,破坏封装。

反射可以访问私有字段和私有方法。虽然很强,但乱用会让代码边界变模糊,维护成本变高。

第五,IL2CPP 下要注意裁剪。

IL2CPP 后仍然可以反射,但如果某些类型和成员只通过反射访问,没有直接引用,Unity Linker 可能把它裁掉。必要时要用 Preservelink.xml 保留。

Unity 项目里怎么用比较好

初始化阶段可以用反射,比如启动时扫描配置类、自动注册事件、生成编辑器数据。 战斗 Update、怪物 AI、技能判定、UI 高频刷新里尽量不要反射。 如果必须用,缓存 TypeFieldInfoPropertyInfoMethodInfo,不要每次重新查。 性能敏感场景可以考虑代码生成、委托缓存、Source Generator、手写映射表。

面试高分回答

NOTE

反射是运行时读取程序集元数据并动态操作类型的机制。它可以在运行时获取类型、字段、方法、Attribute,也可以动态创建对象和调用方法。它的优点是灵活,适合编辑器工具、序列化、配置系统和框架初始化;代价是性能比直接调用慢,可能产生 GC,编译期类型安全变弱,代码维护成本更高。在 Unity 里不要在 Update 或战斗热路径频繁使用反射,IL2CPP 下还要注意代码裁剪,需要时用 Preserve 或 link.xml 保留反射用到的类型。

Attribute 是什么?

一句话讲清楚

Attribute 是 C# 里贴在类、方法、字段、属性上的“标签”。它会被编译进程序集的元数据里,之后可以被编译器、运行时、Unity 或框架读取,用来影响序列化、Inspector 显示、测试、路由、提示等行为。

attribute-explanation

它不是普通注释

注释只是给人看的,编译后通常就没了。

Attribute 是给程序、框架、工具看的,它会进入元数据。运行时可以通过反射读取它。

比如 Unity 里的:

[SerializeField]:让 private 字段也能被 Unity 序列化并显示在 Inspector。 [Header("角色属性")]:在 Inspector 上显示标题。 [Range(0, 100)]:在 Inspector 上显示滑条范围。

它本身不一定执行逻辑

这是重点。

Attribute 本身只是一个标记。真正产生效果的是:有人读取了这个标记,并按规则处理它。

比如 [SerializeField] 不是自己让字段显示,而是 Unity 编辑器和序列化系统读取到它之后,决定把这个字段序列化并显示出来。

自定义 Attribute 示例

c
using System; // 引入 Attribute 和 AttributeUsage 等类型
[AttributeUsage(AttributeTargets.Class)] // 限制这个 Attribute 只能贴在类上
public sealed class TableAttribute : Attribute // 定义一个自定义 Attribute 类
{ // 类开始
    public string TableName { get; } // 保存表名属性
    public TableAttribute(string tableName) // 构造函数接收表名
    { // 构造函数开始
        TableName = tableName; // 把传入的表名保存起来
    } // 构造函数结束
} // 类结束
[Table("PlayerConfig")] // 给 PlayerConfig 类贴上 TableAttribute 标签
public sealed class PlayerConfig // 定义玩家配置类
{ // 类开始
    public int Id; // 配置 ID
    public string Name; // 玩家名字
} // 类结束

通过反射读取 Attribute

c
using System; // 引入 Console 和 Type
public static class AttributeReader // 定义 Attribute 读取类
{ // 类开始
    public static void PrintTableName() // 打印表名方法
    { // 方法开始
        Type type = typeof(PlayerConfig); // 获取 PlayerConfig 的类型信息
        object[] attributes = type.GetCustomAttributes(typeof(TableAttribute), false); // 读取类上的 TableAttribute
        if (attributes.Length == 0) return; // 如果没有找到 Attribute 就直接返回
        TableAttribute table = (TableAttribute)attributes[0]; // 把 Attribute 转成 TableAttribute 类型
        Console.WriteLine(table.TableName); // 输出 Attribute 里保存的表名
    } // 方法结束
} // 类结束

有什么代价

Attribute 本身只是元数据,贴上去一般问题不大。

真正有代价的是读取它的时候。读取 Attribute 常常依赖反射,反射会比直接调用慢,也可能产生 GC。所以 Unity 里不要在 Update、战斗逻辑、技能判定这种高频路径里反复扫描 Attribute。

更好的做法是:启动时或编辑器阶段扫描一次,然后缓存结果。

面试高分回答

TIP

Attribute 是 C# 的元数据标记,可以贴在类、方法、字段、属性等代码元素上。它会被编译进程序集元数据里,运行时可以通过反射读取。Attribute 本身通常不直接执行逻辑,真正产生效果的是编译器、运行时、Unity 或框架读取这些标记后做对应处理。它常用于序列化、编辑器显示、配置映射、测试框架、接口暴露等场景。代价主要来自反射读取,频繁扫描会影响性能并可能产生 GC,所以一般会在初始化阶段读取并缓存。

Unity Inspector 为什么能显示序列化字段?

一句话讲清楚

Unity Inspector 能显示字段,是因为 Unity 有自己的序列化系统。它会扫描脚本中符合规则的字段,把这些字段保存到 Scene、Prefab、ScriptableObject 等数据里,然后 Inspector 再把这些序列化数据画出来。

SVG 下载路径:C:\Users\Administrator\Documents\Codex\2026-07-04\struct-class\outputs\unity-inspector-serialized-fields.svg

Inspector 不是显示所有 C# 成员

Unity Inspector 默认显示的是Unity 能序列化的字段,不是类里所有东西。

能显示的常见情况:

public 字段。 private 字段加 [SerializeField]。 字段类型是 Unity 支持序列化的类型,比如 intfloatboolstringVector3ColorGameObjectMonoBehaviour 引用、ScriptableObject 引用、可序列化类等。

不会默认显示的常见情况:

属性 Propertystatic 字段。 readonly 字段。 const 常量。 不被 Unity 序列化系统支持的复杂类型。

为什么 private 加 SerializeField 能显示

private 本来只是不想让外部代码直接访问。

但你加了 [SerializeField] 后,相当于告诉 Unity:

这个字段虽然是 private,但我希望 Unity 把它保存起来,并显示到 Inspector 里。

using UnityEngine; // 引入 UnityEngine 命名空间
public sealed class PlayerView : MonoBehaviour // 定义一个挂在 GameObject 上的组件
{ // 类开始
    [SerializeField] // 让 Unity 序列化这个 private 字段,并显示在 Inspector
    private int hp = 100; // 定义玩家血量字段,外部代码不能直接访问
    [SerializeField] // 让 Unity 序列化这个 private 字段,并显示在 Inspector
    private Transform weaponRoot; // 定义武器挂点引用字段
    public int Hp // 定义只读属性,给外部代码安全读取
    { // 属性开始
        get { return hp; } // 返回当前血量
    } // 属性结束
} // 类结束

这样设计比直接写 public int hp 更好,因为 Inspector 可以配置,但代码访问仍然受控制。

Inspector 是怎么画出来的

Unity Editor 内部会用类似 SerializedObjectSerializedProperty 的机制来包装目标对象。

它不是简单地直接改字段,而是通过序列化接口修改数据。这样才能支持:

Undo 撤销。 Prefab Override 预制体覆盖记录。 多对象同时编辑。 脏标记保存。 自定义 Inspector 和 PropertyDrawer。

Attribute 的作用

像这些 Attribute 会影响 Inspector 显示:

[SerializeField]:让 private 字段参与序列化。 [HideInInspector]:字段仍可序列化,但不显示。 [Header("xxx")]:显示标题。 [Range(0, 100)]:用滑条显示数值。 [Tooltip("xxx")]:显示提示。 [Serializable]:让普通 C# 类可以被 Unity 序列化。

常见误区

[SerializeField] 不是让字段变成 public。 它只是让 Unity 序列化系统能看到这个字段。

Inspector 显示不等于运行时一定会自动保存。 运行时改对象字段,通常只是改内存里的值,不一定会写回资源文件。

属性默认不会被 Unity 序列化。 哪怕你写了 public int Hp { get; set; },它也不会像普通字段那样自动出现在 Inspector。

面试高分回答

Unity Inspector 能显示字段,是因为 Unity 的序列化系统会扫描脚本中符合规则的字段,比如 public 字段或带 [SerializeField] 的 private 字段,并把这些数据保存到 Scene、Prefab、ScriptableObject 等序列化文件中。Inspector 显示的不是所有 C# 成员,而是这些被 Unity 序列化系统认可的数据。Editor 通过 SerializedObjectSerializedProperty 绘制和修改这些字段,所以才能支持 Undo、Prefab Override、多对象编辑和自定义 Inspector。属性、static、readonly 默认不参与 Unity 序列化,所以不会直接显示。

SerializeFieldpublic 字段区别是什么?

一句话讲清楚

public 字段是访问权限,表示外部代码可以直接访问它;[SerializeField]序列化标记,表示让 Unity 序列化这个字段并显示到 Inspector。最常见推荐写法是:private + [SerializeField]

serializefield-public-difference

public 字段

public 字段默认会显示在 Inspector,因为 Unity 会序列化它。

但问题是:任何外部脚本都能直接改它。

using UnityEngine; // 引入 UnityEngine 命名空间
public sealed class PlayerBadExample : MonoBehaviour // 定义一个不推荐写法的玩家组件
{ // 类开始
    public int hp = 100; // public 字段会显示在 Inspector,也能被外部脚本直接修改
} // 类结束

这样写虽然简单,但封装性差。比如别的脚本可以直接写:

player.hp = -999; // 外部代码可以绕过规则直接把血量改成非法值

SerializeField 字段

[SerializeField] private 的意思是:字段仍然是 private,但 Unity 可以序列化它并显示在 Inspector。

c
using UnityEngine; // 引入 UnityEngine 命名空间
public sealed class PlayerGoodExample : MonoBehaviour // 定义一个推荐写法的玩家组件
{ // 类开始
    [SerializeField] // 让 Unity 序列化这个 private 字段,并显示在 Inspector
    private int maxHp = 100; // private 字段外部不能直接修改,但策划或开发可以在 Inspector 配置
    public int MaxHp // 对外提供只读属性
    { // 属性开始
        get { return maxHp; } // 外部只能读取 maxHp,不能随便改
    } // 属性结束
    public void SetMaxHp(int value) // 提供一个受控方法来修改最大血量
    { // 方法开始
        if (value <= 0) return; // 如果传入非法值,就直接拒绝修改
        maxHp = value; // 通过校验后才允许修改字段
    } // 方法结束
} // 类结束

这样 Inspector 仍然能配置 maxHp,但外部代码不能乱改,只能通过你提供的方法或属性访问。

核心区别

对比点public 字段private + SerializeField
Inspector 显示默认显示会显示
Unity 序列化默认序列化会序列化
外部代码访问可以直接访问和修改不能直接访问
封装性差一些更好
推荐程度Demo 可以,正式项目慎用正式项目更推荐

常见误区

[SerializeField] 不会把 private 变成 public。 它只是让 Unity 序列化系统能看到这个字段。

public 字段显示在 Inspector,不代表它就是好设计。 很多时候只是因为 Unity 默认序列化 public 字段。

属性默认不会被 Unity 序列化。 比如 public int Hp { get; set; } 通常不会直接显示在 Inspector。

面试高分回答

TIP

public 字段表示访问权限对外开放,所以外部代码可以直接读写,同时 Unity 默认会把 public 字段序列化并显示到 Inspector。[SerializeField] 是 Unity 的序列化标记,可以让 private 字段也参与序列化并显示在 Inspector,但不会改变它的访问权限。正式项目里更推荐 private + [SerializeField],再通过只读属性或方法对外暴露,这样既方便在 Inspector 配置,又能保持封装性,避免外部代码随意修改内部状态。

[NonSerialized] 有什么用?

一句话讲清楚

[NonSerialized] 的作用是:告诉序列化系统这个字段不要被序列化。在 Unity 里,常用来让某个 public 字段不要保存到 Scene、Prefab、ScriptableObject,也不要显示在 Inspector。

nonserialized-attribute

它解决什么问题

Unity 默认会序列化 public 字段,所以它会显示在 Inspector,也会被保存。

但有些字段只是运行时临时数据,不应该被保存。 比如缓存、计时器、运行时状态、临时统计、计算出来的派生值。

这时候就可以用 [NonSerialized]

c
using System; // 引入 NonSerializedAttribute 所在命名空间
using UnityEngine; // 引入 UnityEngine 命名空间
public sealed class PlayerRuntimeData : MonoBehaviour // 定义玩家运行时数据组件
{ // 类开始
    public int maxHp = 100; // public 字段默认会被 Unity 序列化并显示在 Inspector
    [NonSerialized] // 告诉序列化系统不要保存这个字段
    public int currentCombo; // 连击数是运行时临时数据,不应该保存到 Prefab 或 Scene
    [NonSerialized] // 告诉序列化系统不要保存这个字段
    public float runtimeTimer; // 运行时计时器不需要被 Inspector 保存
} // 类结束

和 HideInInspector 的区别

[NonSerialized] 是:不序列化,不保存,通常也不显示[HideInInspector] 是:仍然序列化和保存,只是不显示在 Inspector

c
using System; // 引入 NonSerializedAttribute
using UnityEngine; // 引入 UnityEngine
public sealed class CompareExample : MonoBehaviour // 定义对比示例组件
{ // 类开始
    [NonSerialized] // 不保存这个字段
    public int tempScore; // 运行时临时分数,不会写进场景或 Prefab
    [HideInInspector] // 保存这个字段,但是不在 Inspector 显示
    public int savedId; // 这个 ID 会被序列化保存,只是被隐藏
} // 类结束

和 SerializeField 的区别

[SerializeField] 是让 Unity 序列化字段,常用于 private 字段。

[NonSerialized] 是阻止字段被序列化,常用于不想保存的 public 字段。

c
using System; // 引入 NonSerializedAttribute
using UnityEngine; // 引入 UnityEngine
public sealed class SerializeCompare : MonoBehaviour // 定义序列化对比组件
{ // 类开始
    [SerializeField] // 让 Unity 序列化 private 字段并显示在 Inspector
    private int moveSpeed = 5; // 配置数据,应该保存
    [NonSerialized] // 阻止 Unity 序列化 public 字段
    public int runtimeFrameCount; // 运行时帧计数,不应该保存
} // 类结束

什么时候用

运行时缓存:比如缓存组件、缓存路径、临时列表。 运行时状态:比如当前连击数、临时计时器、战斗中临时目标。 计算结果:比如根据配置算出来的值,下次加载时可以重新计算。 调试数据:比如运行时统计,不希望污染 Prefab 或 Scene。

注意点

[NonSerialized] 来自 System 命名空间。

它主要用于字段,不是给类、方法、属性用的。

private 字段默认就不会被 Unity 序列化,所以一般不需要再加 [NonSerialized]。除非你之前有特殊序列化规则,否则重点是用它阻止 public 字段被保存。

面试高分回答

IMPORTANT

[NonSerialized] 是一个告诉序列化系统不要序列化该字段的 Attribute。在 Unity 中,public 字段默认会被序列化并显示在 Inspector,如果某个 public 字段只是运行时临时数据,比如缓存、计时器、连击数、临时状态,就可以加 [NonSerialized],避免它被保存到 Scene 或 Prefab。它和 [HideInInspector] 不一样,HideInInspector 是保存但不显示,而 NonSerialized 是不保存。和 [SerializeField] 也相反,SerializeField 是让字段参与序列化。

自定义 Attribute 可以做什么?

一句话讲清楚

自定义 Attribute 可以给类、字段、方法贴上自己的“业务标签”,然后通过反射、代码生成、Unity Editor 工具或框架扫描这些标签,自动完成配置映射、注册、校验、编辑器显示等事情。custom-attribute-uses

它能做什么

自定义 Attribute 常见用途有这些:

配置表映射:给配置类标记表名,给字段标记列名。 自动注册:给事件、命令、系统、技能处理器贴标签,启动时自动扫描注册。 数据校验:标记必填、范围、唯一性,导表或打包前自动检查。 编辑器工具:给方法标记按钮,给字段标记显示规则,生成自定义 Inspector。 框架扩展:比如路由、依赖注入、序列化、测试框架都大量用 Attribute。

重点:Attribute 本身不执行逻辑

它只是标签。

真正干活的是:

反射代码。 Unity Editor 工具。 框架扫描器。 代码生成器。 编译器或运行时。

也就是说,你写了 [Table("Player")],它不会自动加载表。你必须有一段工具代码去扫描这个 Attribute,然后按规则加载。

自定义 Attribute 示例

c
using System; // 引入 Attribute 和 AttributeUsage
[AttributeUsage(AttributeTargets.Class)] // 限制这个 Attribute 只能贴在类上
public sealed class TableAttribute : Attribute // 定义表格 Attribute
{ // 类开始
    public string TableName { get; } // 保存表名
    public TableAttribute(string tableName) // 构造函数接收表名
    { // 构造函数开始
        TableName = tableName; // 把传入的表名保存起来
    } // 构造函数结束
} // 类结束
[AttributeUsage(AttributeTargets.Field)] // 限制这个 Attribute 只能贴在字段上
public sealed class ColumnAttribute : Attribute // 定义列名 Attribute
{ // 类开始
    public string ColumnName { get; } // 保存列名
    public ColumnAttribute(string columnName) // 构造函数接收列名
    { // 构造函数开始
        ColumnName = columnName; // 把传入的列名保存起来
    } // 构造函数结束
} // 类结束
[Table("PlayerConfig")] // 标记这个类对应 PlayerConfig 表
public sealed class PlayerConfig // 定义玩家配置类
{ // 类开始
    [Column("id")] // 标记 Id 字段对应 id 列
    public int Id; // 玩家配置 ID
    [Column("name")] // 标记 Name 字段对应 name 列
    public string Name; // 玩家名字
} // 类结束

通过反射读取

c
using System; // 引入 Console 和 Type
using System.Reflection; // 引入 FieldInfo
public static class AttributeScanExample // 定义 Attribute 扫描示例类
{ // 类开始
    public static void Scan() // 扫描配置类上的 Attribute
    { // 方法开始
        Type type = typeof(PlayerConfig); // 获取 PlayerConfig 类型
        TableAttribute table = type.GetCustomAttribute<TableAttribute>(); // 读取类上的 TableAttribute
        Console.WriteLine(table.TableName); // 输出表名
        FieldInfo[] fields = type.GetFields(); // 获取所有 public 字段
        for (int i = 0; i < fields.Length; i++) // 遍历字段
        { // for 开始
            FieldInfo field = fields[i]; // 取出当前字段
            ColumnAttribute column = field.GetCustomAttribute<ColumnAttribute>(); // 读取字段上的 ColumnAttribute
            if (column == null) continue; // 如果没有 ColumnAttribute 就跳过
            Console.WriteLine(field.Name + " -> " + column.ColumnName); // 输出字段名和列名的映射关系
        } // for 结束
    } // 方法结束
} // 类结束

Unity 里怎么用

比如你可以做一个编辑器按钮 Attribute:

c
using System; // 引入 Attribute
[AttributeUsage(AttributeTargets.Method)] // 限制这个 Attribute 只能贴在方法上
public sealed class EditorButtonAttribute : Attribute // 定义编辑器按钮 Attribute
{ // 类开始
    public string ButtonName { get; } // 保存按钮名字
    public EditorButtonAttribute(string buttonName) // 构造函数接收按钮名字
    { // 构造函数开始
        ButtonName = buttonName; // 保存按钮名字
    } // 构造函数结束
} // 类结束
public sealed class EnemySpawner // 定义敌人生成器
{ // 类开始
    [EditorButton("生成测试敌人")] // 标记这个方法可以显示成编辑器按钮
    public void SpawnTestEnemy() // 定义生成测试敌人的方法
    { // 方法开始
    } // 方法结束
} // 类结束

然后你写一个 Unity Editor 工具扫描 [EditorButton],把它画成按钮。点按钮时,通过反射调用方法。

代价和注意点

自定义 Attribute 很适合初始化、工具、配置、框架层。 不要在 Update、战斗判定、AI Tick 里每帧反射扫描 Attribute。 如果要用,启动时扫描一次,然后缓存结果。 IL2CPP 下还要注意代码裁剪,反射用到的类型必要时要 Preservelink.xml 保留。

面试高分回答

WARNING

自定义 Attribute 可以把一些规则声明到代码上,比如配置表映射、事件注册、编辑器按钮、数据校验、序列化规则等。它本身不会直接执行逻辑,只是被编译进元数据,真正产生效果的是框架、工具或反射代码读取它并执行对应规则。它的好处是让代码更声明式,减少样板代码;代价是通常需要反射读取,频繁使用会有性能和 GC 成本,所以一般在初始化或编辑器阶段扫描一次并缓存,热路径不要反复反射。

反射在热更新/配置表中怎么用?

一句话讲清楚

反射在热更新和配置表里,主要用来做:运行时发现类型、读取 Attribute 规则、自动建立映射关系。但它通常只适合在初始化阶段用,真正运行时要走缓存,不要每次都反射。

reflection-hotupdate-config

在配置表中怎么用

配置表里常见做法是:给配置类贴表名标签,给字段贴列名标签。加载配置时,通过反射读取这些标签,把表格里的数据自动填进对象。

c
using System; // 引入 Attribute 和 Type
using System.Collections.Generic; // 引入 Dictionary
using System.Reflection; // 引入 FieldInfo
[AttributeUsage(AttributeTargets.Field)] // 限制 ColumnAttribute 只能贴在字段上
public sealed class ColumnAttribute : Attribute // 定义列名 Attribute
{ // 类开始
    public string Name { get; } // 保存列名
    public ColumnAttribute(string name) // 构造函数接收列名
    { // 构造函数开始
        Name = name; // 保存列名
    } // 构造函数结束
} // 类结束
public sealed class PlayerConfig // 定义玩家配置类
{ // 类开始
    [Column("id")] // 表示这个字段对应配置表里的 id 列
    public int Id; // 配置 ID
    [Column("name")] // 表示这个字段对应配置表里的 name 列
    public string Name; // 玩家名字
} // 类结束
public static class ConfigLoader // 定义配置加载器
{ // 类开始
    public static T LoadOne<T>(Dictionary<string, string> row) where T : new() // 把一行表格数据转成配置对象
    { // 方法开始
        T config = new T(); // 创建配置对象
        FieldInfo[] fields = typeof(T).GetFields(); // 通过反射获取所有 public 字段
        for (int i = 0; i < fields.Length; i++) // 遍历所有字段
        { // for 开始
            FieldInfo field = fields[i]; // 取出当前字段
            ColumnAttribute column = field.GetCustomAttribute<ColumnAttribute>(); // 读取字段上的 ColumnAttribute
            if (column == null) continue; // 如果字段没有列名标签就跳过
            if (!row.TryGetValue(column.Name, out string text)) continue; // 如果表格里没有这一列就跳过
            object value = Convert.ChangeType(text, field.FieldType); // 把字符串转换成字段需要的类型
            field.SetValue(config, value); // 通过反射给字段赋值
        } // for 结束
        return config; // 返回填充好的配置对象
    } // 方法结束
} // 类结束

这个思路很灵活,但正式项目里通常不会每一行、每个字段都重新反射。更好的做法是启动时把 FieldInfo、列名、转换器缓存起来。

在热更新中怎么用

热更新里常见用法是:加载热更程序集后,扫描里面带某个 Attribute 的类或方法,然后注册到事件系统、消息系统、命令系统里。

比如某个方法标记自己处理消息 1001

c
using System; // 引入 Attribute
using System.Collections.Generic; // 引入 Dictionary
using System.Reflection; // 引入 MethodInfo
[AttributeUsage(AttributeTargets.Method)] // 限制 HandlerAttribute 只能贴在方法上
public sealed class HandlerAttribute : Attribute // 定义消息处理 Attribute
{ // 类开始
    public int MessageId { get; } // 保存消息 ID
    public HandlerAttribute(int messageId) // 构造函数接收消息 ID
    { // 构造函数开始
        MessageId = messageId; // 保存消息 ID
    } // 构造函数结束
} // 类结束
public sealed class LoginHotfixHandler // 定义热更新登录处理类
{ // 类开始
    [Handler(1001)] // 标记这个方法处理 1001 消息
    public void OnLoginSuccess() // 定义登录成功处理方法
    { // 方法开始
    } // 方法结束
} // 类结束
public static class HotfixRegistry // 定义热更新注册器
{ // 类开始
    private static readonly Dictionary<int, MethodInfo> handlers = new Dictionary<int, MethodInfo>(); // 缓存消息 ID 到方法的映射
    public static void Register(Type type) // 注册某个热更类型里的处理方法
    { // 方法开始
        MethodInfo[] methods = type.GetMethods(); // 通过反射获取所有 public 方法
        for (int i = 0; i < methods.Length; i++) // 遍历方法
        { // for 开始
            MethodInfo method = methods[i]; // 取出当前方法
            HandlerAttribute handler = method.GetCustomAttribute<HandlerAttribute>(); // 读取方法上的 HandlerAttribute
            if (handler == null) continue; // 如果没有 HandlerAttribute 就跳过
            handlers[handler.MessageId] = method; // 把消息 ID 和方法缓存起来
        } // for 结束
    } // 方法结束
} // 类结束

实际项目里更推荐把 MethodInfo 进一步转成委托,或者只在注册阶段反射,分发阶段走缓存,别每次消息来了再 GetMethod

为什么不用每次都反射

反射慢,可能产生 GC。 MethodInfo.Invoke 参数通常是 object[],值类型可能装箱。 字符串查找方法名、字段名也容易运行时报错。 IL2CPP 下还要注意类型和方法可能被裁剪。

所以正确姿势是:

启动时扫描一次。 把结果缓存成字典。 能转委托就转委托。 运行时按 ID 查缓存。 热路径不要反复反射。

IL2CPP 下注意

如果反射依赖的类型、字段、方法没有直接引用,Unity 可能裁剪掉。 解决方式通常是:

[Preserve] 保留类型或方法。 link.xml 保留反射需要的成员。 AOT 注册泛型组合。 尽量用代码生成减少运行时反射。

面试高分回答

CAUTION

反射在配置表和热更新里主要用来做运行时发现和自动注册。比如配置表可以用 Attribute 标记类和字段,通过反射读取字段和列名映射,把表格数据自动填充成配置对象;热更新里可以加载热更程序集后,扫描带有特定 Attribute 的方法,把消息 ID、事件 ID 和处理方法注册到系统中。需要注意的是,反射不适合放在高频逻辑里,通常只在初始化或加载阶段扫描一次,然后缓存 FieldInfo、MethodInfo 或委托,运行时走字典和缓存。IL2CPP 下还要注意代码裁剪和 AOT 泛型问题,必要时用 Preserve、link.xml 或提前注册。

反射调用方法为什么慢?

一句话讲清楚

反射调用慢,是因为它不是像普通方法那样直接跳到目标函数,而是要在运行时查元数据、检查目标对象、检查参数、处理装箱拆箱、包装异常,而且 JIT 很难对它做内联优化。

reflection-invoke-slow

直接调用为什么快

普通方法调用时,编译器和运行时大概知道要调用谁。

c
public sealed class Player // 定义玩家类
{ // 类开始
    public void Attack(int damage) // 定义攻击方法
    { // 方法开始
    } // 方法结束
} // 类结束
public static class DirectCallExample // 定义直接调用示例类
{ // 类开始
    public static void Run(Player player) // 定义运行方法
    { // 方法开始
        player.Attack(100); // 直接调用方法,调用目标和参数类型都很明确
    } // 方法结束
} // 类结束

这种调用路径短,参数类型明确,JIT 有机会做内联、寄存器优化、去虚调用等优化。

反射调用为什么慢

反射调用像这样:

c
using System.Reflection; // 引入 MethodInfo
public static class ReflectionCallExample // 定义反射调用示例类
{ // 类开始
    public static void Run(Player player) // 定义运行方法
    { // 方法开始
        MethodInfo method = typeof(Player).GetMethod("Attack"); // 运行时通过字符串查找方法
        object[] args = new object[] { 100 }; // 把参数包装成 object 数组
        method.Invoke(player, args); // 通过反射调用方法
    } // 方法结束
} // 类结束

慢点主要有几个。

第一,查找元数据。 GetMethod("Attack") 要按名字查方法,读取 MethodInfo。如果你每次调用前都查一次,会更慢。

第二,参数要包装。 Invoke 的参数通常是 object[]。如果参数是 int、float这种值类型,就可能发生装箱。返回值也经常是object`,也可能带来拆箱。

第三,运行时要检查。 反射调用前要检查目标对象是不是对的、参数数量对不对、参数类型能不能匹配、访问权限是否允许。

第四,异常会被包装。 目标方法内部抛异常时,反射调用通常会包装成 TargetInvocationException,异常处理链路更复杂。

第五,很难被内联优化。 普通方法调用,JIT 可能把小方法直接内联进调用处。但 MethodInfo.Invoke 的目标是运行时才确定的,JIT 很难把真正目标方法内联进去。

Unity 里为什么要注意

在 Unity 里,如果你在 Update、AI Tick、技能判定、UI 高频刷新里反复反射,会带来两个问题:

耗时增加。 产生 GC Alloc。

比如每帧 GetMethodGetFieldGetCustomAttributesInvoke,很容易在 Profiler 里看到反射相关开销。

怎么优化

初始化阶段扫描一次。 缓存 TypeFieldInfoMethodInfo。 能转委托就转委托。 热路径走字典和委托,不要反复 Invoke。 配置表可以用代码生成。 消息分发可以用手写注册表或自动生成注册代码。

c
using System; // 引入 Action 委托
using System.Reflection; // 引入 MethodInfo
public static class ReflectionCacheExample // 定义反射缓存示例类
{ // 类开始
    private static Action<Player, int> cachedAttack; // 缓存强类型委托,避免每次 MethodInfo.Invoke
    public static void Init() // 初始化时建立缓存
    { // 方法开始
        MethodInfo method = typeof(Player).GetMethod("Attack"); // 初始化阶段通过反射查找方法
        cachedAttack = (Action<Player, int>)Delegate.CreateDelegate(typeof(Action<Player, int>), null, method); // 把 MethodInfo 转成委托
    } // 方法结束
    public static void Run(Player player) // 运行时调用
    { // 方法开始
        cachedAttack(player, 100); // 运行时直接调用缓存委托,比 Invoke 更快
    } // 方法结束
} // 类结束

面试高分回答

IMPORTANT

反射调用慢主要是因为它绕过了普通的静态调用路径。普通调用在编译期和 JIT 阶段目标比较明确,可以做内联和类型优化;反射调用需要运行时查元数据,构造参数 object 数组,做参数类型和访问权限检查,值类型还可能发生装箱,异常也会被额外包装,所以性能和 GC 都更差。实际项目里一般只在初始化、配置加载、热更注册、编辑器工具里用反射,扫描完后缓存 MethodInfo 或转成委托,战斗、Update、AI 这种热路径不要频繁反射调用。

如何减少反射开销?

一句话讲清楚

减少反射开销的核心不是“完全不用反射”,而是:初始化阶段用反射发现信息,运行阶段走缓存、委托、字典或代码生成

reduce-reflection-cost

最差写法

每次调用都 GetMethod,再 Invoke,这是很贵的。

c
using System.Reflection; // 引入反射相关类型
public sealed class Player // 定义玩家类
{ // 类开始
    public void Attack(int damage) // 定义攻击方法
    { // 方法开始
    } // 方法结束
} // 类结束
public static class BadReflectionExample // 定义不推荐的反射示例
{ // 类开始
    public static void Run(Player player) // 定义运行方法
    { // 方法开始
        MethodInfo method = typeof(Player).GetMethod("Attack"); // 每次运行都通过字符串查找方法,开销较大
        object[] args = new object[] { 100 }; // 每次都创建 object 数组,可能产生 GC
        method.Invoke(player, args); // 每次都通过反射 Invoke 调用,速度比直接调用慢
    } // 方法结束
} // 类结束

优化 1:缓存 MethodInfo

至少不要每次都查找方法。

c
using System.Reflection; // 引入 MethodInfo
public static class CacheMethodInfoExample // 定义缓存 MethodInfo 示例
{ // 类开始
    private static readonly MethodInfo attackMethod = typeof(Player).GetMethod("Attack"); // 程序初始化时缓存 MethodInfo
    private static readonly object[] args = new object[1]; // 复用参数数组,减少重复分配
    public static void Run(Player player) // 定义运行方法
    { // 方法开始
        args[0] = 100; // 设置参数数组里的伤害值
        attackMethod.Invoke(player, args); // 使用缓存的 MethodInfo 调用方法
    } // 方法结束
} // 类结束

这比每次 GetMethod 好,但 Invoke 本身还是慢。

优化 2:转成委托

更推荐把 MethodInfo 转成委托,运行时直接调用委托。

c
using System; // 引入 Action 委托
using System.Reflection; // 引入 MethodInfo
public static class DelegateCacheExample // 定义委托缓存示例
{ // 类开始
    private static readonly Action<Player, int> attackAction = CreateAttackAction(); // 初始化时创建并缓存强类型委托
    private static Action<Player, int> CreateAttackAction() // 创建攻击委托方法
    { // 方法开始
        MethodInfo method = typeof(Player).GetMethod("Attack"); // 只在初始化阶段查找一次方法
        return (Action<Player, int>)Delegate.CreateDelegate(typeof(Action<Player, int>), null, method); // 把 MethodInfo 转成强类型委托
    } // 方法结束
    public static void Run(Player player) // 定义运行方法
    { // 方法开始
        attackAction(player, 100); // 运行时直接调用委托,避免 MethodInfo.Invoke
    } // 方法结束
} // 类结束

这个通常比 MethodInfo.Invoke 更适合运行时调用。

优化 3:配置表用缓存映射

配置表可以启动时扫描字段和 Attribute,建立字段映射。之后加载每一行数据时,不要重新 GetFieldsGetCustomAttributes

正确思路是:

启动时扫描一次。 缓存列名到字段的映射。 缓存转换器或 setter。 加载时按缓存赋值。 业务运行时只查 Dictionary<int, Config>

优化 4:热更新用注册表

热更新里可以反射扫描热更程序集,但只应该在加载热更时做一次。

比如:

扫描带 [Handler] 的方法。 读取消息 ID。 把消息 ID 映射到委托。 运行时消息来了,直接通过字典找到委托调用。 不要每条消息都反射 GetMethodInvoke

优化 5:代码生成

如果配置表、协议、消息分发非常频繁,最好用代码生成。

比如生成:

配置字段赋值代码。 协议解析代码。 消息 ID 分发代码。 UI 绑定代码。

这样运行时就接近手写调用,不需要反射。

Unity 和 IL2CPP 注意

在 Unity 里,反射不要放在 Update、战斗判定、AI Tick、UI 高频刷新里。 IL2CPP 下还要注意代码裁剪和 AOT 泛型问题。反射用到的类型、字段、方法,必要时用:

[Preserve]link.xml AOT 泛型注册 显式引用类型

面试高分回答

TIP

减少反射开销的核心是把反射从热路径移出去。反射可以在初始化、配置加载、热更新程序集加载时使用,用来扫描类型、读取 Attribute、建立映射关系;扫描完之后要缓存 Type、FieldInfo、MethodInfo,最好进一步转成强类型委托,运行时通过字典或委托直接调用。配置表和协议分发这种高频逻辑可以用代码生成替代运行时反射。Unity 中不要在 Update、战斗、AI、UI 刷新里频繁反射,IL2CPP 下还要注意 Preserve、link.xml 和 AOT 泛型注册,避免反射目标被裁剪。

AOT 平台反射有什么坑?

一句话讲清楚

减少反射开销的核心不是“完全不用反射”,而是:初始化阶段用反射发现信息,运行阶段走缓存、委托、字典或代码生成

aot-reflection-pitfalls

最差写法

每次调用都 GetMethod,再 Invoke,这是很贵的。

c
using System.Reflection; // 引入反射相关类型
public sealed class Player // 定义玩家类
{ // 类开始
    public void Attack(int damage) // 定义攻击方法
    { // 方法开始
    } // 方法结束
} // 类结束
public static class BadReflectionExample // 定义不推荐的反射示例
{ // 类开始
    public static void Run(Player player) // 定义运行方法
    { // 方法开始
        MethodInfo method = typeof(Player).GetMethod("Attack"); // 每次运行都通过字符串查找方法,开销较大
        object[] args = new object[] { 100 }; // 每次都创建 object 数组,可能产生 GC
        method.Invoke(player, args); // 每次都通过反射 Invoke 调用,速度比直接调用慢
    } // 方法结束
} // 类结束

优化 1:缓存 MethodInfo

至少不要每次都查找方法。

c
using System.Reflection; // 引入 MethodInfo
public static class CacheMethodInfoExample // 定义缓存 MethodInfo 示例
{ // 类开始
    private static readonly MethodInfo attackMethod = typeof(Player).GetMethod("Attack"); // 程序初始化时缓存 MethodInfo
    private static readonly object[] args = new object[1]; // 复用参数数组,减少重复分配
    public static void Run(Player player) // 定义运行方法
    { // 方法开始
        args[0] = 100; // 设置参数数组里的伤害值
        attackMethod.Invoke(player, args); // 使用缓存的 MethodInfo 调用方法
    } // 方法结束
} // 类结束

这比每次 GetMethod 好,但 Invoke 本身还是慢。

优化 2:转成委托

更推荐把 MethodInfo 转成委托,运行时直接调用委托。

c
using System; // 引入 Action 委托
using System.Reflection; // 引入 MethodInfo
public static class DelegateCacheExample // 定义委托缓存示例
{ // 类开始
    private static readonly Action<Player, int> attackAction = CreateAttackAction(); // 初始化时创建并缓存强类型委托
    private static Action<Player, int> CreateAttackAction() // 创建攻击委托方法
    { // 方法开始
        MethodInfo method = typeof(Player).GetMethod("Attack"); // 只在初始化阶段查找一次方法
        return (Action<Player, int>)Delegate.CreateDelegate(typeof(Action<Player, int>), null, method); // 把 MethodInfo 转成强类型委托
    } // 方法结束
    public static void Run(Player player) // 定义运行方法
    { // 方法开始
        attackAction(player, 100); // 运行时直接调用委托,避免 MethodInfo.Invoke
    } // 方法结束
} // 类结束

这个通常比 MethodInfo.Invoke 更适合运行时调用。

优化 3:配置表用缓存映射

配置表可以启动时扫描字段和 Attribute,建立字段映射。之后加载每一行数据时,不要重新 GetFieldsGetCustomAttributes

正确思路是:

启动时扫描一次。 缓存列名到字段的映射。 缓存转换器或 setter。 加载时按缓存赋值。 业务运行时只查 Dictionary<int, Config>

优化 4:热更新用注册表

热更新里可以反射扫描热更程序集,但只应该在加载热更时做一次。

比如:

扫描带 [Handler] 的方法。 读取消息 ID。 把消息 ID 映射到委托。 运行时消息来了,直接通过字典找到委托调用。 不要每条消息都反射 GetMethodInvoke

优化 5:代码生成

如果配置表、协议、消息分发非常频繁,最好用代码生成。

比如生成:

配置字段赋值代码。 协议解析代码。 消息 ID 分发代码。 UI 绑定代码。

这样运行时就接近手写调用,不需要反射。

Unity 和 IL2CPP 注意

在 Unity 里,反射不要放在 Update、战斗判定、AI Tick、UI 高频刷新里。 IL2CPP 下还要注意代码裁剪和 AOT 泛型问题。反射用到的类型、字段、方法,必要时用:

[Preserve]link.xml AOT 泛型注册 显式引用类型

面试高分回答

NOTE

减少反射开销的核心是把反射从热路径移出去。反射可以在初始化、配置加载、热更新程序集加载时使用,用来扫描类型、读取 Attribute、建立映射关系;扫描完之后要缓存 Type、FieldInfo、MethodInfo,最好进一步转成强类型委托,运行时通过字典或委托直接调用。配置表和协议分发这种高频逻辑可以用代码生成替代运行时反射。Unity 中不要在 Update、战斗、AI、UI 刷新里频繁反射,IL2CPP 下还要注意 Preserve、link.xml 和 AOT 泛型注册,避免反射目标被裁剪。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0