en un clic
unity-csharp
写 Unity C# 时使用。生命周期、协程、GC、性能、序列化的实战规范。
Installer avec Codex ou Claude Copiez ce prompt, collez-le dans Codex, Claude ou un autre assistant, puis laissez-le vérifier la page du skill et l'installer pour vous.
Menu
写 Unity C# 时使用。生命周期、协程、GC、性能、序列化的实战规范。
Installer avec Codex ou Claude Copiez ce prompt, collez-le dans Codex, Claude ou un autre assistant, puis laissez-le vérifier la page du skill et l'installer pour vous.
做 Cocos Creator 多机型/多分辨率适配时使用。Canvas、Widget、安全区。
Cocos Creator 用 AssetBundle 做分包/远程资源时使用。加载、释放、依赖、缓存。
优化 Cocos Creator 渲染性能时使用。合批、图集、动静分离、Label。
给 Cocos Creator 原生包做热更新时使用。version manifest、增量、校验、回滚。
写 Cocos Creator 动效/动画时使用。tween、Animation、Spine、性能与清理。
做 Cocos Creator 大量条目列表时使用。虚拟列表、节点复用。
Basé sur la classification professionnelle SOC
| 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) 而不是 ?. 或 ??。