Flutter 相册 APP 视频模态稳定化实战:从远端重构冲突到真机 Smoke Test

一、背景

最近在维护一个 Flutter 相册类 APP,项目目标是做一个本地智能影像管理应用,支持:

  • 本地图片 / 视频扫描;

  • AI 向量分析;

  • 语义搜索;

  • 标签聚类;

  • 本地缓存管理;

  • 离线模型权重管理。

这一轮主要围绕"视频模态是否真的能稳定工作"展开。

最开始的问题是:项目虽然在媒体层支持图片和视频,但视频在 AI 分析链路里并不够稳。尤其是视频 embedding、抽帧来源、模型权重、ObjectBox 元数据、Android 真机启动之间有很多细节问题。

这篇文章记录一次完整的工程排查和修复过程。

核心不是做算法创新,而是把 APP 的视频模态从"看起来接入了"推进到"工程上可验证、可追溯、可稳定运行"。


二、最初的问题:视频不是简单多一个 mediaKind

在相册 APP 里,支持视频并不只是把媒体类型从 image 扩展成 video。

真正稳定的视频 AI 链路至少要回答几个问题:

复制代码
1. 视频是否真的进入扫描队列?
2. 视频是否能抽到多帧,而不是只拿到封面图?
3. 视频 embedding 是否和图片 embedding 混用?
4. 视频是否误写 imageEmbedding 字段?
5. 视频是否被图片标签 taxonomy 错误处理?
6. 视频是否被图片 junk filter 误伤?
7. 视频分析结果是否能落库?
8. 出问题时能不能知道 frameSource / frameCount / isRepeatedFrame?

一开始视频链路的最大风险是:

复制代码
模型看起来是视频模型,
但输入可能退化成一张缩略图重复 8 次。

如果没有元数据记录,后续用户说"为什么这个视频搜不到",开发者很难判断到底是模型能力问题、抽帧问题,还是索引写入问题。


三、第一阶段:给视频 embedding 增加可追溯元数据

我首先做的是把视频 embedding 从"裸向量"升级成"带语义记录的向量"。

新增的核心思想是:

复制代码
embedding 不只是 vector,
还应该知道它来自什么媒体、什么模型、什么帧来源。

因此给 embedding 索引增加了最小元数据字段:

复制代码
String? embeddingMetaJson;

元数据里记录:

复制代码
{
  "mediaKind": "video",
  "modelFamily": "mobileclip_image",
  "modelVersion": "mobileclip2_litert_xxx",
  "textSpace": "mobileclip2_text",
  "frameSource": "android_thumbnail",
  "frameCount": 8,
  "isRepeatedFrame": false
}

其中几个字段非常关键:

mediaKind

用于区分:

复制代码
image
video
dynamicImage

frameSource

用于标记帧来源,例如:

复制代码
android_thumbnail
ffmpeg_fps_sample
fallback_thumbnail_repeat
none

isRepeatedFrame

用于判断视频是否退化成重复帧输入。

如果:

复制代码
"isRepeatedFrame": true

说明这条视频可能只拿到了重复缩略图。它不是崩溃,但语义质量可能比较弱。

这就是一种"可解释降级"。


四、第二阶段:视频不要污染图片链路

这轮修复里非常重要的一点是:

复制代码
视频 embedding 不再写入 PhotoEntity.imageEmbedding。

原因很简单:字段名叫 imageEmbedding,就应该主要表示图片向量。

如果把视频向量塞进去,会带来很多隐患:

复制代码
1. 图片主题聚类可能读到视频向量;
2. 图片 junk filter 可能误伤视频;
3. 图片标签 taxonomy 可能错误解释视频;
4. 搜索服务无法明确区分图片空间和视频空间;
5. 后续 debug 会非常困难。

所以现在的策略是:

复制代码
图片:
  写 PhotoEntity.imageEmbedding
  写图片向量索引
  可以参与图片标签、图片 junk filter、图片主题聚类

视频:
  不写 PhotoEntity.imageEmbedding
  只写向量索引 + embeddingMetaJson
  标签先保守写"视频"
  不跑图片 junk filter

这个策略不是为了让视频更"聪明",而是为了让系统先稳定。

