简介PhotoWall 是一份面向 Java 与 Android 开发者的瀑布流照片墙实战项目适合想深入理解图片加载优化、内存缓存与自定义布局的初中级开发者参考学习。项目以不规则列宽自上而下排列图片滚动时新图不断填充形成类似真实墙面的连续视觉体验核心亮点是采用 LrcCache最近最少使用缓存策略借助 LinkedHashMap 实现 O(1) 复杂度的插入、删除与查找缓存已加载图片减少重复网络请求与滚动卡顿。压缩包共 57 个文件约 1.87MB包含 6 个 java 源码、16 个 class 编译文件、11 个 png 图片资源、6 个 xml 布局与配置、5 个 jar 依赖库以及 apk、dex、properties 等构建产物目录结构完整便于直接导入运行与二次开发。目前已有 292 人学习下载。读者可从中掌握异步加载、图片压缩、磁盘缓存与瀑布流 ViewGroup 位置计算等关键技巧积累处理大量图片加载与优化内存使用的实战经验。1. 照片墙不是把图片铺满屏幕那么简单很多人第一次做照片墙脑子里想的是「九宫格铺满就行」结果上线后才发现图片比例参差不齐、滚动卡顿、点击放大错位、内存一路飙升。PhotoWall 这类照片墙组件本质是一个带布局算法、懒加载策略和交互状态管理的图片容器它要同时解决三件事——怎么排、怎么省、怎么点。它适合三类人做相册/作品集/电商图集的前端需要在有限屏幕里塞进几十上百张图做数据看板的要把缩略图拼成可交互的墙以及做移动端 H5 的要在低端机上跑出接近原生的滚动体验。这篇文章不讲概念直接按「布局选型 → 最小可跑 → 参数调优 → 踩坑排查 → 进阶技巧」的顺序把一套能复现的照片墙方案拆开讲清楚。2. 布局算法怎么选等高、等宽还是瀑布流2.1 三种主流布局的适用边界照片墙的布局算法决定了整个组件的骨架。常见做法有三种等高行布局Justified Layout每行高度固定图片按原始宽高比缩放后填满整行行与行之间留固定间距。优点是视觉整齐、没有空洞适合图片比例差异大但希望整体规整的场景比如作品集首页。缺点是每行最后一张图可能被拉伸或裁切需要额外处理。等宽网格Fixed Grid每列宽度固定图片统一裁成正方形或固定比例。优点是实现简单、性能好适合缩略图列表。缺点是丢失原始比例竖图横图都被压成一样。瀑布流Masonry每列宽度固定图片按原始比例缩放高度不定新图插入最短列。优点是保留比例、视觉自然适合图片比例差异大的场景。缺点是需要计算列高、滚动时定位复杂且容易出现「底部参差」。选型建议如果图片比例集中在 4:3 到 16:9 之间用等高行布局最稳如果全是缩略图且不介意裁切等宽网格最省事如果图片比例跨度大比如同时有长图和方图瀑布流体验最好但实现成本最高。2.2 用等高行布局跑通最小可运行版本下面这段代码实现一个最简等高行布局输入图片列表和容器宽度输出每张图的坐标和尺寸。核心思路是先按原始宽高比累加宽度超过容器宽度就换行然后按行统一缩放。// 等高行布局输入图片列表和容器宽度返回带坐标的布局结果 function justifiedLayout(images, containerWidth, targetRowHeight 200, gap 8) { const rows []; let currentRow []; let currentWidth 0; for (const img of images) { // 按目标行高缩放后的宽度 const scaledWidth (img.width / img.height) * targetRowHeight; currentRow.push({ ...img, scaledWidth }); currentWidth scaledWidth gap; // 当前行宽度超过容器宽度结算这一行 if (currentWidth - gap containerWidth) { rows.push(currentRow); currentRow []; currentWidth 0; } } // 处理最后一行 if (currentRow.length) rows.push(currentRow); const layout []; let top 0; for (const row of rows) { const totalScaled row.reduce((s, i) s i.scaledWidth, 0); const totalGap gap * (row.length - 1); // 按容器宽度重新分配保证行填满 const scale (containerWidth - totalGap) / totalScaled; const rowHeight targetRowHeight * scale; let left 0; for (const img of row) { const w img.scaledWidth * scale; layout.push({ id: img.id, src: img.src, left, top, width: w, height: rowHeight }); left w gap; } top rowHeight gap; } return layout; }逻辑说明targetRowHeight是期望行高实际行高会根据容器宽度反算保证每行刚好填满。gap是图片间距。关键点在最后一步——不是直接用targetRowHeight而是用scale把整行缩放到容器宽度这样才不会出现右侧留白。参数怎么调targetRowHeight越小每行图片越多、单图越小越大则相反。移动端一般设 120160桌面端设 200280。gap移动端 46桌面端 812。如果图片数量少于一行能放下的数量最后一行会按比例放大这时可以加一个maxRowHeight限制避免单行图片被拉得过大。2.3 瀑布流的高度计算与列分配瀑布流的核心是维护一个列高数组每次把新图放进当前最矮的列。下面是最小实现// 瀑布流布局按列分配每次放入最矮列 function masonryLayout(images, containerWidth, columnCount 3, gap 8) { const columnWidth (containerWidth - gap * (columnCount - 1)) / columnCount; const columnHeights new Array(columnCount).fill(0); const layout []; for (const img of images) { // 找到当前最矮的列 let minCol 0; for (let i 1; i columnCount; i) { if (columnHeights[i] columnHeights[minCol]) minCol i; } const height (img.height / img.width) * columnWidth; const left minCol * (columnWidth gap); const top columnHeights[minCol]; layout.push({ id: img.id, src: img.src, left, top, width: columnWidth, height }); columnHeights[minCol] height gap; } return { layout, totalHeight: Math.max(...columnHeights) }; }逻辑说明columnCount决定列数移动端一般 2 列桌面端 34 列。columnHeights记录每列当前累计高度每次选最矮列插入。返回的totalHeight用于设置容器高度避免滚动条跳动。参数说明columnCount不要超过 4否则单图太窄。gap与等高行布局一致。如果图片高度差异极大瀑布流底部会参差不齐可以在数据末尾补几张占位图拉平或者接受这种自然错落。3. 懒加载与渲染性能让一百张图不卡3.1 IntersectionObserver 懒加载的最小实现照片墙最容易翻车的地方是性能。一百张图同时加载浏览器直接卡死。常见做法是用IntersectionObserver做懒加载只加载视口内的图片。// 懒加载只加载进入视口的图片 function setupLazyLoad(container, rootMargin 200px) { const observer new IntersectionObserver( (entries) { for (const entry of entries) { if (entry.isIntersecting) { const img entry.target; //>// 虚拟滚动根据滚动位置计算可见区间 function getVisibleRange(scrollTop, viewportHeight, layout, buffer 300) { const start scrollTop - buffer; const end scrollTop viewportHeight buffer; const visible layout.filter((item) item.top item.height start item.top end); return visible; } // 滚动时只更新可见项 container.addEventListener(scroll, () { const visible getVisibleRange(container.scrollTop, container.clientHeight, layout); renderVisible(visible); // 只挂载这些节点 });逻辑说明buffer是上下缓冲距离避免快速滚动时出现空白。layout是前面算好的坐标数组。renderVisible负责把可见项挂到 DOM同时移除不可见项。参数说明buffer一般设 200400px。太小会闪白太大会增加渲染量。如果图片高度固定可以直接用Math.floor(scrollTop / itemHeight)算索引比遍历更快。3.3 图片解码与内存控制即使只渲染可见项图片解码仍然占内存。常见做法是给img加decodingasync让浏览器异步解码避免阻塞主线程。另外视口外的图片要及时把src置空或换成占位图释放内存。img>function preloadSize(src) { return new Promise((resolve) { const img new Image(); img.onload () resolve({ width: img.naturalWidth, height: img.naturalHeight }); img.src src; }); }4.2 滚动时布局抖动现象滚动过程中图片位置突然跳变或者滚动条长度反复变化。原因容器高度没有提前设定图片加载后撑开容器导致重排。解决布局计算完成后立即设置容器height为totalHeight图片用绝对定位。这样容器高度固定滚动条不会跳。4.3 快速滚动出现白屏现象快速滑动时中间一片空白停下来才加载。原因rootMargin太小或者虚拟滚动的buffer不够。解决把rootMargin调到 300px 以上虚拟滚动buffer调到 400px。如果还不行检查是否在滚动事件里做了重计算用requestAnimationFrame节流。4.4 移动端点击放大错位现象点击图片放大后放大的位置和点击位置对不上。原因放大动画的transform-origin没设对或者滚动容器有transform导致坐标偏移。解决放大时用getBoundingClientRect()获取图片实际位置把transform-origin设为点击点相对图片的百分比。如果容器有transform先算好偏移量。4.5 内存持续增长不释放现象滚动久了页面越来越卡内存占用一路涨。原因视口外的图片src没清空或者IntersectionObserver没unobserve。解决虚拟滚动移除节点时把img.src设为占位图IntersectionObserver加载完成后立即unobserve。另外大图尽量用缩略图原图只在点击放大时加载。5. 进阶技巧让照片墙从能用变成好用5.1 用 CSS Grid 做响应式列数切换如果不想用 JS 算列数可以用 CSS Grid 的auto-fill加minmax做响应式.photowall { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; } .photowall img { width: 100%; height: 100%; object-fit: cover; }这段 CSS 的意思是每列最小 200px最大 1fr浏览器自动决定列数。窗口变窄列数自动减少不需要 JS 介入。缺点是图片被裁成统一比例适合缩略图场景。5.2 图片压缩与格式选择照片墙的加载速度很大程度取决于图片本身。常见做法是缩略图用 WebP原图用 JPEG缩略图宽度不超过 400px原图不超过 2000px。如果后端支持用srcset让浏览器自己选img srcthumb-400.jpg srcsetthumb-400.jpg 400w, thumb-800.jpg 800w, original.jpg 2000w sizes(max-width: 600px) 50vw, 25vw alt /sizes告诉浏览器在不同屏幕宽度下图片占多大浏览器据此选择最合适的srcset项。这样移动端不会加载 2000px 的大图。5.3 用 requestIdleCallback 做预加载用户滚动停下来后可以用requestIdleCallback预加载下一屏的图片这样再次滚动时不会白屏function preloadNextScreen(layout, currentIndex, count 10) { const next layout.slice(currentIndex, currentIndex count); if (requestIdleCallback in window) { requestIdleCallback(() next.forEach((item) preloadSize(item.src))); } else { setTimeout(() next.forEach((item) preloadSize(item.src)), 200); } }requestIdleCallback会在浏览器空闲时执行不阻塞滚动。count一般设 1020太多会浪费带宽。5.4 一个我踩过的坑早期做照片墙时我为了省事直接在滚动事件里重算布局结果低端机上滚动直接卡成幻灯片。后来改成布局只算一次、滚动只做可见性判断帧率立刻上来了。另一个血泪经验是图片宽高一定要提前拿到不要等onload否则布局会闪。现在我的习惯是后端返回图片列表时强制带上width和height前端拿到数据就能直接算布局图片加载只是填充内容不影响骨架。如果后端给不了宽高那就用preloadSize并发预加载但记得加并发限制别一次发一百个请求。这个方案值不值得做取决于你的图片数量和设备分布——如果只有十几张图怎么排都行如果上百张还要跑在低端安卓机上上面这些优化一个都省不了。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?