一、背景
最近在继续完善一个 Flutter 本地智能相册 APP。前面已经完成了比较核心的视频模态稳定化工作,包括图片 / 视频 / 动态照片扫描、MobileCLIP2 LiteRT 内置模型识别、视频帧聚合 embedding、ObjectBox 持久化 meta、视频聚类页和真机 smoke test。
这一轮不再继续大改模型路线,而是做项目本体的工程收口。
最终在最新远端基础上拆成了 6 个中文 commit:
fix(platform): 使用应用时保持屏幕常亮
feat(ai): 完善标签体系与语义搜索约束
fix(album): 优化标签浏览与分析提示状态
feat(ai): 补齐照片属性后台队列
fix(create): 收紧推荐与创作搜索边界
test: 扩展总测试入口覆盖核心回归
验证结果:
flutter test --no-pub test/all_tests.dart
flutter build apk --debug --no-pub --dart-define-from-file=config/profiles/dev.json
dart analyze
其中 test/all_tests.dart 共 83 个测试通过,debug APK 构建通过,dart analyze 没有 error,只剩项目既有 warning/info。最终 HEAD == origin/master,工作区干净。
这篇文章记录这一轮工程修复的核心思路。
二、为什么要先处理"屏幕常亮"?
这个问题看似很小,但对本地 AI 相册非常关键。
本地相册分析通常不是几秒钟完成,而是可能持续几十分钟甚至一两个小时。尤其是首次扫描几千张图片和视频时,任务链路包括:
相册扫描
缩略图读取
embedding 推理
OCR
人脸检测
caption 生成
joyScore 评分
事件聚类
标签聚类
推荐生成
如果手机锁屏,尤其是 vivo 这类后台策略较激进的系统,前台任务可能被压制,UI 更新和后台分析都可能明显变慢。
所以这一轮先把 APP 前台使用时保持亮屏。
Android 侧在 MainActivity.kt 中添加:
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
iOS 侧在 AppDelegate.swift 中添加:
application.isIdleTimerDisabled = true
这个改动的目标不是绕过后台限制,而是保证用户明确在前台使用 APP 时,设备不会自动熄屏打断长任务。
对本地 AI 相册来说,这是一个很实际的工程体验修复。
三、标签体系完整性:不能让细标签悄悄掉到"其他"
这一轮重点推进了 P0 里的标签 taxonomy 完整性。
原来的问题是:主标签库中有一批细标签没有被映射到有效粗分类。这样在相册浏览、标签过滤、语义搜索或推荐聚合时,这些标签容易变成不可达状态,或者退化到"其他"。
这类问题很隐蔽。
因为单张图可能仍然能打出标签,但上层浏览结构会出问题:
细标签存在
但粗分类缺失
=> 标签页聚合不到
=> 搜索筛选不稳定
=> 用户看到大量"其他"
这次新增了一个 taxonomy 结构测试,直接约束三件事:
1. 粗分类 id 必须存在且唯一;
2. 映射表里的细标签必须来自主标签库;
3. 主标签库里的每个细标签都必须被映射一次。
测试一跑,直接揭出真实缺口:有 24 个主标签没有粗分类映射。
修复时没有简单地把所有缺失标签塞进"其他",而是按语义归位。其中有一类标签本质上不是人物、食物、场景或物体,而是氛围和情绪,例如更偏"温馨""治愈""热闹""浪漫"这一类语义。
所以新增了一个明确的粗类:
atmosphere_mood
这样它们既不会被硬塞进风景 / 人物 / 其他,也能被后续相册聚类和结构化搜索识别。
同时还移除了一个孤儿映射:
医院
因为它不是主标签库里的正式细标签。医疗相关已有更正式的标签,例如医院场馆、医疗场景、就诊检查等。孤儿标签如果留在映射里,会导致 taxonomy 结构不一致。
这一轮的经验是:标签体系不是简单字符串列表,而是一个数据契约。只要有粗细分类、搜索解析、聚类浏览,就必须用测试锁住映射完整性。
四、让语义搜索也认识新的粗类
新增 atmosphere_mood 以后,不能只改标签表。
如果语义搜索解析层不知道这个粗类,那么它只能出现在相册标签聚类里,却不能被结构化搜索识别。
因此这次同步修改了语义查询解析模型,让新的粗类也能参与搜索侧的结构化语义。
这类改动要注意两点:
标签 taxonomy 和搜索 parser 不能各自维护一套割裂语义;
新增粗类后,测试必须覆盖映射完整性;
搜索侧不用马上做复杂策略,但至少不能不知道这个类。
五、相册提示状态:隐藏要持久,但不能永久压住未来任务
之前相册页会出现类似:
还有 3135 个图片没有分析,是否继续分析?
这个提示本身是合理的,但如果用户点了"隐藏",强停重开 APP 后又继续出现,就会很烦。
这一轮把这个横幅改成了可持久隐藏。
更关键的是,它不是永久隐藏,而是按任务状态自动恢复:
未分析数 > 0,用户点过隐藏
=> 不再显示
未分析数归零
=> 自动清除隐藏状态
未来如果又出现新的未分析任务
=> 可以重新显示
这样既尊重用户当前选择,又不会永久屏蔽未来的新任务提醒。
这个功能已经做过真机验证:点一次"隐藏"后,强停重开 APP,横幅不会再回来;如果未分析数归零,会自动清掉隐藏状态。
这属于典型的产品细节修复,但对长期使用体验很重要。
六、创作搜索边界:不要单独过滤截图
这一轮还修正了 create_page.dart 创作入口的搜索边界。
原逻辑里,创作搜索会单独过滤 isProbablyScreenshot。这看起来像是"清理低价值内容",但在创作场景里不一定合理。
因为截图不一定是垃圾内容。
用户可能想用截图做:
笔记整理
灵感拼贴
聊天记录回忆
网页素材创作
海报 / UI / 文档图生成
所以创作入口不应该直接把截图排除。
修改后的策略是:
创作搜索只做统一语义结果合并
去重
数量上限控制
不再额外按 isProbablyScreenshot 过滤
并新增了 create_page_search_test.dart 锁住这个行为,避免后续又把截图直接过滤掉。
这背后的原则是:不同入口的"低价值"定义不同。相册清理可以过滤截图,但创作入口不能简单沿用清理入口的价值判断。
七、照片属性后台队列:正式扫描不能只写 embedding 和 tag
前面的视频模态已经能跑通 embedding 和 meta,但正式扫描里还有一个重要缺口:图片分析完成后,不应该只写 embedding/tag,还要补齐上层功能需要的属性字段。
这一轮补齐了照片属性后台队列,主要涉及:
OCR
人脸检测
caption
joyScore
location
新增或强化了 photo_attribute_background_service.dart 和 unified_analysis_pipeline_service.dart 的配合。
核心策略:
图片分析完成后:
排 location + faceDetection + caption 等属性任务
视频分析完成后:
只排 location
为什么视频只排 location?因为当前视频路线主要是 MobileCLIP2 帧聚合,视频 caption / OCR / 人脸检测还不是这轮目标。先不盲目扩大视频处理范围,避免把不稳定功能塞进正式扫描。
后台属性队列还做了几个稳定性处理:
1. 按 photoId 合并任务,避免重复堆积;
2. OCR 使用现有 OcrService;
3. 人脸使用 MLKit + 现有 FacePipelineService;
4. joyScore 使用现有 AIScoreHelper;
5. worker 失败边界收缩到单个任务;
6. waitUntilIdle() 不会因为一个坏任务永久卡死。
这一步很关键,因为事件聚类、推荐、浏览排序等上层逻辑如果拿到空字段,很容易出现看似"推荐不准"或"事件不聚类"的问题。
这不是模型能力问题,而是分析字段没有补齐。
八、事件聚类前等待属性队列清空
补齐属性队列后,还要处理时序问题。
如果事件聚类在 OCR、人脸、caption、joyScore 之前就开始跑,那么它拿到的字段仍然是空的。
所以这轮在事件聚类前等待属性队列清空,保证上层规则尽可能基于完整字段运行。
这里需要注意:等待不能无限卡死。
因此属性队列要有清晰的失败边界:单个任务失败不能阻塞整个队列,waitUntilIdle() 也不能因为坏任务一直挂住。
这就是为什么本轮专门补了 photo_attribute_background_service_test.dart。
九、创作推荐边界:垃圾隔离要统一
创作推荐里还有一个数据边界问题:推荐结果不能混入已经隔离的垃圾照片。
这一轮把推荐结果统一通过:
JunkPhotoFilterService.isQuarantined
来过滤。
这样待确认垃圾和确认垃圾都不会混进推荐卡片,但用户手动保留的候选仍然可以保留。
这个规则比简单判断某个字段更稳,因为垃圾照片状态可能有多个层级:
疑似垃圾
待确认
已确认
用户保留
推荐系统如果自己再写一套判断,后面很容易和主垃圾过滤逻辑不一致。
所以这里的原则是:
推荐入口不自定义垃圾判断;
统一复用 JunkPhotoFilterService 的隔离语义。
同时新增了推荐合并回归测试,确保推荐合并不会把隔离内容带回来。
十、把核心回归测试接入 test/all_tests.dart
这一轮新增和接入了多类测试:
tag_taxonomy_v2_test.dart
create_page_search_test.dart
photo_attribute_background_service_test.dart
create_recommendation_service_test.dart
已有队列测试
已有垃圾过滤测试
并统一挂到:
test/all_tests.dart
最终总测试达到 83 个。
这一步非常重要。
很多工程里测试"写了但不在主入口跑",后续就会慢慢失效。统一挂到 all_tests.dart 后,每次跑一条命令就能覆盖核心回归:
flutter test --no-pub test/all_tests.dart
这比零散地记住十几个测试文件可靠得多。
十一、为什么要避免 generated/plugin 噪声
这一轮中途也遇到过 Flutter 生成文件、插件注册文件被标记修改的问题。
这类文件有时是本地构建或 flutter pub get 造成的环境噪声,不一定是真实业务改动。
因此在提交前专门检查:
git status --short
git diff --cached --check
git diff --cached --stat
确保只提交真实改动,不把 generated plugin 噪声、APK 构建产物、pubspec.lock 漂移等混进去。
这点很重要。否则一个修复 commit 里混入几十个无关文件,后续 review 和回滚都会很痛苦。
十二、验证结果
这一轮最终验证:
flutter test --no-pub test/all_tests.dart
flutter build apk --debug --no-pub --dart-define-from-file=config/profiles/dev.json
dart analyze
git diff --cached --check
结果:
83 个测试通过
Android debug APK 构建通过
dart analyze 无 error
仍有项目既有 warning/info
git diff --cached --check 通过
最后拆成 6 个中文 commit 并推到远端,当前状态:
HEAD == origin/master
工作区干净
十三、这一轮修改的工程价值
这一轮没有引入新模型,也没有大改推理后端,但它补了很多真实产品会踩的坑。
1. 长任务需要前台常亮
本地 AI 扫描是长任务,锁屏会明显影响体验和稳定性。
2. 标签体系必须有结构测试
标签库不是字符串堆。粗细映射一旦不完整,上层浏览和搜索都会退化。
3. 隐藏提示要尊重用户选择
用户点过隐藏,就不要重启后继续打扰。但任务归零后也要清除隐藏状态。
4. 不同入口的数据边界不同
相册清理可以过滤截图,创作入口不能简单过滤截图。
5. 推荐逻辑要复用统一垃圾隔离语义
不要每个入口都写一套垃圾判断,否则状态会不一致。
6. 正式扫描必须补齐属性字段
embedding/tag 只是基础。OCR、人脸、caption、joyScore、location 这些字段会影响事件、推荐和搜索。
7. 回归测试要进统一入口
测试只有挂到 test/all_tests.dart,才算真正进入长期保护。
十四、总结
这一轮修复的主线可以概括为:
让 APP 在真实长任务中更稳定;
让标签体系更完整;
让搜索和推荐边界更清晰;
让照片属性字段更完整;
让测试真正挡住回归。
最终完成了:
Android/iOS 前台常亮
标签 taxonomy 完整性测试
新增 atmosphere_mood 粗类
语义搜索识别新粗类
未分析横幅持久隐藏
创作搜索不再单独过滤截图
正式扫描补齐 OCR/人脸/caption/joyScore/location
事件聚类前等待属性队列
推荐统一过滤隔离垃圾
总测试入口扩展到 83 个测试
debug APK 构建通过
对于一个本地 AI 相册 APP 来说,真正难的不是"把模型跑起来",而是把模型结果接进稳定的数据流、状态流和产品体验里。
一句话总结:
AI 相册的核心不只是推理,而是数据契约、任务恢复、边界控制和回归测试。