前言

最近在做一个 AI 原生相册项目 Memoria / 智能影记,其中有一个很有产品记忆点的实验功能:记忆星球

它的目标不是简单展示照片列表,而是把用户的照片组织成一个可交互的球形网络:

  • 每个节点是一张照片;

  • 节点之间的连线表示同事件、同地点、同一天等关系;

  • 用户可以拖动旋转球体、双指缩放;

  • 点击某张照片后,高亮它周围的相关记忆。

第一版做出来后,功能是跑通了,但真机效果并不理想:元素太多、视觉过满、球体层次不明显,移动端渲染压力也偏大。

这篇文章记录一次针对「记忆星球 V1」的降级重构:不引入新依赖,不改数据模型,通过控制节点数量、减少关系边、隐藏背面节点、降低阴影和背景复杂度,让照片图谱从技术 Demo 更接近产品页面。


一、原始版本的问题

第一版「记忆星球」的核心思路是对的:

  • Flutter 原生实现 2.5D 球形照片网络;

  • 用 Fibonacci Sphere 算法生成球面节点;

  • CustomPainter 绘制关系线;

  • Stack + Positioned 显示照片节点;

  • 支持事件、地点、时间三种模式切换。

但是从真机截图看,第一版有几个明显问题。

1. 节点和边太多

原始版本默认节点数为 96,关系边最多 220 条。

在桌面端或者截图里看可能还行,但放到手机竖屏上,问题很明显:

96 张照片节点
上百条关系线
大量发光边框
复杂背景
顶部 Tab
底部信息卡

这些元素叠在一起后,用户很难看出这是一个球形结构,更像是一堆照片贴片平铺在屏幕上。

2. 背景过于抢戏

第一版背景里有较多科技感线框、星点和光效。
本来是想增强“星球感”,但实际效果是背景和照片节点抢视觉焦点。

对于照片图谱这种页面,主体应该是照片本身。背景只能烘托氛围,不能抢主角。

3. 球体层次不明显

球形图谱最关键的是“前后景深”:

  • 前面的节点应该更大、更亮;

  • 后面的节点应该更小、更暗;

  • 背面节点最好隐藏或极弱显示;

  • 整体要形成一个明确的圆形轮廓。

第一版前后层次不足,导致用户看不出空间感。

4. UI 面板太重

顶部模式切换区域较高,在小屏手机上甚至可能出现“事件 / 地点 / 时间”文字竖排的问题。
底部信息卡也比较大,在未选中照片时一直遮挡主体。

对于一个强调沉浸感的页面,UI 控件应该尽量轻。


二、优化目标

这次重构的原则是:

惊艳功能不能靠堆元素,而是靠层次、节奏和性能。

因此这次不继续加效果,而是做“减法”。

具体目标如下:

1. 默认节点数从 96 降到 42,最多限制到 48。
2. 全局关系边最多 30,单节点最多 3 条。
3. 选中照片后,最多只高亮 12 条相关边。
4. 背面节点直接不渲染。
5. 前半球节点根据 z 深度调整尺寸和透明度。
6. 去掉抢戏的彩色线框背景,只保留深色背景、少量星点和柔和光晕。
7. 减少节点发光、阴影和复杂裁剪。
8. 给照片节点和背景区域增加 RepaintBoundary。
9. 顶部 Tab 改为 44px 单行胶囊按钮。
10. 底部未选中时只显示一行轻提示,选中后再展示轻量信息卡。

三、数据层优化:控制节点和边的规模

首先处理 PhotoGraphService

原来默认节点数较多:

static const int defaultNodeLimit = 96;
static const int _maxEdges = 220;
static const int _maxEdgesPerNode = 5;

重构后改为:

static const int defaultNodeLimit = 42;
static const int _candidateMultiplier = 3;
static const int _maxEdges = 30;
static const int _maxEdgesPerNode = 3;

同时对外部传入的 limit 做硬限制:

