Skip to main content

🧙‍♂️ Unity C# 进阶开发:高性能与底层原理

📚 1. 理论基础 (Theoretical Basis)

1.1 内存模型:堆 (Heap) 与 栈 (Stack)

在此之前,我们必须明确 C# 在 Unity 中的内存管理方式。
  • 栈 (Stack):
    • 特性: 极快,LIFO (后进先出),由 CPU 指令直接支持。
    • 内容: 局部变量、参数传递、函数调用上下文、值类型 (Value Types) (除非被装箱或作为类字段)。
    • 生命周期: 随作用域结束自动弹出,无 GC 开销。
    • 限制: 空间有限 (通常几 MB),数据生命周期短。
  • 堆 (Heap):
    • 特性: 较慢,动态分配,需要内存管理器查找空闲块。
    • 内容: 引用类型 (Reference Types) (类实例、数组、字符串)、装箱的值类型。
    • 生命周期: 由垃圾回收器 (GC) 管理,不确定性释放。
    • 代价: 分配产生内存碎片,回收产生 CPU 峰值 (Stop-the-World)。
Stack vs Heap Memory
Stack 像一摞盘子,取用极快;Heap 像一个乱糟糟的储物柜,找空位很麻烦。

1.2 CPU 缓存与数据局部性 (Data Locality)

这是高性能编程中最被低估的概念。现代 CPU 计算速度极快,瓶颈通常在于内存读取

🏎️ 原理:车间与仓库

  • CPU (工人): 动作极快,1 纳秒也能干很多活。
  • L1/L2 缓存 (工作台): 就在手边,拿东西只需要 1-5 纳秒。
  • RAM (仓库): 很远,取一次东西需要 100 纳秒。
关键机制:缓存行 (Cache Line) 当你命令 CPU 去仓库 (RAM) 取一个 int (4 字节) 时,CPU 并不是只拿这 4 个字节,它会直接搬回 64 字节 (一个 Cache Line) 的数据放到工作台 (Cache) 上。
核心推论: 如果你接下来的计算不仅需要这个 int,还需要它隔壁的数据,那么这些数据已经在工作台上了 (Cache Hit),存取几乎是免费的! 反之,如果你需要的数据在内存里东一榔头西一棒子 (Cache Miss),CPU 就要反复跑仓库,性能下降几十倍。

💻 编码实战指南

  1. 数组是王道 (Arrays are King)
    • 数组在内存中是连续的。遍历 int[] 时,CPU 抓一次能处理 16 个 int (64/4),极致高效。
    • 链表 (LinkedList) 是性能毒药:每个节点都在内存的不同角落,每次 next 都是一次 Cache Miss。
  2. 结构体数组 (Struct Array) > 类数组 (Class Array)
    • MonsterStruct[]: 数据挤在一起,紧凑。
    • MonsterClass[]: 数组里存的是指针。遍历时需要跳转到堆内存的随机位置 (Pointer Chasing),疯狂触发 Cache Miss。
  3. 冷热数据分离 (Hot/Cold Splitting)
    • 错误: 一个巨大的 Monster 类,包含 HP (每帧用) 和 Description (只有 UI 用)。读取 HP 时,Description 也会被加载进缓存 (占用了宝贵的 64 字节),浪费了缓存空间。
    • 优化: 将数据拆分。
      • int[] allHp; (热数据,紧凑,缓存利用率 100%)
      • string[] allDescriptions; (冷数据,如果不处理就不加载)
  4. 多维数组遍历顺序
    • C# 数组是行优先 (Row-major) 存储。
    • for (i) for (j) arr[i][j] (顺着内存读)
    • for (j) for (i) arr[i][j] (跳着读,Cache Miss 激增)

1.3 内存碎片 (Memory Fragmentation)

这也是导致移动端 Crash 的头号杀手之一。

🧩 原理:瑞士奶酪与俄罗斯方块

