Skip to content

About

记录外卖消费的 Android App,支持截图识图与日历统计

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

外卖记账(FoodExpenseTracker)

一款个人消费记录 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 张,可多次追加),复制到应用私有目录,无需存储权限
图片压缩 相机原图 34MB,入库时自动压缩:长边限制 1600px + JPEG 85% + EXIF 方向校正,典型降到 200400KB(压缩失败会回退为原样保存不丢图);旧图可通过 ⋮ → 压缩已有评价图片 批量压缩,导入备份时也会统一压缩
图片放大 长按(或点击)缩略图打开全屏查看器:双指缩放、双击放大、拖动平移、多张图片左右切换
输入法适配 表单使用 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 个单元测试

使用说明

  1. 看日历(首页):暖色日历卡片上,有外卖的日子整格高亮并显示当天花费(多笔会标注"n笔");点左右箭头翻月,「今天」一键回到本月;点击某一天,下方立即列出当天所有外卖记录。
  2. 记一笔:日历页右下角 +(自动带入所选日期)或记录页 + → 填平台/时间/店铺/商品/价格/数量/实付金额 →(可选)点星星评分、写评价、多选图片 → 保存。
  3. 用截图识图(新):录入页点「从订单截图识别」→ 选一张订单详情页截图 → 等几秒,平台/店铺/商品/价格/实付会自动填上。
    • 识别成功会在下方显示「云端识别:…」或「本地识别:…」的核对提示(来源必须让你看见,否则识别对了也不知道该继续投哪条路);多商品时会提示「已保留 N 件商品明细」。
    • 商品明细出现在表单里,每行右侧 × 可删掉识别错的商品。
    • 识别结果只填空字段,你手填过的内容不会被覆盖;识别失败也不会清空任何字段。
  4. 配置云端识别(新):日历页 ⋮ → AI 识别设置 → 粘贴 API Key → 「测试连接」显示"正常"即可用。
    • 不配 Key 也能用:只是走端侧规则,准确率低一些,全程离线。
    • 首次使用建议先点「测试连接」——连不通时报错就在界面上,不用去翻日志。
  5. 编辑 / 补评价:在日历或记录列表中长按卡片进入编辑页(单击不触发任何操作)。
  6. 删除:列表上没有删除入口——进入编辑页后点右上角垃圾桶图标,二次确认后删除。
  7. 按星级筛选(记录页默认):搜索框下方 6 张不同颜色的星级卡片铺满整屏,显示该星级条数与金额,点一下即可查看该星级的全部记录;在结果页左右滑动、或用系统返回手势即可返回。
  8. 搜索:记录页顶部输入关键字,匹配店铺、商品或评价内容;向下滑动时搜索框自动收起,向上滑动自动展开。
  9. 平台筛选:搜索框右侧的漏斗图标可选美团 / 淘宝闪购 / 京东,与星级、搜索叠加生效。
  10. 看图:长按(或点击)缩略图 → 全屏查看,双指缩放、双击放大、左右切换。
  11. 统计:切到"统计"标签,按月/按年查看外卖天数与总金额。
  12. 导出数据(备份 / 换机 / 分享):日历页右上角 ⋮ → 导出全部数据 → 系统文件选择器里选保存位置。备份包含 data.json(记录、评价、星级、实付、商品明细)与 images/(评价原图)。
  13. 导入数据:⋮ → 导入数据 → 选 zip → 选择覆盖导入(先清空本机再写入,结果与备份完全一致)或追加导入(合并)。旧版本备份(无实付/无明细)导入后对应字段为空,记录照常导入。
  14. 图片体积:评价图片保存时自动压缩(最长边 1600px、JPEG 85%)。旧图可点 ⋮ → 压缩已有评价图片 批量压缩。
  15. 外观:背景插画不透明铺满,顶栏完全透明,压在图上的文字用荧光绿 + 深色描影;想调整可改 ui/components/AppBackground.kt 的 alpha。

