Unity 反射(Reflection)从原理到实战:一篇讲透原理、用法、实战案例与性能优化
环境:Unity 2019 及以上任意版本 | C# | 示例代码可直接复制到工程里运行
关键词:反射、Type、Activator、Invoke、IL2CPP、性能优化
前言:先抛几个"魔法问题"
写 Unity 的时候,你一定遇到过这些"魔法时刻":
- 明明没写任何 UI 代码,Inspector 面板却把你脚本里的字段全列出来了;
- 装个 Odin Inspector,加个
[Button]特性,方法就自动变成了一个按钮; - ILRuntime / HybridCLR 做热更新,主工程居然能调用"打包之后才写的"代码;
- 别人写的工具类,一个
Save()方法就能存档所有数据类,不管你字段有多少。
这些"隔空取物"的操作,背后全是同一个技术:反射(Reflection)。
这篇文章把反射一次性讲透:是什么 → 什么原理 → 怎么用 → Unity 实战 → 性能与坑。全程说人话,代码直接复制就能跑。
一、什么是反射?先说人话
一句话定义:反射就是程序在运行时,动态查看和操作类型信息(字段、属性、方法、特性等)的能力。
还是觉得抽象?来两个比喻。
比喻一:开箱子的两种方式
- 普通写法:开着灯开箱子,东西一目了然,伸手就拿。这叫编译期确定 ------你写
player.Attack()的那一刻,"调谁、干什么"已经锁死了。 - 反射写法:灯关了,但箱子上贴着一张清单,写明了里面每件东西的名字、位置、用法。你摸黑照着清单,照样能取出任何东西。这叫运行时动态获取。
比喻二:程序员的"花名册"
csharp
// 普通调用:我认识 Player,直接喊他干活
Player player = new Player();
player.Attack();
// 反射调用:我不认识它,但我手里有一本"花名册"(元数据)
Type type = typeof(Player); // 拿到"说明书"
object player = Activator.CreateInstance(type); // 照着说明书摸黑造一个
type.GetMethod("Attack").Invoke(player, null); // 隔空喊话:给我干活!
看出区别了吗?普通写法在写代码时就定死了"谁"和"干什么";反射把这两个问题推迟到了运行时,可以由字符串、配置表、遍历结果来决定。 这就是灵活性的来源,也是一切框架魔法的地基。
二、反射的原理:一切靠"元数据"
2.1 C# 代码编译之后发生了什么
先看 C# 代码的编译流程:
text
C# 源码(.cs 文件)
│ csc 编译
▼
程序集(Assembly-CSharp.dll / .exe)
├── IL 中间语言(真正的代码逻辑)
└── 元数据 Metadata(类型的"户口档案")
关键就是这个元数据(Metadata):编译器会把每个类的信息原原本本记录进程序集------类叫什么名字、在哪个命名空间、继承自谁、有哪些字段、方法签名长什么样、挂了哪些特性......全部登记在册。
那为什么连私有成员都藏不住? 因为 private 只是"编译器的访问检查规则",而元数据是一份完整档案------私有字段在档案里记得明明白白。反射就是直接翻档案的人,所以连私有变量都逃不掉。
2.2 Type:每个类的"说明书"
程序运行时,每个类型在 CLR/Mono 里都对应一个 System.Type 对象,可以把它理解为这个类的"身份证 + 说明书":
csharp
Type type = typeof(Player);
// type.Name ------ 类名
// type.Namespace ------ 命名空间
// type.BaseType ------ 父类
// type.GetFields() ------ 字段列表
// type.GetMethods() ------ 方法列表
// ...
2.3 反射家族 API 一览
反射是一大家子,各司其职:
| 类 | 作用 |
|---|---|
Assembly |
程序集,一个 dll 的"总目录" |
Type |
类型说明书 |
FieldInfo |
字段信息(读写字段) |
PropertyInfo |
属性信息(读写属性) |
MethodInfo |
方法信息(调用方法) |
ConstructorInfo |
构造函数信息(创建对象) |
Attribute |
特性(贴在类/成员上的"标签") |
2.4 万变不离其宗:反射三步流程
所有反射代码,不管多花哨,都是这三步:
text
第一步:拿到 Type(说明书)
▼
第二步:按需翻页 GetField / GetMethod / GetProperty / GetConstructor...
▼
第三步:动态操作 GetValue / SetValue / Invoke / CreateInstance
记住这张图,下面看用法就全是"肌肉记忆"了。
三、基本用法:六步玩转反射
3.1 先准备一只"小白鼠"
csharp
using System;
using UnityEngine;
// 自定义特性:给成员"贴标签"用的
[AttributeUsage(AttributeTargets.Field)]
public class DescAttribute : Attribute
{
public string text;
public DescAttribute(string text) { this.text = text; }
}
// 实验用"小白鼠"类
public class Player
{
[Desc("这是玩家的名字")]
public string playerName = "小红";
public int level = 1;
[SerializeField] // 老熟人了,第 4 章马上揭秘它
private float hp = 100f;
private static string secret = "我是私有静态字段,藏得够深了吧";
public int Gold { get; set; } = 666;
public Player() { }
public Player(string name) { playerName = name; }
public void Attack()
{
Debug.Log($"{playerName} 挥出一记普通攻击!");
}
private void SaySecret()
{
Debug.Log($"悄悄话:{secret}");
}
public int Add(int a, int b)
{
return a + b;
}
}
3.2 第一步:拿到 Type(三种方式)
csharp
using System;
// 方式一:typeof 运算符(编译期确定,最常用)
Type t1 = typeof(Player);
// 方式二:从实例身上拿(运行时从对象反查它的说明书)
Player p = new Player();
Type t2 = p.GetType();
// 方式三:用字符串找(最灵活,热更新全靠它)
Type t3 = Type.GetType("Player"); // 只搜当前程序集和核心库
Type t4 = Type.GetType("Player, Assembly-CSharp"); // 完整格式:类全名 + 程序集名
// 附赠:从程序集里捞(加载外部 dll 时的标准姿势)
// Assembly asm = Assembly.Load("Assembly-CSharp");
// Type t5 = asm.GetType("Player");
3.3 第二步:翻说明书(查看成员)
csharp
using System.Reflection;
// 大坑预警:不传参数 = 只查"公有(实例 + 静态)"成员
FieldInfo[] publicOnly = type.GetFields(); // 只能看到 playerName、level
// 想看私有?必须手动指定 BindingFlags
FieldInfo[] all = type.GetFields(
BindingFlags.Public | BindingFlags.NonPublic | // 公有 + 私有
BindingFlags.Instance | BindingFlags.Static); // 实例 + 静态
// 注意:Public/NonPublic 至少选一个,Instance/Static 也至少选一个,
// 少了任何一边,查出来就是空的------这是新手"反射查不出东西"的头号事故现场
GetMethods()、GetProperties()、GetConstructors() 同理,不再赘述。
3.4 第三步:不写 new,凭空造对象
csharp
using System;
// 等价于 new Player()
object p1 = Activator.CreateInstance(typeof(Player));
// 等价于 new Player("小黑")
object p2 = Activator.CreateInstance(typeof(Player), "小黑");
// 注意1:找不到匹配的构造函数会直接抛异常
// 注意2:返回值是 object,用的时候要么强转回具体类型,要么继续用反射操作
3.5 第四步:动态读写字段和属性
csharp
// 读写字段:FieldInfo
FieldInfo nameField = type.GetField("playerName");
Debug.Log(nameField.GetValue(p1)); // 读:小红
nameField.SetValue(p1, "反射改的名字"); // 写
Debug.Log(nameField.GetValue(p1)); // 读:反射改的名字
// 读写属性:PropertyInfo(一个道理)
PropertyInfo goldProp = type.GetProperty("Gold");
goldProp.SetValue(p1, 888);
Debug.Log(goldProp.GetValue(p1)); // 888
3.6 第五步:动态调用方法
csharp
// 无参方法:参数位置传 null
MethodInfo attack = type.GetMethod("Attack");
attack.Invoke(p1, null);
// 有参方法:参数装进 object[],返回值也是 object
MethodInfo add = type.GetMethod("Add");
object result = add.Invoke(p1, new object[] { 3, 5 }); // result = 8
// 有重载时:用参数类型数组区分
MethodInfo add2 = type.GetMethod("Add", new Type[] { typeof(int), typeof(int) });
// 调私有方法:记得带 NonPublic 标志
MethodInfo say = type.GetMethod("SaySecret",
BindingFlags.NonPublic | BindingFlags.Instance);
say.Invoke(p1, null);
// 调静态方法:对象位置传 null
// staticMethod.Invoke(null, null);
3.7 第六步:读取特性(撕标签下来看)
特性(Attribute)本质是贴在类和成员上的"标签",反射就是把标签撕下来看内容:
csharp
foreach (FieldInfo f in type.GetFields())
{
DescAttribute desc = f.GetCustomAttribute<DescAttribute>();
if (desc != null)
Debug.Log($"{f.Name} 身上的标签:{desc.text}");
// 输出:playerName 身上的标签:这是玩家的名字
}
3.8 完整 Demo:新建脚本挂到物体上就能跑
把下面这个脚本挂到场景里任意物体上,点 Play 看 Console:
csharp
using System;
using System.Reflection;
using UnityEngine;
public class ReflectionDemo : MonoBehaviour
{
void Start()
{
// ========== 1. 拿到"说明书" ==========
Type type = typeof(Player);
Debug.Log($"类名:{type.Name},父类:{type.BaseType}");
// ========== 2. 翻说明书:列出所有字段 ==========
FieldInfo[] fields = type.GetFields(
BindingFlags.Public | BindingFlags.NonPublic
| BindingFlags.Instance | BindingFlags.Static);
foreach (FieldInfo f in fields)
Debug.Log($"字段:{f.Name},类型:{f.FieldType},私有:{f.IsPrivate}");
// ========== 3. 不写 new,凭空造对象 ==========
object player = Activator.CreateInstance(type);
// ========== 4. 动态读写字段和属性 ==========
FieldInfo nameField = type.GetField("playerName");
nameField.SetValue(player, "反射改的名字");
PropertyInfo goldProp = type.GetProperty("Gold");
goldProp.SetValue(player, 888);
// ========== 5. 动态调方法(私有也照调) ==========
type.GetMethod("Attack").Invoke(player, null);
MethodInfo say = type.GetMethod("SaySecret",
BindingFlags.NonPublic | BindingFlags.Instance);
say.Invoke(player, null);
object result = type.GetMethod("Add").Invoke(player, new object[] { 3, 5 });
Debug.Log($"反射计算 3 + 5 = {result}");
// ========== 6. 读取特性 ==========
foreach (FieldInfo f in fields)
{
DescAttribute desc = f.GetCustomAttribute<DescAttribute>();
if (desc != null)
Debug.Log($"{f.Name} 贴着标签:{desc.text}");
}
}
}
3.9 运行结果
text
类名:Player,父类:System.Object
字段:playerName,类型:System.String,私有:False
字段:level,类型:System.Int32,私有:False
字段:hp,类型:System.Single,私有:True
字段:secret,类型:System.String,私有:True
字段:<Gold>k__BackingField,类型:System.Int32,私有:True ← 属性 Gold 的"暗仓"
(字段顺序和声明顺序不一定一致,属正常现象)
反射改的名字 挥出一记普通攻击!
悄悄话:我是私有静态字段,藏得够深了吧
反射计算 3 + 5 = 8
playerName 贴着标签:这是玩家的名字
私有字段 hp、secret、连属性 Gold 背后的隐藏字段 <Gold>k__BackingField 都被翻出来了------这就是"档案"的威力。
四、Unity 实战:反射的三个经典应用
4.1 实战一:通用存档系统(一个方法存所有类)
普通存档系统每加一个字段就要多写一行存/读代码。用反射,一个 Save() 通吃所有类:
csharp
using System.Reflection;
using UnityEngine;
public static class SaveSystem
{
// 通用存档:不管你是什么类,字段统统塞进 PlayerPrefs
public static void Save(object data)
{
FieldInfo[] fields = data.GetType().GetFields(
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
foreach (FieldInfo f in fields)
PlayerPrefs.SetString(data.GetType().Name + "_" + f.Name,
f.GetValue(data)?.ToString());
PlayerPrefs.Save();
}
// 通用读档:读出来再"塞回"对象里
public static void Load(object data)
{
FieldInfo[] fields = data.GetType().GetFields(
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
foreach (FieldInfo f in fields)
{
string key = data.GetType().Name + "_" + f.Name;
if (!PlayerPrefs.HasKey(key)) continue;
string value = PlayerPrefs.GetString(key);
if (f.FieldType == typeof(int)) f.SetValue(data, int.Parse(value));
else if (f.FieldType == typeof(float)) f.SetValue(data, float.Parse(value));
else if (f.FieldType == typeof(string)) f.SetValue(data, value);
// 更多类型照猫画虎扩展即可
}
}
}
使用方式:
csharp
using UnityEngine;
public class GameData
{
public string playerName;
public int level;
public float hp;
}
public class SaveTest : MonoBehaviour
{
void Start()
{
GameData data = new GameData { playerName = "小红", level = 99, hp = 66.5f };
SaveSystem.Save(data); // 存档:三个字段全存了
GameData loaded = new GameData(); // 模拟重新进游戏,全默认值
SaveSystem.Load(loaded); // 字段被"自动"填回来
Debug.Log($"读档完成:{loaded.playerName},Lv.{loaded.level},HP {loaded.hp}");
// 输出:读档完成:小红,Lv.99,HP 66.5
}
}
以后 GameData 加字段,存档代码一行都不用改。Newtonsoft.Json 这类序列化库,底层干的就是这件事(当然人家做得更完善,生产项目建议直接上成熟方案,这里主要学思路)。
4.2 实战二:[Button] 特性做出 Odin 同款按钮
再来一个编辑器扩展实战:给方法贴个 [Button],Inspector 上就出现一个按钮,点击直接调用该方法------手写一个"青春版 Odin"。
脚本一:特性本体(普通脚本,随便放哪)
csharp
using UnityEngine;
public class ButtonAttribute : PropertyAttribute
{
}
脚本二:编辑器扩展(注意!必须放在 Assets/Editor 文件夹下)
csharp
using System.Reflection;
using UnityEditor;
using UnityEngine;
// 对所有继承 MonoBehaviour 的脚本生效(全局通用,商业项目建议按具体类单独写)
[CustomEditor(typeof(MonoBehaviour), true)]
public class InspectorButtonEditor : Editor
{
public override void OnInspectorGUI()
{
base.OnInspectorGUI(); // 先画出 Unity 默认的 Inspector
// 反射扫描:找出这个脚本里所有挂了 [Button] 的方法
MethodInfo[] methods = target.GetType().GetMethods(
BindingFlags.Public | BindingFlags.NonPublic
| BindingFlags.Instance | BindingFlags.Static);
foreach (MethodInfo m in methods)
{
if (m.GetCustomAttribute<ButtonAttribute>() == null) continue;
if (GUILayout.Button(m.Name))
{
// 静态方法传 null,实例方法传 target(当前选中的组件)
m.Invoke(m.IsStatic ? null : target, null);
Debug.Log($"通过反射调用了方法:{m.Name}");
}
}
}
}
脚本三:测试
csharp
using UnityEngine;
public class TestButton : MonoBehaviour
{
public int someValue = 10; // 证明默认面板还在
[Button] private void HelloReflection()
{
Debug.Log("按钮被点了!你没写任何按钮 UI 代码,这就是反射的魔法");
}
}
选中挂了 TestButton 的物体,Inspector 底部就会出现一个 HelloReflection 按钮------整套流程:贴标签 → 反射扫标签 → 动态生成 UI → 动态调用,这就是 Odin、各类编辑器插件的核心套路。
4.3 你身边还有哪些反射
- Inspector 面板显示字段 :序列化系统就是靠遍历脚本的
public/[SerializeField]字段来生成面板的,思路与反射一脉相承------所以第 3.1 节那只小白鼠里,[SerializeField] private float hp能显示在面板上,本质上就是"编辑器翻档案"的结果; [MenuItem]、[ContextMenu]、[Header]、[Range]:Unity 引擎看到这些特性标签后的处理动作,都是"反射读标签";- 热更新(ILRuntime / HybridCLR / xLua):主工程和热更层互相调用,全靠反射搭桥;
- 依赖注入框架(Zenject / VContainer):容器自动给字段/属性注入实例,就是反射扫描 + 赋值;
- 单元测试框架(NUnit):自动发现并调用你写的测试方法。
可以说:凡是"我没写调用代码,它却自动生效了"的功能,背后八成是反射。
五、性能问题与优化(面试重点)
5.1 反射为什么慢?
反射慢不是玄学,原因很具体:
- 字符串找成员 :
GetMethod("Attack")内部要遍历方法表逐个比对名字;而普通调用在编译期就锁定了地址,CPU 直接跳过去; - 装箱拆箱 :
Invoke的参数和返回值都是object,int这种值类型每次都要装箱; - 无法内联:JIT 对反射调用做不了内联等优化;
- 额外检查:可见性检查、参数类型匹配等安全检查,每次都有开销。
量级参考:缓存好 MethodInfo 之后,Invoke 的耗时通常是直接调用的几十倍量级(不同平台、不同 Unity 版本差异很大,别背具体数字,记住"明显更慢 + 必须缓存"就够了)。
5.2 优化三板斧
第一斧:缓存!缓存!缓存!
csharp
// 错误示范:每帧都"翻一次说明书",性能黑洞
void Update()
{
typeof(Player).GetMethod("Attack").Invoke(player, null);
}
// 正确姿势:查找一次,缓存 MethodInfo,后面只调 Invoke
static readonly Dictionary<string, MethodInfo> methodCache =
new Dictionary<string, MethodInfo>();
void Awake()
{
methodCache["Attack"] = typeof(Player).GetMethod("Attack");
}
void Update()
{
methodCache["Attack"].Invoke(player, null);
}
第二斧:别在 Update / FixedUpdate 里高频反射。反射适合"低频高价值"场景(初始化、存档、编辑器、框架层),不适合每帧调用的游戏逻辑。
第三斧(进阶):把 MethodInfo 包装成委托,性能可以逼近直接调用:
csharp
using System;
MethodInfo mi = typeof(Player).GetMethod("Add");
var addFunc = (Func<Player, int, int, int>)Delegate.CreateDelegate(
typeof(Func<Player, int, int, int>), mi);
int result = addFunc(player, 3, 5); // 这时候几乎就是普通调用的速度了
另外编辑器代码(Editor 扩展)里可以放心用反射------编辑器不是性能敏感场景,Odin 全家桶就是活例子。
5.3 IL2CPP 打包的三大坑(真机党必看)
坑一:代码裁剪(Managed Stripping)
IL2CPP 打包时会做"裁剪":静态分析哪些代码没被引用,直接删掉省包体。问题是------反射调用在编译器眼里"没有引用" ,会被误杀。结果就是:编辑器里跑得好好的,真机一跑报 MissingMethodException 或找不到类型。
三种解法:
xml
<!-- 解法A:link.xml(放在 Assets 目录下),简单粗暴保留整个主工程程序集 -->
<linker>
<assembly fullname="Assembly-CSharp" preserve="all"/>
</linker>
csharp
// 解法B:给类或成员挂 [Preserve] 特性
using UnityEngine.Scripting;
[Preserve] // 保住这个类不被裁掉
public class Player { }
text
解法C:Project Settings → Player → Managed Stripping Level 调低(Minimal)
坑二:泛型相关限制
IL2CPP 是 AOT(提前编译),不像 Mono 能在运行时自由 JIT。某些泛型组合(典型:泛型虚方法)如果打包时没生成对应代码,运行时反射调用会直接抛 ExecutionEngineException。遇到了别慌,按报错关键字查,通常换一种非泛型写法就能绕过。
坑三:加固 / 混淆
第三方加固、改名工具会修改类名和成员名------你用字符串 "Attack" 去找,人家已经改名了,自然找不到。接了加固 SDK 的项目要重点回归测试反射相关功能。
一句话:用了反射的项目,真机测试别偷懒。
六、优缺点总结表
| 维度 | 优点 | 缺点 |
|---|---|---|
| 灵活性 | 一个方法通吃所有类型,通用框架的基石 | 字符串写错没有编译期报错,运行时才炸 |
| 能力 | 私有、静态、泛型成员都能撬动 | 破坏封装,绕过了访问修饰符的约束 |
| 性能 | ------ | 明显慢于直接调用(几十倍量级) |
| 工程性 | 框架、编辑器、热更新的底层依赖 | IL2CPP 裁剪、加固场景容易踩坑 |
一句话使用原则:框架、工具、编辑器扩展放心用;Update 里高频调用慎重用;上了 IL2CPP 和加固,真机记得好好测。
七、写在最后
反射是典型的"能力越大,越要克制"的技术:用好了,它是序列化、依赖注入、热更新、编辑器插件的灵魂;用滥了,它是性能杀手和线上事故现场。
建议学习路径:把 3.8 的 Demo 跑一遍 → 把 4.1 的存档系统看懂 → 把 4.2 的 Button 扩展亲手做一遍。做完这三件事,反射就算真正上手了。
如果这篇文章帮到了你,点赞、收藏、关注一键三连再走呗,你的支持就是我更新的最大动力。后面计划继续更 Unity 的 IL2CPP、资源管理与热更系列,感兴趣的关注不迷路。
参考资料
- Microsoft Docs:System.Reflection 命名空间
- Unity Manual:Managed Code Stripping
- Unity Manual:Scripting Backend IL2CPP