Future<PhotoGraphData> loadGraph({int limit = defaultNodeLimit}) async {
  final cappedLimit = limit.clamp(18, 48).toInt();

  // 后续读取照片、构造节点、生成关系边
}

这里的关键不是简单把数字调小,而是明确一个产品原则:

球形照片图谱不是相册列表,不应该展示所有照片,而应该展示一批“精选记忆”。

对于手机端来说,42 张照片已经足够形成球体结构。
如果一开始就上 100 张甚至几百张,反而会失去视觉重点。


四、关系边优化:从全局关系网变成轻量主干线

照片图谱里的边非常容易失控。

假设有 100 张照片,如果做全连接,边数会达到:

100 * 99 / 2 = 4950

这无论从性能还是视觉上都不可接受。

因此这次把关系边控制为:

static const int _maxEdges = 30;
static const int _maxEdgesPerNode = 3;

在未选中节点时,只展示少量全局主干关系。
在选中某张照片后,只展示与当前照片相关的少量高亮边:

static const int _maxVisibleEdges = 30;
static const int _maxSelectedEdges = 12;

边的选择逻辑也按权重排序,只保留最重要的关系:

List<PhotoGraphEdge> get _visibleEdges {
  final selectedId = widget.selectedNode?.photoId;

  final source = selectedId == null
      ? widget.data.edges
      : widget.data.edges.where(
          (edge) =>
              edge.sourcePhotoId == selectedId ||
              edge.targetPhotoId == selectedId,
        );

  final limit = selectedId == null ? _maxVisibleEdges : _maxSelectedEdges;

  final sorted = source.toList(growable: false)
    ..sort((a, b) => b.weight.compareTo(a.weight));

  return sorted.take(limit).toList(growable: false);
}

这样页面的关系表达从“线条爆炸”变成了“少量重点关系”。


五、球体层次优化:只渲染前半球节点

为了让用户明显看出这是一个球体,这次引入了背面裁剪。

核心参数如下:

static const double _cameraDistance = 3.4;
static const double _minNodeSize = 42;
static const double _maxNodeSize = 76;
static const double _backFaceCutoff = -0.15;

在计算球面节点时,根据旋转后的 z 值判断是否渲染:

final rotated = _rotate(base, rotationX, rotationY);

if (rotated.z < _backFaceCutoff) {
  continue;
}

这一步非常关键。

以前背面节点也参与渲染,导致整个屏幕都被照片填满。
现在背面节点直接不画,用户看到的是前半球照片,自然会形成球体轮廓。


六、按 z 深度调整节点尺寸和透明度

隐藏背面节点之后,还需要给前半球节点增加景深。

重构后的节点大小不再是固定值,而是根据 frontness 动态计算:

final frontness = ((rotated.z - _backFaceCutoff) / 1.15)
    .clamp(0.0, 1.0);

final nodeSize =
    (_minNodeSize + (_maxNodeSize - _minNodeSize) * frontness) *
    (0.9 + (node.importance - 1.0) * 0.08) *
    _zoom;

透明度也随深度变化:

opacity: (0.22 + frontness * 0.78)
    .clamp(0.0, 1.0)
    .toDouble(),

这样前方照片更大、更清晰,边缘和较远处照片更小、更暗。
球体的空间感会比单纯平铺强很多。


七、视觉降噪:减少背景、阴影和发光

第一版背景过于复杂,因此这次去掉了大量彩色圆角线框,只保留:

  • 深色背景;

  • 少量星点;

  • 柔和渐变光晕。

背景绘制仍然使用 CustomPainter,但外层增加 RepaintBoundary

RepaintBoundary(
  child: CustomPaint(
    painter: _PhotoSphereBackgroundPainter(
      pulse: _controller.value,
    ),
  ),
),

照片节点的发光也大幅减轻。
原来普通节点也有明显阴影,现在只有选中或相关节点才给较弱光效:

final glow = selected
    ? 14.0
    : related
        ? 8.0
        : 0.0;

