WaterFlow 布局与常见问题排查
对比滚动组件与 WaterFlow 布局模式,说明分组、缓存和惰性布局的用法,并通过日志与 dump 排查常见问题。附交互演示及 OpenHarmony 7.1 规划特性。
1. 滚动组件选型
WaterFlow、List、Grid 和 Scroll 都能构建滚动页面,主要区别是内容的排列方式。选型时先看卡片是否等高,以及页面需要列表、网格还是整块内容滚动。
WaterFlow
卡片高度不同,各列紧接排列
List
按列表项顺序排列
Grid
按行列格子对齐
Scroll
让一整块内容区域滚动
| 组件 | 排列方式 | 常见使用场景 |
|---|---|---|
| WaterFlow:瀑布流 | 卡片高度可以不同,每一列各自接着往下排,尽量排紧 | 商品推荐、图片社区、不等高内容卡片 |
| List:列表 | 按列表项和分组组织内容,也能配置多列 | 消息、联系人、设置、带侧滑操作的列表 |
| Grid:网格 | 按行列格子组织内容,强调对齐,也支持跨格 | 相册、应用入口、规则的商品网格 |
| Scroll:滚动容器 | 让一整块内容区域滚动,内部排列由子组件决定 | 详情页、表单、图文混排的长页面 |
2. 布局规则与缓存
2.1 最短列规则
正向布局时,WaterFlow 把每张卡片放到当前最短的一列。以双列为例,前两张卡片分别放在左列和右列。之后逐张比较两列的底边,把卡片接到较短的列下方。
两种模式都遵循最短列规则,区别是保留的布局信息范围。顺排模式(ALWAYS_TOP_DOWN)从顶部布局,保留已计算的位置。滑窗模式(SLIDING_WINDOW)只维护当前视窗附近的布局。
三列看起来一样高,也可能存在布局值的差异。某应用的最后一项排到了最右列。查看 dump(组件状态转储)后发现,左列和中列的底边是 730.70,右列是 730.50。
0.20 的差值在像素取整后难以分辨,但组件仍按布局值选列。右列最短,最后一项就排到右列。只有三列底边完全相等时,组件才从左往右选择。
dump 不单独打印各列高度,可以用 FlowItem 的 FrameRect 计算。将 y 坐标与高度相加,得到该项的底边。同一列最后一项的底边,就是该列的底边。以下是输出示意,右侧标注了计算结果:
FlowItem ID: 120 FrameRect: RectT (0.00, 610.70) - [118.00 x 120.00] 左列底边 730.70
FlowItem ID: 121 FrameRect: RectT (124.00, 640.70) - [118.00 x 90.00] 中列底边 730.70
FlowItem ID: 122 FrameRect: RectT (248.00, 620.50) - [118.00 x 110.00] 右列底边 730.50
FlowItem ID: 123 FrameRect: RectT (248.00, 738.50) - [118.00 x 100.00] 最后一项放到右列
2.2 布局模式对比
下面的蓝框表示当前视窗。远跳后向上回滑,顺排模式读取已记录的位置;滑窗模式则逐项补入上方卡片。滑窗若没有历史列记录,就按当前各列的边界选列;有历史记录时,优先恢复原列。
顺排模式
滑窗模式
2.3 顺排模式:保留已计算的位置
ALWAYS_TOP_DOWN 是默认模式。组件从第一张卡片开始向下布局,并保留已计算的位置。数据、卡片高度和列数不变时,回滑仍能读到原来的位置。
首次远跳需要补算前面的布局。例如,使用 scrollToIndex 跳到第 1000 项,需要先计算前 999 项。计算量较大时,跳转会出现停顿。列数固定、以连续滑动为主的页面,可以使用顺排模式。
2.4 滑窗模式:只维护视窗附近的布局
滑窗模式只维护当前视窗附近的布局。向下滑动时在下方补入卡片,向上滑动时在上方补入。远跳时无需补算前面所有卡片;列数变化时,也只需重排视窗附近的内容。这种模式适合较长的信息流,以及经常远跳或调整列数的页面。
使用滑窗模式时,需要接受两项限制。总滚动距离是估算值,滚动条位置也随估算结果变化。远跳后向上回滑,若上方卡片没有历史列记录,组件会重新选列。此时的排列可能与从顶部连续滑下来的结果不同。
下面的示例演示远跳后的回滑过程。卡片按当前两列的顶端位置向上补入。滑到顶部并停止后,组件检查两列顶端是否对齐。如果顶端不齐,就重新布局,画面会出现一次跳变。
2.5 sections:分组设置布局
sections 描述各段的项数、列数和间距。例如,A 段单列、B 段两列、C 段三列。分组配置只提供布局规则,数据仍由 LazyForEach 提供。两种布局模式都支持分组。
启用 sections 后,各段的 crossCount 决定列数,columnsTemplate 不再生效。所有段的 itemsCount 之和必须等于数据源的 totalCount。数量不一致时的排查方法见 3.1 节。
2.6 cachedCount:设置预加载范围
cachedCount 设置视窗外的预加载范围。按当前实现,WaterFlow 的两种模式都支持前后两侧的节点缓存。List 和 Grid 也设置两侧缓存,但计数单位不同。
下表以 cachedCount(2)、竖向正向滚动和普通数据项为例。此时,前后两侧分别对应视窗的上方和下方。
| 组件 | 计数单位 | cachedCount(2) 的含义 |
|---|---|---|
| WaterFlow(两种模式) | 项(FlowItem) | 可见索引范围前后各 2 项,项数不随列数增加 |
| List | 行 | 上方 2 行、下方 2 行;单列各 2 项,两列且每行排满时各 4 项 |
| Grid | 竖向按行,横向按列 | 竖向上方 2 行、下方 2 行;三列且每行排满时各 6 项 |
表中列出的是目标缓存范围。到达页面开头或末尾时,组件按剩余数据量预加载。List 使用 ListItemGroup 时,按组内的行计数,空组算一行。Grid 包含跨行、跨列项时,每行的实际卡片数会变化。
布局记录与缓存节点需要分开看。布局记录保存卡片的尺寸和位置,缓存节点则是视窗附近已构建的卡片。
顺排模式可以保留远处的位置记录,同时释放对应的卡片节点。视窗上方缓存范围内的节点,则可以保留或重新创建。
使用 LazyForEach 或开启虚拟滚动的 Repeat 时,组件会按需提前构建缓存项,并测量需要布局的项。show 默认是 false。组件利用空闲时间预加载,暂不显示缓存节点,部分节点保存在主树之外。
排查缓存问题时,先确认布局模式和缓存范围,再查看节点状态。设置 cachedCount(2, true) 后,缓存项参与显示。配合裁剪设置,可以显示视窗外的内容。
预加载会消耗时间和内存。建议从默认值开始,先确认掉帧是否发生在卡片构建阶段。如果确实需要提前构建,再逐档增加 cachedCount。卡片内容复杂时,先减少单张卡片的构建和测量开销。
接口说明:WaterFlow cachedCount、List cachedCount 和 Grid cachedCount。
2.7 组合惰性布局
WaterFlow 支持下表中的四种惰性布局组件。子项滚动到视窗附近时,组件才构建和排列这些子项。页面由外层 WaterFlow 统一处理滚动。
| 组件 | 排列方式 | 使用场景 |
|---|---|---|
| LazyVGridLayout | 规则网格 | 分类入口、相册 |
| LazyColumnLayout | 单列列表 | 动态、消息、说明条目 |
| LazyVWaterFlowLayout | 不等高瀑布流 | 商品、图片卡片 |
| LazyDynamicLayout | 应用自定义排列规则 | 预设布局满足不了的特殊卡片编排 |
前三种预设布局可以放在同一个 WaterFlow 中,分别组织网格、列表和瀑布流。
在 Scroll 中嵌套 Grid、List 和 WaterFlow 时,如果内层容器各自滚动,就需要处理手势分配和滚动位置。改用惰性布局后,页面由一个容器滚动,可以减少这类嵌套配置。
如果为了让 Scroll 统一滚动而展开全部内层内容,页面会一次构建所有子项。惰性布局只构建视窗附近的卡片,其余数据滚到附近再构建。各组共用外层 WaterFlow 的 cachedCount 和节点回收机制。
使用 sections 组织多组内容时,需要把数据合并到一个 LazyForEach 数据源,再用 itemsCount 划分各段。任意一组增删数据,都要同步更新分组数量。数量不一致会导致 3.1 节中的布局中断。
惰性布局用组件树表示分组。每组使用自己的 LazyForEach 和数据源,可以分别加载、分页和刷新,无需维护 sections 的数量。
WaterFlow() {
LazyVGridLayout() {
LazyForEach(this.categorySource, (item: Category) => { CategoryCard({ item: item }) }, (item: Category) => item.id)
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.header(() => { Text('推荐分类').height(40) })
LazyColumnLayout() {
LazyForEach(this.feedSource, (item: Feed) => { FeedRow({ item: item }) }, (item: Feed) => item.id)
}
.header(() => { Text('今日动态').height(40) })
LazyVWaterFlowLayout() {
LazyForEach(this.goodsSource, (item: Goods) => { GoodsCard({ item: item }) }, (item: Goods) => item.id)
}
.header(() => { Text('猜你喜欢').height(40) })
.footer(() => { Text('加载更多').height(36) })
}
上例中,“猜你喜欢”触底追加 100 条数据时,只更新 goodsSource。“推荐分类”刷新时,只更新 categorySource。
前三种预设布局支持 header 和 footer。它们参与各组的高度计算和滚动边界计算。配置 sticky 后,可以让组头吸顶或组尾吸底。例如,滚动到某一组时,将该组的 header 固定在视窗顶部。
需要自定义排列规则时,可以使用 LazyDynamicLayout(algorithm)。应用传入布局算法,决定子项的尺寸和位置。这适合预设网格、单列或瀑布流无法满足的页面,例如包含多种尺寸卡片的特殊编排。
自定义算法根据视窗范围处理附近的子项,避免一次展开全部数据。可见项变化时,onVisibleIndexesChange 返回当前可见的索引列表,应用可以据此记录曝光。
接入 WaterFlow 时,先将外层或所在 section 设为单列,再让自定义布局方向与外层滚动方向一致。应用需要实现测量和排布逻辑。排查问题时,应一并提供自定义算法和最小复现示例。
接口说明:LazyDynamicLayout。
3. WaterFlow 常见问题
排查 WaterFlow 卡顿时,先区分现象:滑不动、不流畅和跳一下。
3.1 滑不动:数据源与 sections 数量不一致
加载下一页后滑不动时,先检查 sections 与数据源的数量是否一致。sections 描述每段的项数,LazyForEach 提供数据。所有 section 的 itemsCount 之和必须等于数据源的 totalCount。
组件在每次布局前核对数量。数量不一致时,组件输出告警并跳过本轮布局,于是出现“滑不动”的现象。需要先修正数据,增大缓存或切换布局模式无法解决数量不一致的问题。
分页追加时分两步更新,就可能出现数量不一致。例如,先向数据源追加 100 条并通知 LazyForEach,再到另一个回调中更新 sections,或者漏掉这次更新。在两次更新之间,组件读到 300 条数据,而 sections 只有 200 项,校验就会失败。
先查看 hilog 日志,确认是否有数量不一致的告警:
W C03923/AceWaterFlow: Children count = 300 and doesn't match the number provided in Sections, which is 200.
日志中的前一个数字是 LazyForEach 当前提供的数据项数。后一个数字是 sections 中各段 itemsCount 之和。
再用 dump 核对组件当前的配置和状态。以下命令依次查找窗口、查找节点和查看节点详情:
# 1. 列出窗口,找到应用的 WinId
hdc shell "hidumper -s WindowManagerService -a '-a'"
# 2. 保存组件树,在其中找到 WaterFlow 节点的 ID
hdc shell "hidumper -s WindowManagerService -a '-w 12 -element'" > waterflow-tree.txt
# 3. 查看该节点的详细状态
hdc shell "hidumper -s WindowManagerService -a '-w 12 -element -lastpage 345'" > waterflow-node.txt
将命令中的 12 和 345 替换为实际的窗口 ID 和节点 ID。同一页面有多个 WaterFlow 时,先在组件树中确认目标节点。
排查时重点检查以下 dump 字段:
- 布局模式:检索
WaterFlowLayoutMode或Mode:。确认输出是TOP_DOWN还是SLIDING_WINDOW,再与预期配置核对。 - 分组数量:检索
itemCount:,每段对应一行。将各段itemCount之和与数据源总量比较。注意,dump 字段名为itemCount,接口配置名为itemsCount。 - 滚动边界:检索
offsetEnd:和itemEnd:。两者均为true时,表示已经滚动到末尾。此时无法继续向后滚动,属于正常触边。
sections 的输出示例如下:
[section:0]{ itemCount:4 },{ crossCount:1 },...
[section:1]{ itemCount:2 },{ crossCount:2 },...
[section:2]{ itemCount:194 },{ crossCount:2 },...
数据总量应从应用侧获取。懒加载时,屏幕外的数据不一定已生成节点,因此 dump 中的 FlowItem 节点数不能代表数据总量。
日志有数量告警,且 dump 中的数量与数据源不一致时,应检查应用的分组数量和更新顺序。
初始化时,按数据总量生成分组,确保各段 itemsCount 之和等于 totalCount:
// 分组配置:单列、双列交替,最后一段兜底
oneColumnSection: SectionOptions = { itemsCount: 4, crossCount: 1, columnsGap: 5, rowsGap: 10 };
twoColumnSection: SectionOptions = { itemsCount: 2, crossCount: 2 };
lastSection: SectionOptions = { itemsCount: 20, crossCount: 2 };
aboutToAppear() {
let sectionOptions: SectionOptions[] = [];
let count = 0; // 已分配的 FlowItem 数量
let oneOrTwo = 0;
while (count < this.dataCount) {
if (this.dataCount - count < 20) { // 剩余不足 20 个,交给最后一段
this.lastSection.itemsCount = this.dataCount - count;
sectionOptions.push(this.lastSection);
break;
}
if (oneOrTwo++ % 2 == 0) {
sectionOptions.push(this.oneColumnSection);
count += this.oneColumnSection.itemsCount;
} else {
sectionOptions.push(this.twoColumnSection);
count += this.twoColumnSection.itemsCount;
}
}
this.sections.splice(0, 0, sectionOptions); // 各段 itemsCount 之和 == dataCount
}
触底加载时,在同一个回调中更新数据源和最后一段的 itemsCount:
.onScrollIndex((first: number, last: number) => {
if (last + 20 >= this.dataSource.totalCount()) {
for (let i = 0; i < 100; i++) {
this.dataSource.addLastItem(); // 数据源 +100,内部发出 LazyForEach 通知
}
const sections: Array<SectionOptions> = this.sections.values();
let newSection: SectionOptions = sections[this.sections.length() - 1];
newSection.itemsCount += 100; // 最后一段 +100
this.sections.update(-1, newSection); // -1 表示最后一个分组
}
})
更新后,各段 itemsCount 之和必须等于 totalCount。数据源和分组数量应在同一帧内完成修改。
3.2 不流畅:一帧内工作量过大
快速滑动不流畅,可能是一帧内的工作量超过预算。需要检查卡片创建和测量的耗时、图片解码对主线程的占用,以及滚动回调中的业务任务。仅凭组件的错误日志,未必能定位这些耗时。
卡片的优化方法可参考华为文档《长列表加载丢帧优化》。
顺排模式还有一种需要检查的情况:滚动到较远位置后,切换横竖屏或展开折叠屏。容器宽度变化会重置已有布局信息。组件需要从索引 0 重新布局,直到视窗内布满卡片,因此可能出现卡顿。
滑窗模式只布局视窗范围内的卡片,可用于处理这类重新布局开销。先查看 dump 中的布局模式,抓取方法见 3.1 节:
WaterFlowLayoutMode: TOP_DOWN
若 dump 显示 TOP_DOWN,且卡顿发生在远距离滚动后的横竖屏切换或折叠屏展开,应重点检查这次重新布局的开销。
3.3 回滑时跳动:屏幕外卡片高度变化
回滑时卡片“跳一下”,可能是屏幕外的卡片高度发生了变化。一张卡片变高后,后续卡片需要下移,也可能换列。
变高的卡片在屏幕外时,组件不会立即重排。回滑到附近并重新测量后,组件才读到新高度,再一次性调整后续卡片的位置,画面就会出现跳动。
先验证新内容是否已更新到组件。只修改普通对象的字段,却未发送 LazyForEach 变化通知,可能让复用节点继续显示旧内容。下面的示例通过更新变化项的 key 并发送通知,让组件重建该项。
再确认跳动是否由高度变化引起。补发通知只能保证内容及时更新;卡片高度改变后,组件仍需重排。要保持卡片高度稳定,可以预先设置图片宽高比,并限制文本行数。
可通过 hilog 日志验证高度是否变化。重新测量的高度与记录值不同时,组件会输出:
I C03923/AceWaterFlow: item size change. currentIdx:40,cacheHeight:160.000000,itemHeight:240.000000
currentIdx 是发生变化的卡片索引,cacheHeight 是此前记录的高度,itemHeight 是重新测得的高度。
以下写法只修改对象字段,组件无法获知变化,复用节点会继续显示旧内容:
this.items[index].height = newHeight; // 只改了普通对象
修改变化项的 key,并发送 onDataChange 通知,可让组件重建这一项。示例中的 key 由 id 和版本号生成。只递增变化项的版本号,其他项的 key 保持不变:
this.items[index].height = newHeight;
this.items[index].version++; // key = id + ':' + version
this.listeners.forEach(l => l.onDataChange(index));
4. OpenHarmony 7.1 规划特性
本章整理 OpenHarmony 7.1 的规划特性,涉及 WaterFlow 和其他滚动容器。
内容整理自 7.1 需求库。部分需求仍在评审或实现中,接口名和参数以最终发布的 SDK(Software Development Kit,软件开发工具包)为准。
4.1 scrollToIndex 带动画快速跳转
enableFastScroll 用于带动画的远距离跳转,减少中间卡片的创建。原有 scrollToIndex 需要逐项创建并布局中间卡片,才能到达目标。7.1 计划在 ScrollToIndexOptions 中增加这一选项,默认值为 false。
开启后,组件根据已布局卡片的位置和平均高度估算目标位置,再启动动画。动画允许跳帧,无需逐项创建中间卡片。新卡片完成布局后,组件更新位置数据,并修正动画终点。未开启时,行为保持不变。
this.scroller.scrollToIndex(1000, true, ScrollAlign.START, { enableFastScroll: true })
4.2 滚动时创建占位组件
滚动占位组件用于分摊真实卡片的创建开销。快速滚动时,单张卡片可能需要几毫秒才能创建。同一帧创建多张卡片,就可能超过帧预算。
7.1 计划为 List、Grid 和 WaterFlow 引入滚动负载预测。组件根据当帧剩余预算和近期卡片创建耗时,决定同步创建真实卡片,还是先显示占位组件。耗时按组件类型和模板分别统计。真实卡片在后续帧中分批构建,完成后替换占位组件。
占位组件使用全局、无参的 @Builder,只支持静态渲染,不响应状态和手势。WaterFlow 的两种布局模式都在支持范围内,section 和 footer 不使用占位组件。
@Builder({ isScrollPlaceholder: true })
function CardPlaceholder() {
Column().width('100%').height(100).backgroundColor('#eeeeee') // 只做静态渲染
}
WaterFlow() {
LazyForEach(this.dataSource, (item: number) => {
FlowItem() { HeavyCard({ item: item }) } // 单张创建约 5 ms
}, (item: number) => item.toString())
}
.scrollPlaceholder((index: number) => {
return { id: 'CardPlaceholder', size: { width: 100, height: 100 } } // 尺寸尽量接近真实卡片
})
先调用 UIContext 的 scrollPlaceholderRegister,注册占位 Builder,再按 id 引用。注册接口形态以最终发布的 SDK 为准。
4.3 滚动限位
点击任一对齐方式,自动模拟一次手势滚动和松手后的吸附。蓝色卡片是目标,橙线是对齐位置。
选择上方按钮,观察卡片从松手位置移动到限位线。
scrollSnapStrategy 用于在滚动结束后将卡片吸附到限位线。原有 WaterFlow 没有卡片级吸附。应用若要在停止滚动时将卡片顶部对齐,需要监听滚动、计算位置,再发起第二次滚动,过程中容易抖动。
7.1 计划增加这一策略。滚动结束后,组件从可见卡片和缓存卡片生成候选锚点。低速滚动按阈值选择目标,高速滚动按惯性落点选择目标,再用可打断的弹簧动效对齐到限位线。
滚动限位提供以下配置:
- 对齐位置:
START、CENTER和END。 - 限位线偏移
snapOffset:默认值为 0 vp,不支持百分比。 - 吸附阈值
snapThreshold:取值范围为 0~1,默认值为 0.5。使用默认值时,滚动距离超过相邻候选间距的一半,就切换到下一个目标。
触摸、滚轮、滚动条和惯性滚动结束时,会触发吸附。scrollToIndex、键盘和焦点引起的滚动不触发二次吸附。该策略默认关闭,未配置时行为保持不变。
WaterFlow() { ... }
.scrollSnapStrategy(ScrollSnapAlign.START) // 停下时卡片顶部对齐视窗顶部;不传或 undefined 清除策略
4.4 内置 header、footer 与 sticky
7.1 计划为 WaterFlow 新增三个接口:header(builder)、footer(builder) 和 sticky(StickyStyle)。header 和 footer 参与总高度、滚动边界、跳转和事件索引计算,内容尺寸变化时同步更新。
sticky 提供 None、Header、Footer 和 BOTH 四个值,用于配置吸顶和吸底。已有 footer、footerContent 和 sections 的行为保持不变。与新接口同时设置时,旧接口的优先级也保持不变。
WaterFlow() { ... }
.header(() => { Text('推荐').height(48) })
.footer(() => { Text('加载更多').height(40) })
.sticky(StickyStyle.BOTH)
4.5 惰性布局组件的两项增强
7.1 计划允许惰性布局组件与滚动容器之间嵌套其他容器。原有 LazyVGridLayout 等组件必须是滚动容器的直接子节点,中间增加一层 Column 就会崩溃。
新的实现从惰性布局组件向上查找最近的同轴滚动祖先。中间可以嵌套多层 Column、Stack 和 Flex。普通兄弟节点、padding 和 margin 仍参与布局,无需修改现有接口调用。惰性布局组件仍须位于滚动容器的子树中。
WaterFlow() {
FlowItem() {
Column() { // 以前这里的 Column 会导致崩溃
Text('标题')
LazyVGridLayout() { ... }
}
}
}
LazyVGridLayout 还计划支持按容器断点调整列数。columnsTemplate 增加 string 或 ItemFillPolicy 重载,与 Grid 保持一致。可配置为窄屏 1 列、中屏 2 列和宽屏 3 列,无需自行监听宽度并切换模板。固定模板的写法保持不变。
LazyVGridLayout() { ... }
.columnsTemplate({ fillType: ResponsiveFillType.BREAKPOINT_SM1MD2LG3 })