一款个人消费记录 Android 应用,用于记录美团、淘宝闪购、京东三个平台的外卖消费。首页是日历:哪天吃了外卖、花了多少一眼可见,点日期就能看到当天吃了什么、评价如何;订单与评价合并为一条记录,记一笔时就地写评价。
技术栈:Kotlin + Jetpack Compose + Room + MVVM(Repository + ViewModel + StateFlow)。
2026-10-24 更新:新增「截图识图自动填表」——选一张外卖订单截图,自动填出店铺/商品/价格/实付,并保留整单商品明细。
实现方式是 端侧 ML Kit 中文 OCR 认字 + 云端大模型理解版面,图片不出手机。
这一天的开发过程踩了大量坑(三次换方案、卡了半天的"有概率失败"、两次把真数据压掉的误杀),全部记录在本文件末尾的
开发日志与经验教训一节,建议改动识图相关代码前先读一遍。
| 模块 | 说明 |
|---|---|
| 截图识图(新) | 录入页「从订单截图识别」→ 选图 → 自动填出平台 / 店铺 / 主商品 / 价格 / 数量 / 实付金额,并额外保留整单商品明细。识别结果只填空字段,绝不覆盖你已输入的内容;失败时明确提示且不动任何字段 |
| 实付金额(新) | 订单级「实付」字段 = 整单真正付的钱(含打包费/配送费、已扣优惠)。统计以实付为准,未填写时回退到「价格 × 数量」 |
| 商品明细(新) | 多商品订单会把每一件商品的名字/单价/数量存下来,卡片上列出,供回看"这顿吃了什么"。明细不参与统计(单价之和通常不等于实付),可在保存前单条删除 |
| AI 识别设置(新) | 日历页 ⋮ → AI 识别设置:粘贴 API Key、改模型 ID、开关云端、测试连接。Key 用加密存储(EncryptedSharedPreferences),不硬编码进 APK。关掉开关则全程离线(走端侧规则) |
| 日历首页 | 月历采用暖色底卡片,与下方记录卡片明显区分;有外卖的日期整格高亮并加粗显示当天花费(超 1000 显示为 k),多笔时附"n笔";今天用白底圈标记、选中日期主色高亮;左右翻月、一键回到今天;顶部显示"本月 N 天有外卖 · ¥X";点击日期即列出当天记录 |
| 记录录入 | 订单与评价在同一个表单完成:平台、下单时间、店铺、商品、价格、数量、实付金额(新) + 星级(1–5,可选)、评价内容、评价图片;从日历进入时自动带入所选日期 |
| 交互方式 | 卡片只响应长按(进入编辑页,避免误触),单击不做任何事、也不弹提示;记录页默认展示搜索 + 星级卡片 |
| 记录列表 | 记录页没有标题栏,顶部留白(按可用高度约 17%,自适应,用于露出背景插画的人物脸部),其下是一行:搜索框 + 平台筛选图标,再下方 6 张星级卡片(5★/4★、3★/2★、1★/未评级,两列三行铺满剩余空间),每张卡片颜色不同并显示该星级的条数与金额;点卡片即筛选,结果按日期分组展示(平台色条 + 星级 + 内容 + 图片缩略图);结果页左右滑动(或系统返回手势)即可返回星级卡片;下滑自动收起搜索框,上滑或回到顶部自动展开 |
| 搜索 | 顶部搜索框,同时匹配店铺名 / 商品名 / 评价内容,边输边筛 |
| 筛选 | 星级卡片(5★~1★ / 未评级)+ 平台筛选图标(美团 / 淘宝闪购 / 京东),可与搜索叠加;结果上方显示当前条件并带「清除筛选」 |
| 统计看板 | 按月或按年查看 外卖天数(有订单的日期去重)与 外卖总金额(Σ 实付金额,缺失时回退价格×数量),并展示订单数、平台分布、评价情况(已评价数 / 平均星级) |
| 删除(编辑页内) | 卡片上没有删除入口:删除只在编辑页提供(右上角垃圾桶图标),点击后二次确认才真正删除,同时清理评价图片文件 |
| 图标与背景 | 应用图标为自定义插画(自适应图标 + 各密度圆形/方形图标);App 内背景插画不透明铺满,顶栏完全透明(无白色底衬);压在图案上的文字统一使用荧光绿 + 深色描影(glowingOnBackground)保证对比度,卡片内文字仍为深色(卡片是不透明底色) |
| 一键备份/迁移 | 日历页右上角 ⋮ → 导出全部数据 生成单个 zip(data.json + images/ 评价图片);⋮ → 导入数据 支持覆盖导入(先清空本机再写入,结果与备份完全一致)或追加导入(合并);换手机/分享给别人后导入即可得到一模一样的数据。备份格式当前 v3(含实付与商品明细) |
| 图片多选 | 系统相册一次可选多张(最多 9 张,可多次追加),复制到应用私有目录,无需存储权限 |
| 图片压缩 | 相机原图 3 |
| 图片放大 | 长按(或点击)缩略图打开全屏查看器:双指缩放、双击放大、拖动平移、多张图片左右切换 |
| 输入法适配 | 表单使用 imePadding(),键盘弹出时输入框自动避让,写评价不再被输入法遮挡 |
选一张订单截图
│
├─ ① 端侧 ML Kit 中文 OCR(bundled,离线、不依赖 GMS)── 认出文字行 + 坐标
│
├─ ② 云端大模型理解版面(配了 Key 才走)
│ 只发送「文字行 + 坐标」文本,**图片永远不上传**
│ 发送前过滤掉含收货地址 / 手机号 / 订单号的行
│
├─ ③ 云端失败 → 回落到端侧纯规则解析器
│
└─ ④ 两级都失败 → 只提示,不动任何已填字段(绝不静默清空用户输入)
- 为什么要云端这一层:端侧规则三年都调不好的那一环不是"字认不出来",而是行切错——价格被切成独立文本行、规格行混进商品、
x1不跟商品行合并、划线价与折后价难分。这恰恰是大模型的强项,而 ML Kit 对截图这种印刷体的识字率其实够用。 - 成本:一张订单截图约几百输入 token + 一两百输出 token,一次识别约 0.3 分钱(88 次实测共 ¥0.24)。按每天记 3 笔算,一个月约 ¥0.25(详见成本实测)。
- 不出手机的东西:原图、收货地址、手机号、订单号。
单表 meal_records(订单 + 评价一对一合并):
| 字段 | 说明 |
|---|---|
| platform | 平台(MEITUAN / TAOBAO_FLASH / JD) |
| orderTime | 下单时间(epoch 毫秒) |
| storeName / productName | 店铺名称 / 主商品名称 |
| productPrice / productQuantity | 主商品价格 / 数量(小计 = 价格 × 数量) |
| actualPaid(2026-10-24 新增) | 订单级实付金额(含打包费/配送费、已扣优惠)。null = 未记录,统计时回退到小计 |
| items(2026-10-24 新增) | 整单商品明细 JSON 数组:[{name, price, quantity}]。不参与任何统计,仅供回看 |
| starRating | 星级 1–5,null 表示未评价 |
| reviewContent | 评价内容 |
| reviewImages | 评价图片路径列表(JSON 存储,最多 9 张) |
| reviewTime | 评价时间 |
| createdAt | 记录创建时间 |
统计口径唯一入口是 paidAmount(= actualPaid ?: subtotal),日历日汇总、列表分组汇总、统计看板全部走它。
商品明细不参与统计——单价之和通常不等于实付(还有打包费/配送费/优惠),拿它求和会算错钱,有单测钉死这条边界。
数据库版本:v4。迁移链完整,无破坏性迁移:
| 迁移 | 内容 |
|---|---|
1 → 2 |
旧的 orders + reviews 两张表按"订单 + 其最新一条评价"合并进 meal_records,历史数据不丢失 |
2 → 3 |
新增 actualPaid(可空、不加 DEFAULT),存量用「价格 × 数量」回填 |
3 → 4 |
新增 items(非空 + DEFAULT '[]',实体上标 @ColumnInfo(defaultValue = "[]")),老数据为空列表 |
⚠️ 两次迁移的写法正好相反,改迁移前务必看一眼Migrations.kt里的注释:actualPaid是 Kotlin 可空类型所以必须"可空无 DEFAULT";items是非空类型所以必须"NOT NULL + DEFAULT"。写反了 Room 的 schema 校验会直接抛IllegalStateException。
EAT0827/
├── settings.gradle.kts / build.gradle.kts / gradle.properties
├── gradle/
│ ├── libs.versions.toml # 版本目录(AGP/Kotlin/Room/Compose BOM/ML Kit/Security-Crypto)
│ └── wrapper/gradle-wrapper.properties
├── tools/ # make-keystore.ps1(生成正式签名)
├── _archive/ # 已下架方案的归档(不参与编译,**也不进 git 仓库**,见 .gitignore)
└── app/src/
├── main/java/com/example/foodexpense/
│ ├── FoodExpenseApp.kt # Application:数据库 + 仓库 + OCR/LLM 单例
│ ├── MainActivity.kt
│ ├── data/
│ │ ├── entity/MealRecordEntity.kt # 记录实体(订单 + 评价 + 实付 + 明细)
│ │ ├── entity/OrderItem.kt # ★ 商品明细项
│ │ ├── entity/Platform.kt # 平台枚举与品牌信息
│ │ ├── dao/MealRecordDao.kt # 搜索 + 星级 + 平台组合查询、区间查询
│ │ ├── db/AppDatabase.kt # Room 数据库(version 4)
│ │ ├── db/Migrations.kt # ★ 1→2→3→4 全部迁移
│ │ ├── db/Converters.kt # 图片列表 / 商品明细 的 JSON 转换
│ │ ├── prefs/AiSettings.kt # ★ API Key 与模型的加密存储
│ │ └── repository/MealRecordRepository.kt
│ ├── ocr/ # ★ 识图(纯 Kotlin,零 Android 依赖,可 JVM 单测)
│ │ ├── TextLine.kt # OCR 文本行(文本 + 坐标)
│ │ ├── OcrRecognizer.kt # ML Kit 中文 OCR 封装(含预热、20s 超时)
│ │ ├── LineGeometry.kt # ★ 第 0 步几何行重建(视觉块 → 语义行)+ 内部数据类
│ │ ├── ParserPatterns.kt # ★ 全部常量 / 正则 / 词表 + 文本工具
│ │ ├── OrderScreenshotParser.kt # ★ 主流程 + P1~P6 业务规则
│ │ ├── ParsedOrder.kt # 解析结果(含商品明细)
│ │ ├── LlmOrderParser.kt # 云端结构化(DeepSeek Chat Completions)
│ │ └── LlmJsonMapper.kt # 纯 Kotlin 扁平/数组 JSON 解析器
│ ├── util/
│ │ ├── DateUtils.kt # 时间格式化与解析、日期/时间选择合成
│ │ ├── DataBackup.kt # 一键导出/导入 zip(格式 v3)
│ │ ├── SafeReplace.kt # ★ 覆盖导入的"先写库、后删图"顺序保证
│ │ └── ImageStorage.kt # 图片复制到私有目录 / 删除
│ ├── viewmodel/
│ │ ├── CalendarViewModel.kt
│ │ ├── RecordListViewModel.kt
│ │ ├── RecordFormMerge.kt # ★ 「识别结果 → 表单」合并规则(纯函数,可纯 JVM 单测)
│ │ ├── RecordFormViewModel.kt # ★ 表单 + 识图三级降级链
│ │ ├── BackupViewModel.kt
│ │ └── StatsViewModel.kt
│ └── ui/
│ ├── theme/ # Color / Theme / Type / Overlay(荧光绿文字)
│ ├── components/
│ │ ├── CommonUi.kt / RecordCard.kt / StarRatingBar.kt
│ │ ├── BackupMenu.kt # 右上角 ⋮ 菜单:导出/导入/AI 识别设置
│ │ └── ImageViewer.kt
│ ├── navigation/AppNav.kt # 底部导航 + AI 设置 / OCR 调试页路由
│ └── screens/
│ ├── calendar/CalendarScreen.kt
│ ├── records/RecordListScreen.kt / RecordFormScreen.kt # ★ 表单含商品明细区块
│ ├── stats/StatsScreen.kt
│ ├── settings/AiSettingsScreen.kt # ★ AI 识别设置
│ └── ocr/OcrTestScreen.kt # ★ OCR 调试页(仅 debug 构建可见)
└── test/java/com/example/foodexpense/ # 140 个单元测试
- 看日历(首页):暖色日历卡片上,有外卖的日子整格高亮并显示当天花费(多笔会标注"n笔");点左右箭头翻月,「今天」一键回到本月;点击某一天,下方立即列出当天所有外卖记录。
- 记一笔:日历页右下角
+(自动带入所选日期)或记录页+→ 填平台/时间/店铺/商品/价格/数量/实付金额 →(可选)点星星评分、写评价、多选图片 → 保存。 - 用截图识图(新):录入页点「从订单截图识别」→ 选一张订单详情页截图 → 等几秒,平台/店铺/商品/价格/实付会自动填上。
- 识别成功会在下方显示「云端识别:…」或「本地识别:…」的核对提示(来源必须让你看见,否则识别对了也不知道该继续投哪条路);多商品时会提示「已保留 N 件商品明细」。
- 商品明细出现在表单里,每行右侧 × 可删掉识别错的商品。
- 识别结果只填空字段,你手填过的内容不会被覆盖;识别失败也不会清空任何字段。
- 配置云端识别(新):日历页 ⋮ → AI 识别设置 → 粘贴 API Key → 「测试连接」显示"正常"即可用。
- 不配 Key 也能用:只是走端侧规则,准确率低一些,全程离线。
- 首次使用建议先点「测试连接」——连不通时报错就在界面上,不用去翻日志。
- 编辑 / 补评价:在日历或记录列表中长按卡片进入编辑页(单击不触发任何操作)。
- 删除:列表上没有删除入口——进入编辑页后点右上角垃圾桶图标,二次确认后删除。
- 按星级筛选(记录页默认):搜索框下方 6 张不同颜色的星级卡片铺满整屏,显示该星级条数与金额,点一下即可查看该星级的全部记录;在结果页左右滑动、或用系统返回手势即可返回。
- 搜索:记录页顶部输入关键字,匹配店铺、商品或评价内容;向下滑动时搜索框自动收起,向上滑动自动展开。
- 平台筛选:搜索框右侧的漏斗图标可选美团 / 淘宝闪购 / 京东,与星级、搜索叠加生效。
- 看图:长按(或点击)缩略图 → 全屏查看,双指缩放、双击放大、左右切换。
- 统计:切到"统计"标签,按月/按年查看外卖天数与总金额。
- 导出数据(备份 / 换机 / 分享):日历页右上角 ⋮ → 导出全部数据 → 系统文件选择器里选保存位置。备份包含
data.json(记录、评价、星级、实付、商品明细)与images/(评价原图)。 - 导入数据:⋮ → 导入数据 → 选 zip → 选择覆盖导入(先清空本机再写入,结果与备份完全一致)或追加导入(合并)。旧版本备份(无实付/无明细)导入后对应字段为空,记录照常导入。
- 图片体积:评价图片保存时自动压缩(最长边 1600px、JPEG 85%)。旧图可点 ⋮ → 压缩已有评价图片 批量压缩。
- 外观:背景插画不透明铺满,顶栏完全透明,压在图上的文字用荧光绿 + 深色描影;想调整可改
ui/components/AppBackground.kt的alpha。
- 图片解码 / 缩放 / 重编码全部在
Dispatchers.IO后台线程执行; - 选图后界面会显示"正在压缩图片(在后台处理,界面不卡)…",此时按钮置灰;
- 小图跳过重编码:源图 ≤300KB 直接落盘;
- 识图分两段超时:端侧 OCR 20s、云端 LLM 单独 60s(共用一个短超时会导致云端永远来不及返回,表现为"静默走本地兜底");
- 进入录入页时会预热 OCR 模型,点按钮就不用等模型加载。
| 项目 | 要求 | 说明 |
|---|---|---|
| 操作系统 | Windows / macOS / Linux 均可 | |
| Android Studio | Ladybug (2024.2.1) 及以上(最低 Hedgehog 2023.1.1) | 需支持 AGP 8.5 |
| JDK | 17 | Android Studio 自带 JBR 17,打开即可用;命令行构建需 JDK 17 |
| Android SDK | Platform 34 + Build-Tools 34 | 首次同步时 AS 会提示自动安装 |
| 设备 | Android 8.0(API 26)及以上,arm64-v8a | 依赖已收敛到单一 ABI,见下方说明 |
| 网络 | 能访问 google() / mavenCentral() |
首次需下载 Gradle 8.9 与依赖(数百 MB) |
ABI 收敛为
arm64-v8a:ML Kit 中文模型每 ABI 约 11MB(官方"约 4MB"的说法严重低估),打 4 个 ABI 会让 APK 从 18MB 涨到 61MB。
现在只保留arm64-v8a,代价是极老的 32 位 ARM 设备无法安装(2019 年后的主流机型都是 arm64)。
若要放开,改app/build.gradle.kts里的abiFilters。
- 启动 Android Studio → File → Open… → 选择项目根目录 → OK。
- 弹窗 "Trust Project?" → Trust Project。
- 若提示缺少 SDK 组件 → 安装 Android SDK Platform 34(含 Build-Tools 34)。
- 等待 Gradle Sync 完成(首次 5~15 分钟)。仓库未提交
gradle-wrapper.jar二进制,但 Android Studio 会直接按gradle/wrapper/gradle-wrapper.properties中地址下载 Gradle 8.9,无需 wrapper jar 即可同步。 - 同步完成后
app模块可展开,工具栏出现绿色 Run ▶。
检查 Gradle JDK:File → Settings → Build, Execution, Deployment → Build Tools → Gradle → Gradle JDK,选择 17(jbr-17)。
创建模拟器:工具栏 Device Manager → Create device → 选一台手机(如 Pixel 6)→ Next → 选系统镜像 API 34 → Finish → 启动。
使用真机:手机设置 → 关于手机 → 连点版本号 7 次开启开发者模式 → 打开 USB 调试 → 数据线连接电脑 → 允许调试。
启动运行:顶部设备选择器选中目标设备 → 点击 Run ▶(或 Shift+F10)。
OCR 相关功能必须真机验证:ML Kit 无法在 JVM 单测里跑(
OcrRecognizer.kt零测试覆盖),模拟器上也应实际截图验证。
仓库未提交 gradlew 脚本与 gradle-wrapper.jar(二进制),两种方式补齐:
# 方案 1:本机已安装任意 Gradle(≥ 7.0),在项目根目录执行
gradle wrapper --gradle-version 8.9
# 方案 2:从任意其它 Android 工程复制 gradlew、gradlew.bat 与 gradle/wrapper/gradle-wrapper.jar 到本项目然后:
# Windows
gradlew.bat :app:testDebugUnitTest # 跑 140 个单元测试
gradlew.bat assembleDebug # 构建 debug APK
gradlew.bat installDebug # 安装到已连接设备
# macOS / Linux
./gradlew :app:testDebugUnitTest
./gradlew assembleDebug
./gradlew installDebug产物:app/build/outputs/apk/debug/app-debug.apk(当前约 33MB)。
命令行构建还需:
JAVA_HOME指向 JDK 17(注意:本机环境变量可能指向不存在的路径,用java -version确认后再传给 Gradle);- 项目根目录
local.properties写入sdk.dir=...(或设置ANDROID_HOME)。
在鸿蒙手机上从浏览器 / 文件管理器安装 debug 包时,常会提示:
安装失败 — 失败原因:应用是非正式发布版本,当前设备不支持安装。
这是华为系统对 debug 签名 + 非应用市场渠道 的拦截("纯净模式""未知来源管控"),与代码无关。三选一解决:
方案 A(最快,调试用):用 ADB 安装
adb install -r app\build\outputs\apk\debug\app-debug.apk
# 若提示 test-only 包,可加 -t:
adb install -r -t app\build\outputs\apk\debug\app-debug.apkAndroid Studio 直接点 Run ▶ 走的就是这条通道,通常不会被拦。
方案 B(推荐,分享 / 长期使用):打正式签名包
- 在项目根目录执行(自动生成
release.keystore与keystore.properties):powershell -ExecutionPolicy Bypass -File tools\make-keystore.ps1
也可用 Android Studio:Build → Generate Signed App Bundle / APK… 按向导生成。
- 重新 Sync,然后构建正式包:
产物:
gradlew.bat assembleRelease
app/build/outputs/apk/release/app-release.apk(v2+v3 正式签名)。 - 把这个
app-release.apk拷到手机安装即可。若仍被拦:设置 → 系统和更新 → 纯净模式 → 退出,并给浏览器/文件管理器开启「安装未知应用」权限。
app/build.gradle.kts已内置逻辑:根目录有keystore.properties时用正式签名,没有时 release 退回 debug 签名(保证assembleRelease始终能产出可安装的包)。keystore.properties与*.keystore已加入.gitignore。
方案 C:关闭纯净模式后重试
- 「设置 → 系统和更新 → 纯净模式 → 退出纯净模式」
- 「设置 → 安全 → 更多安全设置 → 安装外部来源应用」给浏览器/文件管理器授权
| 现象 | 原因与解决 |
|---|---|
Sync 报 Could not find com.android.tools.build:gradle:8.5.2 |
无法访问 google()。改用国内镜像:在 settings.gradle.kts 两处 repositories 块顶部加 maven { url = uri("https://maven.aliyun.com/repository/google") } 与 maven { url = uri("https://maven.aliyun.com/repository/public") };Gradle 发行包可将 distributionUrl 换成 https://mirrors.cloud.tencent.com/gradle/gradle-8.9-bin.zip |
出现奇怪的 Unresolved reference |
依赖缓存异常:File → Invalidate Caches… → Invalidate and Restart,或删除 ~/.gradle/caches 后重新 Sync |
报 platform 34 not found / Build Tools 34 缺失 |
Settings → Android SDK → SDK Platforms 勾选 API 34 并安装 |
报 JDK / class file major version 错误 |
Gradle JDK 不是 17:Settings → Build Tools → Gradle → Gradle JDK 选 17 |
安装报 INSTALL_FAILED_OLDER_SDK |
设备系统低于 Android 8.0(minSdk 26) |
安装报 ABI / INSTALL_FAILED_NO_MATCHING_ABIS |
32 位 ARM 老设备。当前只打 arm64-v8a,如需支持改 abiFilters |
| 华为/鸿蒙提示"应用是非正式发布版本,当前设备不支持安装" | debug 签名 + 非应用市场渠道被系统拦截。用 adb install -r -t,或打正式签名包,并关闭「纯净模式」 |
| 启动崩溃提示数据库校验失败 | 不会静默清库:数据库已内置 1→2→3→4 完整迁移,且移除了 fallbackToDestructiveMigration()——漏迁移会显式报错而不是清空用户账本。真出问题请带着报错排查迁移 SQL,不要靠卸载重装蒙混 |
| 识图提示"云端识别未生效,已回退本机规则" | 这是正常降级,不是崩溃。红字里带了失败原因(HTTP 码 / 超时 / 解析失败),可长按复制。常见原因:没配 Key、没网、Key 额度用完、模型 ID 不对。可在「AI 识别设置」里用「测试连接」单独验证 |
| 识图"有概率失败" | 历史上真机出现过,根因是 max_tokens 太小(见开发日志第 6 节),已改为 3000。若再出现,看报错里的 finish_reason 与"正文长度"——这两项是定位这类问题的关键线索 |
| 识图把收货地址/手机号发出去了吗 | 没有。发送前用 PRIVACY_LINE_RE 过滤含地址/手机号/订单号的行,且只发文字不发图片 |
| 中文乱码 | 源码均为 UTF-8;gradle.properties 已设 -Dfile.encoding=UTF-8 |
| 相册选图空白 | 系统 Photo Picker 需要 API 26+,须从 Activity 上下文发起;重试或更换设备相册 |
- 统计口径:外卖天数 = 时间段内订单日期(本地时区)去重个数;外卖总金额 = Σ
paidAmount(实付,缺失时回退价格×数量)。统计通过 RoomFlow实时响应。 - 实付只能"读"不能"算":实付 ≠ 商品原价合计。样例
43.4 + 1 − 21.9 = 22.5 ≠ 26.4,所以解析器是"从截图上找到写着的实付",而不是自己加总推导。 - 商品明细只用于回看:明细定位是"截图上当时写了什么",用户手改商品名/价格后明细不会自动同步(这是刻意取舍,跟随编辑反而丢原始信息)。
- 搜索实现:单条 SQL 组合条件(关键字 + 平台 + 星级),关键字用
LIKE ... ESCAPE '\'并转义% _;flatMapLatest保证快速输入时只保留最新查询。 - 数据校验:店铺/商品非空、价格 > 0、数量 ≥ 1;写了评价内容或加了图片却未选星级时会提示;图片最多 9 张。
- 图片存储:Photo Picker(无需权限)选中后复制到
filesDir/review_images;删除记录或移除图片时同步删除文件。 - 长按交互:卡片只响应长按(进入编辑),避免误触。
- 错误信息必须可见:云端失败、识别失败的原因都直接显示在界面上,且可长按复制——靠用户截图辨认长报错既累又容易看漏字符。
这一节记录「截图识图」功能从零到可用过程中所有失败尝试与关键判断。改动 app/src/main/java/.../ocr/ 下任何代码前,建议先读一遍——下面每一条都是花了真机时间换来的,重复踩一遍代价很高。
| 决策点 | 选项 | 最终选择 | 理由 |
|---|---|---|---|
| 记哪个金额 | 商品原价合计 / 实付金额 | 实付金额 | 用户真正关心的是"这顿花了多少钱"。实付 ≠ 原价合计(有打包费、配送费、优惠),记原价会算错钱 |
| 多商品怎么记 | 全记 / 只记主商品 | 只记主商品(第一版) | 统计口径是整单实付,拆成多条会让"商品单价之和"被当成花费。后来用「方案 A:明细字段」解决了"回看不到其它商品"的问题 |
| 端侧还是云端 | 端侧 OCR + 规则 / 云端 | 端侧(第一版) | 用户要求全程本地、离线可用、不依赖 GMS(国产机无 GMS) |
教训:先定口径再写代码。实付这个口径一旦定错,后面所有规则、所有测试都建立在错误前提上,返工成本极高。
症状:店铺名填成 粉丝群、商品名填成 -¥5、价格与实付都是 5。
根因(结构性,不是词表问题):ML Kit 按视觉分栏切块,右对齐的价格成了独立 TextLine;而解析器假设"一条 TextLine = 一个语义行"。结果两个真实商品全漏,-¥5 因为 residue 里剩一个 - 号成了唯一"商品",itemCount=1 触发兜底逻辑,价格/实付都被写成 5。
三个我判断错的细节(被工程师用真机数据纠正):
- 店铺名的锅不在位置启发式:
STORE_LABEL_RE的「商家」匹配到了进商家粉丝群里的"商家",标签法先命中并返回"粉丝群" → 必须加(?<!进)负向断言。 - 实付错不是因为"取第一个金额",而是
lastPositiveAmountAfter在优惠标签处截断,合计 已优惠8.5元 ¥12.3被整条吃掉 → 改成stripDiscountSegments(整段剥离)。 - 老 30 条用例的坐标不是全 0(
top=100+i*80),所以几何重建在它们身上真实执行,"全 0 跳过"的保护救不了它们。
修法:新增 rebuildVisualLines()——按垂直重叠比例聚类(同一视觉带)+ 簇内按 left 从左到右拼接,把被切开的名字和价格重新合成一个语义行。
教训:解析器里"一条 TextLine = 一行"这种假设,在没有真机数据验证前就是定时炸弹。在真机上跑一遍,胜过在编造数据上过 100 条测试。
三个最终保留的判据(每个都带反向验证测试:关掉判据,测试必须变红):
| 判据 | 作用 |
|---|---|
VERTICAL_ASPECT_RATIO = 3 |
高 > 3×宽 → 竖排/坏框,强制自成一行(不参与桥接) |
HORIZONTAL_OVERLAP_RATIO=0.5 |
横向大幅重叠 → 判为上下堆叠,拒绝合并(这条才是修桥接的正解) |
MAX_ITEM_LEFT_LEN = 30 |
商品名长度上限(原为 14,会误杀 16 字的商品名) |
三个"为了压噪声,结果把真数据压掉"的同类 bug(都是真实数据/用例才暴露的):
- 14 字长度闸门吃掉
超大份双人套餐(含可乐薯条鸡翅) SPEC_FRAGMENT_RE无条件删除长度 ≤14 的括号 → 把(含可乐薯条鸡翅)也删了;改为只删"数字+单位 / 整体命中规格词 / 单字符"三类looksLikeSpec用contains匹配 →超值套餐(推荐)整行被否;改为先剥括号再判断
被否掉的三个错误方案(记录下来避免重走):
- 簇包络比重叠 —— 被高块撑大,
min()仍取小块高度,门槛反而更低 - 簇包络高度上限 2.0× 中位行高 —— 误伤"商品名换行成 h=92"的正常块,价格行随之孤立,商品照样消失
- 高块强制成行 —— 同上,更糟
教训:压噪声的启发式一定会误杀真数据。每加一条"排除规则",都要问一句"有没有正常数据长这样",并补一条正向用例。
踩的第一个坑:网上教程给的 Maven 坐标全是假的,实测 404:
paddleocr.bj.bcebos.com/maven/→ 404com.baidu.paddle:paddleocr_full:4.0.0→ 404com.tencent.ncnn→ 404;io.github.rapidocr→ 404
那些"三行代码接入"的文章基本是 AI 生成的,照抄会卡在第一步。
真实可用的依赖:com.microsoft.onnxruntime:onnxruntime-android(Maven Central 实测存在)。
关键选型判断:要用 PP-OCRv4 模型,走 ONNX Runtime 比 ncnn 划算一个数量级——一行 implementation + 纯 Kotlin 前后处理,无需 NDK/CMake/JNI;ncnn 需要自己编译 so、手写 JNI、实现 DB 后处理与 CTC 解码,3~6 天且极易出细微 bug。
模型获取:GitHub 直连不通(curl 000),必须走 hf-mirror.com。字典在 SWHL 路径 404,最终从 pypi 的 rapidocr yaml 里找到 modelscope 地址才拿到(6623 行)。
架构上做对的事:抽 OcrEngine 接口让 ML Kit 与 Paddle 并存,POC 调试页可 A/B 切换对比。
决定性判断(省下了大量时间):换引擎不解决"行切错"。ML Kit 在失败样本里字是认对的(粉丝群、-¥5 都来自图上真实文字),错的是行框切分与规则。PP-OCRv4 的 DB det 同样会给同一视觉带的左右文本块分别出框。
下架原因:真机报 src.length=480 dst.length=960 数组越界(手写预处理缓冲的 bug);加上包体从 32MB 涨到 63.55MB(ORT so 17.5MB + 模型 15.3MB + ML Kit 11MB)。最终整套移除,归档到 _archive/paddle-ocr/(15MB,不参与编译)而不是删除——之前有过删错文件无法恢复的教训。
(该目录已加入 .gitignore,不会进 git 仓库:里面有两个 ONNX 模型二进制,会让仓库永久变大。README 里的记录已足够还原这段经过。)
教训:
- 网上 AI 生成的接入教程,先验证依赖坐标是否存在再动手。
- 先判断"换方案能不能解决当前的根因",再决定换不换。这次根因是行切分,换 OCR 引擎完全对不上症。
- 归档,不要删除。
用户决定放弃纯端侧后,给了两个选项:
| 方案 | 优点 | 缺点 |
|---|---|---|
| A:只用云端多模态(直接发图) | 实现简单,能看图 | 图片要上传(隐私)、流量大、慢、贵、OCR 精度不可控 |
| B:ML Kit 认字 + LLM 理解版面 | 图片不出手机、只发几百 token、便宜、快 | 看不到图,靠坐标推理版面 |
选 B。模型用 deepseek-flash(DeepSeek-V4.1-Flash,最便宜档,支持 effort 参数)。
做对的几件事:
- Key 存
EncryptedSharedPreferences,绝不硬编码进 APK;模型 ID 做成可配置(平台会改名) - 发送前用
PRIVACY_LINE_RE过滤地址/手机号/订单号行 - 三级降级链:云端 → 端侧规则 → 手动;任一级失败不清空用户已填内容
LlmJsonMapper写成纯 Kotlin 解析器——因为org.json属于android.jar,在isReturnDefaultValues=true的 JVM 单测里只返回默认值,测不出任何东西
教训:org.json 在 JVM 单测里是桩实现。凡是想在单测里覆盖的解析逻辑,都不能依赖它。
症状:同一张截图,第一次失败、第二三次成功、第四次又失败。报错是 云端识别未生效…未返回可解析的 JSON:——冒号后面一片空白。
排查过程中一共修掉 4 个只在真机暴露的 bug:
testConnection()复用了"必须是 JSON"的解析函数,而测试请求要的是纯文本 → 必现失败。- 外层 20s 超时包住了 LLM 调用,而 LLM 的读取超时是 45s → 外层先掐断,静默走本地兜底,从界面上看不出来。
- LLM 偶尔在 JSON 前后带一句解释文字 → 解析器改成取「第一个
{到最后一个}」之间的内容。 - 根本原因:
max_tokens = 800太小。V4 系模型默认effort=high带推理,推理过程吃光了 800 token,正文还没开始写就被截断 → HTTP 200 但content是空字符串(finish_reason=length)。改成 3000 后,用户实测 3 张不同订单截图 × 每张 4~5 次 = 100% 成功。
教训(这一天最有价值的一条):
- 报错信息里"没有什么"和"有什么"同样重要。空正文这个线索是靠用户逐字复述红字才发现的——如果报错只截前 200 字,人的注意力会被引向完全错误的方向。
- 所以现在报错里强制带上
finish_reason+ 正文长度 + 原文,且界面上可长按复制。 - 超时必须分级:不同阶段的合理耗时差一个量级,共用一个超时会制造"静默降级"这种最难排查的故障。
- 静默降级是敌人。任何降级路径都要在界面上留下可见痕迹(现在会显示"云端识别未生效,已回退本机规则:原因")。
- 实测 88 次调用共花 ¥0.24(第一次复盘时误读成 ¥2.4,差了一个数量级,已更正)。折算单次不到 ¥0.003,也就是每笔不到 0.3 分钱。
按每天记 3 笔估算,一个月约 ¥0.25——基本可以忽略。 - 真正的成本大头是输入 prompt 和模型推理,跟商品数量无关。后来加"商品明细"只在输出端多几十 token(占比 1~3%),基本不影响花费。
- 一次失败的成本优化(已回退,别再试):试过在 prompt 开头加「不要任何推理、分析、思考过程,第一个字符必须是
{」,想压低推理 token。实测反而暴涨:汉堡 1234 → 1655,煎饼王 596 → 2411(+304%)。模型会花更多 token 去思考"怎么才能不思考"。这条已写进LlmOrderParser.SYSTEM_PROMPT的注释里。 - 为了能看见成本,界面上会显示每次调用的
用量:N+M tokens(推理 X),json=on,effort=low——实际生效的参数档位也一起显示(参数降级逻辑会在收到 400 时自动去掉response_format/effort,不记录的话"我以为关了其实没关"从外表永远看不出来)。
教训:
- 优化 LLM 成本前先量化。凭直觉加的"少思考"指令,实测能让成本翻三倍。
- 看账单要看清小数点。本次复盘时把 ¥0.24 误读成 ¥2.4,差点得出"月成本两三元、需要认真优化"的错误结论——而真实成本根本不值得为它牺牲准确率。
- 结论:单次不到 0.3 分钱的情况下,除非调用量上两个数量级,否则不要为了省 token 去动 prompt。准确率远比这点费用值钱。
多商品订单只留主商品,用户回看不到"这顿还吃了什么"。解法是加一个明细字段,而不是把一条记录拆成多条——拆多条会让统计把"商品单价之和"当成花费,而它不等于实付。
两次迁移写法完全相反(这是本次最容易踩的坑):
| 字段 | Kotlin 声明 | 迁移 SQL 必须写成 | 还要做什么 |
|---|---|---|---|
actualPaid |
Double?(可空) |
ADD COLUMN actualPaid REAL(可空、不加 DEFAULT) |
无 |
items |
List<OrderItem>(非空) |
ADD COLUMN items TEXT NOT NULL DEFAULT '[]' |
实体加 @ColumnInfo(defaultValue="[]") |
已反向验证:故意把 MIGRATION_3_4 写成 ADD COLUMN items TEXT(照搬上一次的可空写法)后,Migration3To4Test 的 3 个用例全部变红(IllegalStateException at RoomOpenHelper.kt:93),恢复后全绿。证明这些用例有判别力,不是"怎么写都过"。
其它要点:
- 明细不参与统计,有单测钉死:实付 12.3、明细求和 20.7 → 断言
paidAmount == 12.3 - 明细是附属信息:坏 JSON 一律降级为空列表,绝不让整条主记录读不出来
- 主字段全空、只有明细时仍判识别失败(明细不能单独撑起一条记录)
- 卡片上
items.size > 1才展示(1 件时与商品行重复),且刻意不显示合计——显示合计会被误当成花费
- 多会话/多人写同一仓库前,必须先确认没有别人在写。本次事故:工程师把另一个会话产出的
util/ocr/OcrEngine.kt、OrderTextParser.kt当重复实现删掉了,项目没有 git,最终未能恢复。删除任何非自己产出的文件前必须先问。 - 临时探针文件必须在创建它的同一条命令里删掉。本次 QA 三次把
fail()探针(BridgeProbe / ThirdRoundProbe)留在制品里,导致构建变红、取不到"字面绿"。 - 反向验证是刚需:每个修复都要问"关掉它,测试会不会变红"。不红的测试等于没有测试。
- 断言"某修复没有测试守护"之前必须先 grep。本次 QA 误判过一次——实际有
geometry_两行高宽块不做桥接且已反向验证过;另一次把失败原因归错了(MAX_ITEM_LEFT_LEN=14杀掉了 16 字商品名,不是桥接问题)。 - 报错必须可见、可复制。靠用户截图辨认长报错,既累又容易看漏字符。
- 项目根目录没有
gradlew/gradlew.bat(只有gradle/wrapper/gradle-wrapper.properties),PATH 里也没有 gradle。命令行构建要么先gradle wrapper生成,要么直接用缓存的分发包。 JAVA_HOME环境变量指向的路径可能根本不存在(本机指向的.../jbr不存在,真 JDK 在.../AS0827/jbr)。用java -version确认后再传给 Gradle。- 用户 IDE 同时构建会踩坏
app/build(dex 冲突、多个 Kotlin daemon)。命令行构建前最好先停掉 IDE 的构建。
端侧规则解析器(OrderScreenshotParser,已冻结,只在无网/未配 Key 时兜底):
| 限制 | 影响 |
|---|---|
| 数量恒为 1 | x1 与商品行垂直间隔 86px 不重叠,无法合并 |
| 价格在左栏的版式 | 整条漏识别(三个主流平台都是左名右价,暂未遇到) |
促销横幅(限时秒杀)被当店名 |
店铺名可能填错 |
商品名本身含规格词(推荐套餐、标准双人餐) |
商品行被误杀 |
| 栏间空隙里的角标仍会桥接 | 有 bug_marker_ 用例钉住,未修 |
已下架/未修复(不阻塞使用):
- PaddleOCR 整套已在
_archive/paddle-ocr/(不参与编译,也不进 git 仓库) - OCR 自身的误识码改不了:
秋雅→秋园、¥12.8→412.8、¥4→4 OcrRecognizer.kt零测试覆盖(ML Kit 只能在真机验)
- 140 个单元测试全绿(解析器 / 几何 / 边界 / 真机 fixture / 迁移 / 备份 / JSON / 明细 / 识别合并 / 备份顺序)
- debug APK 约 33MB(
arm64-v8a单 ABI) - 云端识别真机验收:3 张不同订单截图 × 每张 4~5 次 = 100% 成功
- 多商品订单识别 + 明细展示:已真机验证通过
起因是做了一次完整评审,挑出三件该做的事。核心原则还是那句:"改完测试全绿"不算证据,要能证明测试会红。
BackupViewModel 的覆盖导入原本是先删旧图片、再写库:
// ❌ 旧代码
getAllRecords().flatMap { it.reviewImages }.forEach { deleteFile(it) } // 图片先没了
replaceAll(result.records) // 这行一失败就完了replaceAll 内部虽然有事务,但图片删除在事务之前。只要写库这一步失败(DB 异常 / 磁盘满 / 进程被杀),就会停在「库里还是旧记录、旧图片已被删除」的状态——点开评价全是裂图,而且无法恢复。覆盖导入偏偏是个高危低频操作(用户通常在换手机时用)。
修法:抽出一个 replaceRecordsThenCleanupImages(util/SafeReplace.kt),把「读旧路径 / 写库 / 删文件」做成注入参数,顺序固定为 读 → 写 → 删差集。
为什么要抽成这个形状:这条不变量是「顺序」,光靠注释守不住——后来的人把两行换个位置,代码看着同样合理,而真实触发需要"恰好写库失败",手工几乎复现不了。做成注入参数后,一个纯 JVM 测试就能直接断言「写库抛异常时 delete 一次都没被调用」。
删的时候用「旧路径 − 新记录引用的路径」的差集,不是全删旧路径。现在备份图片都会重新落盘成新文件名,理论上不重名;但那是
ImageStorage的实现细节,一旦改成复用路径,全删就会把刚导入的记录指向的图片删掉。差集是零成本保险。
mergeParsed(识别结果 → 表单)原本是 RecordFormViewModel 的私有方法,而 ViewModel 要 Application 才能构造——等于把「识别只填空、不覆盖用户已输入的内容」这条用户唯一能直接感知的契约锁在了测试之外。
抽到同包的 viewmodel/RecordFormMerge.kt(纯函数,零 Android 依赖),补了 19 个纯 JVM 用例:手填的四个字段一个都不许被改、明细同样只填空、数量/平台仅在还是默认值时才被覆盖、isRecognizing 必须复位、cloudError 必须透传、以及提示文案的各种分支。
这条契约坏了没有任何异常、没有日志——表单里就是少了几行字,可能坏几个月都没人发现。
| 文件 | 行数 | 职责 |
|---|---|---|
ocr/LineGeometry.kt |
253 | 第 0 步几何行重建 + Line/VisualLine/Item 数据类 |
ocr/ParserPatterns.kt |
369 | 全部常量、正则、词表 + 文本工具 |
ocr/OrderScreenshotParser.kt |
779 | 主流程 + P1~P6 业务规则 |
关键手段是"让调用点零改动":常量放到同包的顶层声明(internal)而不是 object 里,这样两个文件都能直接引用、不需要写限定前缀——原本散落在类体里的 47 处常量引用一个都不用动。5 个公开文本工具(normalize / stripNonMoneyNumbers / stripDiscountSegments / amountsIn / parseQuantity)保留成 companion 的同名转发,所以测试文件一行没改。
⚠️ 转发里必须写全包名com.example.foodexpense.ocr.normalize(text)。直接写normalize(text)会解析到 companion 自己,造成无限递归。
零改动换来的好处很实在:拆分前后 115 个测试一个不差全绿,这就是"行为没变"最硬的证据。
把 bug 注射回去,确认测试真的会红:
- 把删除挪回写库之前 →
SafeReplaceTest4 条变红(含核心的「写库失败时一个图片都不删」) - 把
ifBlank改成?:覆盖 →RecordFormMergeTest2 条变红
合计 6 条精准命中,恢复后全部转绿。不会红的测试等于没写。
本项目采用 MIT 协议(见 LICENSE):可以自由使用、修改、分发、商用,只需要保留版权与协议声明。软件按"原样"提供,不含任何担保。
| 文件或目录 | 为什么不在仓库里 |
|---|---|
_archive/ |
下架的 PaddleOCR 方案归档,含两个 ONNX 模型二进制(15MB),会让仓库永久变大。本地仍在 |
keystore.properties / *.keystore |
签名文件与口令,绝不能进仓库 |
local.properties |
本机 Android SDK 路径(含用户名) |
app/build、app/release |
构建产物 |
首次打正式包前需要自己生成签名配置:
release.keystore不在仓库里。 执行powershell -ExecutionPolicy Bypass -File tools\make-keystore.ps1即可自动生成; 不配也能构建,只是 release 会退回 debug 签名(详见第 5 节)。
识图依赖云端大模型时需要自备 API Key,在 App 内「AI 识别设置」里填写,Key 不会进仓库(AiSettings 只在运行时读加密存储,代码里没有任何默认值)。
不填 Key 也能用,会走端侧规则兜底,全程离线。