羽毛球学习 HarmonyOS 设计续篇(24):图片缓存与列表滚动稳定性方案 📅 2026/7/25 15:53:04 一、现有边界能显示图片不等于有缓存治理课程、资讯和装备卡片已经能够在网络地址与本地资源之间选择加载失败时也有兜底图。这解决的是“有没有图”还没有回答快速滚动时是否重复下载、解码任务能否取消、内存压力如何控制、同一地址的不同尺寸是否会相互污染。当前设置页中的“清理缓存”主要处理浏览历史也不能作为图片缓存已经落地的证据。因此本文定位为待实施方案。目标不是承诺列表已经达到某个帧率而是建立一套可验收的图片管线列表只请求当前卡片需要的尺寸相同请求只执行一次离屏任务可以取消内存超限时按确定规则淘汰磁盘不可用时仍可退回本地占位图。当前已有仍需设计不纳入本期网络封面地址、本地兜底资源、卡片列表缓存键、预算、并发合并、取消、指标图片编辑、云端 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: PromiseImageResult cancel(): void } export interface ImageCoordinator { request(url: string, variant: ImageVariant): ImageTicket trimMemory(level: moderate | critical): void clearDisk(): Promisevoid 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): PromiseCoverVisual { 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外面包一层工具类而是一条有预算、有版本、有取消、有原子提交的资源管线。列表稳定性也不能靠“看起来不卡”判断。先把键、任务和降级合同固定再用真实数据校准预算才能让封面加载在快速滚动、弱网和内存压力下保持可解释。