Unity 反射(Reflection)从原理到实战:一篇讲透原理、用法、实战案例与性能优化

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 贴着标签:这是玩家的名字

私有字段 hpsecret、连属性 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 反射为什么慢?

反射慢不是玄学,原因很具体:

  1. 字符串找成员GetMethod("Attack") 内部要遍历方法表逐个比对名字;而普通调用在编译期就锁定了地址,CPU 直接跳过去;
  2. 装箱拆箱Invoke 的参数和返回值都是 objectint 这种值类型每次都要装箱;
  3. 无法内联:JIT 对反射调用做不了内联等优化;
  4. 额外检查:可见性检查、参数类型匹配等安全检查,每次都有开销。

量级参考:缓存好 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
相关推荐
HwJack201 小时前
【HarmonyOS开发小实践】ArkUI 交互事件与手势:从触摸到组合手势
ui·华为·性能优化·harmonyos
hanchenxing1 小时前
日志采集日志采集性能优化:从正则解析到 Grok 与 JSON 的双级加速
性能优化·filebeat
晚安日记wanna1 小时前
React 为何不做双端 diff?Vue 与 React 更新逻辑的三层差异
前端·react.js·面试
贾伟康2 小时前
【HarmonyOS 7新能力|039】冷启网络预建链工程封装:把接入逻辑放进可维护的分层结构
性能优化·harmonyos·arkts·软件架构·网络优化
keke.shengfengpolang2 小时前
2026年资金分析岗校招技术栈拆解:Excel预测模型、SQL取数、SAP与Oracle资金模块,附2027届备考路线
面试
顶点多余2 小时前
9.16 面试总结
linux·面试·职场和发展
Alice-YUE14 小时前
工业级 AI Agent 架构:5 层、2 范式、4 原则,一次讲透
面试·系统架构·大模型·ai agent·智能体
TrebleZ17 小时前
为什么总爱问Java中怎么判断两个对象是相同的
面试
政企项目老覃18 小时前
边缘 AI 推理部署:安防零售场景下的模型裁剪与端侧落地实践
人工智能·程序人生·算法·性能优化·vllm