HarmonyOS ArkGraphics 2D 复杂文本排版实操:用 ParagraphBuilder 做资讯阅读卡片

中文标题、英文标签、数字、行内图片占位符和长文省略,都放进同一个 Paragraph。下面的 GIF 和截图来自 HUAWEI Mate 60 Pro 真机,页面可以直接切换三种排版场景。

这篇完成后得到一个"资讯阅读卡片"实验页:

  • "混排卡片"把中文、English、数字和图片占位符放入同一段文本,自动换行;
  • "居中海报"验证段落级居中、行距和字重;
  • "长文折行"把最大行数限制为 2,并通过 didExceedMaxLines() 判断是否触发省略;
  • 页面下方同步显示真实布局宽度、高度、行数、占位符数量、绘制次数和动画回调次数。

先看真机结果

混排卡片

首页向下滑到实验列表,点击"实验 23:TEXTFLOW 复杂文本排版工坊"。默认进入混排卡片,Canvas 中可以看到彩色标题、英文标签、中文说明和一个参与排版的 IMG 占位框。

居中海报

点击"居中海报",段落整体切换为居中对齐。A NEW LINE、中文标题和说明文字仍然来自同一个 Paragraph,只是 ParagraphStyle 的 align 改为 CENTER

长文折行

点击"长文折行",长摘要被限制为两行。画布左下角显示 LINES 2,右侧显示 MAX LINES / ELLIPSIS,页面数据区显示"已触发省略"。

本次要完成什么

本次只围绕一个可运行页面完成四件事:

  1. 在 ArkGraphics 2D Canvas 中创建 ParagraphBuilder
  2. pushStylepopStyle 组合不同字号、颜色和字重的文本片段。
  3. addPlaceholder 插入一个行内占位框,并通过 getRectsForPlaceholders() 取回它的真实位置。
  4. layoutSync()getLineCount()getHeight()didExceedMaxLines() 把布局结果显示出来。

页面不申请相机、相册、网络或存储权限,本次实验只使用 ArkUI、ArkGraphics 2D 和 @kit.PerformanceAnalysisKit 的 Hilog。

准备环境

  • DevEco Studio 工程,目标 API 26。
  • 一台已经连接的 HarmonyOS 真机。本次验证设备为 HUAWEI Mate 60 Pro,HarmonyOS 7.0。
  • 工程入口:entry/src/main/ets/features/graphics2d/ComplexTextLayoutPage.ets
  • 首页入口已经接入 Index.ets 的实验 23 状态分支。

官方参考:

实施过程

1. 创建 RenderNode 和 Canvas

页面使用 NodeContainer 承载自定义 RenderNode。每次绘制先清理背景,再绘制网格、圆形光晕、段落面板和指标标签,最后把 Paragraph 画到同一个 Canvas:

ArkTS/ets 复制代码
class ComplexTextRenderNode extends RenderNode {
  draw(context: DrawContext): void {
    const width: number = Math.max(1, Math.round(context.sizeInPixel.width));
    const height: number = Math.max(1, Math.round(context.sizeInPixel.height));
    const canvas: drawing.Canvas = context.canvas;
    canvas.clear(0xFF071321);

    const contentWidth: number = Math.max(520, Math.round(width * 0.82));
    const paragraph: text.Paragraph = this.buildParagraph(this.stateValue.modeIndex);
    paragraph.layoutSync(contentWidth);
    paragraph.paint(canvas, (width - contentWidth) / 2, height * 0.28);
  }
}

layoutSync() 必须在 paint() 前调用,否则拿不到当前宽度下的换行结果。这里的 contentWidth 使用 Canvas 宽度的 82%,所以手机横竖屏变化时,段落会重新排版。

2. 用 ParagraphStyle 设置段落级规则

三种场景共用一个 buildParagraph(),只改变段落对齐方式、最大行数和基础字号:

ArkTS/ets 复制代码
const paragraphStyle: text.ParagraphStyle = {
  textDirection: text.TextDirection.LTR,
  align: modeIndex === 1 ? text.TextAlign.CENTER : text.TextAlign.LEFT,
  wordBreak: text.WordBreak.BREAK_WORD,
  breakStrategy: text.BreakStrategy.BALANCED,
  maxLines: modeIndex === 2 ? 2 : 8,
  lineSpacing: modeIndex === 1 ? 12 : 8,
  textStyle: {
    color: this.makeColor(0xFFF2F7FF),
    fontSize: modeIndex === 1 ? 29 : 21,
    fontWeight: text.FontWeight.W400,
    locale: 'zh-Hans'
  }
};