性能说明(图片压缩与识图为什么不会卡界面)

  • 图片解码 / 缩放 / 重编码全部在 Dispatchers.IO 后台线程执行;
  • 选图后界面会显示"正在压缩图片(在后台处理,界面不卡)…",此时按钮置灰;
  • 小图跳过重编码:源图 ≤300KB 直接落盘;
  • 识图分两段超时:端侧 OCR 20s、云端 LLM 单独 60s(共用一个短超时会导致云端永远来不及返回,表现为"静默走本地兜底");
  • 进入录入页时会预热 OCR 模型,点按钮就不用等模型加载。

运行环境与构建(详细指南)

1. 环境准备

项目 要求 说明
操作系统 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。

2. 打开项目(方式 A:Android Studio,推荐)

  1. 启动 Android Studio → File → Open… → 选择项目根目录 → OK。
  2. 弹窗 "Trust Project?" → Trust Project。
  3. 若提示缺少 SDK 组件 → 安装 Android SDK Platform 34(含 Build-Tools 34)。
  4. 等待 Gradle Sync 完成(首次 5~15 分钟)。仓库未提交 gradle-wrapper.jar 二进制,但 Android Studio 会直接按 gradle/wrapper/gradle-wrapper.properties 中地址下载 Gradle 8.9,无需 wrapper jar 即可同步。
  5. 同步完成后 app 模块可展开,工具栏出现绿色 Run ▶。

检查 Gradle JDK:File → Settings → Build, Execution, Deployment → Build Tools → Gradle → Gradle JDK,选择 17(jbr-17)。

3. 运行到设备

创建模拟器:工具栏 Device Manager → Create device → 选一台手机(如 Pixel 6)→ Next → 选系统镜像 API 34 → Finish → 启动。

使用真机:手机设置 → 关于手机 → 连点版本号 7 次开启开发者模式 → 打开 USB 调试 → 数据线连接电脑 → 允许调试。

启动运行:顶部设备选择器选中目标设备 → 点击 Run ▶(或 Shift+F10)。

OCR 相关功能必须真机验证:ML Kit 无法在 JVM 单测里跑(OcrRecognizer.kt 零测试覆盖),模拟器上也应实际截图验证。

4. 命令行构建(方式 B:可选)

仓库未提交 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)。

5. 安装到华为 / 鸿蒙(HarmonyOS)设备

在鸿蒙手机上从浏览器 / 文件管理器安装 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.apk

Android Studio 直接点 Run ▶ 走的就是这条通道,通常不会被拦。

方案 B(推荐,分享 / 长期使用):打正式签名包

  1. 在项目根目录执行(自动生成 release.keystore 与 keystore.properties):
    powershell -ExecutionPolicy Bypass -File tools\make-keystore.ps1

    也可用 Android Studio:Build → Generate Signed App Bundle / APK… 按向导生成。

  2. 重新 Sync,然后构建正式包:
    gradlew.bat assembleRelease
    产物:app/build/outputs/apk/release/app-release.apk(v2+v3 正式签名)。
  3. 把这个 app-release.apk 拷到手机安装即可。若仍被拦:设置 → 系统和更新 → 纯净模式 → 退出,并给浏览器/文件管理器开启「安装未知应用」权限。

app/build.gradle.kts 已内置逻辑:根目录有 keystore.properties 时用正式签名,没有时 release 退回 debug 签名(保证 assembleRelease 始终能产出可安装的包)。keystore.properties 与 *.keystore 已加入 .gitignore。

方案 C:关闭纯净模式后重试

  • 「设置 → 系统和更新 → 纯净模式 → 退出纯净模式」
  • 「设置 → 安全 → 更多安全设置 → 安装外部来源应用」给浏览器/文件管理器授权

6. 常见问题排查

现象 原因与解决
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(实付,缺失时回退价格×数量)。统计通过 Room Flow 实时响应。
  • 实付只能"读"不能"算":实付 ≠ 商品原价合计。样例 43.4 + 1 − 21.9 = 22.5 ≠ 26.4,所以解析器是"从截图上找到写着的实付",而不是自己加总推导。
  • 商品明细只用于回看:明细定位是"截图上当时写了什么",用户手改商品名/价格后明细不会自动同步(这是刻意取舍,跟随编辑反而丢原始信息)。
  • 搜索实现:单条 SQL 组合条件(关键字 + 平台 + 星级),关键字用 LIKE ... ESCAPE '\' 并转义 % _;flatMapLatest 保证快速输入时只保留最新查询。
  • 数据校验:店铺/商品非空、价格 > 0、数量 ≥ 1;写了评价内容或加了图片却未选星级时会提示;图片最多 9 张。
  • 图片存储:Photo Picker(无需权限)选中后复制到 filesDir/review_images;删除记录或移除图片时同步删除文件。
  • 长按交互:卡片只响应长按(进入编辑),避免误触。
  • 错误信息必须可见:云端失败、识别失败的原因都直接显示在界面上,且可长按复制——靠用户截图辨认长报错既累又容易看漏字符。