想象内存是一列俄罗斯方块的底座,你不断地往里面放方块 (New Object) 和消除方块 (GC Collect)。
  • 理想情况: 所有方块严丝合缝,没有空隙。
  • 现实情况:
    1. 你申请了一个 1KB 的对象 A。
    2. 你申请了一个 10MB 的纹理 B。
    3. A 被释放了,B 还在。
    4. 结果: 内存出现了一个 1KB 的“洞”。
    5. 你现在想申请一个 2KB 的对象 C。虽然可能有 100MB 的总空闲,但那个 1KB 的洞塞不进去!
随着时间推移,堆内存会变成一块布满小洞的瑞士奶酪

⚠️ 严重后果

  • 过早 GC: 明明还有空闲内存,但因为找不到足够大的连续空间,系统被迫触发 GC。
  • OOM (Out of Memory): 如果 GC 后还是找不到连续块,OS 会强行杀掉你的 App。这是 Unity 游戏闪退的常见原因。

🛡️ 避坑指南

  1. 对象池 (Object Pooling)
    • 核心: 不要反复 New 和 Destroy。用一个 Queue<T> 把不用的对象存起来,下次要用直接拿。
    • 场景: 子弹、特效、伤害飘字、小怪。
    • 收益: 0 分配,0 碎片。
  2. 预估容量 (Pre-sizing Collections)
    • List<T> 是基于数组的。当你 Add 超过容量时,它会 New 一个双倍大小的新数组,把旧数据拷过去,然后丢弃旧数组。
    • 旧数组就是碎片!
    • new List<int>(1000); (一开始就占好坑,永远不扩容)
    • new List<int>(); (随着 Add 产生大量废弃数组碎片)
  3. 避免频繁的大块内存分配
    • 如果要处理大文件或大数组,尽量复用 buffer,不要每次都 new byte[1024*1024]

🛠️ 2. 实践应用 (Practical Implementation)

2.1 高性能排序 (Sorting)

Update 或高频逻辑中,避免使用 LinQOrderBy

❌ 错误示范 (GC 噩梦)

// 每一帧都会产生闭包所有的垃圾 (Delegate Allocation)
var nearest = enemies.OrderBy(e => Vector3.Distance(e.pos, myPos)).First();

✅ 优化方案 (In-Place Sort)

使用 List<T>.Sort 并传入自定义 IComparer<T> 结构体 (避免装箱)。
// 0 GC, 极快
public struct EnemyDistanceComparer : IComparer<Enemy> {
    public Vector3 targetPos;
    public int Compare(Enemy x, Enemy y) {
        float d1 = (x.pos - targetPos).sqrMagnitude; // 避免开方
        float d2 = (y.pos - targetPos).sqrMagnitude;
        return d1.CompareTo(d2);
    }
}

// 使用
var comparer = new EnemyDistanceComparer { targetPos = transform.position };
enemyList.Sort(comparer);

2.2 参数修饰符与内存拷贝 (ref, out, in)

在 Vampirefall 这种海量弹幕/怪物游戏中,大量使用 class 会导致 Cache Miss,因此我们推荐使用 struct。但 struct 默认为值传递 (Value Copy)。如果 struct 很大 (比如超过 16 字节),传参时发生的内存拷贝开销可能比指针跳转还大。 此时需要使用修饰符:

1. in (只读引用)

  • 语义: “我把这个大对象借你看一眼,你不准改,也不准拷贝。”
  • 场景: 传递大于 IntPtr.Size * 2 (16 bytes) 的只读结构体。
  • 注意: 能够完美避免拷贝,且编译器会确保数据安全性。
// ❌ 慢:发生拷贝
void ProcessDamage(DamageInfo info) { ... }

// ✅ 快:引用传递,无拷贝,且安全
void ProcessDamage(in DamageInfo info) {
    // info.damage = 0; // 编译报错,只读
}

2. ref (读写引用)

  • 语义: “我把这个变量的地址给你,你可以随意读取和修改。”
  • 场景: 这个函数本身就是为了修改传入的那个对象 (比如 Swap 函数,或者修改巨大的 Context 对象)。
