HarmonyOS7 虚拟列表不卡顿的关键在哪?动态高度和多列布局这样封装

📅 2026/6/30 14:34:41
HarmonyOS7 虚拟列表不卡顿的关键在哪?动态高度和多列布局这样封装
文章目录前言LazyForEach 的局限在哪动态高度预估 缓存双保险多列布局Grid 结合虚拟滚动头部吸附 Sticky Header实战通用 VirtualList 组件下拉刷新的集成用起来的感受前言用过 HarmonyOS 的LazyForEach都知道它帮我们解决了大列表全量渲染的问题。但用久了你会发现这东西在几个场景下力不从心列表项高度不固定、要做多列瀑布流、还要搞头部吸附——光靠原生 API 根本搞不定。这篇文章我把这几个坑全踩一遍最后给你一个可以直接拿去用的VirtualList组件。LazyForEach 的局限在哪LazyForEach的核心思路是滑到可视区才创建组件听着挺好。但它有个前提假设每个列表项的高度是已知或固定的。一旦你的列表项里混着单行文本、多行图文、甚至嵌套卡片高度参差不齐LazyForEach就懵了——它算不准滚动偏移量会出现跳动、白屏、定位错乱。再加上它不支持多列布局你想做个类似小红书的瀑布流得自己另起炉灶。动态高度预估 缓存双保险我的方案是先估后测。给每种类型的数据项一个预估高度渲染完成后再用onAreaChange回调拿到真实高度存进缓存。// 高度缓存管理器classHeightCache{privatecache:Mapstring,numbernewMap()privatedefaultHeight:numberconstructor(defaultHeight:number80){this.defaultHeightdefaultHeight}getHeight(key:string):number{returnthis.cache.get(key)??this.defaultHeight}setHeight(key:string,height:number):void{this.cache.set(key,height)}// 计算指定范围内的累计高度getTotalHeight(startIndex:number,endIndex:number,keyGetter:(index:number)string):number{lettotal0for(letistartIndex;iendIndex;i){totalthis.getHeight(keyGetter(i))}returntotal}}关键点在于预估高度让滚动条一开始就有正确的比例真实高度缓存让后续滚动越来越精准。跑通这个逻辑后列表跳动的问题基本消失了。多列布局Grid 结合虚拟滚动HarmonyOS 的WaterFlow组件本身支持虚拟滚动但在自定义程度上有很多限制。我选择用Grid 手动可视区计算来实现。思路是把数据按列数分组每列独立维护一个高度累加器新数据总是丢给当前最短的那列functiondistributeToColumnsT(items:T[],columnCount:number,heightCache:HeightCache):T[][]{constcolumns:T[][]Array.from({length:columnCount},()[])constcolumnHeights:number[]newArray(columnCount).fill(0)for(constitemofitems){// 找最短列constminIndexcolumnHeights.indexOf(Math.min(...columnHeights))columns[minIndex].push(item)columnHeights[minIndex]heightCache.getHeight((itemasany).id)}returncolumns}这样做瀑布流布局每列的高度差异最小化视觉上更协调。头部吸附 Sticky HeaderSticky Header 的实现核心是监听滚动偏移量。当某个 section 的 header 滚出可视区顶部时用一个Stack在顶部叠一层吸住的 header。StatestickyHeaderIndex:number0StatestickyOffset:number0// 在 onScroll 回调里计算onScroll((scrollOffset:number){// 遍历 section 的累计高度找到当前应该吸附的 sectionletaccumulated0for(leti0;ithis.sections.length;i){constsectionTopaccumulatedconstsectionBottomaccumulatedthis.heightCache.getHeight(section-${i})if(scrollOffsetsectionTopscrollOffsetsectionBottom){this.stickyHeaderIndexi// 当下一段 header 要顶上来时当前吸附 header 要往上推this.stickyOffsetMath.max(0,scrollOffset-sectionTop)break}accumulatedsectionBottom}})这个stickyOffset很关键——它让吸附的 header 在被下一个 header 推走时有个自然的过渡效果不会突然消失。实战通用 VirtualList 组件把这些能力拼到一起封装成一个通用组件。对外暴露数据源、列数、header 构建器、item 构建器Componentexportstruct VirtualListT{Propitems:T[][]PropcolumnCount:number1PropestimatedItemHeight:number80BuilderParamitemBuilder:(item:T,index:number)voidBuilderParamsectionHeaderBuilder?:(sectionIndex:number)voidPropenableStickyHeader:booleanfalsePropenablePullRefresh:booleantrueEventonRefresh?:()voidprivateheightCache:HeightCachenewHeightCache()StateprivatestickyHeaderIndex:number0StateprivatestickyOffset:number0StateprivateisRefreshing:booleanfalsebuild(){Stack(){Scroll(){Column(){ForEach(this.sections,(section:T[],sectionIdx:number){// Section Headerif(this.sectionHeaderBuilder){Column(){this.sectionHeaderBuilder(sectionIdx)}.onAreaChange((_old:Area,newArea:Area){this.heightCache.setHeight(section-${sectionIdx},newArea.heightasnumber)})}// 多列布局Row(){ForEach(distributeToColumns(section,this.columnCount,this.heightCache),(column:T[],colIdx:number){Column(){ForEach(column,(item:T,itemIdx:number){Column(){this.itemBuilder(item,itemIdx)}.onAreaChange((_old:Area,newArea:Area){this.heightCache.setHeight((itemasany).id,newArea.heightasnumber)})})}.layoutWeight(1)})}})}}.onScroll((offset:number){if(this.enableStickyHeader){this.updateStickyHeader(offset)}})// Sticky Header 覆盖层if(this.enableStickyHeaderthis.sectionHeaderBuilder){Column(){this.sectionHeaderBuilder(this.stickyHeaderIndex)}.translate({y:-this.stickyOffset}).position({top:0}).width(100%).zIndex(10)}}}privateupdateStickyHeader(scrollOffset:number):void{// 同上面 Sticky Header 的计算逻辑}}下拉刷新的集成下拉刷新直接在Scroll外面包一层Refresh组件就行但要注意跟 Sticky Header 的层级关系Refresh({refreshing:this.isRefreshing}){// 上面的 Scroll 内容}.onRefreshing((){this.onRefresh?.()// 数据加载完后关闭刷新状态setTimeout((){this.isRefreshingfalse},1000)})用起来的感受封装完之后我在一个电商项目里实测了 5000 条混合高度的商品列表滚动流畅度跟原生LazyForEach固定高度的场景几乎没有区别。多列瀑布流的列间距、item 间距都可以正常控制。唯一需要注意的是onAreaChange在高频触发时有一定性能开销。建议在列表项类型有限的场景下给预估高度设得准一些减少高度缓存的修正次数。动态高度虚拟列表这个需求HarmonyOS 官方后续大概率会给出原生支持。但在那之前这套方案能帮你撑过业务需求。代码量不大但细节挺多建议跑一遍 demo 再往项目里搬。