private IEnumerator TypeText(TextMeshProUGUI text) { text.text = ""; foreach (char c in curDialogues.dialogues[curDialogueNum].dialogueContent) { text.text += c; yield return new WaitForSeconds(dialogueDelay); } continueButton.SetActive(true); }打字机逐字显示对话内容,我使用了协程,在这里有几个问题:
1.每多显示一个字的时候,字符串都要更新一次,c#的string是不可变的,text+=c的时候不是简单追加,而是把旧的整个字符串拷贝过去再加上这个字符。
2.每次给text赋值的时候,tmp都要重新解析文本重新进行排版,计算换行和字的位置。
3.new了很多对象,每个字符都new了一个WaitForSeconds,全变成垃圾等GC回收。
三个开销,全都在主线程上,而 GC 在 Unity 里是主线程执行的——一旦触发,那一帧就要停下来回收。60 帧的预算是 16.7 毫秒,一次 GC 就可能吃掉几毫秒。
而对于这三个问题的解决:
1.改为一次性text.text=content,字符串只构造一次,不再产生新的string。
2.用maxVisibleCharacter控制显示进度,文本内容不变,tmp不需要重新解析和排版。
3.循环外new一次WaitForSeconds,循环内复用。
private IEnumerator TypeText(TextMeshProUGUI text) { string content = curDialogues.dialogues[curDialogueNum].dialogueContent; text.text = content; //问题1,字符串只构造一次 text.maxVisibleCharacters = 0; //问题2,字符全部不显示 text.ForceMeshUpdate(); //问题2,主动排一次版 int total = text.textInfo.characterCount; //问题2,按可见字符数算 var wait = new WaitForSeconds(dialogueDelay); //问题3,全程只分配一次 for (int i = 1; i <= total; i++) { text.maxVisibleCharacters = i; //问题2,每次增加一个字显示效果 yield return wait; //复用,不再new } continueButton.SetActive(true); }关于string:string是不可变的
例:string s = "abc";
s += "d";
看似是追加,但实际上是s指向了一个全新的字符串对象,abc那个对象还在内存中,只是没人用了等待被回收。
所做的事:
1.分配一个长度为4的新char[]
2.把abc三个字符拷贝过去
3.把d放进第四位
原因:
1.线程安全,字符串可以被任意多线程共享
2.相同的字符串只存一份,节省内存
3. 哈希值可缓存——Dictionary 的 key 大量用 string,如果可变,哈希就失效了
4.避免别名问题
TMP的原理:
1.旧版text的原理从字体图集里取字,但那张图是普通位图,所以才会放大就很糊。
TMP 用的是 SDF 字体图集:每个字符烘焙到图集的一块矩形区域,存的不是颜色,而是每个像素到字形边缘的距离。渲染时由 shader 用这个距离算出边缘和透明度,所以任意字号都清晰,还能做描边和发光。
2.tmp拿到字符串后,会先解析,例如,<color=red>你好</color>,再按每个字去图集里找到对应的uv区域,再按字宽,字据,对齐规则排版,算出每个字的位置和换行。
3.将排版存进textInfo,
characterInfo[]——每个字的位置、大小、UV、可见性
lineInfo[]——每一行的起止和基线
meshInfo[]——顶点和三角形
然后每个字符生成一个四边形,uv指向图集里字符的矩形(4个顶点,2个三角形),最后交给Canvas渲染,材质用SDF shader使边缘平滑。
因此,修改 text 会触发重新解析、重新排版、重建 mesh 和 Canvas 重建,所以相对较贵;而修改 maxVisibleCharacters 只影响字符可见性,不触发重排,所以适合做打字机效果。
WaitForSeconds 为什么可以复用
new WaitForSeconds(t) 是在堆里分配一个对象,里面只存着要等待多少秒。
协程 yield return 它的时候,调度器读出这个值,算出什么时候恢复协程——这个对象本身不保存“这一次等待”的任何状态,它只是一张说明书。
所以它可以被反复 yield。
但前提是每次等待时长固定。如果时长会变,就不能共用。
这些问题都指向同一个:GC
分配 → 变成垃圾 → 堆增长到阈值 → 触发 GC → 主线程停下来回收 → 帧时间尖峰
GC 为什么会“卡”?
Unity 默认用的是 Boehm GC:非分代、stop-the-world。
意思是:GC 一开始,整个游戏逻辑都得停,等它标记、清除、压缩完内存才能继续。
一次 GC 可能几毫秒。而 60 帧的预算是 16.7 毫秒——一次 GC 就能吃掉小半帧。
所以性能优化需要减少分配。