// 直接修改外面的 struct,而不是修改副本
void ApplyBuff(ref EnemyStats stats) {
    stats.attack += 10;
}

3. out (输出引用)

  • 语义: “这里有个空篮子,你进去必须把它装满再出来。”
  • 场景: 返回多个值,避免创建 Tuple 或临时对象 (GC 友好)。

4. ref return (引用返回)

这是 C# 7.0 的黑科技。允许你返回一个数组元素的引用 (指针),而不是它的副本。
// 假设有一个巨大的结构体数组
MyBigStruct[] allData = new MyBigStruct[10000];

// ❌ 传统写法:返回的是副本,修改副本不会影响原数组
public MyBigStruct GetData(int index) { return allData[index]; }

// ✅ ref return:返回的是allData[index]的地址!
public ref MyBigStruct GetDataRef(int index) { return ref allData[index]; }

// 使用:
ref var item = ref GetDataRef(5);
item.value = 999; // 直接修改了数组里的值!0 拷贝!

2.3 高级语法 (Advanced Syntax)

Span<T>StackAlloc

在不产生 GC 的情况下操作数组切片或在栈上分配临时数组。
// 只有在极度追求性能且不逃逸出函数时使用
public void ProcessData() {
    // 在栈上分配 100 个 int,速度极快,函数结束即销毁,0 GC
    Span<int> numbers = stackalloc int[100];
    for(int i=0; i<100; i++) numbers[i] = i;
    // ...
}

2.3.2 免费的加速器:sealed 关键字

很多开发者懒得写 sealed,觉得没必要。但在 Unity (IL2CPP) 中,这是一个免费的性能开关
  • 原理 (Devirtualization):
    • 调用虚函数 (virtual/override) 通常需要查虚函数表 (vtable)。
    • 如果类被标记为 sealed,编译器十分确定“这个类绝后了”。
    • 于是,IL2CPP 可以大胆地把所有虚函数调用优化为直接函数调用 (Direct Call),省去了查表和跳转的开销。
  • 收益: 在高频调用 (如 Update 中每帧几千次) 的虚函数上,性能提升显著。
// ❌ 普通类:调用 Update() 可能需要查表 (取决于上下文)
public class NormalEntity : BaseEntity { ... }

// ✅ 密封类:IL2CPP 知道没人能继承它,直接调用,快!
public sealed class FastEntity : BaseEntity { ... }

2.3.3 暴力内联:[MethodImpl(MethodImplOptions.AggressiveInlining)]

  • 场景: 极其短小(1-2 行代码)、调用频率极高(每帧万次)的 Helper 函数。
  • 作用: 告诉编译器“别搞函数跳转那一套,直接把代码用胶水粘到调用处”。
  • 注意: 滥用会导致代码提及膨胀 (Instruction Cache Miss),只给那些真正的小型热点函数用。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static float SqrDistance(Vector3 a, Vector3 b) {
    var d = a - b;
    return d.x*d.x + d.y*d.y + d.z*d.z;
}

2.3.4 防御性拷贝克星:readonly struct

  • 场景: 不可变的数据结构 (如自定义的 Vector3, ComplexNumber)。
  • 痛点: 普通 struct 如果被标记为 readonly 字段,编译器为了防止你修改它,会在访问时偷偷创建防御性副本 (Defensive Copy)
  • 解法: 直接在定义时声明 readonly struct,编译器就知道它绝对不会变,从而大胆优化掉所有防御性拷贝。
// ❌ 依然可能产生副本
public struct MyData { public int x; }

// ✅ 真正的不可变,性能极佳
public readonly struct ImmutableData {
    public readonly int x;
    public ImmutableData(int v) => x = v;
}

2.4 必须避免的语法 (The “No-Go” Zone)

🚫 dynamic 关键字

  • 原理: 运行时反射,绕过编译器检查。
  • 后果: 在 Unity (IL2CPP) 中,任何 dynamic 使用都会导致复杂的反射调用,极度 缓慢,且无法被裁剪 (Strip) 优化,显著增加包体大小。

