Appearance
反射与特性
反射是什么?有什么代价?
一句话讲清楚
反射就是:程序在运行时读取程序集里的元数据,动态知道“有哪些类、字段、方法、特性”,甚至可以动态创建对象、访问字段、调用方法。
反射能做什么
正常写代码时,我们是编译期就知道类型:
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 可能把它裁掉。必要时要用 Preserve 或 link.xml 保留。
Unity 项目里怎么用比较好
初始化阶段可以用反射,比如启动时扫描配置类、自动注册事件、生成编辑器数据。 战斗 Update、怪物 AI、技能判定、UI 高频刷新里尽量不要反射。 如果必须用,缓存 Type、FieldInfo、PropertyInfo、MethodInfo,不要每次重新查。 性能敏感场景可以考虑代码生成、委托缓存、Source Generator、手写映射表。
面试高分回答
NOTE
反射是运行时读取程序集元数据并动态操作类型的机制。它可以在运行时获取类型、字段、方法、Attribute,也可以动态创建对象和调用方法。它的优点是灵活,适合编辑器工具、序列化、配置系统和框架初始化;代价是性能比直接调用慢,可能产生 GC,编译期类型安全变弱,代码维护成本更高。在 Unity 里不要在 Update 或战斗热路径频繁使用反射,IL2CPP 下还要注意代码裁剪,需要时用 Preserve 或 link.xml 保留反射用到的类型。
Attribute 是什么?
一句话讲清楚
Attribute 是 C# 里贴在类、方法、字段、属性上的“标签”。它会被编译进程序集的元数据里,之后可以被编译器、运行时、Unity 或框架读取,用来影响序列化、Inspector 显示、测试、路由、提示等行为。
它不是普通注释
注释只是给人看的,编译后通常就没了。
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 支持序列化的类型,比如 int、float、bool、string、Vector3、Color、GameObject、MonoBehaviour 引用、ScriptableObject 引用、可序列化类等。
不会默认显示的常见情况:
属性 Property。 static 字段。 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 内部会用类似 SerializedObject 和 SerializedProperty 的机制来包装目标对象。
它不是简单地直接改字段,而是通过序列化接口修改数据。这样才能支持:
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 通过 SerializedObject 和 SerializedProperty 绘制和修改这些字段,所以才能支持 Undo、Prefab Override、多对象编辑和自定义 Inspector。属性、static、readonly 默认不参与 Unity 序列化,所以不会直接显示。
SerializeField 和 public 字段区别是什么?
一句话讲清楚
public 字段是访问权限,表示外部代码可以直接访问它;[SerializeField] 是序列化标记,表示让 Unity 序列化这个字段并显示到 Inspector。最常见推荐写法是:private + [SerializeField]。
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。
它解决什么问题
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 工具或框架扫描这些标签,自动完成配置映射、注册、校验、编辑器显示等事情。
它能做什么
自定义 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 下还要注意代码裁剪,反射用到的类型必要时要 Preserve 或 link.xml 保留。
面试高分回答
WARNING
自定义 Attribute 可以把一些规则声明到代码上,比如配置表映射、事件注册、编辑器按钮、数据校验、序列化规则等。它本身不会直接执行逻辑,只是被编译进元数据,真正产生效果的是框架、工具或反射代码读取它并执行对应规则。它的好处是让代码更声明式,减少样板代码;代价是通常需要反射读取,频繁使用会有性能和 GC 成本,所以一般在初始化或编辑器阶段扫描一次并缓存,热路径不要反复反射。
反射在热更新/配置表中怎么用?
一句话讲清楚
反射在热更新和配置表里,主要用来做:运行时发现类型、读取 Attribute 规则、自动建立映射关系。但它通常只适合在初始化阶段用,真正运行时要走缓存,不要每次都反射。
在配置表中怎么用
配置表里常见做法是:给配置类贴表名标签,给字段贴列名标签。加载配置时,通过反射读取这些标签,把表格里的数据自动填进对象。
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 很难对它做内联优化。
直接调用为什么快
普通方法调用时,编译器和运行时大概知道要调用谁。
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。
比如每帧 GetMethod、GetField、GetCustomAttributes、Invoke,很容易在 Profiler 里看到反射相关开销。
怎么优化
初始化阶段扫描一次。 缓存 Type、FieldInfo、MethodInfo。 能转委托就转委托。 热路径走字典和委托,不要反复 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 这种热路径不要频繁反射调用。
如何减少反射开销?
一句话讲清楚
减少反射开销的核心不是“完全不用反射”,而是:初始化阶段用反射发现信息,运行阶段走缓存、委托、字典或代码生成。
最差写法
每次调用都 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,建立字段映射。之后加载每一行数据时,不要重新 GetFields、GetCustomAttributes。
正确思路是:
启动时扫描一次。 缓存列名到字段的映射。 缓存转换器或 setter。 加载时按缓存赋值。 业务运行时只查 Dictionary<int, Config>。
优化 4:热更新用注册表
热更新里可以反射扫描热更程序集,但只应该在加载热更时做一次。
比如:
扫描带 [Handler] 的方法。 读取消息 ID。 把消息 ID 映射到委托。 运行时消息来了,直接通过字典找到委托调用。 不要每条消息都反射 GetMethod 和 Invoke。
优化 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 平台反射有什么坑?
一句话讲清楚
减少反射开销的核心不是“完全不用反射”,而是:初始化阶段用反射发现信息,运行阶段走缓存、委托、字典或代码生成。
最差写法
每次调用都 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,建立字段映射。之后加载每一行数据时,不要重新 GetFields、GetCustomAttributes。
正确思路是:
启动时扫描一次。 缓存列名到字段的映射。 缓存转换器或 setter。 加载时按缓存赋值。 业务运行时只查 Dictionary<int, Config>。
优化 4:热更新用注册表
热更新里可以反射扫描热更程序集,但只应该在加载热更时做一次。
比如:
扫描带 [Handler] 的方法。 读取消息 ID。 把消息 ID 映射到委托。 运行时消息来了,直接通过字典找到委托调用。 不要每条消息都反射 GetMethod 和 Invoke。
优化 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 泛型注册,避免反射目标被裁剪。