Flutter 智能相册实战:把“记忆星球”从炫技 Demo 优化成可用的照片图谱
前言
最近我在做一个智能相册项目 Memoria。它不只是普通的照片瀑布流,而是围绕照片实体、事件聚类、地点信息、视觉标签、OCR、MobileCLIP embedding、LLM 故事生成等能力,构建一个更“理解照片关系”的相册。
这轮我尝试新增一个比较有视觉记忆点的功能:记忆星球。
它的目标是把照片做成一个可旋转的球形图谱:
- 每个节点是一张照片;
- 节点大小代表照片的重要程度;
- 关系线表示同一事件、同一地点、同一天;
- 用户可以拖动旋转、双指缩放、点击照片查看关联记忆。
第一版效果确实“很热闹”,但也暴露了一个典型问题:惊艳功能不能靠堆元素,必须靠层次、节奏和性能。
第一版的问题
最开始的版本一次性展示了:
- 96 张照片节点;
- 100 多条关系线;
- 大量发光边框;
- 星空背景和动态线框;
- 玻璃拟态的顶部 Tab;
- 底部大信息卡。
结果就是:视觉上太满,球体层次不明显;性能上也不理想,拖动时容易产生卡顿。
问题不在方向,而在 V1 做得太重了。
第一轮优化:先做减法
我先把页面从“技术展示”收敛成“可用产品页面”。
核心策略是:
- 节点数从 96 降到 42;
- 关系边限制到 30;
- 单个节点最多 3 条边;
- 背面节点不渲染;
- 节点根据 z 深度调整大小和透明度;
- 去掉抢戏的背景线框;
- 顶部模式切换压缩成 44px 单行胶囊;
- 底部未选中时只显示一行提示;
- 选中照片后才显示轻量信息卡。
这一步的关键不是“少做功能”,而是让用户第一眼看清楚:这是一个球形照片网络,而不是一堆贴片。
第二轮优化:先显示节点,再异步补关系
原来的数据入口是一次性加载完整图谱:
Future<PhotoGraphData> loadGraph() async { final nodes = await loadNodes(); final edges = buildEdges(nodes); return PhotoGraphData(nodes: nodes, edges: edges); }
这样的问题是:页面必须等节点和关系线都算完,用户才看到内容。
优化后改成两阶段:
Future<List<PhotoGraphNode>> loadNodes(); Future<List<PhotoGraphEdge>> buildEdgesAsync( List<PhotoGraphNode> nodes, );
页面进入后先加载节点并立即渲染照片球,然后在后台生成关系线:
final nodes = await _photoGraphService.loadNodes(); setState(() { _nodes = nodes; _isBuildingEdges = true; }); final edges = await _photoGraphService.buildEdgesAsync(nodes); setState(() { _edges = edges; _isBuildingEdges = false; });
这样用户体验会变成:
先看到记忆星球,关系线随后淡入。
首屏感知速度明显更好。
第三轮优化:CPU 建边放到 isolate
Dart 的 async/await 只能避免 I/O 阻塞。对于排序、分组、建边这类 CPU 密集逻辑,如果仍然跑在 UI isolate,就可能影响动画和手势。
所以我把建边逻辑改成纯函数,并通过 Isolate.run() 执行:
Future<List<PhotoGraphEdge>> buildEdgesAsync( List<PhotoGraphNode> nodes, ) async { final input = nodes .map(PhotoGraphNodeBuildDto.fromNode) .toList(growable: false); return Isolate.run(() => buildPhotoGraphEdges(input)); }
这里有一个重要原则:不要把 Isar Entity、Widget、BuildContext、Image 对象传进 isolate。
我只传轻量 DTO:
class PhotoGraphNodeBuildDto { final int photoId; final int timestamp; final double importance; final int? eventId; final String? locationKey; }
这样 isolate 只负责纯计算,数据边界清晰,也更安全。
建边算法
当前 V1 只做三类关系:
- 同一事件;
- 同一地点;
- 同一天。
建边流程是:
- 按 eventId、locationKey、dayKey 分组;
- 每组按 importance 排序;
- 每个节点最多连接 3 个同组节点;
- 所有候选边按权重排序;
- 全局最多保留 30 条;
- 单节点最多保留 3 条边。
这样既能表达关系,又不会让画面变成“蜘蛛网”。
第四轮优化:拖动时减少绘制
渲染层也做了几处优化。
拖动或缩放时,默认关系线会暂停绘制:
final shouldDrawEdges = !_isInteracting || widget.selectedNode != null;
也就是说:
- 用户拖动球体时,优先保证照片节点旋转流畅;
- 如果已经选中照片,只保留与选中照片相关的高亮线。
此外,我还做了这些优化:
- 背景改成静态 CustomPainter,不再每帧重绘星点;
- 每帧不再重复排序边;
- 节点是否相关改成提前构建 Set<int>,渲染时 O(1) 查询;
- 图片节点加 RepaintBoundary;
- 缩略图预热最多前 16 张,并逐张让出帧,避免一次性解码打爆内存。
页面状态设计
页面不再依赖 FutureBuilder<PhotoGraphData> 等完整数据,而是维护增量状态:
List<PhotoGraphNode> _nodes = const []; List<PhotoGraphEdge> _edges = const []; bool _isLoadingNodes = true; bool _isBuildingEdges = false;
顶部状态也能更自然地表达当前阶段:
- 加载中;
- 生成关系…;
- 42张。
这比“空白等待完整图谱”更符合移动端体验。
这轮优化后的结构
主要涉及几个文件:
- photo_graph_models.dart:图谱节点、边、模式等 VO;
- photo_graph_service.dart:节点加载、DTO 转换、isolate 建边;
- photo_graph_page.dart:增量加载状态和页面结构;
- photo_sphere_graph.dart:球形投影、节点渲染、关系线绘制;
- album_page.dart:相册页实验入口。
相册入口目前仍然是实验功能,没有急着作为正式卖点宣传。因为这类视觉功能必须经过真机验证,确认流畅度和视觉层次都达标后再推到主路径。
验证结果
这轮优化后做了基础验证:
- 目标文件 flutter analyze 无新增问题;
- 完整项目 analyze 仍只保留原有 warning/info;
- 项目测试集合通过;
- 没有引入新的重型依赖。
总结
这次优化最大的收获是:
性能优化不只是“多线程”,而是把任务拆到正确的位置。
对于 Flutter 里的照片图谱这类功能,我现在的经验是:
- I/O 用 async;
- CPU 计算用 isolate;
- UI 首屏不要等完整数据;
- 拖动时少画非必要元素;
- 视觉上先做减法,再做特效;
- 数据结构要服务于渲染,别让每帧做重复计算。
后续如果继续优化,可以考虑把节点从 Widget 树进一步下沉到 Canvas:预先解码缩略图为 ui.Image,再用 CustomPainter.drawImageRRect() 批量绘制。那会比几十个 Positioned + Image + ClipRRect 更轻,但实现复杂度也更高,适合作为 V2。
目前 V1 的方向是:先把记忆星球做轻、做稳、做清楚,再谈更炫的 3D 效果。
更多推荐

所有评论(0)