UNIAPP跨端瀑布流列表实现:双列拆分、图片高度估算与三端性能优化 做这个UNIAPP瀑布流列表起因是接手了一个内容社区的移动端改版需求产品要求首页信息流必须改成瀑布流形态同时要覆盖App、微信小程序、H5三端。当时第一反应是这不难无非是分两列渲染但真正做起来才发现瀑布流这套东西在移动端跨端场景下坑远比想象中多。这篇文章就把我做这个UNIAPP瀑布流列表的完整思路、实现方案、三端差异和踩坑过程记录下来希望能让你少走弯路。1. 瀑布流为什么在UNIAPP里看起来容易做起来难1.1 三种主流实现方案先岔开聊聊各自的脾气做瀑布流前端圈子公认的玩法大致有三种CSS多列布局column-count、绝对定位手算坐标、双列拆分渲染。三种方案各有拥趸但在UNIAPP这种跨端场景下它们的处境完全不一样。CSS多列布局在H5端是天生支持的写起来最爽几行CSS就能把列表分成两列甚至三列浏览器自己负责排版。但到了微信小程序端基础库对column-count的支持情况就很微妙实测在部分安卓机型上会出现列高度错乱、内容重叠的问题。App端如果用vue页面倒是支持但一旦涉及nvueCSS多列直接不好使。更关键的是CSS多列的渲染顺序是从第一列满到第二列视觉上和接口返回的数据顺序对不上产品往往要求新内容优先出现在高优先级的位置这就不太合适。绝对定位方案是前端框架玩瀑布流的老套路把所有卡片全部绝对定位通过维护两个或多个列的累计高度动态计算每个卡片左上角的坐标。好处是任意顺序可控数据和位置完全绑定坏处是必须预先知道每张卡片的高度这对图片流简直是噩梦。UNIAPP里图片走了wx/uni的异步加载高度在数据绑定那一刻是未知的不等图加载完你算不准位置等加载完再定位又已经闪了一遍体验很难处理好。双列拆分渲染是我最终选用的方案思路简单粗暴把接口返回的列表按卡片估算高度贪心地分配进左列和右列两个数组然后用CSS flex布局让两列并排列内各自纵向滚动。这个方案对UNIAPP三端的兼容性最好因为底层就是最基础的flex小程序、H5、App都能稳定支持而且可以做到增量更新加载下一页时把新数据接着拆分追加到两列性能也可控。1.2 真正的难点不在分两列在高度估算很多人觉得双列拆分最核心的是拆分算法其实不对。真正的难点在于每张卡片的高度怎么估准。一张卡片由封面图、标题、描述、作者信息、标签等组成。其中文字部分高度是相对稳定的标题可以限制行数描述可以截断这些都可以事先算个大概。真正不确定的是图片高度。如果服务端不返回图片宽高前端只能等图片加载完才知道比例这就导致先拆分的列高度可能是错的后加入的数据可能加到了其实是更矮的那一列上整体列平衡完全失控。所以做这个项目时我第一件事就是找后端确认所有列表接口必须带上图片的宽高字段哪怕是近似值也行。有了宽高比例前端就能在拿到数据的第一时间算出卡片高度列平衡算法才能正常工作。这是整个瀑布流列表能够稳定运行的前提后面章节我会展开讲具体的估算公式和代码。2. 双列拆分方案的核心从布局计算到数据分配2.1 屏幕信息获取与列宽计算别小看这两步UNIAPP里做双列布局列宽不能写死因为不同设备的逻辑宽度不一样。标准做法是使用uni.getSystemInfoSync()拿到当前窗口宽度然后换算成对应的像素尺寸。// 获取屏幕信息并计算列宽 const systemInfo uni.getSystemInfoSync(); const screenWidth systemInfo.windowWidth; // 定义左右间距和列间距单位rpx const pagePadding 20; // 页面左右边距 const gutter 20; // 两列之间的间距 const flexUnit screenWidth / 750; // rpx转px // 列宽 (屏幕宽度 - 左右边距 - 列间距) / 2 const columnWidthPx (screenWidth - (pagePadding gutter) * flexUnit * 2) / 2;这里有一个新手特别容易踩的坑直接用750rpx的换算。UNIAPP的rpx定义是屏幕宽750等分但windowWidth返回的是px所以必须先算出每个rpx对应的px值再做加减乘除。你当然可以在模板里直接写列宽为345rpx之类但一旦要动态计算图片高度这是肯定的就必须有一个精确的像素值。我在组件里用data维护一个columnWidth物理像素的精确度直接决定高度估算是否靠谱。2.2 图片高度预估服务端先给宽高前端再兜底假设接口返回的每个item里有coverWidth和coverHeight两个字段前端拿到后就可以结合列宽算出图片渲染高度。公式很简单渲染高度 列宽 × (原始高度 / 原始宽度)。function calculateItemHeight(item, columnWidth) { const imageHeight columnWidth * (item.coverHeight / item.coverWidth); const textHeight 60; // 估算文字区域高度标题、描述、作者信息等 const paddingSpace 20; // 卡片内边距与间隔 return imageHeight textHeight paddingSpace; }文字高度这块建议在真实UI稿渲染出来以后取几次平均值定死不要用js动态测量。因为标题可能两行也可能一行我们通过CSS给标题固定高度比如两行截断描述固定一行截断这样文字区域的高度就是确定的量。这个思路很多做瀑布流的老手都在用它牺牲了极端内容下的展示完整性换来了列表稳定性和可计算性。对于内容流形态来说这个取舍是值得的。如果服务端不给宽高怎么办那就得前端懒加载图片后再更新高度。UNIAPP里可以用uni.getImageInfo拿到图片宽高后重新计算该item的高度并更新对应的列数组。但这么做首屏大概率会闪跳而且在小程序端频繁调用getImageInfo会有一堆回调管理问题。这里我的建议是能推动后端加字段就坚决推动前端兜底方案仅作为线上异常时期的安全网。2.3 数据拆分算法贪心分配谁矮加谁拿到列表数据并用上面的方法算出每个item的估算高度之后拆分就很简单了维护两个列的累计高度新数据依次判断哪一列当前累计高度矮就把卡片插到哪一列同时累加该列的高度。splitIntoColumns(items) { const left []; const right []; let leftHeight 0; let rightHeight 0; items.forEach((item, index) { const itemHeight this.calculateItemHeight(item); if (leftHeight rightHeight) { left.push({ ...item, _height: itemHeight }); leftHeight itemHeight this.gutterPx; } else { right.push({ ...item, _height: itemHeight }); rightHeight itemHeight this.gutterPx; } }); return { left, right }; }这个算法的复杂度是O(n)页面加载个几十条数据完全没有性能压力。注意我用的判断条件是leftHeight rightHeight这个等号很重要当两列高度一致时新数据优先去左列能确保列表在页面上不会整体向右偏移。还有一点间隙值gutter也要计入累计高度不然两列会越来越不平衡视觉上一边会比另一边高半个卡片中间的列间距看起来也是歪的。加载下一页的数据时不能重新调用splitIntoColumns把所有数据重新拆一遍那样会导致已渲染的列表全部重排。正确做法是把新旧数据先合并成一个临时数组只对新增的那部分数据调用拆分逻辑分别append到两列已有的数据末尾。这样vue的虚拟dom只做增量更新已渲染的卡片不会被动到。3. 三端差异小程序、H5、App各有各的性子3.1 微信小程序onReachBottom天然加持但setData才是真瓶颈微信小程序是UNIAPP生态里表现最特殊的一端。好处是有原生的onReachBottom触底事件和scroll-view的bindscrolltolower页面级滚动用onReachBottom就够了H5和App反而要自己监听scroll。但小程序的性能瓶颈集中在setData上。瀑布流两列数据是嵌套在自定义组件里的每次向数组里push新条目小程序都会把整份列表数据经历一次序列化传输到渲染层。列表达到100条以上如果每次setData都传全量数组低端安卓机的卡顿会非常明显。我的处理方式是组件内设置一个阈值比如每页加载20条那么每次触底只对左右两列做一次数组concat并把拼接后的新数组一次性setData。注意不要拆成左列一次、右列一次连续两次setData这两次之间页面会有一次中间态渲染高度重新计算容易闪。合并成一次setData传左右两列加控制字段渲染层只re-render一次体感好很多。3.2 H5没有onReachBottom滚动监听要注意防抖UNIAPP在H5端页面级onReachBottom其实也能触发但它的实现依赖window的scroll事件监听比较粗糙。如果你在H5上测试发现触底加载总是提前触发或者不触发不要怀疑十有八九是page底部的body高度计算和实际内容高度对不上尤其当页面里有transform动画或者绝对定位元素时。更稳的方案是自己监听滚动在mounted里给window加上scroll事件计算scrollTop windowHeight是否接近document.body.scrollHeight。但千万记得要加节流否则滚一次列表能触发十几二十次回调就算你有loading状态兜底逻辑上也是在空转。onScrollHandle() { if (this.loading || this.finished) return; const scrollTop window.pageYOffset || document.documentElement.scrollTop; const windowHeight window.innerHeight; const bodyHeight document.body.scrollHeight; if (scrollTop windowHeight bodyHeight - 200) { this.loadMore(); } }H5端的优势是DOM操作灵活可以用IntersectionObserver做图片懒加载性能比小程序lazy-load属性稳定得多。但注意不要给瀑布流卡片都包上一堆多余div层级H5的渲染层没有小程序那么严峻但节点数量超过2000个之后滚动一样会掉帧保持DOM结构干净是第一原则。3.3 App端vue派生的webview渲染rpx换算最容易出偏差App端的vue页面其实跑在webview里所以很多H5的CSS能力它都支持这也是为什么你用H5预览觉得没问题App里大概率也没问题。但有个细节App端rpx换算和H5、小程序略有差异。某些安卓WebView对windowWidth的返回存在偏差导致uni.getSystemInfoSync()得到的数值在不同系统版本上不完全一致直接影响列宽计算和高度估算。我的建议是App端优先使用px作为布局计算单位所有需要参与高度计算的尺寸都通过getSystemInfoSync换算成px不要用字符串拼接rpx再交给CSS去渲染。如果实在需要rpx可以用uni.upx2px()函数统一转换避免自己写的换算式子和UNIAPP内部出现偏差。另外App端vue页面滚动时不会有小程序的渲染层通信开销但又没有H5的浏览器优化长列表内存占用问题比较突出。瀑布流图片又多建议在App端做图片的内存缓存控制图片库选用支持缓存上限配置的组件库防止列表持续滑动导致WebView内存飙升后白屏。3.4 公共封装一个组件打三端靠条件编译收口差异为了让调用的页面保持干净我把瀑布流封装成了一个公共组件WaterfallList.vue。组件内部通过条件编译区分端的差异。!-- #ifdef H5 -- script import WaterfallMixinH5 from ./mixins/h5.js export default { mixins: [WaterfallMixinH5] } /script !-- #endif -- !-- #ifdef MP-WEIXIN -- script import WaterfallMixinWx from ./mixins/wx.js export default { mixins: [WaterfallMixinWx] } /script !-- #endif --条件编译是UNIAPP跨端开发最核心的工具同样的滚动监听、触底判断、图片懒加载在不同端走不同实现但对外暴露的props和events完全一致。比如触底加载事件统一叫loadMore页面上只需要监听它然后请求下一页数据刷新数据重新塞回去即可。这样任何业务页面接入瀑布流都像接入一个普通list组件一样三端差异被很好地封装在组件内部了。4. 滚动加载、状态管理、图片懒加载的联动设计4.1 触底加载的节流与加载态控制别让三重请求打爆接口瀑布流列表的滚动加载最怕的就是用户滚动速度很快触底事件连续触发结果同一个接口被发了三四遍。我见过很多初学者在这里只判断一个loading状态但异步接口返回速度慢loading还没置为true下一次触底判定又进来了最后还是重复请求。成熟的方案是三重锁// 三重锁防止请求风暴 function loadMore() { if (this.loading) return; // 锁1正在加载 if (this.finished) return; // 锁2没有更多 if (this.loadingLock) return; // 锁3网络层防重入 this.loading true; this.loadingLock true; // 请求下一页... }最后一个loadingLock是为了防止绑定在H5滚动、小程序onReachBottom、App端onscrolllower中的回调同时执行时出现的竞态。第一重锁在渲染层第二重和第三重在逻辑层双保险。接口回调里必须用finally置false不能只在成功分支里解除否则一旦报错列表整个废掉怎么滚都不加载。加载状态我用一个footer区域展示三态对应三种文案加载中显示加载中...没有更多显示已经到底啦加载失败显示加载失败点击重试。footer的展示逻辑因为是组件内部状态触发重试时emit一个retry事件给父页面父页面重新请求对应页码。4.2 图片加载失败与加载中的高度占位策略瀑布流的卡片高度一但确定任何一张图片的加载失败都可能导致卡片高度出问题。最恶心的场景是图片加载失败时容器高度塌陷下方所有卡片上移几像素然后再回来用户看着就是整个列表跳了一下。应对方法是给图片容器设置一个最小高度并利用CSS的背景色作为占位。具体做法是在渲染卡片时不是直接放image组件而是包一层view这个view的height由我们计算好的高度值控制图片absolute铺满占比百分之百。view classcard-image-wrap :style{ height: itemHeight px } image classcard-image :srcitem.cover modewidthFix lazy-loadtrue erroronImageError / /view即使图片加载失败外层容器高度也由itemHeight兜住了卡片不会塌陷。如果图片最后真的加载出来了但高度和估算值有偏差由于列里的卡片都是用flex布局纵向排列的多几像素少几像素只会影响该列后面的累计高度不会导致客户遮挡视觉上也只是近似基本看不出破绽。之前说的lazy-load在小程序端的坑也得提一句部分低版本基础库对这个属性支持不稳定表现为先一次性加载所有图片懒加载形同虚设。所以我后来在H5端直接用IntersectionObserver来替代小程序端则保持lazy-load属性能省则省。4.3 空态、异常态、首屏体验瀑布流这种形态因为内容密度高空态和异常态很容易被忽略但偏偏列表为空时用户打开页面直接傻眼连个提示都没有。我设计了一个全覆盖的状态机loading首屏骨架屏、empty空态提示、error网络失败重试、normal正常内容。首屏不能用loading转圈占着用户体验太差我直接用八张灰色占位卡片当骨架屏等数据回来后替换成真实卡片这样用户第一时间看到的是页面框架已经就位而不是一个白色的底加菊花图。骨架屏的实现不复杂写一个占位卡片组件内部用固定高宽的灰色圆角块模拟图片和文字透明度做成呼吸动画数据到达后v-if切换成真实卡片。这里有个小细节要注意骨架屏要用和真实卡片完全一致的布局结构否则用户二次刷新时会觉得页面结构变了产生不信任感。5. 三次迭代复盘那些必须记录下来的坑5.1 第一次盲目用column-count小程序安卓机直接翻车最开始我图省事直接给外层容器加column-count:2H5端显示完美。结果微信小程序安卓端一测出现了严重的列错乱第一列的卡片跑到第二列去第二列有的卡片又叠在第一列的下方整个列表像被打乱的扑克牌。后来查原因是部分安卓基础库渲染column布局时对图片类异步加载元素的高度计算存在bug并且layout缓存更新不及时。这个教训让我明白一件事在小程序端dom布局越基础越安全。flex的横向排列经过这么多年小程序渲染层的迭代已经非常稳定column这种冷门布局在新渲染引擎上的适配并没有想象中乐观。如果你做瀑布流的第一版就想省事建议直接跳过column-count走flex双列少踩一个版本的雷。5.2 第二次服务端没给图片宽高首屏闪烁到怀疑人生第一次迭代上线后测试同学反馈首屏卡片经常跳有时候往下滑一下第一张图突然变高后面的卡片像多米诺骨牌一样塌下去又立起来。根因就是当时后端没有返回图片宽高前端用uni.getImageInfo去取图算高度图片是异步加载的高度计算自然就滞后了。我给后端提了需求要求列表接口必须返回图片宽高同时前端加了兜底方案如果item没有宽高字段卡片高度先给一个固定值当作占位图片加载成功后通过emit事件把真实高度传回来再更新。但这个兜底方案我只开了灰度因为它在逻辑上引入了高度二次变更的复杂度而且极低概率下会碰到服务端返回的宽高比与实际图片有出入。最后稳定上线的方案就是前面提到的服务端字段优先前端在不改变已渲染卡片高度的情况下仅对新增卡片做高度计算。首屏之后除非用户刷新否则不再有任何动态高度变更闪烁问题彻底消除。5.3 第三次安卓低端机长列表滚动掉帧优化了一整天稳定跑了两周之后有用户反馈在某安卓千元机上滑动列表非常卡帧率大概十几帧。我用老旧安卓真机测了发现不光是滚动掉帧有时候还会白屏一下。当时第一反应是图片太大压缩一下就行但压缩后改善有限。后来用开发者工具的Performance面板去测小程序端发现瓶颈竟然在setData的单次数据量当列表已经积累了200条数据时每次触底setData传输的数据大小有480KB左右这在小程序渲染层和小程序端js线程之间通信时要被序列化低端机直接拖垮。优化思路有两个方向一是分页加载时只传新增的几条而不是全量二是把列表拆成多个子组件每个子组件只负责10条数据这样单个组件的setData更新范围缩小渲染层只需要局部更新。我采用了第二种比较灵活——把两列渲染改造成两个子列表组件左列组件只接收左列数据右列组件只接收右列数据上层变化时两个子组件各自更新通信数据量瞬间砍半。同时把图片统一压到宽度800px以内格式转成webp安卓低端机的帧率回来了。5.4 一个小彩蛋模拟器的正常不代表真机正确最后补一个经验这个项目在小程序和App的模拟器上怎么看都正常但真机上就是有问题。比如App端由uni.getSystemInfoSync拿到的windowWidth在iPad上会因为屏幕比例不同导致列宽计算偏差小程序模拟器的onReachBottom触发节奏和真机也完全不一样。所以瀑布流这种对几何尺寸极其敏感的组件一定不能只在模拟器里测试多借几台真机尤其是不同逻辑宽度、不同系统版本的机器跑一遍能提前发现很多隐藏的bug。6. 让瀑布流组件更通用的几个扩展思路如果只是做一个两列固定间距的瀑布流上面这些已经够用了。但如果你要考虑更多业务场景有些扩展点可以提前设计好。一个是列数可配置。平板或者横屏场景下两列可能显得太空做成props里的column参数拆分成多列。算法逻辑不变就是维护一个columns数组判断时找累计高度最小的那列。另一个是卡片间距可配置。不同页面的设计规范不一样有的要紧凑有的要宽松。间距作为prop传进来组件内部用这个间距去计算列宽和累计高度保证页面的视觉风格统一。还有一个是自定义渲染插槽。不同业务的卡片内容差异很大图文卡片、视频卡片、纯文字卡片高度计算方法都不一样。可以把高度计算策略做成可注册的解析器页面传入自己每个卡片的高度计算函数组件只负责摆放。这样瀑布流组件本身不参与业务判断可复用性会提升一个台阶。我在实际做下来的感受是瀑布流本身不是高深技术但它对数据的结构性要求非常高。如果你能推着后端把宽高、附加信息都规范化返回前端的分层设计就容易很多。反过来如果前端强行自己猜高度短期内能跑长期一定会累积出一堆边缘问题。希望这篇记录能对正在做UNIAPP瀑布流列表的你有点帮助。最后再提醒一次真机测试千万别省。