工程里很多时候要先做到:

复制代码
先别错,再变强。

五、第三阶段:远端 master 已重构,不能机械 cherry-pick

本地做完几个修复 commit 后,准备 push 时发现远端 master 已经前进了 22 个提交。

本地状态大概是:

复制代码
本地 master:ahead 5, behind 22
工作区:还有未提交改动
远端:已经重构视频 / AI 相关路线

这种情况下不能直接:

复制代码
git pull --rebase

因为当前工作区还有很多未提交内容,直接 rebase 容易把工作区搅乱。

最终采用了一个更稳的方案:

复制代码
1. git fetch origin
2. 新建临时 worktree 到最新 origin/master
3. 在临时 worktree 中 cherry-pick 本地 5 个提交
4. 在临时目录里解决冲突
5. 确认能 fast-forward push
6. 推送到 origin/master
7. 清理临时 worktree

命令类似:

复制代码
git worktree add --detach D:\StudioProjects\Memoria_push_tmp origin/master
cd D:\StudioProjects\Memoria_push_tmp
git cherry-pick <commit1> <commit2> <commit3> ...

这个过程里发现一个重要事实:

复制代码
远端已经删除了旧 MobileViCLIP / NCNN / spool 路线,
改成了 MobileCLIP2 帧聚合路线。

所以冲突不能简单地选 ours 或 theirs。

如果机械保留本地旧代码,就会把远端重构推翻,甚至恢复一堆已经删除的旧文件。

最终的处理原则是:

复制代码
保留远端新架构,
只把我们需要的视频稳定性语义迁移过去。

也就是说,不恢复旧 MobileViCLIP 路线,而是在远端新的 MobileCLIP2 帧聚合架构上补:

复制代码
1. embeddingMetaJson;
2. mediaKind/video/dynamicImage 元数据;
3. 视频不写 imageEmbedding;
4. 视频标签保守为"视频";
5. 视频跳过图片 junk filter;
6. 测试适配新路线。

这一步很关键。

很多工程问题不是"代码冲突",而是"架构语义冲突"。


六、第四阶段:ObjectBox 生成同步

由于新增了 embeddingMetaJson 字段,ObjectBox 的 entity 和 generated bindings 必须同步。

执行:

复制代码
dart run build_runner build --delete-conflicting-outputs

生成后确认:

复制代码
PhotoEmbeddingIndexEntity.embeddingMetaJson 已存在
ObjectBox generated code 已同步

这里有一个经验:如果项目依赖 ObjectBox generated code,而 generated code 未跟踪或被忽略,团队协作时很容易出现:

复制代码
实体字段存在,
但 generated binding 不存在,
导致测试和构建失败。

所以这次把相关 generated binding 也纳入提交,保证别人拉代码后不会因为生成文件不同步直接炸。


七、第五阶段:Android MainActivity 找不到

修完视频链路后,真机运行又遇到一个 Android 原生错误:

复制代码
java.lang.ClassNotFoundException:
Didn't find class "com.example.photo_album.MainActivity"

Manifest 里写的是:

复制代码
<activity android:name=".MainActivity" />

源码路径和包名也是对的:

复制代码
package com.example.photo_album

class MainActivity : FlutterActivity()

所以问题不是包名,也不是 Manifest,而是:

复制代码
MainActivity.kt 没有被编译进 APK。

继续查 android/app/build.gradle.kts,发现 app module 没有应用 Kotlin Android 插件。

修复方式是在 plugins 里加入:

复制代码
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("dev.flutter.flutter-gradle-plugin")
}

注意 Flutter Gradle Plugin 仍然放在最后,符合 Flutter 模板要求。

修复后可以在构建目录里看到:

复制代码
build/app/tmp/kotlin-classes/debug/com/example/photo_album/MainActivity.class

这说明 Kotlin Activity 已经被编译进 APK。


八、第六阶段:AI 权重页误报缺少模型

后来打开"AI 模型权重"页,发现模型显示缺少权重,并且 MobileViCLIP 远程下载 404。

问题本质不是模型推理失败,而是:

复制代码
权重管理层没有识别 Flutter assets 中已经内置的模型。

项目里模型有两种来源:

