文搜图的检索结果一多,通常要从普通网格改成瀑布流。第一眼看起来区别只是图片高低错落,但真正滚动时,最烦人的不是排版,而是某张图片刚加载完,卡片突然变高,下面的内容一起向下挤;用户正在点第三行,指尖落下那一刻,第三行的位置却变了。页面还在响应,体验却像一张不停抖动的纸。这次我做一个小范围的 PhotoFlowLedger Demo:不接真实在线检索服务,不做图片向量推理,只用预设检索结果来模拟异步缩略图展示。演示任务编号固定为 FLOW_20261016_19,关键词“秋日街景”,图卡数 12,双栏布局,卡片 P_019_04 的原图比例为 4:3,单列图片显示宽度 160vp,其图片占位高度为 120vp,加上文字区 44vp,卡片高度 164vp。这些是计算值,不是性能实测数据。图 01:WaterFlow 瀑布流概念封面。高低不一的卡片以已知比例提前占位,避免图片返回后再挤动邻近内容。## 一、不要让图片下载决定卡片高度瀑布流常见写法是先渲染一个占位图,等图片加载回来再读其尺寸,随后更新 FlowItem 高度。问题在于列表的布局和滑动是同时发生的。一个条目尺寸变化可能牵动相邻列的摆放,特别是用户已经滚到中部时,重新测量更容易形成视野里的跳动。华为官方《瀑布流场景性能优化》强调了异步图片加载、预置 FlowItem 高度和固定宽高对于抑制闪烁的意义。我更愿意把它前移到数据层:服务器或本地索引在返回缩略图 URL 的同时,给出可信的宽高元数据。页面在图片真正加载前,就能算出卡片需要多少空间;图片只负责填进去,而不是决定外层如何重新排版。第一段代码的任务是根据已知元数据计算占位高度,并对零尺寸与极端长图做裁剪保护。这个函数是纯业务计算,不依赖设备,也不伪造某个图片加载 API。arktsexport interface PhotoCard { id: string name: string widthPx: number heightPx: number asset: string}export function reserveCardHeight(card: PhotoCard, columnVp: number): number { const titleVp: number = 44 if (card.widthPx <= 0 || card.heightPx <= 0) { return columnVp + titleVp } const aspect: number = card.heightPx / card.widthPx const safeAspect: number = Math.max(0.6, Math.min(1.8, aspect)) return Math.round(columnVp * safeAspect) + titleVp}高度裁剪不是偷懒,它决定了极端长图在瀑布流里的呈现边界。如果产品必须完整显示长图,应当把完整比例交给独立详情页,而列表继续用受约束的预览区。这能让滚动稳定,也能防止上传一张异常长图把整列拖出可理解范围。对 P_019_04 这样的 4:3 图片,计算就是 160 × 3/4 + 44 = 164vp。## 二、卡片 ID 稳定,比卡片的序号好维护检索词改变、图片筛选或下一页结果回来之后,索引会变化。假如把 ForEach 的 key 设成数组下标,渲染框架可能把原来第 4 张图的节点复用给新的第 4 张图。即使两者高度刚好接近,也会出现旧图闪一下或者焦点跳到其他卡片的问题。所以瀑布流除了要知道每张图应占的尺寸,还得知道每张图究竟是谁。第二段代码展示最小的 WaterFlow / FlowItem 组合。为了让篇幅聚焦在高度问题,示例只渲染 12 条本地数据,使用 ForEach 加稳定 ID;真正的海量结果列表应采用 LazyForEach 和 IDataSource,否则全部条目直接挂树会产生不必要的开销。这里的静态图片资源也要由工程的 resources 目录提供。arkts@Entry@Componentstruct PhotoFlowPage { @State cards: PhotoCard[] = [ { id: 'P_019_04', name: '秋日街景', widthPx: 800, heightPx: 600, asset: 'sample04' } ] private columnVp: number = 160 build() { Column({ space: 10 }) { Text('文搜图 · 秋日街景') WaterFlow() { ForEach(this.cards, (card: PhotoCard) => { FlowItem() { Column() { Image($r('app.media.sample04')) .width('100%') .height(reserveCardHeight(card, this.columnVp) - 44) .objectFit(ImageFit.Cover) Text(card.name).height(44) } } .width('100%') .height(reserveCardHeight(card, this.columnVp)) }, (card: PhotoCard) => card.id) } .columnsTemplate('1fr 1fr') .columnsGap(8).rowsGap(8) .width('100%').height('85%') } }}这里有两个需要交代的取舍。其一,columnVp 是这份固定双栏原型按容器宽度预先计算好的值;真正适配平板与折叠屏时,应根据可用宽度、列数和间距重新求值,不能把 160 永远写死。其二,示例的 Image 统一引用一张演示资源,真实列表应通过合法的数据模型映射各自资源;不要以这段单图代码宣称已完成完整相册动态解码。图 02:DevEco Studio 白色主题演示,重点显示 reserveCardHeight() 的比例计算和 FlowItem 高度约束。右侧示意 P_019_04 的 164vp 总高度,日志不是实机记录。## 三、元数据不可靠时,不能悄悄连续改布局很多相册图片没有完整宽高元数据,也有文件头与实际解码尺寸不一致的情况。此时最差的办法是:先按正方形展示,解码后改为长图,再根据服务端补充信息改一次。用户已经在滚动了,卡片高度却连续变化,技术上每次调整都“有依据”,体验却持续变差。我会把“本次布局采用的尺寸”当成一个冻结快照。同一次查询代次里,已有条目只修改图片内容,不直接修改已使用的外框高度;如果元数据后续证明确实错误,就把修正记录到待应用队列,在下一次主动刷新或重新排版时一并落地。第三段代码解决的就是旧查询的尺寸更新不能污染新一轮瀑布流,并且把尺寸修正留到明确的刷新边界。arktsexport class FlowLayoutLease { private generation: number = 0 private pending: Map<string, number> = new Map<string, number>() startQuery(): number { this.generation++ this.pending.clear() return this.generation } recordCorrection(id: string, newHeight: number, generation: number): boolean { if (generation !== this.generation || newHeight <= 0) return false this.pending.set(id, newHeight) return true } takeForRefresh(): Map<string, number> { const updates = new Map<string, number>(this.pending) this.pending.clear() return updates }}这层逻辑不表示所有图片都应该永远保持占位尺寸。它只是告诉调用方,异步纠错信息可以先存起来,由业务决定什么时候更新布局。Map 的键使用卡片 ID,所以分页顺序变化不会把 P_019_04 的修正高度错写给邻居;代次用来防止旧关键词结果回流。这也避免把“网络图片已加载”错误地理解为“这一轮查询仍然有效”。## 四、图上展示 12 张图片,却不承诺性能提升百分比为了便于核对,我把演示终态设成:任务 FLOW_20261016_19,关键词“秋日街景”,12 个候选卡片,双栏布局。P_019_04 的元数据为 800×600,卡片图片占位 120vp、文字 44vp,合计 164vp,界面上标记为 HEIGHT_RESERVED。其他卡片使用不同的预设比例,只是为了让瀑布流结构更直观。*图 03:纯手机演示图。可以查看任务 ID、候选数与 P_019_04 的预留高度。图中卡片和统计均为预设数据,不是实际网络检索或帧率测试。*真正需要采集性能数据时,应分别看首次渲染耗时、滑动时长帧、图片异步加载次数以及布局重新测量次数,并注明设备、分辨率和 SDK。官方最佳实践中有性能对比数据,但那是对应测试条件下的结果,不能直接搬过来当自己的 Demo 收益。本文只验证高度计算公式的自洽性,不提供虚假的 FPS 或内存占用。## 五、比加载快一点更重要的,是让布局可预期瀑布流的观感来自图片比例变化,但它的稳定性依赖外层条目的尺寸可预期。把高度算在加载前、给条目稳定 ID、把异步修正限制在清晰的刷新边界,这是三条可以独立复用的约束。它们同样适用于照片搜索、图片超分结果页、商品列表和内容推荐流。不过,上述原型没有覆盖几万条数据的复用池管理、网络缩略图缓存和真实设备的滚动性能。生产版本要按官方指导接入懒加载与缓存数量管理,还要处理窗口改变时列宽变化的重新计算。页面是否真的不再抖动,最终只能在目标设备上用可复现的滑动路径和帧数据证明。如果网络缩略图最终加载失败,卡片也不应该因为没有得到图片而突然收缩。列表应继续保留已经计算的占位高度,用明确的错误插画或“重试加载”按钮替换图片内部区域;下一次重试成功时,仅更新图像内容。这样即使用户连续向下滚动,也不会因为某条资源异常而改变已经建立的空间位置。要记录错误原因,可保留图片 ID、查询代次、状态和重试次数,但不要把真实本地文件路径或带有访问令牌的原始 URL 直接写进分享日志。官方资料:华为开发者联盟《Optimizing Waterfall Loading》《WaterFlow 实现横向布局展示和滑动》及菜谱示例中的 WaterFlow/LazyForEach 用法。本文的卡片与日志属于演示,未进行真实网络加载或设备性能采样。

Logo

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

更多推荐