《HarmonyOS技术精讲-ArkGraphics 2D(方舟2D图形服务)》第三篇:文字的智慧——文本渲染与排版实战
《HarmonyOS技术精讲-ArkGraphics 2D(方舟2D图形服务)》第三篇:文字的智慧——文本渲染与排版实战

开篇:文本渲染的常见误区
HarmonyOS NEXT 开发里,文字在 Canvas 上渲染这件事,比想象中要复杂一点。很多人直接用 fillText 把字符串丢上去,发现位置不对、字体不生效、长文本截断了,还得自己写换行逻辑。官方示例虽然能运行,但放到实际项目里,排版、对齐、省略号这些细节,都需要额外处理。
这篇文章就从基础 API 开始,一步步实现一个支持自动换行、溢出省略号的自定义文本框组件。这个能力在游戏 HUD、数据面板、自定义弹窗等场景里很常用。
它解决什么问题
Canvas 本身的 fillText 和 strokeText 只能单行渲染。如果需要多行、对齐、省略号,或者要精确计算文本占用的像素宽度,就必须自己实现排版逻辑。
适合场景:
- 游戏内 UI(血量、得分、对话气泡)
- 自定义数据面板(图表标注、KPI 卡片)
- 富文本编辑器(特殊排版需求)
- 动画文字(文字逐个出现、交叉淡化)
不适合场景:
- 普通列表文本 → 用
Text组件,性能更好 - 复杂的富文本(涉及样式混排)→ 考虑
RichText或Span
与 Text 组件的对比:
| 能力 | Canvas 渲染 | Text 组件 |
|---|---|---|
| 自动换行 | 需要手动实现 | 支持 |
| 省略号 | 需要手动实现 | 支持 |
| 精确位置控制 | 精确到像素 | 依赖布局 |
| 动画集成 | 直接配合 Canvas 动画 | 需要额外处理 |
| 性能(大量文本) | 中 | 高 |
如果需要精确排版(比如文字和图形严格对齐),建议用 Canvas。如果只是展示内容,优先用 Text 组件。
环境说明
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机
核心实现
1. 基础文字渲染:fillText 与 strokeText
这一段代码演示最基础的文字渲染,包括字体属性设置。
@Entry
@Component
struct BasicTextDemo {
private canvasCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D();
build() {
Stack() {
Canvas(this.canvasCtx)
.width('100%')
.height('100%')
.onReady(() => {
this.renderText();
});
}
.width('100%')
.height('100%')
}
renderText() {
const ctx = this.canvasCtx;
ctx.clearRect(0, 0, ctx.width, ctx.height);
// 设置字体:大小 字体样式 字体族
ctx.font = '24px bold sans-serif';
ctx.fillStyle = '#FF0000';
// fillText 绘制实心文字
ctx.fillText('Hello HarmonyOS', 50, 100);
// strokeText 绘制空心文字
ctx.font = '20px italic serif';
ctx.strokeStyle = '#00FF00';
ctx.lineWidth = 2;
ctx.strokeText('文字描边效果', 50, 200);
}
}
注意事项:
font属性的格式是[font-style] [font-weight] [font-size]/[line-height] [font-family]font-size和font-family是必填的,其他可选fillText和strokeText前四项参数相同:文本内容、x 坐标、y 坐标,可选 maxWidth
2. 对齐与基线控制:textAlign 和 textBaseline
很多人刚开始用的时候,发现文字位置总和自己想的不一样,问题就出在基线和对齐没理解透。
// textAlign: left | center | right | start | end
// textBaseline: top | hanging | middle | alphabetic | ideographic | bottom
renderAlignmentDemo() {
const ctx = this.canvasCtx;
ctx.clearRect(0, 0, ctx.width, ctx.height);
// 画一条参考线
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(50, 150);
ctx.lineTo(350, 150);
ctx.stroke();
ctx.font = '18px sans-serif';
ctx.fillStyle = '#333';
// 不同 textAlign 演示
ctx.textAlign = 'left';
ctx.fillText('left对齐', 200, 50);
ctx.textAlign = 'center';
ctx.fillText('center对齐', 200, 100);
ctx.textAlign = 'right';
ctx.fillText('right对齐', 200, 150);
// 不同 textBaseline 演示
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText('top基线', 50, 150);
ctx.textBaseline = 'middle';
ctx.fillText('middle基线', 150, 150);
ctx.textBaseline = 'bottom';
ctx.fillText('bottom基线', 250, 150);
}
实际开发建议:
- 如果文字要和矩形严格对齐,建议
textBaseline用top或bottom - 如果只是垂直居中,用
middle配合textAlign: 'center'更方便 alphabetic是默认值,和中文排版不太适配,中文场景建议用top或middle
3. 文本测量:TextMeasurer 精确计算宽度
要自己实现换行,必须先知道每个字符或每段文字的像素宽度。这时候就需要 TextMeasurer 了。
// 使用 TextMeasurer 测量文字宽度
measureTextWidth(text: string, font: string): number {
const measurer = new TextMeasurer();
measurer.font = font;
const result = measurer.measureText(text);
measurer.destroy(); // 注意:用完后必须销毁
return result.width;
}
这段代码的关键点:
TextMeasurer 和 CanvasRenderingContext2D 的 measureText 功能类似,但 TextMeasurer 是独立对象,不依赖 Canvas 实例。这意味着可以在任意位置测量文字宽度,然后再决定是否渲染。
实际测量结果和 Canvas 渲染结果一致,因为底层用的是相同的文本引擎。
重要注意事项:
TextMeasurer对象用完后必须调用destroy(),否则会有内存泄漏- 每次测量都创建新对象性能较差,建议复用
- 不要频繁在
build()中创建TextMeasurer,会触发不必要的组件重建
4. 自定义文本框组件:支持换行与省略号
这是本文的核心实现。一个完整的 CustomTextBox 组件,支持设置宽度、行数、省略号。
// CustomTextBox.ets
@Component
export struct CustomTextBox {
// 输入参数
private text: string = '';
private maxWidth: number = 200;
private maxLines: number = 3;
private ellipsis: string = '...';
private fontSize: number = 16;
private fontFamily: string = 'sans-serif';
private textColor: string = '#333';
private lineSpacing: number = 4;
// Canvas 上下文
private canvasCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D();
// 缓存测量结果
@State private lines: string[] = [];
@State private actualLines: number = 0;
build() {
Canvas(this.canvasCtx)
.width(this.maxWidth)
.height(this.calcHeight())
.onReady(() => {
this.layoutText();
this.renderTextContent();
});
}
calcHeight(): number {
const lineHeight = this.fontSize + this.lineSpacing;
return lineHeight * this.maxLines;
}
layoutText() {
const ctx = this.canvasCtx;
ctx.font = `${this.fontSize}px ${this.fontFamily}`;
const measurer = new TextMeasurer();
measurer.font = ctx.font;
const words = this.text.split('');
let currentLine = '';
let resultLines: string[] = [];
let lineCount = 0;
for (let i = 0; i < words.length; i++) {
const testLine = currentLine + words[i];
const testWidth = measurer.measureText(testLine).width;
// 如果超过最大宽度,需要换行
if (testWidth > this.maxWidth) {
resultLines.push(currentLine);
lineCount++;
// 如果已经达到最大行数,停止处理
if (lineCount >= this.maxLines) {
break;
}
currentLine = words[i];
} else {
currentLine = testLine;
}
}
// 最后一行处理
if (currentLine.length > 0 && lineCount < this.maxLines) {
resultLines.push(currentLine);
lineCount++;
}
// 如果内容被截断,最后一行加省略号
if (lineCount >= this.maxLines && currentLine.length > 0) {
// 检查最后一行是否需要加省略号
const lastLine = resultLines[resultLines.length - 1];
const ellipsisWidth = measurer.measureText(this.ellipsis).width;
// 如果最后一行超出宽度,先去掉一个字符再加省略号
let truncatedLine = lastLine;
while (measurer.measureText(truncatedLine + this.ellipsis).width > this.maxWidth) {
truncatedLine = truncatedLine.slice(0, -1);
if (truncatedLine.length <= 0) break;
}
resultLines[resultLines.length - 1] = truncatedLine + this.ellipsis;
}
measurer.destroy();
this.lines = resultLines;
this.actualLines = lineCount;
}
renderTextContent() {
const ctx = this.canvasCtx;
ctx.clearRect(0, 0, ctx.width, ctx.height);
ctx.font = `${this.fontSize}px ${this.fontFamily}`;
ctx.fillStyle = this.textColor;
ctx.textBaseline = 'top';
ctx.textAlign = 'left';
const lineHeight = this.fontSize + this.lineSpacing;
for (let i = 0; i < this.lines.length; i++) {
const y = i * lineHeight;
ctx.fillText(this.lines[i], 0, y);
}
}
}
这段代码的工作流程:
layoutText()负责把长文本按宽度切分成多行- 如果内容超过
maxLines,最后一行加省略号 - 加省略号时,需要保证总宽度不超过
maxWidth renderTextContent()负责实际绘制
使用方式:
@Entry
@Component
struct Index {
build() {
Scroll() {
Column({ space: 20 }) {
Text('单行文字(无溢出):').fontSize(14).fontColor('#666')
CustomTextBox({
text: '这是一行短文本',
maxWidth: 200,
maxLines: 1
})
Text('多行文字(有溢出):').fontSize(14).fontColor('#666')
CustomTextBox({
text: '这是一段比较长的文本,用来测试自动换行和省略号功能,确保多行排版正确。',
maxWidth: 200,
maxLines: 2
})
Text('三行文字(内容较短):').fontSize(14).fontColor('#666')
CustomTextBox({
text: '第一行\n第二行\n第三行',
maxWidth: 200,
maxLines: 3
})
}
.padding(20)
}
}
}
踩坑记录
坑1:TextMeasurer 的 destroy 问题
现象: 频繁使用 TextMeasurer 后,应用内存持续增长,页面滑动变卡。
原因: TextMeasurer 对象在创建时会分配图形引擎资源,如果只创建不销毁,这些资源不会被自动回收。官方文档提到了 destroy() 方法,但没有强调必须调用。
解决方案:
- 每个
TextMeasurer对象用完后立即调用destroy() - 在组件销毁的生命周期中检查是否有未释放的
TextMeasurer实例 - 如果测量操作非常频繁(比如每帧都测),考虑缓存测量结果,而不是每次都创建新对象
aboutToDisappear() {
// 确保测量器被销毁
if (this.measurer) {
this.measurer.destroy();
this.measurer = null;
}
}
坑2:省略号位置不对
现象: 加上省略号后,最后一行的文字位置比预期靠右,换行时省略号出现在不该出现的地方。
原因: 省略号的计算依赖于当前字体和字号,但加省略号时的 font 设置可能没有和前面保持一致。此外,省略号宽度是 ... 三个点的宽度,不同字体下差异很大。
解决方案:
- 测量省略号和测量文本用同一个
TextMeasurer实例 - 确保
measurer.font和渲染时的font完全一致 - 加省略号时,从后往前逐个字符删除,直到
文字 + 省略号的总宽度不超过maxWidth
坑3:混合换行符处理
现象: 文本中含有 \n 换行符时,组件没有正确处理,导致和自动换行策略冲突。
原因: split('') 把字符串拆成字符数组后,\n 也被当成一个字符。当 \n 出现在行首或行尾时,会导致空行或排版错乱。
解决方案:
- 在
split之前先替换\n为空格或特殊标记 - 在
layoutText函数中主动处理换行符:遇到\n时强制换行,不计入长度判断
// 替换换行符为空格,让自动换行处理
const cleanText = this.text.replace(/\n/g, ' ');
const words = cleanText.split('');
最佳实践
-
不要在 build() 中调用 layoutText():
build()每次状态变化都会执行,频繁创建TextMeasurer和测量文本会导致性能问题。建议在组件初始化或文本属性变化时只执行一次。 -
复用 TextMeasurer 实例:如果需要在短时间内多次测量,创建一个实例后反复使用,而不是每次
new一个。测量完后统一destroy()。 -
文字宽度精确到像素:中文字符的宽度和英文字符不同,不同字体下宽度也不同。不要用
字符数 * 固定宽度来估算,必须通过measureText精确计算。 -
Canvas 的 font 属性和 TextMeasurer 的 font 属性必须完全一致:字号、字体族、粗细、斜体等任何差异都会导致测量和渲染结果不一致。
Demo 入口
// pages/Index.ets
import { CustomTextBox } from '../components/CustomTextBox';
@Entry
@Component
struct Index {
build() {
Scroll() {
Column({ space: 16 }) {
Text('Canvas 文本渲染实战').fontSize(24).fontWeight(FontWeight.Bold)
Divider().height(1).color('#eee')
Text('1. 基础文字渲染').fontSize(18).fontWeight(FontWeight.Bold)
// 基础渲染 demo 略,参考 BasicTextDemo 组件
Divider().height(1).color('#eee')
Text('2. 自定义文本框组件').fontSize(18).fontWeight(FontWeight.Bold)
CustomTextBox({
text: '这是一段用于演示自动换行和省略号功能的文本内容,测试多行排版效果。',
maxWidth: 280,
maxLines: 3,
fontSize: 16
})
}
.padding(20)
.alignItems(HorizontalAlign.Start)
}
}
}
示例代码地址:项目地址
FAQ
Q:为什么真机上文字位置和模拟器不一致?
A:不同设备的屏幕密度和字体渲染引擎略有差异。TextMeasurer 的测量结果在不同设备上可能会有 1-2 像素的偏差。建议以真机为准,或在布局时留出一定的边距余量。
Q:为什么页面返回后,文字重新渲染了但位置变乱了?
A:检查 Canvas 的 onReady 回调。页面返回时 onReady 会重新触发,但之前的测量结果可能已经过期。建议在 aboutToAppear 中做重置,确保每次进入页面都重新测量和渲染。
Q:为什么我设置了 font,但文字依然使用系统默认字体?
A:font 属性的格式必须严格按照 [font-style] [font-weight] [font-size]/[line-height] [font-family] 顺序填写。常见错误是只写了字号和字体族,但中间缺少空格或顺序颠倒。另外,部分自定义字体在 Canvas 中可能不会立即生效,请确认字体文件已正确加载。
更多推荐


所有评论(0)