Flutter 相册 APP 收尾优化实战:未分析任务横幅持久隐藏与标签回归测试补强

一、背景

最近在维护一个 Flutter 本地智能相册 APP,项目已经完成了比较关键的视频模态稳定化工作,包括:

  • 图片 / 视频 / 动态照片扫描;

  • MobileCLIP2 LiteRT 内置模型识别;

  • 视频帧聚合 embedding;

  • ObjectBox 持久化 embedding meta;

  • 视频聚类页展示;

  • Android 真机安装与 smoke test;

  • 后台 AI 分析任务恢复。

在前一轮真机验证中,APP 已经能正常扫描数千张图片和视频,ObjectBox 中也已经能看到 imageMetavideoMetadynamicImage 逐步增长,视频重复帧比例也维持在健康范围内。

到这一轮,主要目标不再是"视频模态能不能跑",而是做两个更接近产品收尾质量的修复:

复制代码
1. 未分析任务横幅支持持久隐藏;
2. 标签回归测试补强,避免弱置信样本错误落到"其他"。

这类改动看起来不如模型接入、视频抽帧那么大,但对真实 APP 体验非常重要。


二、问题一:未分析任务横幅每次重开都会回来

在相册页里,APP 会提示类似:

复制代码
还有 3135 个图片没有分析,是否继续分析?

这个提示本身是合理的,因为相册里确实还有大量媒体没有完成 AI 分析。

但是问题在于:如果用户点了"隐藏",强停 App 后重新打开,这个横幅又会出现。

这会带来一个很差的体验:

复制代码
用户已经表达过"我暂时不想看这个提示",
但 App 每次重启都继续打扰用户。

对于一个本地相册 APP 来说,AI 分析可能持续几十分钟甚至一两个小时。用户并不一定想一直看到这个横幅。

所以这里需要的不是 session 级隐藏,而是:

复制代码
可持久隐藏。

三、正确的状态语义

这个功能不能简单做成"点隐藏后永远不显示"。

因为如果这轮未分析任务已经全部完成,未来又新增了一批未分析照片,App 仍然应该可以重新提示用户。

所以状态机应该是这样的:

复制代码
未分析数 > 0,且用户没有隐藏
=> 显示横幅

未分析数 > 0,且用户已经隐藏
=> 不显示横幅

未分析数 == 0
=> 自动清除隐藏状态

之后如果未分析数再次 > 0
=> 可以重新显示横幅

也就是说,隐藏状态只针对当前这轮积压任务有效。


四、实现思路

可以用本地持久化存一个布尔值,例如:

复制代码
analysisReminderDismissed = true / false

核心逻辑大概是:

复制代码
final shouldShowReminder =
    unAnalyzedCount > 0 && !analysisReminderDismissed;

用户点击隐藏时:

复制代码
await settings.setAnalysisReminderDismissed(true);

当未分析数归零时:

复制代码
if (unAnalyzedCount == 0 && analysisReminderDismissed) {
  await settings.setAnalysisReminderDismissed(false);
}

这段逻辑的关键不是代码复杂度,而是产品语义:

复制代码
用户隐藏的是"这一轮未完成任务提醒",
而不是永久关闭所有 AI 分析提醒。

五、为什么要真机验证"强停重开"?

这个功能如果只在当前页面点一下隐藏,然后看横幅消失,其实是不够的。

因为那只能证明内存状态生效,不能证明持久化生效。

必须验证:

复制代码
1. 点击隐藏;
2. 强停 App;
3. 重新打开 App;
4. 横幅不再出现;
5. 未分析数归零后,隐藏状态会自动清除。

这一轮真机已经验证了前半部分:

复制代码
点一次"隐藏"后,强停重开 App,横幅不会再回来。

这说明状态确实持久化成功,不只是页面内临时状态。


六、问题二:标签策略不能只看绝对阈值

这一轮还补强了标签回归测试。

问题来自一个很典型的分类后处理场景:模型给出多个候选标签分数,但所有绝对分数都不算特别高。

如果规则只看绝对阈值,那么可能出现:

复制代码
人物:0.42
美食:0.39
风景:0.18
其他:0.10

假设旧绝对阈值是 0.50,那么人物和美食都低于阈值。

如果规则写得太粗暴,就可能把它归到:

复制代码
其他

但这其实不合理。