🚫 频繁的装箱 (Boxing)

  • 定义: 值类型 -> Object 或 接口。
  • 场景:
    • string.Format("HP: {0}", hp) (int 被装箱)。
    • Dictionary<struct, string> (如果你没以此 struct 实现 IEquatable 接口)。
    • Dictionary<struct, string> (如果你没以此 struct 实现 IEquatable 接口)。
    • 将 struct 存入 List<object> (尽量不要这么做)。
    • 枚举 (Enum): enum 是值类型,直接 Debug.Log(myEnum) 会导致装箱。应该使用 myEnum.ToString() (虽然生成字符串但至少不装箱) 或者使用 C# 8.0 Enum.TryFormat

🚫 字符串拼接 (String Concatenation)

  • 问题: string 是不可变的。s = s + "a" 会创建一个新的字符串对象。
  • 后果: 在循环中 s += "a" 会产生 O(N^2) 数量级的垃圾。
  • 解决: 使用 StringBuilder
// ❌ 产生大量垃圾
string s = "";
for (int i=0; i<100; i++) s += i;

// ✅ 0 垃圾 (复用 buffer)
StringBuilder sb = new StringBuilder(512);
for (int i=0; i<100; i++) sb.Append(i);

2.5 隐形杀手:闭包 (Closures)

“闭包”这个词听起来很数学,我们可以用一个更形象的比喻:“可以带着走的背包”

🧐 什么是闭包?

正常情况下,一个函数执行完,它里面的局部变量 (int i, float x) 就会被销毁 (Stack Pop)。 但如果你在函数里又定义了一个“小函数” (Lambda),并且这个小函数引用了外面的变量,神奇的事情发生了: 编译器被迫把这些变量“打包”进一个背包 (Heap Class) 里,让小函数背着走。 这样,即使外面的大函数执行完了,小函数以后执行时,还能从背包里掏出这些变量来用。

💀 性能代价

这个“打包”的过程,就是内存分配 (New Class)

❌ 陷阱:无意间的捕获

void Update() {
    int targetId = 100; // 局部变量,本该死在这一帧

    // 因为 Lambda 用到了 targetId,编译器必须 new 一个对象把 targetId 存起来!
    // 结果:每帧产生 Garbage (GC Alloc)
    ProcessList(x => x.Id == targetId);
}

✅ 优化:拒绝“背包”

如果 Lambda 不引用任何外部变量,它就是“赤条条来去无牵挂”,编译器会把它编译成一个静态单例,永远只存在一份,0 GC
// 这里的 1 是常量,x 是参数。没有引用任何外部“活”变量。
// 结果:0 GC,编译器会缓存这个 Lambda
ProcessList(x => x.Id == 1);
如果必须使用 Lambda,确保不引用外部变量,或者手动传入状态。
// 如果 Lambda 不捕获任何外部变量 (只用参数),编译器会将其缓存为静态单例 -> 0 GC
ProcessList(x => x.Id == 1);

🌟 3. 业界优秀案例 (Industry Best Practices)

3.1 Unity DOTS (Data-Oriented Technology Stack)

  • 分析: DOTS 的核心就是利用这一文档所述的所有原理:
    • ECS: 强制数据在内存中连续 (Archetype Chunk) -> 极致 Cache Hit。
    • Burst Compiler: 强制使用栈上数据和原生指针,利用 SIMD 指令集。
    • Job System: 多线程,消除竞争。
  • 借鉴: 即使不完全上 DOTS,也要学习其“数据为先”的思维。如果是处理 1000+ 怪物逻辑,尽量把数据 (HP, Pos) 抽离成数组,而不是分散在 1000 个 Monobehaviour 对象里。

3.2 《Minecraft》区块优化

  • 案例: Minecraft 将世界分为 Chunks (16x16x256)。
  • 原理: 空间局部性。玩家通常只在一个小范围内活动,加载周围 Chunk 到内存 (连续数组),处理极快。不活跃的区块序列化到磁盘,避免内存碎片。

🔗 4. 参考资料 (References)