开发日志与经验教训(2026-10-24)

这一节记录「截图识图」功能从零到可用过程中所有失败尝试与关键判断。改动 app/src/main/java/.../ocr/ 下任何代码前,建议先读一遍——下面每一条都是花了真机时间换来的,重复踩一遍代价很高。

1. 起点:三个产品决策(先定口径,再写代码)

决策点 选项 最终选择 理由
记哪个金额 商品原价合计 / 实付金额 实付金额 用户真正关心的是"这顿花了多少钱"。实付 ≠ 原价合计(有打包费、配送费、优惠),记原价会算错钱
多商品怎么记 全记 / 只记主商品 只记主商品(第一版) 统计口径是整单实付,拆成多条会让"商品单价之和"被当成花费。后来用「方案 A:明细字段」解决了"回看不到其它商品"的问题
端侧还是云端 端侧 OCR + 规则 / 云端 端侧(第一版) 用户要求全程本地、离线可用、不依赖 GMS(国产机无 GMS)

教训:先定口径再写代码。实付这个口径一旦定错,后面所有规则、所有测试都建立在错误前提上,返工成本极高。

2. 第一次真机实测:结果全错

症状:店铺名填成 粉丝群、商品名填成 -¥5、价格与实付都是 5。

根因(结构性,不是词表问题):ML Kit 按视觉分栏切块,右对齐的价格成了独立 TextLine;而解析器假设"一条 TextLine = 一个语义行"。结果两个真实商品全漏,-¥5 因为 residue 里剩一个 - 号成了唯一"商品",itemCount=1 触发兜底逻辑,价格/实付都被写成 5。

三个我判断错的细节(被工程师用真机数据纠正):

  1. 店铺名的锅不在位置启发式:STORE_LABEL_RE 的「商家」匹配到了 进商家粉丝群 里的"商家",标签法先命中并返回"粉丝群" → 必须加 (?<!进) 负向断言。
  2. 实付错不是因为"取第一个金额",而是 lastPositiveAmountAfter 在优惠标签处截断,合计 已优惠8.5元 ¥12.3 被整条吃掉 → 改成 stripDiscountSegments(整段剥离)。
  3. 老 30 条用例的坐标不是全 0(top=100+i*80),所以几何重建在它们身上真实执行,"全 0 跳过"的保护救不了它们。

修法:新增 rebuildVisualLines()——按垂直重叠比例聚类(同一视觉带)+ 簇内按 left 从左到右拼接,把被切开的名字和价格重新合成一个语义行。

教训:解析器里"一条 TextLine = 一行"这种假设,在没有真机数据验证前就是定时炸弹。在真机上跑一遍,胜过在编造数据上过 100 条测试。

3. 几何层的三次迭代,与三个被否掉的"看起来很对"的方案

三个最终保留的判据(每个都带反向验证测试:关掉判据,测试必须变红):

判据 作用
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"的正常块,价格行随之孤立,商品照样消失
  • 高块强制成行 —— 同上,更糟

教训:压噪声的启发式一定会误杀真数据。每加一条"排除规则",都要问一句"有没有正常数据长这样",并补一条正向用例。

4. PaddleOCR:从选型到下架(完整走了一遍弯路)

踩的第一个坑:网上教程给的 Maven 坐标全是假的,实测 404:

  • paddleocr.bj.bcebos.com/maven/ → 404
  • com.baidu.paddle:paddleocr_full:4.0.0 → 404
  • com.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 里的记录已足够还原这段经过。)

