dsh-project-based-learning 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/CONTRIBUTING.md +80 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/README.zh.md +118 -0
- package/cordis.patch.yml +15 -0
- package/docs/DESIGN-AUDIT.md +505 -0
- package/docs/ENGINE-REVISION-2.zh.md +487 -0
- package/docs/installing.zh.md +105 -0
- package/docs/original-workflow.zh.md +379 -0
- package/docs/releasing.zh.md +85 -0
- package/docs/review-round1-A-edu.zh.md +66 -0
- package/docs/review-round1-B-eng.zh.md +60 -0
- package/docs/review-round1-C-bounded.zh.md +55 -0
- package/docs/zero-knowledge-path.zh.md +60 -0
- package/examples/PROGRESS.demo.md +72 -0
- package/examples/state.demo.json +185 -0
- package/examples/state.selftest-invalid.json +58 -0
- package/lib/index.js +64 -0
- package/package.json +77 -0
- package/skills/dsh-coach/SKILL.md +108 -0
- package/skills/dsh-coach/assets/review-report.md +40 -0
- package/skills/dsh-coach/assets/stage-acceptance.md +51 -0
- package/skills/dsh-coach/assets/state.template.json +59 -0
- package/skills/dsh-coach/assets/task-card.md +29 -0
- package/skills/dsh-coach/references/domains/unity-csharp/archetypes.md +306 -0
- package/skills/dsh-coach/references/domains/unity-csharp/diagnosis-bank.md +978 -0
- package/skills/dsh-coach/references/domains/unity-csharp/example.md +356 -0
- package/skills/dsh-coach/references/domains/unity-csharp/glossary.md +110 -0
- package/skills/dsh-coach/references/domains/unity-csharp/manifest.yml +14 -0
- package/skills/dsh-coach/references/domains/unity-csharp/pitfalls.md +400 -0
- package/skills/dsh-coach/references/domains/unity-csharp/verification.md +308 -0
- package/skills/dsh-coach/references/engine/adapt.md +48 -0
- package/skills/dsh-coach/references/engine/diagnosis.md +76 -0
- package/skills/dsh-coach/references/engine/domain-contract.md +73 -0
- package/skills/dsh-coach/references/engine/intake.md +63 -0
- package/skills/dsh-coach/references/engine/permissions.md +44 -0
- package/skills/dsh-coach/references/engine/review-acceptance.md +67 -0
- package/skills/dsh-coach/references/engine/route.md +51 -0
- package/skills/dsh-coach/references/engine/state.md +116 -0
- package/skills/dsh-coach/references/engine/task-loop.md +68 -0
- package/skills/dsh-coach/scripts/coach-install.mjs +98 -0
- package/skills/dsh-coach/scripts/coach-selftest.mjs +205 -0
- package/skills/dsh-coach/scripts/coach-validate.mjs +817 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# Unity / C# 常见陷阱手册
|
|
2
|
+
|
|
3
|
+
> 用途:审阅与复盘时按「症状」检索,快速给出机制解释与最小修复。
|
|
4
|
+
> 每条固定五字段:**症状 / 机制 / 最小修复 / 验证方式 / 对应能力维度**。
|
|
5
|
+
> 「对应能力维度」用于把一次具体错误挂到 7 维度画像上,取值与 `state.capability[].dimension` 一致。
|
|
6
|
+
>
|
|
7
|
+
> **API 版本提示**:文中 `Rigidbody2D.linearVelocity`(Unity 6 / 6000.x)在 Unity 2022 及更早版本中名为 `velocity`;`Object.FindObjectOfType` 在 Unity 6 中已标记为过时(obsolete),官方建议改用 `Object.FindFirstObjectByType` 或更快的 `Object.FindAnyObjectByType`。判读代码时以学员实际使用的版本为准。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 一、生命周期
|
|
12
|
+
|
|
13
|
+
### P-01 Awake 里给字段赋默认值,覆盖了 Inspector 设置
|
|
14
|
+
|
|
15
|
+
**症状**
|
|
16
|
+
Inspector 里改了数值(速度、血量、间隔),Play 之后用的是代码里的值,不是调过的值。改 Inspector 完全没有效果。
|
|
17
|
+
|
|
18
|
+
**机制**
|
|
19
|
+
序列化数据在对象创建时就已经从场景/预制体写入字段;`Awake` 在这之后执行,因此 `Awake` 里的赋值会盖掉 Inspector 值。`[SerializeField]` 只影响「是否显示与保存」,不会让字段在被赋值后保持锁定。
|
|
20
|
+
|
|
21
|
+
**最小修复**
|
|
22
|
+
删掉 `Awake` 里对该字段的赋值。若需要一个「编辑器里点 Reset 恢复默认」的行为,把它放到 `Reset()` 回调或 `OnValidate()` 中,而不是运行时回调。
|
|
23
|
+
|
|
24
|
+
**验证方式**
|
|
25
|
+
在 Inspector 里把值改成一个明显异常的数字(如 `999`),Play 后 `Debug.Log` 打印该字段;打印出 `999` 即修复,打印回默认值即未修复。
|
|
26
|
+
|
|
27
|
+
**对应能力维度**:基础知识
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
### P-02 在 Awake 里依赖另一个对象的 Start 结果
|
|
32
|
+
|
|
33
|
+
**症状**
|
|
34
|
+
偶尔报空引用,或者同一个脚本有时正常有时崩。把代码顺序调换一下偶尔就好了。
|
|
35
|
+
|
|
36
|
+
**机制**
|
|
37
|
+
同一场景内 Unity 会先对所有对象执行 `Awake`,再对所有对象执行 `Start`——但**同一回调之间的对象顺序不由脚本挂载顺序或代码书写顺序决定**。在 `Awake` 里访问另一个对象「在 `Start` 里才创建/赋值」的成员,就等于依赖一个尚未发生的动作。
|
|
38
|
+
|
|
39
|
+
**最小修复**
|
|
40
|
+
把跨对象的初始化依赖反转:让「提供方」在 `Awake` 里完成赋值(而不是 `Start`),或让「使用方」把依赖读取推迟到 `Start`。更稳的做法是显式注入:由管理脚本在 `Awake` 里调用 `target.Init(dep)`。
|
|
41
|
+
|
|
42
|
+
**验证方式**
|
|
43
|
+
在所有脚本里给 `Awake`/`Start` 各加一条带对象名的日志,观察实际执行顺序;再把提供方的赋值从 `Start` 移到 `Awake`,确认空引用消失。
|
|
44
|
+
|
|
45
|
+
**对应能力维度**:基础知识
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 二、协程与时间
|
|
50
|
+
|
|
51
|
+
### P-03 协程里做的状态复位,在对象被禁用后永远不执行
|
|
52
|
+
|
|
53
|
+
**症状**
|
|
54
|
+
「无敌 2 秒」变成永久无敌;「开火冷却」卡住再也不恢复;重新启用对象后行为异常。
|
|
55
|
+
|
|
56
|
+
**机制**
|
|
57
|
+
协程由承载它的那个 MonoBehaviour 驱动。组件被 `enabled = false` 或 GameObject 被 `SetActive(false)` 时,该组件上的协程停止推进,`yield` 之后的代码不会执行;再次启用也**不会**从断点恢复。
|
|
58
|
+
|
|
59
|
+
**最小修复**
|
|
60
|
+
不要把「必须执行的状态复位」放在协程尾部。把复位逻辑抽成一个方法,在 `OnDisable()` 里调用它(`OnDisable` 在禁用与销毁时都会触发)。或者改用时间戳方式计时(记录结束时刻,在属性里算剩余时间),这类状态不需要「执行」就能自然结束。
|
|
61
|
+
|
|
62
|
+
**验证方式**
|
|
63
|
+
在协程启动后立刻 `SetActive(false)`,等超过设定时长再 `SetActive(true)`,打印状态字段;若仍停留在「生效中」即确认该陷阱。
|
|
64
|
+
|
|
65
|
+
**对应能力维度**:实际应用
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
### P-04 用 WaitForSeconds 做计时,游戏暂停后计时永远不会结束
|
|
70
|
+
|
|
71
|
+
**症状**
|
|
72
|
+
按暂停(`Time.timeScale = 0`)后再恢复,某个「等待 N 秒」的逻辑没有按预期继续;或者在暂停期间它反而立刻结束了。
|
|
73
|
+
|
|
74
|
+
**机制**
|
|
75
|
+
`WaitForSeconds` 依赖缩放后的游戏时间。`Time.timeScale = 0` 时缩放时间为 0,等待永不完成。而 `WaitForSecondsRealtime` 使用不受缩放影响的真实时间,暂停期间照常走完。
|
|
76
|
+
|
|
77
|
+
**最小修复**
|
|
78
|
+
按语义选时间源:游戏内逻辑(技能冷却、无敌、刷怪间隔)通常应与游戏时间一致,保留 `WaitForSeconds`;UI 动画、与游戏暂停无关的真实等待用 `WaitForSecondsRealtime`。选完后在代码里写一句注释说明选择理由。
|
|
79
|
+
|
|
80
|
+
**验证方式**
|
|
81
|
+
在 Play 模式里把 `Time.timeScale` 设为 `0`,观察该逻辑是否按预期停住/继续;恢复后再看一次。两种时间源各测一次即可看出差别。
|
|
82
|
+
|
|
83
|
+
**对应能力维度**:实际应用
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 三、序列化与 Inspector
|
|
88
|
+
|
|
89
|
+
### P-05 私有字段忘了 [SerializeField],Inspector 里看不到也存不住
|
|
90
|
+
|
|
91
|
+
**症状**
|
|
92
|
+
字段在 Inspector 里根本不存在;或者运行时赋的值在退出 Play 模式后消失。
|
|
93
|
+
|
|
94
|
+
**机制**
|
|
95
|
+
Unity 只序列化 `public` 字段,以及带 `[SerializeField]` 的私有字段。**属性(`{ get; set; }`)不会被序列化**——即使加了 `[SerializeField]` 也不会出现。另外带 `[NonSerialized]` 的字段完全不参与序列化,运行时赋值不会保留。
|
|
96
|
+
|
|
97
|
+
**最小修复**
|
|
98
|
+
把需要暴露的私有字段改成 `[SerializeField] private T name;`。若必须是属性,则写一个带 `[SerializeField]` 的后备字段,属性读写该字段。
|
|
99
|
+
|
|
100
|
+
**验证方式**
|
|
101
|
+
在 Inspector 里改值 → 退出并重新进入 Play 模式 → 看值是否保持。保持即已被序列化。
|
|
102
|
+
|
|
103
|
+
**对应能力维度**:基础知识
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
### P-06 直接改 ScriptableObject 资源的运行时数据,污染了源文件
|
|
108
|
+
|
|
109
|
+
**症状**
|
|
110
|
+
Play 模式里改了数值,退出后编辑器里的资源**也**被改了;下次运行起点错误,且改动会跟着提交进版本库。
|
|
111
|
+
|
|
112
|
+
**机制**
|
|
113
|
+
`ScriptableObject` 是资源(资产),编辑器里对它的字段写入会直接落到资源文件上。它不像场景对象那样在退出 Play 模式时回滚。用 `Resources.Load` / Inspector 引用拿到的是**同一个共享实例**。
|
|
114
|
+
|
|
115
|
+
**最小修复**
|
|
116
|
+
明确区分「配置资源」与「运行时数据」:配置只读,运行时状态放在普通 C# 对象或场景组件里。若确实需要「基于配置的运行时副本」,用 `ScriptableObject.CreateInstance<T>()` 建内存实例并 `CopyFrom(config)`,或者退一步用普通类做数据载体。
|
|
117
|
+
|
|
118
|
+
**验证方式**
|
|
119
|
+
记录资源改动前的字段值 → Play 模式里修改它 → 退出 Play → 检查资源字段。若被改变,即确认污染。
|
|
120
|
+
|
|
121
|
+
**对应能力维度**:结构与质量
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## 四、性能与分配
|
|
126
|
+
|
|
127
|
+
### P-07 把「每帧调用」当成「每帧产生 GC」,优化错方向
|
|
128
|
+
|
|
129
|
+
**症状**
|
|
130
|
+
学员花大量时间「消除 GC」,但 Profiler 的 GC Alloc 列几乎没有变化;真正的耗时(查找、渲染、物理)没被处理。
|
|
131
|
+
|
|
132
|
+
**机制**
|
|
133
|
+
「调用开销」和「堆分配」是两件独立的事。值类型(`struct`,如 `Vector3`、`Color`、`float`)的运算与返回通常**不产生**托管堆分配;`Color.Lerp`、`Mathf.PingPong` 之类不分配。真正产生分配的常见来源是:`new` 引用类型、装箱(值类型转 `object`)、字符串拼接与 `ToString()`、闭包/lambda 捕获、以及每次返回新数组或新列表的 API。
|
|
134
|
+
|
|
135
|
+
**最小修复**
|
|
136
|
+
先用 Profiler 的 GC Alloc 列确认到底有没有分配、在哪里分配,再针对性改。每帧的 `GetComponent` / `Find*` 属于**调用开销**问题,修法是缓存引用(`Awake` 里取一次存字段),理由是省查找而不是省 GC。
|
|
137
|
+
|
|
138
|
+
**验证方式**
|
|
139
|
+
Profiler → CPU 模块 → 按 GC Alloc 列排序;或在关键方法前后用 `ProfilerMarker` 标记后比较。基线值与修改后值必须在同一场景、同样时长下采集。
|
|
140
|
+
|
|
141
|
+
**对应能力维度**:解释与迁移
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
### P-08 循环里用 string 拼接日志,或在高频路径上装箱
|
|
146
|
+
|
|
147
|
+
**症状**
|
|
148
|
+
每帧产生持续的小额 GC 分配,长时间运行后周期性卡顿。
|
|
149
|
+
|
|
150
|
+
**机制**
|
|
151
|
+
`"a" + value` 对值类型会调用 `ToString()` 并新建 `string`;字符串是不可变的,每次拼接都产生新对象。`Debug.Log` 的参数在**调用之前**就已经被拼接完成,所以即使日志被过滤掉,字符串分配**照样发生**。把值类型放进 `object` 参数(如 `Debug.Log(object)`、非泛型集合)会触发装箱。
|
|
152
|
+
|
|
153
|
+
**最小修复**
|
|
154
|
+
高频路径上不要拼字符串;需要调试日志时用条件编译或开关包起来,避免在正式包里执行。给 `Debug.Log` 传结构化参数时注意它只接受一个 `object`,需要格式化时考虑是否真的要每帧打印。
|
|
155
|
+
|
|
156
|
+
**验证方式**
|
|
157
|
+
Profiler 的 GC Alloc 列在关闭/开启该日志两种情况下对比;或把日志移到 `if` 开关内再测一次。
|
|
158
|
+
|
|
159
|
+
**对应能力维度**:解释与迁移
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## 五、物理与碰撞
|
|
164
|
+
|
|
165
|
+
### P-09 在 Update 里改物理位置或速度,导致抖动与不稳定
|
|
166
|
+
|
|
167
|
+
**症状**
|
|
168
|
+
移动时画面抖动;跳跃高度时高时低;性能差的机器上行为不同;对象偶尔穿过薄墙。
|
|
169
|
+
|
|
170
|
+
**机制**
|
|
171
|
+
物理在固定步长(`FixedUpdate` / `Time.fixedDeltaTime`)中推进,而 `Update` 按渲染帧执行,两者调用次数比例不固定。在 `Update` 里直接改 `transform.position` 会绕过物理引擎的插值与碰撞解算,产生视觉抖动;在 `Update` 里读输入并直接赋速度,则赋值时机与物理步不同步,结果随帧率变化。
|
|
172
|
+
|
|
173
|
+
**最小修复**
|
|
174
|
+
物理位移与速度赋值放进 `FixedUpdate`;在 `Update` 里只记录输入意图(置 `bool` / 存 `float`),在 `FixedUpdate` 里消费它。移动用刚体 API(`Rigidbody2D.linearVelocity`、`MovePosition`、`AddForce`)而不是直接改 `transform.position`。
|
|
175
|
+
|
|
176
|
+
**验证方式**
|
|
177
|
+
把帧率上限压到很低(如 `Application.targetFrameRate = 15`)再测一次,观察跳跃高度/移动距离是否与 60 帧时一致。不一致即证明存在帧率依赖。
|
|
178
|
+
|
|
179
|
+
**对应能力维度**:问题拆解
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
### P-10 触发器不触发:漏了刚体、勾错 Is Trigger、或被 Layer 矩阵挡住
|
|
184
|
+
|
|
185
|
+
**症状**
|
|
186
|
+
`OnTriggerEnter2D` / `OnTriggerEnter` 完全不执行,或只在某些对象之间执行。Console 没有任何报错。
|
|
187
|
+
|
|
188
|
+
**机制**
|
|
189
|
+
触发回调需要满足多个条件,缺一不可:
|
|
190
|
+
1. 双方都有碰撞体(`Collider2D` / `Collider`);
|
|
191
|
+
2. **至少一方勾选了 `Is Trigger`**(触发只勾一边即可,不是两边都要勾);
|
|
192
|
+
3. 至少一方带刚体,且刚体类型组合能产生接触——两个都不动的静态/`Kinematic` 组合通常不会产生触发;
|
|
193
|
+
4. 双方所在 Layer 在 **Physics 2D**(或 Physics)的 Layer Collision Matrix 中互相勾选;
|
|
194
|
+
5. 碰撞体尺寸不为 0,且在空间上真的重叠。
|
|
195
|
+
|
|
196
|
+
**最小修复**
|
|
197
|
+
按上面 5 条逐条核对。最常见的是第 2 条(以为两边都要勾)与第 4 条(改了 Layer 但没回看矩阵)。
|
|
198
|
+
|
|
199
|
+
**验证方式**
|
|
200
|
+
Scene 视图打开 Gizmos,确认两个碰撞体的绿色轮廓**肉眼可见地重叠**——这一步能立刻排除几何与尺寸问题。再把双方临时都设为 `Default` 层,若能触发则问题在碰撞矩阵。
|
|
201
|
+
|
|
202
|
+
**对应能力维度**:调试与纠错
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## 六、事件与生命周期泄漏
|
|
207
|
+
|
|
208
|
+
### P-11 订阅不解绑:重复触发,并且对象无法被回收
|
|
209
|
+
|
|
210
|
+
**症状**
|
|
211
|
+
开关面板几次后,点一次按钮触发多次(次数随开关次数递增);或者场景反复加载后,事件回调被调用到已经销毁的对象上,抛 `MissingReferenceException`。
|
|
212
|
+
|
|
213
|
+
**机制**
|
|
214
|
+
`AddListener` / `+=` 是**追加**语义,重复订阅就多挂一份委托。若被订阅者(尤其是静态事件总线、单例、`ScriptableObject` 事件通道)的生命周期**长于**订阅者,那么订阅者被销毁后,被订阅者仍持有它的委托引用,导致:一则对象无法被 GC 回收(内存泄漏),二则事件再次触发时调用到已销毁对象的方法。
|
|
215
|
+
|
|
216
|
+
**最小修复**
|
|
217
|
+
在 `OnEnable` 订阅、在 `OnDisable` 解绑,两者**成对**出现。不要用 `OnDestroy` 代替 `OnDisable`——`OnDisable` 可能发生多次而 `OnDestroy` 只一次,只解绑一次不够。
|
|
218
|
+
|
|
219
|
+
**验证方式**
|
|
220
|
+
反复启用/禁用该组件 N 次,然后在事件触发处打印接收者数量或日志次数;次数随 N 增长即确认。更严格的做法是连续多次加载/卸载场景,观察内存快照中该类型实例数是否持续增长。
|
|
221
|
+
|
|
222
|
+
**对应能力维度**:调试与纠错
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
### P-12 用 lambda 订阅,于是根本解绑不掉
|
|
227
|
+
|
|
228
|
+
**症状**
|
|
229
|
+
看代码「明明写了 `RemoveListener`」,但重复触发依旧;换成具名方法后就正常了。
|
|
230
|
+
|
|
231
|
+
**机制**
|
|
232
|
+
每次写 `() => DoSomething(this)` 都会创建一个**新的委托实例**。因此 `RemoveListener(() => DoSomething(this))` 里的 lambda 与添加时的不是同一个对象,解绑静默失败(不报错)。此外 lambda 捕获了 `this`,会额外延长该 MonoBehaviour 的存活,配合全局事件就形成泄漏链。
|
|
233
|
+
|
|
234
|
+
**最小修复**
|
|
235
|
+
把 lambda 提取成具名方法,用方法组订阅与解绑:
|
|
236
|
+
```csharp
|
|
237
|
+
private void OnClick() => DoSomething(this);
|
|
238
|
+
// 订阅
|
|
239
|
+
button.onClick.AddListener(OnClick);
|
|
240
|
+
// 解绑
|
|
241
|
+
button.onClick.RemoveListener(OnClick);
|
|
242
|
+
```
|
|
243
|
+
若因参数不同必须用 lambda,则把委托实例存进字段,用同一个字段做删加。
|
|
244
|
+
|
|
245
|
+
**验证方式**
|
|
246
|
+
改成具名方法后重复开关面板,确认触发次数不再递增。
|
|
247
|
+
|
|
248
|
+
**对应能力维度**:调试与纠错
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## 七、异步与主线程
|
|
253
|
+
|
|
254
|
+
### P-13 从子线程调用 Unity API:开发版报错,正式版直接崩
|
|
255
|
+
|
|
256
|
+
**症状**
|
|
257
|
+
开发版本里出现 `UnityException: ... can only be called from the main thread.`;换成正式构建后没有报错,但出现随机崩溃或状态错乱。
|
|
258
|
+
|
|
259
|
+
**机制**
|
|
260
|
+
大多数 Unity API 不是线程安全的,只能在主线程调用。据 Unity 文档,**开发版本**会检测并打印 `UnityException: Internal_CreateGameObject can only be called from the main thread.` 之类的错误;而**出于性能考虑,非开发版本不做这项检查、也不显示该错误**——也就是说子线程误用 API 在正式包里不会被拦下,而是「很可能崩溃或产生不可预测的错误」。
|
|
261
|
+
另外需要注意:`Task.Run` 的方法体在后台线程执行,其内部不能碰 Unity API。
|
|
262
|
+
|
|
263
|
+
**最小修复**
|
|
264
|
+
子线程里只做纯计算/IO,把结果交回主线程后再操作 Unity 对象。用 `Awaitable` 并借助 `Awaitable.MainThreadAsync()` 切回主线程;若坚持用 `Task`,则从主线程发起 `await`,让续体由 Unity 的同步上下文回到主线程。Unity 文档也建议:与其自己开线程,不如使用 Job System。
|
|
265
|
+
|
|
266
|
+
**验证方式**
|
|
267
|
+
切到 Development Build 打一次包运行,把 Console 里的 `UnityException` 当成硬失败处理;同时在主线程关键 API 处打印线程 id 做对照。
|
|
268
|
+
|
|
269
|
+
**对应能力维度**:实际应用
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
### P-14 以为 await Task 会「立刻」继续,结果每次都被推迟到下一帧
|
|
274
|
+
|
|
275
|
+
**症状**
|
|
276
|
+
异步流程看起来「能跑通」但整体比预期慢一截;连续 `await` 多个 `Task` 时总耗时明显大于各部分之和。
|
|
277
|
+
|
|
278
|
+
**机制**
|
|
279
|
+
据 Unity 文档:如果在主线程调用一个返回 `Task` 的方法且它没有同步完成,续体会被投递到 `UnitySynchronizationContext`,并**在下一帧的 `Update` 时机**在主线程执行——不是立即。所以从主线程发起的 `Task` 异步,每次等待至少跨一帧(30fps 下约 33ms)。`Awaitable` 的调度不同:从主线程调用则在主线程恢复,从其它线程调用则在 `ThreadPool` 线程恢复,因此更省开销。
|
|
280
|
+
|
|
281
|
+
**最小修复**
|
|
282
|
+
区分「纯 IO/计算等待」与「需要跨帧」的语义。要减少延迟就用 `Awaitable` 系列 API(以及 `Awaitable.MainThreadAsync` / `Awaitable.BackgroundThreadAsync` 明确线程),并避免在高频路径上用 `Task` 连续 await。
|
|
283
|
+
|
|
284
|
+
**验证方式**
|
|
285
|
+
在每次 `await` 前后打印 `Time.frameCount`;若帧号每次都 +1 甚至更多,即确认被推迟到后续帧。
|
|
286
|
+
|
|
287
|
+
**对应能力维度**:解释与迁移
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
### P-15 退出 Play 模式后后台任务仍在跑
|
|
292
|
+
|
|
293
|
+
**症状**
|
|
294
|
+
停止 Play 模式后 Console 仍打印日志、出现「访问已销毁对象」的异常,或下一次进入 Play 模式时状态异常。
|
|
295
|
+
|
|
296
|
+
**机制**
|
|
297
|
+
据 Unity 文档:**Unity 不会在退出 Play 模式时自动停止后台运行的代码**。用 `Awaitable.BackgroundThreadAsync` 或线程池启动的工作会继续执行,并在回来时操作已经销毁的对象。
|
|
298
|
+
|
|
299
|
+
**最小修复**
|
|
300
|
+
用 `Application.exitCancellationToken` 取消后台操作:把它传给支持取消的 API,或在自己的循环里 `ThrowIfCancellationRequested()` / 检查 `IsCancellationRequested`。协程在退出 Play 模式时会被停止,`Task` 与后台线程不会。
|
|
301
|
+
|
|
302
|
+
**验证方式**
|
|
303
|
+
在后台任务开头与结束各打一条日志,进入再停止 Play 模式,观察「结束」日志是否在停止后才出现。
|
|
304
|
+
|
|
305
|
+
**对应能力维度**:实际应用
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## 八、编辑器与运行时差异
|
|
310
|
+
|
|
311
|
+
### P-16 编辑器脚本泄漏到运行时,打包时报 UnityEditor 找不到
|
|
312
|
+
|
|
313
|
+
**症状**
|
|
314
|
+
编辑器里一切正常,一点「Build」就报错,提示找不到 `UnityEditor` 命名空间或类型。
|
|
315
|
+
|
|
316
|
+
**机制**
|
|
317
|
+
`UnityEditor` 命名空间只在编辑器环境中存在。任何引用它的脚本如果被编进运行时程序集(例如放在 `Assets` 下但没有放进 `Editor` 文件夹、也没有用编辑器专用 asmdef),打包时就会失败。
|
|
318
|
+
|
|
319
|
+
**最小修复**
|
|
320
|
+
把编辑器脚本放进名为 `Editor` 的文件夹,或放进一个在 asmdef 里把平台限定为 Editor 的程序集。运行时确实需要共享逻辑时,把共享部分抽到普通程序集,编辑器侧只做调用。若必须在同一文件里写两套逻辑,用 `#if UNITY_EDITOR` 包住编辑器部分。
|
|
321
|
+
|
|
322
|
+
**验证方式**
|
|
323
|
+
在 Build Settings / Build Profiles 里切换到非 Editor 平台(如 Windows Standalone)并触发一次编译,确认无 `UnityEditor` 相关错误。只看编辑器里的 Console 是查不出来的。
|
|
324
|
+
|
|
325
|
+
**对应能力维度**:结构与质量
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
### P-17 关掉 Domain Reload 后,静态状态不再自动重置
|
|
330
|
+
|
|
331
|
+
**症状**
|
|
332
|
+
第二次进入 Play 模式时行为与第一次不同:单例指向已销毁对象、静态计数器从上次的值继续、静态事件列表越积越长。
|
|
333
|
+
|
|
334
|
+
**机制**
|
|
335
|
+
默认情况下进入 Play 模式会重载脚本域(Domain Reload),静态字段因此回到初始值。关闭「Enter Play Mode Options」中的 Domain Reload(为加快进入 Play 的速度)后,静态字段**会保留上一次运行的值**,于是静态单例、静态事件、静态缓存全部变成脏状态。
|
|
336
|
+
|
|
337
|
+
**最小修复**
|
|
338
|
+
不要依赖域重载来清理状态:给静态状态写显式重置逻辑,并在 `RuntimeInitializeOnLoadMethod`(例如 `RuntimeInitializeLoadType.SubsystemRegistration`)标记的方法里执行;或干脆减少静态可变状态,改用场景内对象持有。
|
|
339
|
+
|
|
340
|
+
**验证方式**
|
|
341
|
+
进入 Play → 修改一个静态字段 → 退出 → 再次进入,打印该字段。若保留了上次的值,即确认该陷阱(需先按上述设置关闭 Domain Reload 才能复现)。
|
|
342
|
+
|
|
343
|
+
**对应能力维度**:结构与质量
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## 九、程序集与 asmdef
|
|
348
|
+
|
|
349
|
+
### P-18 拆了 asmdef 之后一堆「找不到类型」
|
|
350
|
+
|
|
351
|
+
**症状**
|
|
352
|
+
把脚本按文件夹拆成多个 `.asmdef` 后,原来能用的 `using` 报「找不到命名空间/类型」;或提示程序集引用存在循环。
|
|
353
|
+
|
|
354
|
+
**机制**
|
|
355
|
+
`.asmdef` 划定了编译边界。每个 asmdef 是一个独立程序集,**默认只能访问自己以及自己显式引用的程序集**。拆分之前,所有没有 asmdef 覆盖的脚本都在同一个预定义程序集(如 `Assembly-CSharp`)里,所以互相可见;拆分后这种默认可见性消失。另外**程序集不能循环依赖**:A 引用 B 且 B 引用 A 会让编译顺序无法确定,Unity/编译器会拒绝。
|
|
356
|
+
|
|
357
|
+
**最小修复**
|
|
358
|
+
按依赖方向添加**单向**引用(例如 Gameplay → Core)。要打破环,就把双方共用的类型(接口、枚举、数据类、事件定义)下沉到一个更底层的第三个程序集,让双方都只引用它;或者用接口反转依赖。若两个程序集其实无法理清方向,说明它们本属同一层,合并即可——不要为了拆而拆。
|
|
359
|
+
|
|
360
|
+
**验证方式**
|
|
361
|
+
在 asmdef 的 Inspector 里逐项核对 `Assembly Definition References` 是否存在环;再切一次非 Editor 平台触发编译,确认编辑器专用引用已被平台设置排除。
|
|
362
|
+
|
|
363
|
+
**对应能力维度**:结构与质量
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
### P-19 只靠反射调用的代码在打包时被剥离
|
|
368
|
+
|
|
369
|
+
**症状**
|
|
370
|
+
编辑器里一切正常,IL2CPP / 正式构建后运行时报「找不到类型/方法」或行为缺失,且**不稳定**(有时又正常)。
|
|
371
|
+
|
|
372
|
+
**机制**
|
|
373
|
+
构建时托管代码剥离(managed stripping)会移除被判定为「未被引用」的代码。只通过反射、`Activator.CreateInstance`、字符串类型名等方式使用的类型,静态分析看不到引用关系,可能被误删。
|
|
374
|
+
|
|
375
|
+
**最小修复**
|
|
376
|
+
用 `link.xml` 显式声明需要保留的类型/程序集,或在代码中用 `[Preserve]` 标记。更根本的做法是减少对「字符串反射」的依赖,改成显式引用。
|
|
377
|
+
|
|
378
|
+
**验证方式**
|
|
379
|
+
用与发布相同的后端(IL2CPP)和剥离级别做一次完整构建并运行到相关功能处。只在编辑器里验证是无效的——这正是该陷阱最难发现的原因。
|
|
380
|
+
|
|
381
|
+
**对应能力维度**:结构与质量
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
## 附:按症状快速检索
|
|
386
|
+
|
|
387
|
+
| 学员说 | 先看 |
|
|
388
|
+
| --- | --- |
|
|
389
|
+
| 「Inspector 里改了没用」 | P-01、P-05 |
|
|
390
|
+
| 「退出 Play 后值变了 / 场景变脏」 | P-06、P-17 |
|
|
391
|
+
| 「概率性空引用」「换个顺序就好了」 | P-02 |
|
|
392
|
+
| 「状态卡住不恢复」 | P-03、P-04 |
|
|
393
|
+
| 「点一次触发多次」 | P-11、P-12 |
|
|
394
|
+
| 「画面抖动 / 帧率一变就不对」 | P-09 |
|
|
395
|
+
| 「回调根本不执行,也不报错」 | P-10 |
|
|
396
|
+
| 「卡顿 / GC 高」 | P-07、P-08 |
|
|
397
|
+
| 「编辑器好,打包就挂」 | P-16、P-19 |
|
|
398
|
+
| 「停止运行后还在打印」 | P-15 |
|
|
399
|
+
| 「子线程报 main thread 异常」 | P-13、P-14 |
|
|
400
|
+
| 「拆完 asmdef 一堆编译错」 | P-18 |
|