同时普通节点的边框透明度降低:

border: Border.all(
  color: borderColor.withValues(
    alpha: selected ? 0.92 : 0.42,
  ),
  width: selected ? 2 : 0.8,
),

移动端性能优化很多时候不是某一个 API 的问题,而是这些细节叠加:

少一个 BoxShadow
少一个 BackdropFilter
少一层 Opacity
少几十个节点
少上百条线

最后效果会明显不同。


八、节点级 RepaintBoundary:减少重绘扩散

照片节点本身是一个较复杂的 Widget:

Positioned
RepaintBoundary
Opacity
Transform.scale
GestureDetector
AnimatedContainer
Clip
Image
Gradient overlay

为了避免节点内部重绘影响整个页面,照片节点外层加入了 RepaintBoundary

child: RepaintBoundary(
  child: Opacity(
    opacity: item.opacity,
    child: Transform.scale(
      scale: selected ? 1.12 : 1,
      child: GestureDetector(
        onTap: () => widget.onNodeSelected(item.node),
        child: AnimatedContainer(
          duration: const Duration(milliseconds: 160),
          curve: Curves.easeOut,
          // ...
        ),
      ),
    ),
  ),
),

这不是万能优化,但在这种大量照片节点同时存在的页面中,可以减少不必要的重绘扩散。


九、顶部 Tab 优化:从厚重控件变成单行胶囊按钮

第一版顶部使用的模式切换区域较重,在手机竖屏上容易挤压主体。

这次改成 44px 高度的单行胶囊按钮:

SizedBox(
  height: 44,
  child: DecoratedBox(
    decoration: BoxDecoration(
      color: Colors.black.withValues(alpha: 0.34),
      borderRadius: BorderRadius.circular(999),
      border: Border.all(
        color: Colors.white.withValues(alpha: 0.1),
      ),
    ),
    child: Padding(
      padding: const EdgeInsets.all(4),
      child: Row(
        children: [
          ...PhotoGraphMode.values.map(
            (value) => Expanded(
              child: _ModeButton(
                selected: mode == value,
                label: value.label,
                onTap: () => onModeChanged(value),
              ),
            ),
          ),
        ],
      ),
    ),
  ),
)

按钮文字强制单行:

Text(
  label,
  maxLines: 1,
  overflow: TextOverflow.clip,
  softWrap: false,
)

这个改动看似很小,但对移动端观感影响很大。
顶部控件变轻后,用户注意力会重新回到中间的照片星球。


十、底部信息区优化:未选中时只显示一句提示

第一版底部卡片常驻,导致页面上下都很满。

重构后改成:

  • 未选中照片:只显示一行轻提示;

  • 选中照片:才显示轻量信息卡。

提示文案类似:

'拖动星球,点击照片查看$relation关系'

轻提示 UI:

return Center(
  child: DecoratedBox(
    decoration: BoxDecoration(
      color: Colors.black.withValues(alpha: 0.28),
      borderRadius: BorderRadius.circular(999),
      border: Border.all(
        color: Colors.white.withValues(alpha: 0.08),
      ),
    ),
    child: Padding(
      padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
      child: Text(
        '拖动星球,点击照片查看$relation关系',
        maxLines: 1,
        overflow: TextOverflow.ellipsis,
      ),
    ),
  ),
);

选中照片后再展示信息卡,包括照片缩略图、时间、地点、事件、标签和关系摘要。

这样页面默认状态更干净,也更符合“探索式交互”的产品节奏。


十一、入口文案:从正式功能降级为实验功能

相册页入口的 tooltip 也做了调整。

原来是:

tooltip: '记忆星球',

现在改成:

tooltip: '实验功能:照片图谱',

这个改动不是技术层面的性能优化,但很重要。

因为当前功能仍处于 V1 阶段,虽然方向正确,但还没有完全达到正式产品亮点的标准。
用“实验功能”包装,可以降低用户预期,同时保留探索感。