教训:

  1. 网上 AI 生成的接入教程,先验证依赖坐标是否存在再动手。
  2. 先判断"换方案能不能解决当前的根因",再决定换不换。这次根因是行切分,换 OCR 引擎完全对不上症。
  3. 归档,不要删除。

5. 转向云端:方案 B(ML Kit 认字 + 大模型理解版面)

用户决定放弃纯端侧后,给了两个选项:

方案 优点 缺点
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 单测里是桩实现。凡是想在单测里覆盖的解析逻辑,都不能依赖它。

6. 卡了半天:云端"有概率失败",真因是 max_tokens = 800

症状:同一张截图,第一次失败、第二三次成功、第四次又失败。报错是 云端识别未生效…未返回可解析的 JSON:——冒号后面一片空白。

排查过程中一共修掉 4 个只在真机暴露的 bug:

  1. testConnection() 复用了"必须是 JSON"的解析函数,而测试请求要的是纯文本 → 必现失败。
  2. 外层 20s 超时包住了 LLM 调用,而 LLM 的读取超时是 45s → 外层先掐断,静默走本地兜底,从界面上看不出来。
  3. LLM 偶尔在 JSON 前后带一句解释文字 → 解析器改成取「第一个 { 到最后一个 }」之间的内容。
  4. 根本原因:max_tokens = 800 太小。V4 系模型默认 effort=high 带推理,推理过程吃光了 800 token,正文还没开始写就被截断 → HTTP 200 但 content 是空字符串(finish_reason=length)。改成 3000 后,用户实测 3 张不同订单截图 × 每张 4~5 次 = 100% 成功。

教训(这一天最有价值的一条):

  • 报错信息里"没有什么"和"有什么"同样重要。空正文这个线索是靠用户逐字复述红字才发现的——如果报错只截前 200 字,人的注意力会被引向完全错误的方向。
  • 所以现在报错里强制带上 finish_reason + 正文长度 + 原文,且界面上可长按复制。
  • 超时必须分级:不同阶段的合理耗时差一个量级,共用一个超时会制造"静默降级"这种最难排查的故障。
  • 静默降级是敌人。任何降级路径都要在界面上留下可见痕迹(现在会显示"云端识别未生效,已回退本机规则:原因")。

7. 成本实测与一次失败的成本优化

  • 实测 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。准确率远比这点费用值钱。

8. 方案 A:商品明细字段(DB v3 → v4)

多商品订单只留主商品,用户回看不到"这顿还吃了什么"。解法是加一个明细字段,而不是把一条记录拆成多条——拆多条会让统计把"商品单价之和"当成花费,而它不等于实付。

两次迁移写法完全相反(这是本次最容易踩的坑):

字段 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 件时与商品行重复),且刻意不显示合计——显示合计会被误当成花费

9. 流程与协作教训

  • 多会话/多人写同一仓库前,必须先确认没有别人在写。本次事故:工程师把另一个会话产出的 util/ocr/OcrEngine.kt、OrderTextParser.kt 当重复实现删掉了,项目没有 git,最终未能恢复。删除任何非自己产出的文件前必须先问。
  • 临时探针文件必须在创建它的同一条命令里删掉。本次 QA 三次把 fail() 探针(BridgeProbe / ThirdRoundProbe)留在制品里,导致构建变红、取不到"字面绿"。
  • 反向验证是刚需:每个修复都要问"关掉它,测试会不会变红"。不红的测试等于没有测试。
  • 断言"某修复没有测试守护"之前必须先 grep。本次 QA 误判过一次——实际有 geometry_两行高宽块不做桥接 且已反向验证过;另一次把失败原因归错了(MAX_ITEM_LEFT_LEN=14 杀掉了 16 字商品名,不是桥接问题)。
  • 报错必须可见、可复制。靠用户截图辨认长报错,既累又容易看漏字符。

10. 环境坑(本机)

  • 项目根目录没有 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 的构建。

11. 当前仍存在的已知限制

端侧规则解析器(OrderScreenshotParser,已冻结,只在无网/未配 Key 时兜底):

