一个相册瀑布流页面,双列,图片高度不等。数据用 LazyForEach,卡片套了 @Reusable,cachedCount 也给了 5。功能一切正常,但快速上下滚动的时候,卡片会短暂“穿模”——上一条的图片残留在新卡片的位置上,然后才刷成对的图;偶尔回滚一下,列高还会整体跳一次,滚动位置跟着飞走。你检查了 key,是业务 ID;检查了 aboutToReuse,该清的 src 也清了。但问题依旧偶现。

这类问题很容易被归到“复用没写好”或者“图片库缓存策略不对”上。但 @Reusable 只是其中一个环节。WaterFlow 的布局引擎本身有一条非常主动的规则:纵向排列时,每个 FlowItem 会被放入当前累计高度最小的那一列。它不等你,它按它自己算出来的高度决定位置。当这个高度在显示之后发生变化,WaterFlow 会清理受影响的布局信息并重新计算,而重新参与布局的 FlowItem 会按“最小高度优先”重新找列——列分配变了,位置就跳了。快速滚动时图片密集加载完成,高度回调每一帧都在触发重布局,穿模、跳动、回滚错位,都是这条链路的下游表现。

高度在显示之后变,WaterFlow 就会把已经排好的列重新洗一遍

先把这个机制钉死。WaterFlow 不是“先把所有卡片排好再一次性画出来”,它是在滚动和加载过程中持续做放置决策的。一个 FlowItem 进入可视区,WaterFlow 用当前已知的主轴大小把它放进累计高度最小的列;如果这个 FlowItem 的主轴大小在显示之后才确定,或者确定之后又变了,WaterFlow 的文档写得很直接:它会清理受影响的布局信息,并从变化位置或窗口起始位置重新计算相关 FlowItem 的位置。由于放置规则是最小高度优先,重新参与布局的卡片可能被分到和之前不同的列。

这就是“穿模”和“回滚跳动”的根源。不是复用池坏了,是布局本身在你眼皮底下被重排了。图片异步加载完成、拿到真实高度、撑开容器、触发 WaterFlow 重算——这条路径上每一步都是合法的,但组合在一起,快速滚动时就变成了每一帧都在洗牌。

数据入列之前把高度算死,之后只换图不换容器

解法不是让 WaterFlow “别重算”,而是不给它重算的理由。核心原则一句话:高度在数据进入列表之前就确定,显示之后不再变化。

如果接口返回了图片宽高,按列宽和宽高比在进入数据源之前把卡片高度算好。如果接口不返回,用固定比例的占位高度,图片加载完成之后只替换图片内容,不改变容器的尺寸。这样 WaterFlow 在布局阶段拿到的就是确定值,图片加载完成不会触发任何主轴大小变化,列分配从头到尾不移动。

typescript

interface FeedItem {
  id: string;
  imageUrl: string;
  cellHeight: number;   // 入列前已确定,显示后不再变
}

function toFeedItem(raw: RawItem, columnWidth: number): FeedItem {
  // 接口有宽高就用真实比例,没有就用固定占位比例
  const ratio = raw.height > 0 && raw.width > 0
    ? raw.height / raw.width
    : 4 / 3;
  return {
    id: raw.id,
    imageUrl: raw.imageUrl,
    cellHeight: columnWidth * ratio + TITLE_HEIGHT + PADDING
  };
}

cellHeight 只在 toFeedItem 里计算一次,后续图片加载、状态切换都不再修改它。FlowItem 的高度直接绑定 item.cellHeight,不要用 'auto',也不要在图片 onComplete 里回头去改这个值。容器尺寸稳定了,WaterFlow 的列分配就是稳定的,穿模和跳动失去触发条件。

如果 WaterFlow 使用了 sections,官方还提供了 onGetItemMainSizeByIndex,让布局阶段按索引直接拿到主轴大小,而不是等 FlowItem 渲染后再测量。把预计算的高度通过这个回调给出去,比依赖子组件运行时撑开更早、更确定。

typescript

const section: SectionOptions = {
  itemsCount: this.dataSource.totalCount(),
  crossCount: 2,
  onGetItemMainSizeByIndex: (index: number) => {
    return this.dataSource.getData(index).cellHeight;
  }
};

onGetItemMainSizeByIndex 返回的是布局引擎直接使用的值,它不等待 FlowItem 内部子组件完成布局。只要这个值和 FlowItem 的实际高度一致,就不会出现“先按一个高度排,再按另一个高度重排”的拉扯。

局部刷新用 notifyDataChange,别让 onDataReloaded 把整列推倒重来

高度稳定之后,还有一个会主动触发全量重排的动作:onDataReloaded()。它的语义是“数据全变了,重新来”,WaterFlow 收到这个信号会把整个布局推倒重算,滚动位置可能直接丢失。图片加载完成、单条状态更新这类局部变化,应该用 notifyDataChange(index) 只通知受影响的那一个索引。