因为虽然绝对分数不高,但相对关系已经很清楚:

复制代码
人物明显最像
或者美食明显最像

所以标签策略不应该只依赖绝对阈值,也要看相对最优类。


七、为什么"低于旧阈值但最像人物/美食"不应落到其他?

在相册 APP 里,标签不是严肃分类比赛里的 top-1 accuracy,而是用户浏览和聚类的入口。

如果大量弱置信但有明显倾向的图片都被打成"其他",用户体验会很差:

复制代码
人物照片进不了人物;
美食照片进不了美食;
大量内容堆到"其他";
聚类结果失去可浏览性。

所以标签策略需要在"保守"和"可用"之间平衡。

更合理的规则是:

复制代码
如果最高分低于绝对阈值,
但它相对其它候选明显更优,
且属于高价值主类,
则仍然允许打到这个主类。

这一轮测试明确覆盖了:

复制代码
低于旧绝对阈值,
但相对最像人物 / 美食时,
不应该落到其他。

这是很有价值的回归测试。


八、回归测试的价值

这类测试不是为了覆盖代码行数,而是为了固定产品语义。

也就是说,我们要防止后续有人重构标签逻辑时,又把它改回:

复制代码
低于阈值 => 其他

所以测试应该表达的是规则意图:

复制代码
弱置信不等于无语义;
相对最优类仍然有价值;
人物/美食这类主类不能轻易掉到其他。

伪测试可以理解为:

复制代码
test('relative best-match should not fall back to other', () {
  final result = classifyTags({
    'person': 0.42,
    'food': 0.21,
    'other': 0.10,
  });

  expect(result.primaryTag, isNot('其他'));
  expect(result.primaryTag, '人物');
});

另一个例子:

复制代码
test('food below old absolute threshold can still win by relative score', () {
  final result = classifyTags({
    'food': 0.43,
    'person': 0.19,
    'other': 0.12,
  });

  expect(result.primaryTag, '美食');
});

真实测试不一定长这样,但核心语义就是这个。


九、split contract 测试为什么要修?

这一轮还修了一个已有的 split contract 测试过严问题。

这类问题也很常见:测试本来是为了保护接口契约,但写得太细之后,反而会锁死合理实现。

好的 contract test 应该关注:

复制代码
输入输出契约是否成立;
关键边界是否稳定;
外部依赖是否能正确消费结果。

而不是过度绑定内部实现细节。

如果测试过严,就会导致:

复制代码
代码行为没有错,
但测试因为细节变化失败。

所以这次修正 split contract 测试,本质是让测试更贴近真正的契约,而不是绑死实现。


十、把新测试挂到 test/all_tests.dart

新增测试如果只放在单独文件里,后续很容易被忘掉。

所以这一轮也把新测试挂进了:

复制代码
test/all_tests.dart

这样以后跑:

复制代码
flutter test test/all_tests.dart

就能覆盖这些关键回归场景。

这一步非常重要。

很多工程里测试"写了但没人跑",最后效果和没写差不多。把它纳入统一入口,才算真正进入回归保护体系。


十一、验证结果

这一轮最后跑了:

复制代码
flutter test test/all_tests.dart
flutter build apk --debug --dart-define-from-file=config/profiles/dev.json

结果都通过。

dart analyze 仍然有既有 warning/info,但没有 error。

这说明本轮改动至少满足三个条件:

复制代码
1. 单元/回归测试通过;
2. Debug APK 可构建;
3. 没有新增编译级 error。

对于一个 Flutter + Android + 本地 AI 模型的 APP 来说,能同时跑过全量测试入口和 debug APK 构建,说明这轮收尾修复是比较稳的。


十二、这轮改动应该如何拆 commit?

这轮改动建议拆成两类:

1. 横幅持久隐藏

复制代码
fix(album): persist analysis reminder dismissal

这个 commit 只放:

复制代码
相册页横幅隐藏逻辑;
本地设置持久化;
未分析数归零后清除隐藏状态。

2. 标签回归测试补强

复制代码
test(tagging): cover relative best-match fallback

这个 commit 放:

复制代码
人物/美食弱置信但相对最优的测试;
test/all_tests.dart 挂载;
split contract 测试合理放宽。

如果 split contract 修改较独立,也可以再拆一个:

复制代码
test(split): relax overly strict contract assertion

