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

在这里插入图片描述

开篇:文本渲染的常见误区

HarmonyOS NEXT 开发里,文字在 Canvas 上渲染这件事,比想象中要复杂一点。很多人直接用 fillText 把字符串丢上去,发现位置不对、字体不生效、长文本截断了,还得自己写换行逻辑。官方示例虽然能运行,但放到实际项目里,排版、对齐、省略号这些细节,都需要额外处理。

这篇文章就从基础 API 开始,一步步实现一个支持自动换行、溢出省略号的自定义文本框组件。这个能力在游戏 HUD、数据面板、自定义弹窗等场景里很常用。

它解决什么问题

Canvas 本身的 fillTextstrokeText 只能单行渲染。如果需要多行、对齐、省略号,或者要精确计算文本占用的像素宽度,就必须自己实现排版逻辑。

适合场景:

  • 游戏内 UI(血量、得分、对话气泡)
  • 自定义数据面板(图表标注、KPI 卡片)
  • 富文本编辑器(特殊排版需求)
  • 动画文字(文字逐个出现、交叉淡化)

不适合场景:

  • 普通列表文本 → 用 Text 组件,性能更好
  • 复杂的富文本(涉及样式混排)→ 考虑 RichTextSpan

与 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-sizefont-family 是必填的,其他可选
  • fillTextstrokeText 前四项参数相同:文本内容、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);
}

实际开发建议:

  • 如果文字要和矩形严格对齐,建议 textBaselinetopbottom
  • 如果只是垂直居中,用 middle 配合 textAlign: 'center' 更方便
  • alphabetic 是默认值,和中文排版不太适配,中文场景建议用 topmiddle

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;
}

这段代码的关键点:

TextMeasurerCanvasRenderingContext2DmeasureText 功能类似,但 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);
    }
  }
}

这段代码的工作流程:

  1. layoutText() 负责把长文本按宽度切分成多行
  2. 如果内容超过 maxLines,最后一行加省略号
  3. 加省略号时,需要保证总宽度不超过 maxWidth
  4. 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('');

最佳实践

  1. 不要在 build() 中调用 layoutText()build() 每次状态变化都会执行,频繁创建 TextMeasurer 和测量文本会导致性能问题。建议在组件初始化或文本属性变化时只执行一次。

  2. 复用 TextMeasurer 实例:如果需要在短时间内多次测量,创建一个实例后反复使用,而不是每次 new 一个。测量完后统一 destroy()

  3. 文字宽度精确到像素:中文字符的宽度和英文字符不同,不同字体下宽度也不同。不要用 字符数 * 固定宽度 来估算,必须通过 measureText 精确计算。

  4. 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 中可能不会立即生效,请确认字体文件已正确加载。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