
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 加开关不等于走了这条优化路径策略是否启用默认 NONEundefined 也按 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 配置。这让性能开关关闭时文本仍然是正确的。以后看到“缓存开了但不快”先查普通字符串还是属性字符串、绑定对象是否换了、上游传增量还是全文、测量是否覆盖真正布局。不要一上来再套一层未经验证的自制排版缓存。官方来源Text.incrementalUpdatePolicy 的生效范围与默认值IncrementalUpdatePolicy 的对象身份前提Text 官方示例31属性字符串段落缓存LayoutManager获取布局信息的时机