HarmonyOS Navigation 邻篇预取队列与阅读缓存回收【共创稿事节】
大屏阅读工作台的目录与详情已经能够保持一致:Navigation 负责详情历史,重复点击不会制造多余页面;切换窗口后,阅读位置依靠段落锚点恢复;正文异步回写受当前文章标识与页面激活状态约束;轻量书签则由 ArkData Preferences 保存。读者的下一种操作更加普通——读完“城市光谱”以后,连续打开相邻文章。此时页面可能频繁经历内容读取、格式整理和重新排版,明明文章已经打开过,却还需要再次准备完整正文。
预取能减少这些等待,但它也容易引入新的问题。如果目录中的文章全部提前载入,短时间看起来很流畅,长时间阅读却会积累不必要的文本、图片描述与段落索引。尤其在折叠屏尺寸频繁改变时,旧布局产生的缓存并不一定能够继续使用。这里将预取限定为业务层的邻篇内容包管理,不把路由栈当缓存,也不把持久化书签当正文副本。

一、缓存跟随文章身份,而不是跟随页面对象
阅读目录继续使用三个稳定标识:canyon 对应“峡谷行记”,metro 对应“城市光谱”,cloud 对应“云端书签”。详情页仍使用 ReaderDetail,由原有 Navigation 入口维护文章选择与返回逻辑。缓存新增 articleId、revision、sizeKB、content 四个字段。articleId 用于查找,revision 用于判断内容是否已修订,sizeKB 用于控制缓存预算,content 保存已经完成格式规整的正文。
三个样本的正文预算分别为 144 KB、168 KB 和 132 KB。阅读工作台为这一层安排 340 KB 上限,它只是可调整的应用配置值,不是系统给某个组件划定的配额。选择“城市光谱”时,它属于正在阅读的内容,必须被标为常驻;另两篇按目录邻近顺序进入预取队列。当三份内容全部到达时,总量达到 444 KB,超过预算。管理器可以移走旧的“峡谷行记”,让正在阅读的“城市光谱”和后来取回的“云端书签”留在缓存中。
这里有意区分“常驻”和“永久保留”。常驻的含义是当前阅读期间不参与淘汰;用户切换到另一篇时,原文章要解除保护,重新进入普通候选。书签仍由 Preferences 保存最小位置快照,缓存回收并不会清除读者的阅读进度。这样既延续上一阶段的书签设计,也避免因内存回收导致目录高亮或路由历史变化。
二、用有预算的缓存替代无限增长的 Map
缓存条目按最近使用次序维护。读取命中时更新次序,放入修订后的同名文章时替换旧数据;一旦超限,从最早且不属于当前文章的条目开始回收。超过整个预算的单篇正文不进入缓存,页面仍可以按正常读取路径取得内容。这种方式的关键不在复杂算法,而在每一个淘汰决定都能够解释。
下面的代码围绕文章内容包建立一个独立类,适合放在 entry/src/main/ets/model/ReadingCache.ets。存储容量按规整后的业务内容估算,若真实工程还持有 PixelMap、富文本布局对象或文件句柄,应分别统计和释放对应资源,不能把同一个 sizeKB 解释为完整进程内存。
type ArticlePacket = {
articleId: string;
revision: number;
sizeKB: number;
content: string;
};
class ReadingCache {
private entries: Map<string, ArticlePacket> = new Map();
private activeId: string = '';
constructor(private readonly limitKB: number) {}
activate(articleId: string): void {
this.activeId = articleId;
this.get(articleId);
}
get(articleId: string): ArticlePacket | undefined {
const value = this.entries.get(articleId);
if (value) {
this.entries.delete(articleId);
this.entries.set(articleId, value);
}
return value;
}
put(packet: ArticlePacket): string {
if (packet.sizeKB <= 0 || packet.sizeKB > this.limitKB) {
return 'BYPASS';
}
this.entries.delete(packet.articleId);
this.entries.set(packet.articleId, packet);
while (this.usedKB() > this.limitKB) {
const victim = Array.from(this.entries.keys())
.find((id: string) => id !== this.activeId);
if (!victim) {
this.entries.delete(packet.articleId);
return 'BYPASS';
}
this.entries.delete(victim);
if (victim === packet.articleId) return 'BYPASS';
}
return 'STORED';
}
invalidate(articleId: string, revision: number): boolean {
const old = this.entries.get(articleId);
if (!old || old.revision === revision) return false;
this.entries.delete(articleId);
return true;
}
usedKB(): number {
let total = 0;
this.entries.forEach((item: ArticlePacket) => total += item.sizeKB);
return total;
}
}
Map 的遍历顺序提供了简单的最近使用队列,已经命中的条目先删除再放入末尾。淘汰时跳过 activeId,避免预取请求把正在显示的内容挤走。这里的 BYPASS 是一条正常结果,表示正文可继续读取,只是不占用此层缓存。如果未来正文条目增长到数百个,可以再把线性寻找候选改为双向链表,当前三到几十篇的邻近窗口没有这个必要。
另一个容易忽视的分支是“内容变更”。revision 与上一阶段书签中的修订号含义相同,但它们各自有独立的存储生命周期。服务端或本地文章目录指出 cloud 从修订 5 更新到修订 6 时,旧缓存应失效;书签则继续按上一阶段的段落迁移规则决定是否可恢复,不能因为缓存条目被移走就删除整个书签。
三、预取队列必须尊重当前页面的代次
缓存本身不会决定什么时候加载。目录发生切换时,由阅读控制器决定要保留哪个文章、预取哪些邻篇。这里沿用异步正文回写阶段建立的“代次”机制:每次改变当前阅读文章都会增加一个序号,较早发起的预取即便完成,也不能重新选择旧文章,更不能覆盖详情区。预取结果只允许进入内容缓存,而页面显示仍由当前 ReaderDetail 的文章身份决定。
以下控制器把队列控制在两个相邻条目,且同一文章同一修订只有一次正在处理的请求。示例的 load 是项目已有的正文读取函数,返回 ArticlePacket;Navigation 路由 API 不承担正文读取责任。
class NeighborLoader {
private generation: number = 0;
private inflight: Set<string> = new Set();
constructor(private cache: ReadingCache) {}
select(articleId: string): number {
this.generation += 1;
this.cache.activate(articleId);
return this.generation;
}
async preload(
packetId: string,
revision: number,
generation: number,
load: () => Promise<ArticlePacket>
): Promise<string> {
const key = `${packetId}:${revision}`;
if (this.inflight.has(key)) return 'JOIN';
if (this.cache.get(packetId)?.revision === revision) return 'HIT';
this.inflight.add(key);
try {
const packet = await load();
if (generation !== this.generation) return 'STALE';
if (packet.articleId !== packetId || packet.revision !== revision) {
return 'MISMATCH';
}
return this.cache.put(packet);
} finally {
this.inflight.delete(key);
}
}
}
队列去重使用文章与修订号的组合键,因此同一文章的新版正文可以与旧版读取区分开。finally 必须保留:无论读取成功、参数不符还是 Promise 抛出异常,占用标记都要移除,否则后续点击会长期得到 JOIN。实际接入网络时,还应给读取操作设置超时、可取消句柄和并发上限;代次检查负责禁止旧结果进入新阅读状态,取消请求负责减少无意义的资源占用,两者作用不同。
还有一个取舍:切换文章以后,是否丢弃已经完成的上一代预取?上述实现选择直接返回 STALE,更容易理解且不会让废弃目录版本混入缓存。若后续希望复用相同文章、相同修订的已取回正文,可以把“完成结果是否可信”和“能否刷新界面”拆成两个判断,前者允许安全入缓存,后者始终检查当前代次。但不能省掉修订号与来源身份的校验。