const builder: text.ParagraphBuilder = new text.ParagraphBuilder(
  paragraphStyle, text.FontCollection.getGlobalInstance());

这里最容易漏掉的是 maxLines。第一次验证时三种场景都使用了较大的行数上限,长文没有超过限制,didExceedMaxLines() 一直为 false。将长文场景改为 maxLines: 2 后,真机数据变成"2 行 · 已触发省略"。

3. 在一个段落中混排多种样式

pushStyle() 后加入文本,加入完成后用 popStyle() 恢复上一层样式。中文标题、正文、英文标签和数字不需要拆成多个 Text 组件:

ArkTS/ets 复制代码
builder.pushStyle({
  color: this.makeColor(0xFF68F3D2),
  fontSize: 31,
  fontWeight: text.FontWeight.W700,
  letterSpacing: 1,
  locale: 'zh-Hans'
});
builder.addText('城市慢下来');
builder.popStyle();

builder.pushStyle({
  color: this.makeColor(0xFFFFB36C),
  fontSize: 18,
  fontWeight: text.FontWeight.W600,
  locale: 'zh-Hans'
});
builder.addText('FEATURE  /  01  ');
builder.popStyle();

builder.addText('把中文、English 和数字放进同一个段落,交给排版引擎自动换行。');

这就是 GIF 中默认"混排卡片"的来源:颜色变化来自 TextStyle,换行位置来自 Paragraph 的布局结果。

4. 插入行内图片占位符

资讯摘要通常会在文字中夹一张缩略图。这个实验先用占位框模拟图片,但占位框本身参与 Paragraph 布局:

ArkTS/ets 复制代码
builder.addPlaceholder({
  width: 58,
  height: 58,
  align: text.PlaceholderAlignment.CENTER_OF_ROW_BOX,
  baseline: text.TextBaseline.ALPHABETIC,
  baselineOffset: 0
});
builder.addText('图片位置也会一起参与布局。');

布局完成后读取占位框的矩形并绘制边框、圆点和 IMG 标签:

ArkTS/ets 复制代码
const placeholderBoxes: Array<text.TextBox> = paragraph.getRectsForPlaceholders();
placeholderBoxes.forEach((box: text.TextBox): void => {
  const left: number = x + box.rect.left;
  const top: number = y + box.rect.top;
  const right: number = x + box.rect.right;
  const bottom: number = y + box.rect.bottom;
  this.fillRoundRect(canvas, left, top, right, bottom, 11, 0xFF193E57);
  this.strokeRoundRect(canvas, left, top, right, bottom, 11, 0xFF64E7D3, 2);
});

真机上可以看到 IMG 框跟着文字一起换行,而不是通过固定坐标硬塞到段落上。

5. 读取真实的布局指标

排版数据区直接使用 Paragraph 返回值:

ArkTS/ets 复制代码
paragraph.layoutSync(contentWidth);
const paragraphWidth: number = paragraph.getMaxWidth();
const paragraphHeight: number = paragraph.getHeight();
const lineCount: number = paragraph.getLineCount();
const exceeded: boolean = paragraph.didExceedMaxLines();

真机在混排模式记录到 2 行、1 个占位符;切换长文折行后记录到 2 行、0 个占位符,并且 exceeded=true。这些字段同时显示在页面底部和 Hilog 中,截图和日志可以互相核对。

6. 加入暂停、恢复和退出释放

页面用 ArkUI Animator 驱动光标和进度条,排版本身每帧都会重新绘制。点击"暂停排版"后只改变 running,Animator 回调不再更新相位;点击"继续排版"后从原相位继续。退出时统一取消 Animator、释放 RenderNode:

ArkTS/ets 复制代码
private releaseAll(reason: string): void {
  if (this.released) {
    return;
  }
  this.released = true;
  this.running = false;
  this.animator?.cancel();
  hilog.info(DOMAIN, TAG,
    'TEXTFLOW_RELEASE reason=%{public}s drawCount=%{public}d callbacks=%{public}d',
    reason, this.controller.getMetrics().drawCount, this.frameCallbacks);
  this.controller.release();
  this.animator = undefined;
}

暂停与恢复的真机截图如下:

遇到的情况与处理

长文没有触发省略

第一次版本给所有模式设置了 8 行上限,长文在当前宽度下只有 2 行,因此没有超出。把长文模式的 maxLines 改为 2,再调用 didExceedMaxLines(),页面才会显示"已触发省略"。

占位符没有跟着文字移动

占位符如果用固定 Canvas 坐标绘制,文字换行后位置会错开。现在先让 Paragraph 完成布局,再用 getRectsForPlaceholders() 取回矩形,按照矩形绘制 IMG 框,换行和居中时都能保持一致。

退出后仍有绘制回调

退出路径最初只返回首页,没有取消 Animator。现在 exitPage()aboutToDisappear() 都调用同一个 releaseAll(),并用 released 防止重复释放。Hilog 中可以看到退出时的 TEXTFLOW_RELEASE

真机 Hilog

下面是本次 Mate 60 Pro 验证时保留下来的关键行。包名、进程号和时间戳已删除,只保留排版、切换、暂停、恢复和释放字段:

text 复制代码
TEXTFLOW_LAB_ENTER modes=mixed,center,ellipsis expectedFps=30 permissionRequired=false
TEXTFLOW_READY modes=3 durationMs=5600 expectedFps=30 permissionRequired=false
TEXTFLOW_RESIZE widthVp=425 heightVp=360
TEXTFLOW_DRAW mode=0 drawCount=30 width=1174 height=995 lines=2 paragraphWidth=1116 paragraphHeight=101 placeholders=1 exceeded=false
TEXTFLOW_MODE mode=1 name=居中海报
TEXTFLOW_MODE mode=2 name=长文折行
TEXTFLOW_DRAW mode=2 drawCount=3750 width=1174 height=995 lines=2 paragraphWidth=963 paragraphHeight=76 placeholders=0 exceeded=true
TEXTFLOW_PAUSE mode=2 phasePermille=530 drawCount=1766 callbacks=1764
TEXTFLOW_RESUME mode=2 phasePermille=530 drawCount=1766 callbacks=1764
TEXTFLOW_RELEASE reason=EXIT_BUTTON drawCount=6116 callbacks=6114

暂停和恢复的 phasePermille 相同,说明恢复时沿用原来的动画相位;退出时记录了释放原因和最终绘制计数。

验证结果

验证项 真机结果
混排卡片 中文、English、数字和占位符在同一 Paragraph 中自动换行
居中海报 TextAlign.CENTER 生效,标题和说明整体居中
长文折行 2 行上限生效,didExceedMaxLines() 返回 true
占位符 getRectsForPlaceholders() 返回 1 个矩形,IMG 框随段落布局
动画控制 暂停后状态保持,恢复后继续,退出时 Animator 和 RenderNode 释放
权限 页面不申请相机、相册、网络或存储权限

退出后首页显示"TEXTFLOW 已退出,Paragraph 与 RenderNode 已释放"。

本次文件

text 复制代码
entry/src/main/ets/features/graphics2d/ComplexTextLayoutPage.ets
entry/src/main/ets/pages/Index.ets
scripts/check-complex-text-layout.ps1
articles/第二十一篇-ArkGraphics 2D复杂文本排版实战/
docs/qa/textflow-21/动态原始帧/
articles/第二十一篇-ArkGraphics 2D复杂文本排版实战/真机验证日志.txt

动态 GIF 使用真机连续抓取的 32 帧制作,帧率 8 FPS、画布裁剪为 720×610;静态图保留了完整手机界面,便于核对实验入口、模式按钮和实时数据。

相关推荐
大雷神1 小时前
HarmonyOS ArkGraphics 2D NativeImage 实操:获取 NativeWindow 与 SurfaceId
华为·harmonyos
小玮看世界1 小时前
当“教用户配置系统“成为产品的遮羞布:从鸿蒙拦截栈与小红书联系机制看科技公司的“驯化式创新“
科技·华为·harmonyos
梦想不只是梦与想2 小时前
鸿蒙 应用类型:企业应用
华为·harmonyos·企业应用
无限码力2 小时前
华为非AI方向笔试真题【工厂落点最小加权路程】
算法·华为·华为非ai方向笔试真题·华为笔试真题·华为最新笔试真题·华为笔试题库
大雷神2 小时前
HarmonyOS ArkGraphics 2D 文本绘制实操:用 TextBlob 做动态文字排版
华为·harmonyos
搬砖的kk2 小时前
从 0 到 1:用 KMP + Compose Multiplatform + Ktor 实现鸿蒙「历史上的今天」应用
开源·harmonyos
星辰徐哥2 小时前
鸿蒙PC平台 Gnote 笔记应用适配实战:从 Linux 到 鸿蒙PC 的 Electron 迁移
linux·笔记·electron·harmonyos·gnote
Swift社区3 小时前
鸿蒙 App 如何设计 Memory Center?一文讲透 Agent 的长期记忆架构
华为·架构·harmonyos
binbin_523 小时前
HarmonyOS 应用功耗优化实战:定位耗电、收口任务与验证回归
android·回归·harmonyos