复制代码
1. Flutter assets 内置;
2. 远程下载。

当模型已经打包进 assets 时,权重页不应该继续强制远程下载。

最开始的错误逻辑类似:

复制代码
检查下载目录
发现没有
尝试远程 preflight
远程 404
判定缺少权重

但实际上本地 assets 里已经有模型。

因此改成:

复制代码
内置 asset 优先;
远程下载只是补充。

并且判断内置模型时不能直接:

复制代码
rootBundle.load(bigModelPath)

因为大模型可能 100MB+,打开权重页只是检查状态,不应该把模型本体读进内存。

最终改成检查 AssetManifest:

复制代码
AssetManifest 中存在模型路径
=> 认为模型已内置
=> 权重页显示"已内置"
=> ensureWeightsAvailableForInference 不再被远程 404 拦住

这次修完后,权重页里 MobileCLIP2 LiteRT 正确显示:

复制代码
已内置

九、第七阶段:同步远端后,本地环境也要清理

远端推送成功后,本地原工作区仍然是旧状态,而且还有未提交改动。

这里不能继续直接开发,必须先同步到最新 origin/master。

为了安全,先做备份分支和 stash:

复制代码
git branch backup-before-sync
git stash push -u -m "wip before syncing origin master"
git fetch origin
git reset --hard origin/master

之后还需要补 submodule:

复制代码
git submodule update --init --recursive

因为远端最新 master 依赖:

复制代码
third_party/music_feat_analyzer

如果 submodule 没补齐,flutter pub get 会被路径依赖卡住。


十、第八阶段:当前 Flutter SDK 兼容问题

同步远端后又遇到一个和视频无关、但会阻塞验证的问题:

复制代码
scrollCacheExtent: ScrollCacheExtent.pixels(700)

当前 Flutter SDK 不支持这个 API,导致 album_page 和 create_page 编译失败。

为了兼容当前 SDK,改成老版本支持的:

复制代码
cacheExtent: 700,