十二、验证结果

本次重构后进行了静态分析和测试集合验证。

目标文件 flutter analyze

No issues found.

完整项目分析:

flutter analyze --no-pub --no-fatal-infos --no-fatal-warnings

结果仍然是项目原有的 66 个 warning/info,没有因为本次改动新增问题。

测试集合:

tool\run_test_suite.ps1

结果:

39 个测试通过

本次改动保持未提交状态,适合先进行一轮真机复测,重点观察:

1. 进入照片图谱是否明显更快;
2. 拖动旋转是否更流畅;
3. 球体层次是否更明显;
4. 顶部 Tab 是否不再竖排;
5. 未选中时底部是否不再遮挡主体;
6. 点击照片后关系线是否足够克制;
7. 是否仍然存在白屏、掉帧、闪退。

十三、这次优化的核心经验

这次重构最大的收获是:

对移动端视觉功能来说,“少”往往比“多”更高级。

一开始我们很容易想把功能做满:

更多照片
更多连线
更多发光
更多背景
更多信息
更多动效

但在手机屏幕上,这些东西会互相抢注意力,也会叠加渲染压力。

最终真正有效的优化是:

减少节点数量
减少边数量
隐藏背面节点
强化前后景深
减轻背景存在感
降低阴影和发光
减少常驻 UI 面板
把复杂关系留到点击后再展示

这也是从技术 Demo 走向产品功能必须经历的一步。


十四、后续优化方向:异步加载与 isolate 建边

当前这次主要解决的是视觉和渲染层面的负担。
下一步还可以继续优化加载体验。

现在的理想方案是:

1. 先异步加载照片节点,立即显示球体。
2. 不等待完整关系边计算。
3. 后台使用 isolate 构建关系边。
4. 边计算完成后再无阻塞更新 UI。

也就是说,页面不要死等完整 PhotoGraphData

Future<void> _loadGraph() async {
  final nodes = await service.loadNodes();

  if (!mounted) return;
  setState(() {
    _nodes = nodes;
    _edges = const [];
  });

  final edges = await service.buildEdgesAsync(nodes);

  if (!mounted) return;
  setState(() {
    _edges = edges;
  });
}

CPU 密集的关系计算可以放到 isolate 中:

final edges = await Isolate.run(
  () => buildPhotoGraphEdges(input),
);

需要注意的是,不能把 Isar Entity、Widget、BuildContext、Image 这类对象直接传入 isolate。
应该传轻量 DTO:

class PhotoGraphNodeBuildDto {
  const PhotoGraphNodeBuildDto({
    required this.photoId,
    required this.timestamp,
    required this.importance,
    this.eventId,
    this.locationKey,
  });

  final int photoId;
  final int timestamp;
  final double importance;
  final int? eventId;
  final String? locationKey;
}

这样可以进一步减少首屏等待,让用户先看到照片星球,再看到关系线逐步出现。


结语

「记忆星球」这个功能本身是值得做的。
它把普通相册中的照片、事件、地点、时间、标签关系可视化出来,让用户不只是“浏览照片”,而是在探索自己的记忆网络。

但这次实践也说明:

一个功能能不能成为产品亮点,不只取决于技术是否跑通,更取决于视觉层次、交互节奏和移动端性能。

这次 V1 降级重构没有引入新依赖,也没有推翻原有架构,而是通过控制节点规模、减少关系边、强化球体景深、弱化背景和信息面板,让功能从“堆满屏幕的技术 Demo”向“可用的实验功能”迈进一步。

下一步如果继续迭代,我会优先做:

1. 节点先显示,边异步补充;
2. isolate 后台构建关系边;
3. 拖动时暂停绘制非选中关系线;
4. 缩略图分批预热;
5. 后续再接语义相似边、人脸关系边和故事关系边。

等这些完成后,「记忆星球」就不只是一个炫酷页面,而有机会成为智能相册里真正有辨识度的核心功能。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