限制 影响
数量恒为 1 x1 与商品行垂直间隔 86px 不重叠,无法合并
价格在左栏的版式 整条漏识别(三个主流平台都是左名右价,暂未遇到)
促销横幅(限时秒杀)被当店名 店铺名可能填错
商品名本身含规格词(推荐套餐、标准双人餐) 商品行被误杀
栏间空隙里的角标仍会桥接 有 bug_marker_ 用例钉住,未修

已下架/未修复(不阻塞使用):

  • PaddleOCR 整套已在 _archive/paddle-ocr/(不参与编译,也不进 git 仓库)
  • OCR 自身的误识码改不了:秋雅→秋园、¥12.8→412.8、¥4→4
  • OcrRecognizer.kt 零测试覆盖(ML Kit 只能在真机验)

12. 当前状态

  • 140 个单元测试全绿(解析器 / 几何 / 边界 / 真机 fixture / 迁移 / 备份 / JSON / 明细 / 识别合并 / 备份顺序)
  • debug APK 约 33MB(arm64-v8a 单 ABI)
  • 云端识别真机验收:3 张不同订单截图 × 每张 4~5 次 = 100% 成功
  • 多商品订单识别 + 明细展示:已真机验证通过

13. 2026-10-25:一次小规模重构(修 P1 + 补测试 + 拆文件)

起因是做了一次完整评审,挑出三件该做的事。核心原则还是那句:"改完测试全绿"不算证据,要能证明测试会红。

13.1 修掉一个会造成永久数据丢失的 P1

BackupViewModel 的覆盖导入原本是先删旧图片、再写库:

// ❌ 旧代码
getAllRecords().flatMap { it.reviewImages }.forEach { deleteFile(it) }  // 图片先没了
replaceAll(result.records)                                             // 这行一失败就完了

replaceAll 内部虽然有事务,但图片删除在事务之前。只要写库这一步失败(DB 异常 / 磁盘满 / 进程被杀),就会停在「库里还是旧记录、旧图片已被删除」的状态——点开评价全是裂图,而且无法恢复。覆盖导入偏偏是个高危低频操作(用户通常在换手机时用)。

修法:抽出一个 replaceRecordsThenCleanupImages(util/SafeReplace.kt),把「读旧路径 / 写库 / 删文件」做成注入参数,顺序固定为 读 → 写 → 删差集。

为什么要抽成这个形状:这条不变量是「顺序」,光靠注释守不住——后来的人把两行换个位置,代码看着同样合理,而真实触发需要"恰好写库失败",手工几乎复现不了。做成注入参数后,一个纯 JVM 测试就能直接断言「写库抛异常时 delete 一次都没被调用」。

删的时候用「旧路径 − 新记录引用的路径」的差集,不是全删旧路径。现在备份图片都会重新落盘成新文件名,理论上不重名;但那是 ImageStorage 的实现细节,一旦改成复用路径,全删就会把刚导入的记录指向的图片删掉。差集是零成本保险。

13.2 给"只填空"这条契约补上测试

mergeParsed(识别结果 → 表单)原本是 RecordFormViewModel 的私有方法,而 ViewModel 要 Application 才能构造——等于把「识别只填空、不覆盖用户已输入的内容」这条用户唯一能直接感知的契约锁在了测试之外。

抽到同包的 viewmodel/RecordFormMerge.kt(纯函数,零 Android 依赖),补了 19 个纯 JVM 用例:手填的四个字段一个都不许被改、明细同样只填空、数量/平台仅在还是默认值时才被覆盖、isRecognizing 必须复位、cloudError 必须透传、以及提示文案的各种分支。

这条契约坏了没有任何异常、没有日志——表单里就是少了几行字,可能坏几个月都没人发现。

13.3 把 1352 行的解析器拆成三个文件

文件 行数 职责
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 个测试一个不差全绿,这就是"行为没变"最硬的证据。

13.4 反向验证结果(这一步不能省)

把 bug 注射回去,确认测试真的会红:

  • 把删除挪回写库之前 → SafeReplaceTest 4 条变红(含核心的「写库失败时一个图片都不删」)
  • 把 ifBlank 改成 ?: 覆盖 → RecordFormMergeTest 2 条变红

合计 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 也能用,会走端侧规则兜底,全程离线。

About

记录外卖消费的 Android App,支持截图识图与日历统计

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages