中文标题、英文标签、数字、行内图片占位符和长文省略,都放进同一个 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,页面数据区显示"已触发省略"。
本次要完成什么
本次只围绕一个可运行页面完成四件事:
- 在 ArkGraphics 2D Canvas 中创建
ParagraphBuilder。 - 用
pushStyle、popStyle组合不同字号、颜色和字重的文本片段。 - 用
addPlaceholder插入一个行内占位框,并通过getRectsForPlaceholders()取回它的真实位置。 - 用
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;静态图保留了完整手机界面,便于核对实验入口、模式按钮和实时数据。