前言

最近我在做一个智能相册项目 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 只做三类关系:

  • 同一事件;
  • 同一地点;
  • 同一天。

建边流程是:

  1. 按 eventId、locationKey、dayKey 分组;
  2. 每组按 importance 排序;
  3. 每个节点最多连接 3 个同组节点;
  4. 所有候选边按权重排序;
  5. 全局最多保留 30 条;
  6. 单节点最多保留 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 效果。

更多推荐