一、背景
最近在维护一个 Flutter 本地智能相册 APP,项目已经完成了比较关键的视频模态稳定化工作,包括:
-
图片 / 视频 / 动态照片扫描;
-
MobileCLIP2 LiteRT 内置模型识别;
-
视频帧聚合 embedding;
-
ObjectBox 持久化 embedding meta;
-
视频聚类页展示;
-
Android 真机安装与 smoke test;
-
后台 AI 分析任务恢复。
在前一轮真机验证中,APP 已经能正常扫描数千张图片和视频,ObjectBox 中也已经能看到 imageMeta、videoMeta、dynamicImage 逐步增长,视频重复帧比例也维持在健康范围内。
到这一轮,主要目标不再是"视频模态能不能跑",而是做两个更接近产品收尾质量的修复:
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 功能跑通之后,真正决定体验的是工程细节。