同时发现 dart analyze 会扫进 third_party/** 子模块,导致分析器被 vendored example/test 拖死。

于是把 third_party 加入 analyzer exclude:

复制代码
analyzer:
  exclude:
    - third_party/**

这一步属于验证环境兼容修复,不改变视频逻辑。

提交为:

复制代码
fix(build): 兼容当前 Flutter 验证环境

十一、第九阶段:NDK 安装不完整

构建 debug APK 时又遇到 Android NDK 问题:

复制代码
NDK 27.0.12077973 缺 source.properties

这不是 Dart 或业务代码错误,而是本机 Android SDK 安装损坏。

处理方式:

复制代码
1. 把坏的 NDK 目录改名备份;
2. 用 sdkmanager 重新安装同版本 NDK;
3. 重新 flutter build apk。

修复后:

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

构建通过。


十二、第十阶段:真机安装和启动

一开始安装被 vivo 手机拒绝:

复制代码
INSTALL_FAILED_ABORTED: User rejected permissions

这不是代码问题,而是手机侧 USB 安装授权问题。

后来使用机主用户安装:

复制代码
adb install --user 0 -r -d -g build/app/outputs/flutter-apk/app-debug.apk

安装成功。

启动后检查:

复制代码
adb shell am start -n com.example.photo_album/.MainActivity
adb shell pidof com.example.photo_album

APP 成功前台运行,PID 存在。

logcat 检查没有:

复制代码
FATAL EXCEPTION
ClassNotFoundException
MissingPluginException
模型权重 404

说明 Android 原生层、Flutter 层、模型权重检查层都过了。


十三、第十一阶段:真机视频 Smoke Test

接下来开始真正验证视频模态。

操作流程:

复制代码
1. 打开 APP;
2. 进入 AI 模型权重页;
3. 确认 MobileCLIP2 LiteRT 显示"已内置";
4. 回到相册页;
5. 选择导入已授权范围内的新图片和视频;
6. 观察后台扫描和 AI 分析进度;
7. 查看 ObjectBox 中 embedding meta;
8. 打开"视频"聚类页。

真机结果如下:

复制代码
APP 前台进程正常;
没有 FATAL;
没有模型权重 404;
scan=4402/4402;
AI 分析后台继续消费;
失败数=0;

ObjectBox 中已经写入 embedding meta:

复制代码
imageMeta=702
videoMeta=15
dynamicImage=6

视频 meta 抽样:

复制代码
{
  "mediaKind": "video",
  "modelFamily": "mobileclip_image",
  "textSpace": "mobileclip2_text",
  "frameSource": "android_thumbnail",
  "frameCount": 8,
  "isRepeatedFrame": false
}

并且同时观察到了:

复制代码
isRepeatedFrame=true
isRepeatedFrame=false

这说明当前视频链路支持两种情况:

复制代码
1. 正常多帧视频输入;
2. 退化成重复帧 fallback,但能被 meta 记录。

这正是我们想要的"可解释退化路径"。

另外,相册页已经出现"视频 63"的聚类,并且视频聚类页可以正常打开。

这说明视频标签保守策略也生效了。


十四、如何理解 frameSource=android_thumbnail

这次 meta 中很多视频帧来源是:

复制代码
frameSource=android_thumbnail

这说明当前 Android 设备主要通过系统 thumbnail API 获取视频帧,而不是 FFmpeg。

这不是坏事。

对于移动端 APP 来说,Android thumbnail 有优点:

复制代码
速度快;
功耗低;
不需要额外解码链路;
权限路径更稳定。

但也有缺点:

复制代码
不同厂商实现不一致;
某些视频可能拿到重复帧;
复杂视频抽帧质量不一定稳定。

所以 isRepeatedFrame 这个字段非常重要。

如果未来统计发现:

复制代码
isRepeatedFrame=true 比例很高

就说明需要考虑 FFmpeg fallback 或更可靠的真实文件抽帧。

但当前 smoke test 里 true/false 都出现,说明至少不是全部退化。


十五、当前视频架构的重新理解

经过远端重构后,现在项目的视频路线已经不是旧的 MobileViCLIP 路线,而是:

复制代码
MobileCLIP2 LiteRT
+
视频/GIF 帧聚合
+
textSpace = mobileclip2_text
+
mediaKind/meta 区分 image/video/dynamicImage

因此后续验证不要再盯:

复制代码
MobileViCLIP Small
mobileviclip_video_small_onnx_v1
NCNN text space

而应该看:

复制代码
MobileCLIP2 LiteRT 是否已内置;
视频是否写入 video meta;
frameSource 是否可信;
frameCount 是否正常;
isRepeatedFrame 是否可解释;
视频是否能被聚类和搜索召回。

这个认知转变很重要。

否则很容易拿旧架构标准误判新架构。


十六、这轮修改的 commit 记录

这轮最终推到远端的关键提交包括:

复制代码
34a8bb9 fix(ai): 优先使用内置模型资源
9751472 fix(album): 添加本地缓存清理操作
5b82d08 fix(android): 编译 app 模块的 Kotlin MainActivity
7055150 fix(video): 稳定视频 embedding 并持久化元数据
f1d9d7e chore(objectbox): 重新生成 embedding 元数据绑定
43b47fd fix(build): 兼容当前 Flutter 验证环境

这些 commit 分工比较清楚:

复制代码
ObjectBox 生成同步;
视频 embedding 稳定化;
Android Kotlin Activity 编译;
AI 内置模型权重识别;
相册本地缓存清理;
当前 Flutter SDK 构建兼容。

拆开提交的好处是:后续如果某一层出问题,可以单独回滚或定位。


十七、这次工程排查的经验总结

1. 支持视频文件不等于支持视频语义

媒体层能播放视频,只能说明 ExoPlayer/MediaCodec 链路正常。

视频模态真正可用,还要看:

复制代码
能不能抽帧;
能不能生成 embedding;
能不能落库;
能不能搜索;
能不能不污染图片链路;
能不能 debug。

2. embedding 必须有元数据

只保存 vector 不够。

至少要知道:

复制代码
mediaKind
modelFamily
modelVersion
textSpace
frameSource
frameCount
isRepeatedFrame

否则后续搜索不准时,无法判断根因。


3. 不要让视频冒充图片

视频不应该随便写入 imageEmbedding

字段名背后是数据契约。

如果字段叫 imageEmbedding,就不要轻易把 video embedding 塞进去。


4. 远端重构后不要机械 cherry-pick

这次远端已经把视频路线改成 MobileCLIP2 帧聚合。

本地旧提交如果原样搬上去,会恢复一堆旧架构文件。

正确做法是:

复制代码
保留远端新架构,
迁移稳定性语义。

5. 权重检查不要读大模型本体

判断模型是否内置,不应该直接 rootBundle.load 几百 MB 的模型。

更合理的是检查 AssetManifest。


6. 真机问题要分层看

一次完整真机启动可能经过这些层:

复制代码
USB 安装授权
APK 安装
Android Activity 启动
Flutter Engine 启动
ObjectBox 初始化
模型权重检查
AI 分析任务
视频解码 / 抽帧
embedding 落库

每一层错误都不一样,不能看到 flutter run 失败就直接怪 Dart 代码。


十八、下一步计划

当前已经证明:

复制代码
最新 origin/master 可安装启动;
权重页能识别内置模型;
视频 / dynamicImage meta 已落库;
视频聚类页可以打开;
后台扫描分析稳定推进;
失败数目前为 0。

下一步不建议马上继续大改代码,而应该做更系统的测试:

1. 等后台 AI 分析跑完

记录最终:

复制代码
scan 总数
AI analyzed 总数
失败数
imageMeta 数量
videoMeta 数量
dynamicImage 数量

2. 做 30 条视频黄金集

记录:

复制代码
视频类型
编码格式
时长
frameSource
frameCount
isRepeatedFrame
是否出现在视频聚类
搜索 query
是否能搜到

3. 统计 repeated frame 比例

如果 repeated frame 比例太高,再考虑优化抽帧。

4. 增加内部 debug 页面

点开任意媒体,显示:

复制代码
mediaKind
aiTags
embeddingMetaJson
hasImageEmbedding
isAiAnalyzed
是否 junk candidate

这会让后续排查效率大幅提高。


十九、结语

这次修复不是一个单点 bug,而是一串典型的移动端 AI 工程问题:

复制代码
模型权重管理
视频抽帧
embedding 语义
ObjectBox 落库
Git 远端重构冲突
Flutter SDK 兼容
Android Kotlin Activity 编译
NDK 安装
真机权限

最后比较重要的结论是:

复制代码
视频模态不是简单加一个 video 类型,
而是要给它完整的数据契约和可观测性。

当前视频链路已经从"能不能跑"推进到"可以验证、可以解释、可以继续迭代"。

对于 APP 工程来说,这一步比盲目追求更大的模型更重要。

一句话总结:

复制代码
先让链路可信,再谈模型变强。
相关推荐
月疯2 小时前
torch:view和reshape的区别
pytorch·python·深度学习
谷歌玩家2 小时前
人工智能、机器学习、生成式AI、神经网络、Transformer 概念梳理
人工智能
一切皆是因缘际会2 小时前
因果推理人工智能
大数据·数据结构·人工智能
AI原来如此2 小时前
Claude Opus与GPT-5激战,国内API中转站如何应对2026模型迭代潮?
大数据·人工智能·gpt·ai·大模型·ai编程
好评笔记2 小时前
深度学习面试八股—— GRU(Gated Recurrent Unit)
人工智能·rnn·深度学习·算法·机器学习·gru·校招
comcoo2 小时前
避坑指南:OpenClaw v2.7.9 Windows/macOS 零基础安装全过程
人工智能·windows·macos·github·开源软件·open claw·open claw部署包
南檐巷上学2 小时前
基于改进型CNN神经网络的车牌定位识别系统(Matlab)
人工智能·神经网络·matlab·cnn·车牌识别·vgg
3DVisionary2 小时前
模具电极3D检测真实案例:手机后盖注塑模石墨电极全流程实录
人工智能·3d·智能手机·案例分析·蓝光三维扫描·模具检测·石墨电极
AI人工智能+2 小时前
往来港澳通行证识别系统,深度融合计算机视觉与自然语言处理,为“智慧口岸”和“数字政务”提供了强有力的技术支撑
人工智能·深度学习·ocr·往来港澳通行证识别