HarmonyOS 7 长文本性能实验:PARAGRAPH_CACHE 的对象身份陷阱与流式追加
HarmonyOS 7 长文本性能实验:PARAGRAPH_CACHE 的对象身份陷阱与流式追加
做一个日志查看页,每收到一段新内容,就把全部文本拼起来,再 new 一个 MutableStyledString 交给 Text。短日志时看不出差别,几百段之后,追加文字开始影响滚动。升级到 HarmonyOS 7,打开 PARAGRAPH_CACHE,代码还是照旧重建对象,这个优化就先少了一个必要前提。
这篇只解决一条具体链路:收到增量段落,校验归属和顺序,追加到同一个属性字符串,重新交给 TextController;切换另一份文档时,再有意识地建立新对象。日志尾部追加和流式会话切换是两个不同案例,最后用对照实验判断是否值得启用缓存,不预设一定能快多少。
版本与证据:Text.incrementalUpdatePolicy 和 IncrementalUpdatePolicy 起始版本为 26.0.0,适用 Stage 模型。本文核对了官方 Text 接口、示例31和文本公共接口中的策略定义,页面更新日期为 2026-09-09 14:54,核对日期为 2026-09-27。公共流式输入模型和断言已在本机 Node.js 运行;ArkTS 页面未在 API 26 SDK 或真机编译运行,没有设备端耗时、帧率或缓存命中率实测结果。

