外观
Lesson 043|Label 为什么通常比 Sprite 更复杂?
Tags: #Creator2.x #Stage04 #Label #Font #UIDifficulty: ⭐⭐⭐☆☆
真实问题:一个倒计时为什么能拖慢整页 UI
活动页只有一个倒计时 Label 每帧更新:
ts
label.string = remain.toFixed(2);但文本变化可能触发:
text
字符串解析
→ 查找/生成字形
→ 排版与换行
→ 更新节点尺寸
→ Layout/Widget 重排
→ 重建顶点和 UV
→ 字形纹理或图集更新Sprite 通常已有固定四边形和 SpriteFrame;Label 的几何与纹理来源却由文字内容动态决定。
🎯 本课目标
同样是屏幕上的一块矩形,Label 往往比 Sprite 更容易产生 CPU、内存和更新成本。本课解释原因,并建立 UI 文本优化的判断方法。
二、Sprite 和 Label 的输入不同
Sprite 的输入大多是固定资源:
text
纹理区域
尺寸
颜色Label 的输入可能是动态字符串:
text
文本内容
字体
字号
颜色
对齐
行距
换行
溢出策略文本变化往往需要重新测量、排版和生成字形几何。
三、Label 的高层流程
text
label.string 改变
↓
文本测量
↓
换行、对齐和布局
↓
查找或生成字形纹理
↓
生成字符顶点和 UV
↓
更新 RenderData
↓
进入 Renderer因此逐字修改 Label 可能比移动一个 Sprite 贵得多。
四、字体资源和字形缓存
字体系统可能使用:
- 系统字体。
- 位图字体。
- BMFont 图集。
- 动态字体。
- 字形缓存纹理。
动态文本遇到新字符时,可能需要把字形绘制或加入缓存。首次显示大量新字符时容易出现尖峰。
五、为什么每帧更新 string 有风险
ts
update(dt: number) {
this.label.string = String(Math.floor(this.time));
}即使显示内容没有变化,也可能触发:
text
字符串创建
文本比较
布局检查
字形或顶点更新更好的方式是只在显示值变化时更新:
ts
const next = String(Math.floor(this.time));
if (next !== this.label.string) {
this.label.string = next;
}还可以把刷新频率从每帧降低到每秒或每 0.1 秒。
六、Label 的布局成本
多行文本可能涉及:
text
字符宽度测量
换行计算
对齐
Content Size
父级 Layout
Widget 重新适配如果 Label 位于 Layout 和 Widget 链中,修改一个字符串可能导致父子布局级联刷新。
七、文本优化方法
- 避免每帧无条件赋值
string。 - 低频数据使用定时刷新。
- 固定文本优先使用位图字体或预生成资源。
- 统一字体和字号,减少字形缓存碎片。
- 列表只更新可见 Item。
- 避免在复杂 Layout 中频繁改变大段文本。
- 对富文本、描边和阴影进行单独评估。
八、案例:倒计时 Label
错误写法:
ts
update(dt: number) {
this.left -= dt;
this.label.string = this.left.toFixed(2);
}如果用户只需要看到整数秒:
ts
update(dt: number) {
this.left -= dt;
const next = Math.max(0, Math.ceil(this.left));
if (next !== this.lastShown) {
this.lastShown = next;
this.label.string = String(next);
}
}逻辑时间仍每帧更新,但文本表现按需要刷新。
九、常见误区
误区一:Label 只是另一种 Sprite
它可能涉及文本测量、排版、字形缓存和动态几何。
误区二:字符串内容没变,重复赋值也没成本
重复赋值可能触发检查和更新,应避免无意义写入。
误区三:所有文本都应该每帧刷新
显示频率应匹配用户可感知的变化频率。
误区四:字体资源只影响包体,不影响运行时
字形生成、缓存和纹理更新都会影响 CPU、内存和 GPU。
十、深入推导:文本成本由哪些变量决定
先把一行字符串变成像素的过程完整展开:
text
JavaScript string
→ 按字体、字号、行高和宽度约束测量
→ 决定换行和每个字符位置
→ 为每个字符寻找对应字形
→ 字形不存在时生成或加入缓存
→ 得到字形所在纹理区域
→ 为字符生成顶点、UV、颜色和索引
→ 组织字体纹理与材质批次
→ Fragment Shader 采样字形SpriteFrame 通常已经知道固定纹理矩形;Label 的纹理区域和几何却取决于字符串内容,这就是两者复杂度差异的根源。
“字符串长度没变”为什么仍可能重排
1111 换成 8888 时字符数相同,但比例字体中不同数字的字形宽度可能不同;中文、英文、Emoji 和富文本图片的度量更不一样。
因此是否重排取决于:
text
字体是否等宽;
字形 advance 是否相同;
是否自动换行;
Label 宽度是否固定;
Overflow 模式;
父 Layout 是否读取尺寸。仅比较 string.length 不能证明布局没有变化。
字符数量
更多字符通常意味着更多字形四边形、顶点和排版工作。
字符集合
只变化数字 0~9 与不断出现新中文字符,对字形缓存压力不同。
排版约束
自动换行、Overflow、行高、对齐、节点尺寸变化会影响布局计算。
缓存模式
不同 Label Cache Mode 在生成速度、纹理占用、动态文字和合批之间取舍。具体枚举和适用字体类型以 Creator 2.4.x 当前版本为准。
更新频率
一秒一次与每帧一次的视觉差距可能很小,CPU 工作量却相差几十倍。
十一、Creator 2.4.x 实验:固定显示与高频文本
场景
text
Canvas
├── StaticLabels(100 个固定文本)
├── DynamicLabels(100 个数字)
└── Control所有 Label 使用相同字体、字号和材质,先避免无关变量。
LabelCostProbe.ts
ts
const { ccclass, property } = cc._decorator;
@ccclass
export default class LabelCostProbe extends cc.Component {
@property([cc.Label])
labels: cc.Label[] = [];
private mode = 0;
private elapsed = 0;
private lastSecond = -1;
setMode(value: number) {
this.mode = value;
}
update(dt: number) {
this.elapsed += dt;
if (this.mode === 1) {
this.updateAll(this.elapsed.toFixed(2));
} else if (this.mode === 2) {
const second = Math.floor(this.elapsed);
if (second !== this.lastSecond) {
this.lastSecond = second;
this.updateAll(String(second));
}
}
}
private updateAll(value: string) {
for (let i = 0; i < this.labels.length; i++) {
const next = value + '-' + i;
if (this.labels[i].string !== next) {
this.labels[i].string = next;
}
}
}
}对照
- mode 0:完全静态。
- mode 1:每帧更新。
- mode 2:每秒更新。
- 固定 Label 节点宽高后再对比 Layout。
- 分别测试只含数字与不断引入新字符。
- 在相同环境比较 Creator 提供的 Cache Mode。
记录 Frame Time、DrawCall、节点/布局表现和纹理变化。不要只记录 FPS 整数。
十二、如何避免无意义写入
优化前先分清三个频率:
text
业务模拟频率:数值多久计算一次
显示值变化频率:用户真正能看到的新值多久出现一次
Label 刷新频率:代码多久给 string 赋值一次例如倒计时内部可以每帧累积 dt,但界面只显示整数秒:
text
内部剩余时间:9.93、9.91、9.90……
显示字符串:9这一秒内重复写入 "9" 没有视觉价值。缓存上一次显示值,只在跨秒时更新,能直接减少文本下游工作。
还要避免用“所有 Label 都缓存”为统一答案。缓存模式会在更新成本、字形纹理、内存、画质和合批之间取舍;静态标题、聊天长文本和高频数字的最佳策略可能不同。
即使值没变化,这段代码也每帧赋值:
ts
this.label.string = this.score.toString();使用值变化驱动:
ts
private shownScore = -1;
refreshScore(score: number) {
if (score === this.shownScore) {
return;
}
this.shownScore = score;
this.label.string = String(score);
}倒计时若只显示整数秒,也只需跨秒时更新。
十三、真实项目故障:聊天频道越聊越卡
这个故障不能只归因于“Label 太多”。它通常是多条增长曲线叠加:
text
历史消息越来越多
→ Node / Label / RichText 数量增长
新字符和 Emoji 持续出现
→ 字形或图片资源集合增长
Content 越来越高
→ Layout 与滚动边界计算范围增长
所有历史节点常驻
→ 即使屏幕只显示十几条,CPU 管理对象仍不断增加虚拟列表的关键不是把数据删掉,而是把“历史消息数据量”和“同时存在的渲染节点量”解耦:
text
上千条消息数据
→ 只为可见区域及缓冲区保留少量 Item
→ 滚动时复用 Item 并替换显示数据这样同时缩小 Node、Label、Layout 和渲染收集范围。
现象
聊天列表保留上千条 Label,包含大量不同中文与表情。滚动一段时间后内存和卡顿上升。
根因组合
text
无限保留历史节点
→ 大量 Label 与顶点
→ 新字符持续进入字形缓存
→ ScrollView/Layout 全量参与
→ 表情资源与文本混排增加批次修复
- 虚拟化可见消息;
- 历史数据与渲染节点分离;
- 限制富文本和表情组合;
- 选择与内容特征匹配的字体/缓存方案;
- 减少宽度变化引发的重复排版;
- 在真机记录字形缓存、节点和帧时间。
十四、Label 故障诊断
文本更新导致整页重排
检查 Label Overflow、节点尺寸是否自适应、父 Layout 和 Widget。固定数字栏尺寸常比优化 shader 更直接。
首次出现某字符卡一下
检查动态字体字形生成/上传、Cache Mode 和字符预热。预热也有启动成本,不能无上限预热所有字符。
DrawCall 增加
检查字体纹理、材质、缓存页、富文本图片和渲染顺序。Label 数量不是唯一条件。
十五、练习、推导答案与版本边界
Label 的缓存模式、字形图集和内部 Assembler 属于 Creator 2.4.x 具体实现。实验应固定引擎版本、字体资源和文本集合。
- 为什么数字倒计时不应每帧更新整数显示? 一秒内显示值不变,重复赋值只制造排版/几何检查。
- 固定 Label 宽度如何减少连锁? 文本宽度变化不再推动父 Layout 重排,影响范围更小。
- Cache Mode 是否有一个永远最优选项? 没有。静态/动态、字符集合、内存和合批需求不同,需要同场景测量。
- Label 卡顿为什么不能只看 DrawCall? 字符串、字形、排版、布局和顶点重建主要可能发生在 CPU,DrawCall 不一定变化。
能力验收
你应该能把一次 Label.string 修改拆成字符串、排版、字形缓存、几何、布局和批次六类可能成本,并用每帧赋值、值变化时赋值、固定宽度三组实验找到真正的刷新边界。
本课总结
text
Sprite 主要消费固定纹理区域。
Label 还要处理文本测量、排版和字形缓存。
动态文本更新可能带来 CPU、RenderData 和纹理成本。
文本逻辑频率和显示刷新频率可以分离。十一、下一课预告
text
Lesson044|Material、Effect、Technique、Pass、Shader 的关系