typescript

// 不要:图片加载完触发全量重排
this.dataSource.onDataReloaded();

// 正确:只通知当前索引的局部变化
this.dataSource.notifyDataChange(index);

notifyDataChange 的前提仍然是“该条目的主轴大小没有变化”。如果你在图片加载完成后既调了 notifyDataChange,又偷偷把 cellHeight 改大了,局部刷新救不了你,WaterFlow 照样会重算。高度定死和局部刷新是配套的两件事,缺一个都不成立。

sections 的 itemsCount 差一个,整个容器就划不动

瀑布流加分组混合布局,比如顶部一个满宽 banner,下面双列商品流,用 sections 来实现。页面能渲染,banner 和商品都在,手势滑动毫无反应,Scroller.scrollToIndex() 也失效,没有任何报错日志。

根因是 sections 里每个 SectionOptions 的 itemsCount 累加总和,必须严格等于 LazyForEach 数据源的 totalCount()。WaterFlow 的布局引擎按 itemsCount 分配每个分组该渲染多少个 FlowItem,总数对不上,布局引擎就无法完成可滚动区域的高度计算,容器不响应滚动,但已经算出来的部分仍然渲染,所以你看得到内容,就是划不动。

typescript

aboutToAppear(): void {
  const bannerSection: SectionOptions = {
    itemsCount: 1,
    crossCount: 1,
    onGetItemMainSizeByIndex: () => BANNER_HEIGHT
  };
  const goodsSection: SectionOptions = {
    itemsCount: this.goodsCount,   // 必须和数据源里商品数量严格一致
    crossCount: 2,
    onGetItemMainSizeByIndex: (i) => this.goodsHeights[i]
  };
  this.sections.splice(0, 0, [bannerSection, goodsSection]);
}

商品数据追加、删除、筛选之后,goodsSection.itemsCount 必须同步更新,不能只在初始化时算一次。splice、push 修改 sections 之后,所有分组的 itemsCount 之和要和 this.dataSource.totalCount() 重新对齐。差一个,整个瀑布流从“能滚”变成“完全滚不动”,而且不给你任何错误提示。

复用池取出来的卡片,布局字段也必须全部来自当前 item

高度定死和 itemsCount 对齐解决了布局引擎层面的稳定性。@Reusable 在 WaterFlow 里的复用还有自己的时序问题:aboutToReuse 可能晚于图片组件开始渲染,导致上一条卡片的图片先闪现一帧,然后才被清空并加载新图。

除了在 aboutToReuse 里重置 src,更根本的保障是所有影响布局的字段在复用时都必须从当前 item 重新赋值,不能依赖上一个实例留下的任何值。cellHeight、标题行数、标签数量,这些参与高度计算的字段,如果有一个在复用时没有从新 item 刷新,WaterFlow 拿到的就是旧高度,布局从第一步就错了。

typescript

@Reusable
@Component
struct FeedCard {
  @State cellHeight: number = 0;
  @State imageUrl: string = '';

  aboutToReuse(params: Record<string, Object>): void {
    const item = params.item as FeedItem;
    this.cellHeight = item.cellHeight;   // 布局字段必须从当前 item 来
    this.imageUrl = item.imageUrl;
  }

  build() {
    Column() {
      Image(this.imageUrl)
        .height(this.cellHeight - TITLE_HEIGHT - PADDING)
        .objectFit(ImageFit.Cover);
      Text('...').height(TITLE_HEIGHT);
    }
    .height(this.cellHeight)
  }
}

aboutToReuse 里 cellHeight 和 imageUrl 同步赋值,容器高度和图片高度都来自当前 item。如果 aboutToReuse 的时序仍然导致旧图闪现,可以在 aboutToRecycle 里主动取消未完成的图片加载任务,把“旧资源还在路上”这个条件提前拆掉,而不是指望 aboutToReuse 执行的时候旧请求已经自然结束。

综上所述,WaterFlow 的布局不是一次性的排版,而是一个持续做放置决策的引擎,它按“最小高度优先”把卡片放入列中,并在主轴大小变化时重新洗牌。让这个引擎稳定的方式不是阻止它重算,而是不给它重算的输入:高度在数据入列前算死,显示之后不改;局部变化用 notifyDataChange,不用 onDataReloaded 推倒整列;sections 的 itemsCount 总和与数据源严格对齐,差一个就划不动;复用卡片的所有布局字段从当前 item 重新派生,旧实例不留下任何能影响高度的残值。瀑布流不问你“这张卡片应该多高”,它只按你给它的高度算。你给得越稳定,它排得越稳;你给得越晚,它跳得越狠。下一次滚起来觉得“穿模”或者“划不动”的时候,先别翻复用池,打开数据源看一眼:那张卡片的 cellHeight,到底是在进列表之前定死的,还是等渲染完了才告诉 WaterFlow 的。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