Flutter 实现「记忆星球」:一次照片图谱的视觉降级与性能优化实践
前言
最近在做一个 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. 后续再接语义相似边、人脸关系边和故事关系边。
等这些完成后,「记忆星球」就不只是一个炫酷页面,而有机会成为智能相册里真正有辨识度的核心功能。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)