但如果它只是为了配合本轮测试入口跑通,放在第二个 commit 里也可以接受。


十三、为什么这轮修改很重要?

从功能规模上看,这轮没有接入新模型,也没有改视频 embedding 算法。

但是它解决了两个真实产品问题:

复制代码
1. 用户不想反复被未分析任务横幅打扰;
2. 标签策略不能因为绝对分数低就把有语义倾向的内容扔进"其他"。

前者是用户体验问题。

后者是语义聚类质量问题。

它们都属于"模型工程落地"的最后一公里。

模型跑起来只是第一步。

用户真正感受到的是:

复制代码
提示是否烦人;
标签是否合理;
聚类是否可浏览;
任务是否可恢复;
失败是否可解释。

十四、和前面视频模态验证的关系

前面已经验证了视频模态主链路:

复制代码
视频 meta 落 ObjectBox;
dynamicImage meta 落 ObjectBox;
视频重复帧比例健康;
视频聚类页可打开;
权重页能识别内置模型;
扫描分析失败数为 0。

这一轮则是在这个基础上继续补产品质量:

复制代码
未分析任务提示更可控;
标签回归更稳;
测试入口更完整;
构建验证继续通过。

所以整体项目状态已经从:

复制代码
视频能不能跑?

推进到:

复制代码
真实用户使用时是否舒服?
未来重构时是否不容易退化?

这是一个很重要的阶段变化。


十五、经验总结

1. 用户点击"隐藏"应该被尊重

如果一个横幅用户已经隐藏过,重启后又出现,会让用户觉得 App 不听话。

持久化隐藏状态是小功能,但体验提升很明显。


2. 隐藏状态不能永久压住未来任务

未分析数归零后自动清除隐藏状态,是这个功能的关键边界。

否则未来新增任务时,提醒永远不出现,也是不对的。


3. 标签规则不能只看绝对阈值

模型分数不是绝对真理。

在真实产品里,相对排序也很重要。

低置信但明显最像人物/美食的样本,不应该轻易落到"其他"。


4. 测试要保护产品语义,而不是锁死实现细节

split contract 测试过严时,需要适当放松。

测试应该保护真正的外部契约,而不是内部实现偶然细节。


5. test/all_tests.dart 是回归保护入口

新增测试只有挂到统一入口里,才更容易在后续开发中持续生效。


十六、当前阶段结论

这一轮修改完成后,项目状态更稳了:

复制代码
未分析横幅可持久隐藏;
未分析数归零后隐藏状态自动清除;
标签弱置信相对最优回归测试已覆盖;
split contract 测试已修正;
test/all_tests.dart 通过;
debug APK 构建通过;
dart analyze 无 error。

这类改动不是"炫技",但非常工程化。

对一个本地 AI 相册 APP 来说,最终成败不只取决于模型强不强,还取决于:

复制代码
状态是否可恢复;
提示是否可控;
标签是否符合直觉;
测试是否能防止退化;
真机是否能稳定跑完。

一句话总结:

复制代码
AI 功能跑通之后,真正决定体验的是工程细节。
相关推荐
林间码客1 小时前
02数据挖掘:数据属性、类型与相似性度量
人工智能·算法·机器学习
me8321 小时前
【AI面试】小白理解大模型:关于RoPE 旋转位置嵌入
人工智能·ai·embedding
阿标在干嘛1 小时前
从“拍脑袋”到“数据驱动”:政策平台的A/B测试实践
大数据·人工智能·算法·ab测试
汇海老周1 小时前
FX110金融历史复盘:1869年黑色星期五事件解析
人工智能·金融
实在智能RPA1 小时前
气象预警Agent等级判定算法:2026年AI驱动的概率集合预报与自动化闭环实践
人工智能·算法·ai·自动化
陕西企来客1 小时前
2026 西安 GEO 优化市场深度解析:豆包更新后原因分析与行业变革
人工智能·搜索引擎
亦暖筑序1 小时前
Java 8老系统SQL Agent实战:AI生成候选SQL,安全引擎拦截后再执行
java·人工智能·sql
HIT_Weston1 小时前
113、【Agent】【OpenCode】项目配置(package.json)
人工智能·agent·opencode
大囚长1 小时前
大模型服务端如何命中缓存
java·人工智能·缓存·dubbo