相册里的左右连看功能,最开始总让人觉得没有什么难度:放一个 Swiper,每页一张图片,用户划走就显示下一张。但在图片数量多、每张图片都要异步读取的情况下,难题并不在滑动动画,而在“当前页面已经换了,上一张图片的读取结果才刚回来”。如果把结果不分身份地写到一个共享 @State,就会出现页面先展示 B、转眼又闪回 A 的奇怪现象。这篇用 PhotoLane 设计相册连看 Demo,固定共 8 张相片,当前显示第四张 PIC_020_04,相邻预览窗口覆盖 PIC_020_03 和 PIC_020_05,业务加载代次 generation=7,预设过期结果丢弃 2 次。最终页面状态为 READY。所有这些数字均是演示数据,不表示真实设备已经完成图片解码或性能压测。图 01:相册连看主题封面,三张相邻相片说明预加载的局部窗口,不是一次要把全相册放进内存。## 一、预加载有两层,混在一起会很难调试看到 Swiper.cachedCount(1),容易产生一个误解:设置了 1,系统就会提前下载或解码下一张图片。实际上,官方文档描述的是相邻子组件的缓存及预加载行为,并明确其对 LazyForEach 或开启虚拟滚动的 Repeat 的适用限制。它解决的是 UI 子组件生命周期,并不是图片文件网络拉取或业务解码任务的自动管理器。因此我把设计分成两层:第一层是 Swiper 对当前页附近视图节点的保留,第二层才是自己的图片预览任务队列。视图可以被保留,但预览缓存仍需由业务决定哪些结果能显示、何时释放。否则滑动十几张以后,你可能有一套看似正常的 UI 缓存,却在后台悄悄积累了多份旧图片资源。先定义一个独立于 ArkUI 的窗口计算函数。它解决的问题是根据当前索引,只生成必要的相邻候选项,避免直接把全部 8 张图片一次塞进预取队列。arktsinterface PhotoEntry { id: string title: string}const PHOTOS: PhotoEntry[] = [ { id: 'PIC_020_01', title: '晨雾' }, { id: 'PIC_020_02', title: '海岸' }, { id: 'PIC_020_03', title: '松林' }, { id: 'PIC_020_04', title: '山湖' }, { id: 'PIC_020_05', title: '古镇' }, { id: 'PIC_020_06', title: '云海' }, { id: 'PIC_020_07', title: '城市' }, { id: 'PIC_020_08', title: '夜景' }]function neighbors(index: number, total: number): number[] { if (total <= 0) return [] const safe = Math.max(0, Math.min(index, total - 1)) const values = [safe - 1, safe, safe + 1] return values.filter((i: number): boolean => i >= 0 && i < total)}第四张的索引按零起始是 3,neighbors(3, 8) 返回 [2,3,4],正好对应图片 03、04、05。这个算法不会因为图片 ID 字符串长短发生变化,也不会在第一张时出现负下标。但如果数据源会动态插入或排序,索引依旧不是稳定身份;预览任务应当以 id 为 key,索引用来决定当前窗口即可。## 二、Swiper 的视图缓存不等于任意异步任务的缓存页面层使用 Swiper 和 LazyForEach 来生成每一页。这里把 IDataSource、图片资源管理抽象成项目自己的 photoSource 和 PhotoCard,因为文章要讨论的是缓存边界,而不是用大量样板代码掩盖问题。核心组件路径为 entry/src/main/ets/pages/PhotoLanePage.ets,列表服务放在 model/PhotoDataSource.ets。下面这段代码解决的是页面滑动时只维护相邻视图窗口,并把当前索引同步给业务预览管理器。arkts@Entry@Componentstruct PhotoLanePage { @State currentIndex: number = 3 @State currentId: string = 'PIC_020_04' private photoSource: PhotoDataSource = new PhotoDataSource(PHOTOS) private controller: PhotoPreviewController = new PhotoPreviewController() aboutToAppear(): void { this.controller.switchTo(this.currentId) } aboutToDisappear(): void { this.controller.close() } build() { Column({ space: 12 }) { Text(`${this.currentIndex + 1} / ${PHOTOS.length}`) Swiper() { LazyForEach(this.photoSource, (item: PhotoEntry) => { PhotoCard({ photo: item }) }, (item: PhotoEntry): string => item.id) } .cachedCount(1) .onChange((index: number) => { this.currentIndex = index this.currentId = PHOTOS[index].id this.controller.switchTo(this.currentId) }) } }}````PhotoDataSource` 和 `PhotoPreviewController` 是业务类名,不是 HarmonyOS 系统 API;`PhotoCard` 也是项目自定义组件。上面的片段需要连同数据源实现、组件定义和资源映射补齐后才能组成可编译工程。真正可依据的系统组件是 `Swiper`、`LazyForEach`、`cachedCount()` 与 `onChange()`。我刻意不把 `cachedCount(1)` 描述成“已经完成图片预取”,因为业务解码与 UI 组件预加载完全是两回事。![](https://i-blog.csdnimg.cn/direct/72683454bebc4a199fefc52db97a8fe4.png)*图 02:白色 DevEco Studio 风格演示图,左侧 PhotoLane 项目目录,中间突出 `cachedCount(1)` 和 `switchTo()`,右侧模拟第 4/8 张图片,底部显示 `generation=7` 和过期结果拦截的预设日志。*## 三、快速滑动以后,旧任务应该被判定为“没有展示资格”假设用户在一秒内从图片 02 滑到 04,图片 02 的缩略预览后返回。它有可能仍然值得进入二级缓存,但不能再把当前页面的结果替换成 02。真正要判的不是 Promise 是否完成,而是回调返回时这个 ID 和加载代次是否仍然属于用户看到的页面。下面这段代码给 `PhotoPreviewController` 增加简单的代次租约。它解决的是**只让当前页面的异步结果改变当前预览状态**。arktsclass PhotoPreviewController { private currentId: string = ‘’ private generation: number = 6 private disposed: boolean = false ignored: number = 0 switchTo(id: string): number { this.currentId = id this.disposed = false this.generation++ return this.generation } canPublish(id: string, generation: number): boolean { const allowed = !this.disposed && id === this.currentId && generation === this.generation if (!allowed) this.ignored++ return allowed } close(): void { this.disposed = true this.generation++ }}// 解码回调示意:先验 ID、代次,再更新当前页// if (controller.canPublish(id,lease)) updatePreview(pixelMap)// else pixelMap.release()```这段纯业务代码无法中止系统已经开始的解码,它能保证的是:过期解码结果即使返回,也不能被发布到当前页面。注释中的 pixelMap.release() 表达资源责任,实际释放方式须与使用的 ImageKit 类型、所有权和 SDK 接口对齐,不能让一个仍被其他页面持有的资源被重复释放。如果图像复用缓存由独立模块管理,拒绝发布并不意味着立刻销毁共享缓存,而应交给引用计数或缓存淘汰策略。## 四、用一组固定演示数据检查页面有没有“串图”这轮页面展示 PIC_020_04,第四张,共 8 张,预加载窗口 03–05,代次 7,状态 READY,过期回调计数为 2。手机图用三张预览卡说明“上一张 / 当前张 / 下一张”,当前张被明确高亮。开发图底部的 stale result ignored 也是演示输出,并非经过模拟器实际执行记录。*图 03:9:16 纯屏相册演示,当前 PIC_020_04,总数 8,状态 READY,generation 为 7。底部注明是页面演示,未运行真实解码。*真实联调要刻意做四组操作:快速前后来回滑动、滑到第一张和最后一张、后台加载中离开页面、插入新图片后重建数据源。观察的重点不是“画面能不能滑”,而是旧结果是否覆盖了新内容,未展示节点是否继续持有大图,缓存窗口是否随数据变化正确转移,以及离开页面后是否还有不必要的任务。除了接口行为,预取调度还有一个现实问题:图片可用性不同。有的相片位于设备本地,有的只是云端缩略图,有的资源还需要权限确认。对本地素材,较小的缓存窗口往往已经能带来平顺体验;对网络素材,预加载时机、带宽占用与用户流量设置同样重要。因此代码中的相邻三张只是“候选窗口”,并不是强制立刻下载三张。可以先按可用性筛选,再把真正需要加载的项送入任务队列。还有一个很容易漏掉的释放场景:图片组件已经不可见,但某个异步任务仍持有它准备赋值的回调。只设置 currentIndex 无法解决这个问题,因为索引可能在下一轮重新变成同一个数字。代次租约能识别“同一个索引、不同一次展示”,但它依旧不能代替引用计数和错误恢复。对网络失败要展示占位或重试入口,对解码失败应清理本次句柄;对内存压力则需要主动降低预取并释放不可见大图。如果要设计可重复的实验,可以把相同 8 张图片存放在应用沙箱,用固定顺序播放“3→4→5→4→2”,每次记录显示 ID、任务代次、有效回调数与过期回调数。先检验状态正确性,再拿性能工具查看帧率与内存。不要把一次主观上的滑动顺畅写成毫秒级优化结果,尤其不能把封面里的三张相片当作已经解码成功的性能证据。## 五、工程上的边界不在缓存个数,而在资源所有权对于大多数单页展示一张图片的场景,官方文档给出的缓存邻页数量通常以 1 或 2 作为起点,但这不是每台设备都适用的固定性能结论。高分辨率原图、缩略图、端侧超分结果和网络缩略图的大小差别很大,所谓“预加载一页”也可能意味着截然不同的内存成本。我的实际取舍是:页面节点缓存由 Swiper 负责;图片业务缓存则用稳定 ID 标记;每次滑动产生新 generation;页面离开时清理当前展示租约;图片对象的释放归真正拥有它的缓存层。这样即使后来接入网络图库或本地 PixelMap,状态正确性仍然有明确的边界,不需要在每个 onChange() 回调里堆满特殊判断。资料与验证范围: 华为 Swiper 组件官方参考 https://developer.huawei.com/consumer/en/doc/harmonyos-references-V13/ts-container-swiper-V13 ,尤其注意 cachedCount 对 LazyForEach 的约束。本文未验证实际设备的图片加载时间和内存占用,所有进度及日志为设计演示。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