四、在页面生命周期中只做必要的订阅
ReaderDetail 进入可操作状态时,选择当前文章并安排邻篇预取;离开可操作状态时,解除与当前页面绑定的界面更新资格。NavDestination 提供 onActive、onInactive 等生命周期回调,应用需要把它们转换为业务控制器的进入和离开动作。代码中的 onActivate、onDeactivate 是阅读工作台自定义方法,不属于平台接口。
@Component
struct ReaderDetail {
@Prop articleId: string;
@State cacheText: string = '正文准备中';
private onActivate(): void {
this.cacheText = `正在阅读 ${this.articleId}`;
// 这里交给页面持有的阅读控制器选择文章、排队邻篇
}
private onDeactivate(): void {
this.cacheText = '保留书签,暂停详情更新';
// 页面控制器递增代次,释放已订阅的回调
}
build() {
NavDestination() {
Column() {
Text(this.cacheText)
Text('继续阅读')
}.width('100%')
}
.onActive(() => this.onActivate())
.onInactive(() => this.onDeactivate())
}
}
这里没有把缓存所有权交给单个 NavDestination 页面。如果组件销毁就立即清掉缓存,切回上一篇会再次读取;如果页面销毁仍保留所有回调,又可能发生旧页面更新。较合理的位置是阅读工作台级别的内容仓库,页面通过可解除的订阅读取结果;书签存储仍由 Preferences 负责。页面退出时要关闭计时器、监听和临时读取句柄,正文纯数据缓存则根据统一预算管理。
五、把容量、命中和版本变化放在同一条检查路径
用固定三篇文章检查这一层规则,可以先放入“城市光谱”168 KB 并设为当前阅读,再放入“峡谷行记”144 KB。两者占用 312 KB;接着预取“云端书签”132 KB,总量暂时达到 444 KB,管理器需要回收“峡谷行记”,回到 300 KB。读者再次点“峡谷行记”时属于缓存未命中,但返回路径和已保存书签不受影响。
随后接收“云端书签”修订号更新为 6 的目录信息,旧缓存失效,当前占用回到 168 KB。业务状态依次可以记录为 ACTIVE metro、STORE canyon、STORE cloud、EVICT canyon、INVALIDATE cloud、USED 168/340 KB。这些信息适合用于定位缓存行为,不要把一次容量检查的通过理解为复杂文本渲染已达到某个固定耗时。

进一步检查还应覆盖超大文章、空内容、同修订重复预取、页面快速往返以及网络读取中断。一个超过 340 KB 的正文可以正常打开,但返回 BYPASS;失活回执不能修改当前详情;同一预取键的并发调用应合并,而不是各自存入副本。若图片参与预取,还要分别限制图片数量与位图大小,避免仅用压缩文件的 KB 数估算解码后的内存。
这一步带来的改变不是把阅读工作台变成无限内容仓库,而是给连续阅读建立了有界的加速层。Navigation 仍负责页面历史,书签仍负责长期位置,预取缓存只保存短时可复用的正文;三者分工明确,后续加入多窗口会话或更复杂的预读策略时,也能定位缓存应由谁创建、谁回收、何时失效。
相关接口与生命周期可参考华为开发者文档:NavDestination 页面生命周期、Navigation 路由基础与参数。
更多推荐



所有评论(0)