先把缓存开关的三个前提摆在一起
官方定义可以收敛成三个判断:
| 判断 | 官方边界 | 对代码的影响 |
|---|---|---|
| Text 是否使用属性字符串 | incrementalUpdatePolicy 仅对包含 StyledString 的内容生效 | 给普通字符串 Text 加开关,不等于走了这条优化路径 |
| 策略是否启用 | 默认 NONE;undefined 也按 NONE 处理 | 需要明确使用 PARAGRAPH_CACHE |
| 绑定的属性字符串对象是否保持不变 | 对象变化则无法命中缓存 | 内容相同但重新 new,不满足对象不变这一前提 |
注意最后一行只说“必要前提”。保留对象不等于所有段落都会复用,更不等于布局宽度、字号、样式改变后还能复用旧的排版结果。平台的内部缓存键与淘汰算法没有在这个接口表中公开,应用不应该假装能用一个自制的 widthBucket 参数控制它。
例如将 690vp 和 710vp 都放进同一个宽度档,再复用外部记录的换行位置,很可能让文字越界。Text 自己管理布局;应用若另外缓存行数、选区矩形或截图,宽度和字体条件改变时,要重新获取,而不是借“段落缓存”之名跳过更新。
案例一:日志尾部追加,不要每次重建整份对象
先看反例。问题不是 setStyledString 调用次数本身,而是每次传进去的都不是原来的对象:
// 反例:每个增量都重建对象,破坏原缓存命中的必要前提。
allText += nextParagraph;
controller.setStyledString(new MutableStyledString(allText));
按照官方示例31的用法,同一份文档持有同一个 MutableStyledString,用 appendStyledString 追加,再把这个对象交给控制器。下面是独立的日志页面接入示例,没有复制整个官方示例的样式代码。
@Entry
@Component
struct AppendLogPage {
private controller: TextController = new TextController();
private document: MutableStyledString = new MutableStyledString('运行日志');
private sequence: number = 0;
@State useCache: boolean = true;
build() {
Column({ space: 12 }) {
Toggle({ type: ToggleType.Switch, isOn: this.useCache })
.onChange((value: boolean) => { this.useCache = value; })
Text(this.useCache ? '段落缓存开启' : '全量布局策略')
Scroll() {
Text(undefined, { controller: this.controller })
.width('100%')
.fontSize(18)
.incrementalUpdatePolicy(this.useCache ?
IncrementalUpdatePolicy.PARAGRAPH_CACHE : IncrementalUpdatePolicy.NONE)
.onAppear(() => { this.controller.setStyledString(this.document); })
}.layoutWeight(1)
Button('追加一段日志').onClick(() => {
this.sequence++;
const line = '\n记录 ' + this.sequence + ':本段用于观察长文本追加。';
this.document.appendStyledString(new StyledString(line));
this.controller.setStyledString(this.document);
})
}.width('100%').height('100%')
}
}
这里新建的是“待追加的一小段” StyledString,不是 Text 正在绑定的整份 MutableStyledString。这两个对象不能混为一谈。换行符表示示例的段落边界;如果数据源提供的是半句话,就不要人为给每个网络分片补换行,否则显示内容会被传输分片方式改变。
验证对象身份时,可以在开发阶段保存初始引用,再比较 document === initialReference。这只能验证应用没有重建对象,不能当作平台缓存命中率。性能数据仍然要看设备端布局与帧时间。
案例二:流式会话切换,缓存不能成为串文档的理由
打开会话 A,已收到第1段;切到会话 B 后,A 的第2段姗姗来迟。如果只有一个全局属性字符串,旧内容就会被追加到 B。反过来,为了避免串内容而让每个回调都重建对象,又丢掉了同一会话内的增量路径。
解决方法是明确对象的生命周期:同一个会话实例内复用;切换文档或重新开始一次会话时重建。 即使两次都叫“会话 A”,也要分配不同的会话实例 ID,否则离开再回来时,第一次 A 的旧响应仍有机会混进来。
下面的输入层按单调递增的分片编号接收增量。它拒绝旧会话、重复分片和缺口。遇到缺口暂不接纳后续分片,调用方应补取缺失数据或进行完整快照恢复;这比默默漏掉一段更容易定位。
export class ParagraphStream {
private session: string = '';
private lastSequence: number = 0;
open(session: string): void {
if (session.length === 0) throw new Error('session is required');
this.session = session;
this.lastSequence = 0;
}
consume(session: string, sequence: number, text: string,
append: (value: string) => void): string {
if (session !== this.session || this.session.length === 0) return 'old-session';
if (!Number.isInteger(sequence) || sequence < 1) return 'invalid-sequence';
if (sequence <= this.lastSequence) return 'duplicate';
if (sequence !== this.lastSequence + 1) return 'gap';
append(text);
this.lastSequence = sequence;
return 'appended';
}
}
这是应用自己的输入模型,不是 PARAGRAPH_CACHE 的内部实现。append 回调只做同步追加,不要塞进一个未等待的异步存储操作。输入协议还应保证同一编号不会携带不同内容;若服务端会修订已经发出的文本,需要另设“替换/快照”消息,不能把修订当重复分片直接忽略。
在页面里,连接方式如下。把 ParagraphStream 保存为独立的 ParagraphStream.ets,以下字段和方法放到持有 TextController 的组件中,网络回调仅调用 receive。sessionId 必须由调用方为每次会话创建唯一值。
private stream: ParagraphStream = new ParagraphStream();
private controller: TextController = new TextController();
private document: MutableStyledString = new MutableStyledString('');
private openSession(sessionId: string, heading: string): void {
this.stream.open(sessionId);
this.document = new MutableStyledString(heading);
this.controller.setStyledString(this.document);
}
private receive(sessionId: string, sequence: number, text: string): string {
return this.stream.consume(sessionId, sequence, text, (delta: string) => {
this.document.appendStyledString(new StyledString(delta));
this.controller.setStyledString(this.document);
});
}
实际接入时补上 import { ParagraphStream } from './ParagraphStream'。openSession 只在 Text 已绑定后执行,或保存对象等 Text 的 onAppear 再绑定。网络层取消旧请求可以减少浪费,但取消不应替代会话校验,已经排队的回调仍要核对归属。
在电脑上先验证输入行为
将下面测试接在 ParagraphStream 模型后,保存为 .ts,在支持类型擦除的 Node.js 中执行。AppendBuffer 是可检查内容与身份的测试替身,并不模拟 ArkUI 排版或缓存。第一个用例验证连续追加、重复分片和补齐缺口;第二个验证切换会话后拒绝旧数据,并检查新的缓冲对象没有复用旧对象。
class AppendBuffer {
text: string = '';
append(value: string): void { this.text += value; }
}
function expect(value: boolean, message: string): void {
if (!value) throw new Error(message);
}
const stream = new ParagraphStream();
let buffer = new AppendBuffer();
const original = buffer;
stream.open('A#1');
const append = (value: string): void => { buffer.append(value); };
expect(stream.consume('A#1', 1, '第一段\n', append) === 'appended', 'first');
expect(stream.consume('A#1', 1, '第一段\n', append) === 'duplicate', 'duplicate');
expect(stream.consume('A#1', 3, '第三段\n', append) === 'gap', 'gap');
expect(stream.consume('A#1', 2, '第二段\n', append) === 'appended', 'second');
expect(stream.consume('A#1', 3, '第三段\n', append) === 'appended', 'third');
expect(buffer === original, 'same session keeps the same buffer');
expect(buffer.text === '第一段\n第二段\n第三段\n', 'exact content');
console.log('append case passed');
stream.open('B#2');
buffer = new AppendBuffer();
expect(buffer !== original, 'new session starts with a new buffer');
expect(stream.consume('A#1', 4, '旧内容', append) === 'old-session', 'late A');
expect(stream.consume('B#2', 1, 'B的首段', append) === 'appended', 'new B');
expect(buffer.text === 'B的首段', 'B stays isolated');
stream.open('A#3');
buffer = new AppendBuffer();
expect(stream.consume('A#1', 1, '旧A', append) === 'old-session', 'reopened A');
expect(stream.consume('A#3', 1, '新A', append) === 'appended', 'fresh A');
expect(buffer.text === '新A', 'fresh session content');
console.log('session case passed');
本机结果为 append case passed、session case passed。测试覆盖的是数据顺序、去重和隔离,不是“缓存加速已验证”。如果服务器推送的是每次都包含全文的快照,不能直接把快照送进这个增量接口,否则 A、AB、ABC 会被拼成 AABABC。先确定协议,再选择差量追加或整体替换。
性能实验要有对照,不能只截一次流畅的录屏
建议使用相同文本、样式、设备、构建类型和初始宽度,分别测三组:
| 组别 | 更新方式 | 策略 | 主要用来回答 |
|---|---|---|---|
| A | 同一属性字符串追加 | NONE | 没开优化的基线是多少 |
| B | 同一属性字符串追加 | PARAGRAPH_CACHE | 开关本身是否带来收益 |
| C | 每次重建属性字符串 | PARAGRAPH_CACHE | 重建对象是否破坏预期路径 |
先预热,再使用相同的段落增量序列,多轮记录追加期间的帧时间、布局耗时和内存峰值。不要只用 JavaScript/ArkTS 函数返回的耗时代表屏幕完成渲染,因为布局可能发生在后续阶段。也不要把第一次创建长文本的冷启动成本混进稳态追加平均值。
另外做一轮改变宽度、系统字号和文本样式的正确性测试:先确认换行、末尾段落、选择与复制没有问题,再讨论性能。如果使用 LayoutManager 读取行数或矩形,官方明确要求内容变化后等待布局完成,刚调用 setStyledString 就读到的值不能直接当最新布局结果。
当前没有设备端结果,所以这里不给“提升50%”之类数字,也不把函数断言包装成性能报告。最终应保留原始测量条件和分布数据;只有 B 比 A 有稳定收益且正确性无回归,才把开关作为项目默认配置。
对象要复用,但不能无限留在内存里
长文本持续增长并不因为启用缓存就没有内存成本。日志工具应设置保留条数或内容规模边界,超过边界时由产品逻辑决定裁剪、分段展示或重开文档。裁剪和全量恢复是另一类编辑操作,不要继续假定只是尾部追加。
对短标签、频繁整段替换的文案,增加这套会话输入层未必划算;对大篇幅连续输出、日志和转写结果,稳定对象与增量协议更值得先试。我的选择是把“输入正确性”和“平台缓存策略”分开封装:前者负责顺序与归属,后者保持简单的 Text 配置。这让性能开关关闭时,文本仍然是正确的。
以后看到“缓存开了但不快”,先查普通字符串还是属性字符串、绑定对象是否换了、上游传增量还是全文、测量是否覆盖真正布局。不要一上来再套一层未经验证的自制排版缓存。
官方来源
更多推荐



所有评论(0)