一、现有边界:能显示图片,不等于有缓存治理
课程、资讯和装备卡片已经能够在网络地址与本地资源之间选择,加载失败时也有兜底图。这解决的是“有没有图”,还没有回答快速滚动时是否重复下载、解码任务能否取消、内存压力如何控制、同一地址的不同尺寸是否会相互污染。当前设置页中的“清理缓存”主要处理浏览历史,也不能作为图片缓存已经落地的证据。
因此本文定位为待实施方案。目标不是承诺列表已经达到某个帧率,而是建立一套可验收的图片管线:列表只请求当前卡片需要的尺寸;相同请求只执行一次;离屏任务可以取消;内存超限时按确定规则淘汰;磁盘不可用时仍可退回本地占位图。
| 当前已有 | 仍需设计 | 不纳入本期 |
|---|---|---|
| 网络封面地址、本地兜底资源、卡片列表 | 缓存键、预算、并发合并、取消、指标 | 图片编辑、云端 CDN 改造、预下载整套课程 |
| 页面级刷新和空状态 | 离屏回收与失败可见性 | 预测用户下一次访问 |
二、先定义两级预算,再讨论命中率
图片缓存不能以“尽量多存”为策略。内存层适合保存已经解码、可直接绘制的小图,磁盘层保存压缩后的原始响应;两者需要独立预算。建议内存预算取设备可用内存的较小比例并设置硬上限,磁盘预算按应用数据规模设定,例如 80 MB。收到内存压力通知时,内存层应立即缩减到目标水位,而不是等待下次启动。
export interface ImageBudget { memoryBytes: number diskBytes: number maxParallelRequests: number prefetchDistance: number } export const DEFAULT_IMAGE_BUDGET: ImageBudget = { memoryBytes: 24 * 1024 * 1024, diskBytes: 80 * 1024 * 1024, maxParallelRequests: 4, prefetchDistance: 2 } export interface CacheUsage { memoryBytes: number diskBytes: number inFlightCount: number evictionCount: number }预算是稳定性的前提。没有预算时,命中率越高可能意味着驻留对象越多,最终以卡顿或系统回收结束;有了预算,命中率、淘汰次数和失败率才有共同解释。
三、缓存键必须包含尺寸、裁剪方式和资源版本
只用 URL 作为键会产生隐蔽错误:列表缩略图可能污染详情页大图,圆角裁剪版本也可能复用未经裁剪的结果。建议将标准化地址、目标宽高、缩放方式、主题和版本摘要一起计算为键。服务端若提供 ETag 或 Last-Modified,可进入版本摘要;没有版本信息时使用带有效期的地址摘要。
export interface ImageVariant { widthPx: number heightPx: number fit: 'cover' | 'contain' theme: 'light' | 'dark' revision: string } export function imageCacheKey(url: string, variant: ImageVariant): string { const normalized = url.trim().toLowerCase() return [ normalized, variant.widthPx, variant.heightPx, variant.fit, variant.theme, variant.revision ].join('|') }| 键维度 | 缺失后的风险 | 建议来源 |
|---|---|---|
| 宽高与缩放方式 | 小图被放大、重复解码 | 卡片布局规格 |
| 主题 | 深浅色资源串用 | 当前主题状态 |
| 版本摘要 | 旧封面长期驻留 | ETag、更新时间或业务版本 |
四、请求合并、取消与提交必须在协调器中完成
列表滚动会让多个卡片在短时间内请求同一图片。协调器先查内存,再查磁盘,最后才进入网络;相同键已有进行中任务时,新的订阅者共享 Promise。卡片离屏只取消自己的订阅,最后一个订阅者离开才中止底层任务,避免一个组件误伤另一个仍可见组件。
export type ImageSource = 'memory' | 'disk' | 'network' | 'fallback' export interface ImageResult { source: ImageSource pixelMap?: PixelMap errorCode?: string } export interface ImageTicket { key: string result: Promise<ImageResult> cancel(): void } export interface ImageCoordinator { request(url: string, variant: ImageVariant): ImageTicket trimMemory(level: 'moderate' | 'critical'): void clearDisk(): Promise<void> usage(): CacheUsage }磁盘写入需要先写临时文件、校验长度或摘要,再原子替换正式文件。应用被杀或空间不足时,临时文件可以在下次启动清理,不能把半截文件登记为缓存命中。
五、列表单元只消费视觉状态,不直接管理网络
卡片需要的状态只有占位、加载成功、加载失败和已取消。网络重试、磁盘路径、淘汰顺序不应进入 UI。列表项出现时创建 ticket,消失时取消;键改变时先取消旧任务,再绑定新任务。结果回调还要比较当前键,防止旧请求覆盖复用后的卡片。
export type CoverVisual = | { kind: 'placeholder' } | { kind: 'ready'; pixelMap: PixelMap; source: ImageSource } | { kind: 'failed'; retryable: boolean } export class CoverPresenter { private generation: number = 0 async bind(ticket: ImageTicket): Promise<CoverVisual> { const current = ++this.generation const result = await ticket.result if (current !== this.generation) { return { kind: 'placeholder' } } if (result.pixelMap) { return { kind: 'ready', pixelMap: result.pixelMap, source: result.source } } return { kind: 'failed', retryable: result.errorCode !== 'INVALID_URL' } } invalidate(): void { this.generation += 1 } }六、失败与降级要对用户无惊扰、对诊断有信号
断网时优先显示磁盘副本;磁盘无副本则使用本地兜底图。解码失败应删除损坏条目并最多重试一次,避免循环。空间不足时暂停磁盘写入但保留受预算约束的内存层。地址非法直接进入不可重试状态。滚动期间不弹连续 Toast,错误通过卡片占位和聚合指标表达。
| 失败 | 页面表现 | 后台动作 | 恢复条件 |
|---|---|---|---|
| 断网 | 磁盘副本或兜底图 | 标记网络未命中 | 网络恢复后按需重试 |
| 文件损坏 | 兜底图 | 删除条目、单次重新下载 | 校验通过 |
| 空间不足 | 已显示图片保持 | 停止磁盘写入 | 清理后重新开放 |
| 内存压力 | 可见项保持优先 | 淘汰离屏和低频项 | 水位下降 |
七、取舍:稳定优先于激进预取
预取距离越大,首屏之后的体验可能更顺,但会与当前可见请求争夺带宽和解码时间。第一阶段只预取可见窗口之后两项,快速滑动时取消低优先级预取。磁盘层采用 LRU 加有效期,而内存层采用带成本权重的 LRU;尺寸更大的对象成本更高,更早进入淘汰候选。
建议记录memoryHit、diskHit、networkLoad、decodeFailure、cancelled和visibleReadyLatencyMs。这些指标只用于判断管线是否稳定,不在未采集数据前写出命中率或帧率结论。
还要避免把缓存命中率当作唯一目标。若为了提高命中率长期保留大尺寸对象,解码内存和垃圾回收压力可能反而拉长可见图片的就绪时间。参数校准应同时观察可见延迟、峰值内存、取消比例和网络流量,并按低端设备的结果确定默认预算;高端设备可以在运行时放宽水位,但不能改变键模型和失败合同。
八、实施顺序与证据计划
第一步落地键模型、内存预算和单元测试;第二步增加请求合并与 generation 防串图;第三步加入磁盘索引、原子写入和启动清理;第四步接入列表生命周期与两项预取;第五步再做压力测试和参数校准。
验收至少覆盖:连续快速滚动 200 个卡片不串图;相同键并发只触发一次底层加载;卡片离屏后任务可取消;损坏文件不会作为命中返回;断网有兜底;内存压力后用量回落到目标水位;清理缓存不删除收藏等用户资产。后续证据应包含性能采样、缓存指标、断网录屏和内存压力日志,取得这些证据后才能把文章定位升级为实战。
参考:HarmonyOS 图片开发指导。
九、总结
图片缓存不是给Image外面包一层工具类,而是一条有预算、有版本、有取消、有原子提交的资源管线。列表稳定性也不能靠“看起来不卡”判断。先把键、任务和降级合同固定,再用真实数据校准预算,才能让封面加载在快速滚动、弱网和内存压力下保持可解释。