with one click
unity-csharp
写 Unity C# 时使用。生命周期、协程、GC、性能、序列化的实战规范。
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Menu
写 Unity C# 时使用。生命周期、协程、GC、性能、序列化的实战规范。
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Based on SOC occupation classification
做 Cocos Creator 多机型/多分辨率适配时使用。Canvas、Widget、安全区。
Cocos Creator 用 AssetBundle 做分包/远程资源时使用。加载、释放、依赖、缓存。
优化 Cocos Creator 渲染性能时使用。合批、图集、动静分离、Label。
给 Cocos Creator 原生包做热更新时使用。version manifest、增量、校验、回滚。
写 Cocos Creator 动效/动画时使用。tween、Animation、Spine、性能与清理。
做 Cocos Creator 大量条目列表时使用。虚拟列表、节点复用。
| name | unity-csharp |
| description | 写 Unity C# 时使用。生命周期、协程、GC、性能、序列化的实战规范。 |
| category | gamedev |
| tags | ["unity","csharp","性能"] |
规则: Update 里只放真正需要每帧响应的逻辑;能用事件、协程或 InvokeRepeating 驱动的,就不放进 Update;任何 GetComponent、Find、tag 比较一律在 Awake/Start 里缓存,不在 Update 里调用。
为什么: AI 和新手最常见的模式是把所有逻辑堆进 Update——"反正每帧都会跑到"。结果是:GetComponent 每帧反射查找,100 个对象就是 100 次反射;倒计时用 timer -= Time.deltaTime 没问题,但 UI 刷新、动画触发、状态机判断全挤在里面,Profiler 一看 Update 占 80% CPU。更隐蔽的问题:新人把 FindObjectOfType<GameManager>() 放进 Update,场景里一有几十个对象就掉帧,排查时根本不知道从哪查。
怎么做:
Awake(自身组件)或 Start(跨对象引用)。InvokeRepeating 或协程 WaitForSeconds,不用 Update 里的计数器模拟低频事件。if (state == X)。规则: Update、FixedUpdate、协程的热路径里禁止出现 new(含 LINQ、字符串拼接、装箱);高频复用的对象用对象池管理;容器在初始化时预分配容量。
为什么: Unity 使用 Mono / IL2CPP 的 GC,GC 触发时会造成明显的帧率刺尖(spike)。AI 写的代码里最常见的杀手:string.Format($"Score: {score}") 每帧执行一次,enemies.Where(e => e.isAlive).ToList() 每帧生成一个新 List,new Vector3(...) 看起来是值类型不会 GC——但装箱到 object 参数时就会。新手则喜欢在子弹生成时 Instantiate、销毁时 Destroy,百发子弹就是百次 GC 压力。
怎么做:
StringBuilder 或 TMP_Text.SetText(format, arg) 替代字符串拼接。ObjectPool<T>(Unity 2021+ 内置)或自实现的栈式对象池。List<T>(initialCapacity) 预分配,避免频繁扩容。规则: 明确 Awake → OnEnable → Start → Update → OnDisable → OnDestroy 的时序;协程在对象 SetActive(false) 或 OnDisable 时自动停止,但 OnDestroy 时不会自动停止跑在其他 MonoBehaviour 上的协程;协程引用必须在 OnDisable/OnDestroy 里主动 StopCoroutine。
为什么: AI 最典型的错误:在 Awake 里启动协程并访问 Start 才会初始化完毕的跨组件引用,导致 NullReferenceException;或者在 OnDestroy 不清理,导致协程持有对已销毁对象的引用,每帧报 MissingReferenceException,但对象本体早就没了,难以溯源。新手则习惯用 StartCoroutine 后不记返回值,后来想停却只能 StopAllCoroutines,误杀其他协程。
怎么做:
Start,同组件自身初始化放 Awake。StartCoroutine 返回值存成字段:_loopCoroutine = StartCoroutine(Loop());。OnDisable 里 if (_loopCoroutine != null) StopCoroutine(_loopCoroutine);。if (this == null) yield break; 防止僵尸协程。规则: Inspector 可调的字段声明为 private + [SerializeField],不用 public 字段;多处共享的静态配置(数值、曲线、音效引用)抽成 ScriptableObject 资产;运行时不修改 prefab 资产本身,只改实例。
为什么: AI 生成代码时为了"方便 Inspector 看到",大量字段声明成 public,导致类的 API 边界模糊,其他脚本可以随意修改本不该暴露的状态。新手则把所有敌人的血量、速度都硬编码在 MonoBehaviour 字段里,20 种敌人要改数值就要手动改 20 个 prefab。ScriptableObject 的价值在于:一份资产,多个 prefab 引用,改一处全更新;且美术/策划可以在编辑器里直接调,不需要改代码。运行时误改 prefab.GetComponent<X>().hp = 5 会永久写入资产(编辑器模式下),是高频"改完关编辑器发现数值乱了"的根源。
怎么做:
[SerializeField] private float _speed = 5f; — 外部只读时加 public float Speed => _speed;。[CreateAssetMenu] public class EnemyConfig : ScriptableObject,字段暴露在 Inspector,在 prefab 里只挂引用。PrefabUtility 或直接修改资产路径上的对象。[Header("")] 和 [Tooltip("")] 给 Inspector 分组加注释,而不是靠 public 字段名猜。规则: Destroy(obj) 后,C# 引用变量仍然持有一个"假对象"(Unity 重载了 == 运算符让它等于 null,但 object.ReferenceEquals(obj, null) 为 false);判空用 if (obj == null) 而非 if (obj != null) 后直接用;不缓存已销毁对象的引用;用 null 条件运算符 ?. 时要注意 Unity 假 null 会绕过重载,导致访问已销毁对象。
为什么: 这是 Unity C# 里最隐蔽的 bug 来源之一,AI 几乎每次都会犯。场景:_target = FindObjectOfType<Enemy>(); ... _target?.Attack();——Enemy 已经被 Destroy,?. 用的是 C# 原生引用判断(不走 Unity 重载),_target 不是真 null,于是 Attack() 被调用,访问已释放的 C++ 底层对象,抛出 MissingReferenceException。更诡异的是,这个异常信息指向调用栈而不是销毁点,新手排查数小时找不到原因。
怎么做:
if (obj == null)(走 Unity 重载),不用 obj?.Method()、obj ?? fallback 在 MonoBehaviour 引用上。?. 语法时,先 var safeRef = obj; if (safeRef == null) return; safeRef.Method();。_target = null;。if (ref == null) { Debug.LogError(...); return; }。// 反例 — 每帧 GetComponent,每帧字符串格式化,每帧 GC Alloc
void Update()
{
var rb = GetComponent<Rigidbody2D>(); // ❌ 每帧反射查找
rb.velocity = new Vector2(_speed, rb.velocity.y);
var ui = GetComponent<TextMeshProUGUI>(); // ❌ 又一次反射
ui.text = "Score: " + GameManager.score; // ❌ 每帧字符串拼接,GC Alloc
}
// 正例 — 缓存引用,热路径零 GC
private Rigidbody2D _rb;
private TextMeshProUGUI _scoreText;
void Awake()
{
_rb = GetComponent<Rigidbody2D>(); // ✅ 只查一次
_scoreText = GetComponent<TextMeshProUGUI>();
}
void Update()
{
_rb.velocity = new Vector2(_speed, _rb.velocity.y);
}
// 分数变化时由事件触发,不在 Update 轮询
void OnScoreChanged(int newScore)
{
_scoreText.SetText("Score: {0}", newScore); // ✅ TMP 零 GC 重载
}
// 反例 — 协程不清理,?. 绕过 Unity 假 null
private Enemy _target;
void Start()
{
_target = FindObjectOfType<Enemy>();
StartCoroutine(ChaseLoop()); // ❌ 不保存引用,无法单独停止
}
IEnumerator ChaseLoop()
{
while (true)
{
_target?.MoveTo(transform.position); // ❌ ?. 绕过 Unity == 重载,可能访问已销毁对象
yield return new WaitForSeconds(0.1f);
}
}
// OnDisable/OnDestroy 里什么都没有 — ❌ 协程泄漏
// 正例 — 保存协程引用,OnDisable 清理,判空走 Unity 重载
private Enemy _target;
private Coroutine _chaseCoroutine;
void Start()
{
_target = FindObjectOfType<Enemy>();
_chaseCoroutine = StartCoroutine(ChaseLoop()); // ✅ 保存引用
}
IEnumerator ChaseLoop()
{
while (true)
{
if (_target == null) yield break; // ✅ 走 Unity == 重载,正确检测假 null
_target.MoveTo(transform.position);
yield return new WaitForSeconds(0.1f);
}
}
void OnDisable()
{
if (_chaseCoroutine != null)
{
StopCoroutine(_chaseCoroutine); // ✅ 主动清理
_chaseCoroutine = null;
}
}
new 分配、无字符串拼接、无 LINQ。private [SerializeField],无不必要的 public 字段。if (obj == null) 而不是 ?. 或 ??。