feat: 领域/组件/服务三层目录重构 + 微信无障碍绕过方案 + 新 UI 组件体系 + 账本增删改增强
### 架构重构:三层分层目录化 - domain 拆分为 8 个子目录(core/pipeline/rules/finance/stats/taxonomy/transaction/platform) - components 拆分为 6 个子目录(form/ui/layout/account/category/stats/transaction) - services 拆分为 5 个子目录(automation/data/ocr/security),accessibilityParser 从 automationPipeline 提取 - 新增 ruleConfig.ts — 规则配置唯一数据源(关键词/方向/OCR 模式),与业务逻辑解耦 ### 微信账单抓取:节点混淆绕过(核心突破) - BillingAccessibilityService 重命名为 SelectToSpeakService,完整伪装为系统服务 - 同时伪装包名+类名为 com.google.android.accessibility.selecttospeak,规避微信 8.0.52+ 白名单校验 - Config Plugin 重写:8 个 kt 文件整体复制+package 正则替换+Manifest/import 联动 - 支付宝/微信无障碍文本解析器全面增强(方向推断/账单分类提取/付款方式提取/容错) - 补充文档 accessibility-wechat-guide.md(伪装原理、踩坑全记录) ### 新 UI 组件体系 - Toast:全局轻量 toast(Context Provider + 入场动画 + 操作按钮 + 自动消失) - ErrorBoundary:React class 错误边界(降级 UI + 重试) - EmptyState / ConfirmDialog / SegmentedControl / Skeleton / TimePicker - BottomSheet 重写:SafeAreaProvider 修复、手势下滑关闭、键盘响应式避让 - FormModal 重构:拆出 FormFields 子组件(TextField/SelectField/DropdownField) - 新增 PeriodSwitcher、RangeStatsCard 独立组件 ### 新 Hooks & 工具 - useBottomInset — 统一底部安全区留白 - useKeyboardAvoiding — 键盘高度响应式 hook(替代 translateY 方案) - sanitize.ts — 日志脱敏工具提取 ### 账本增删改增强 - 写锁增加代际计数器(lockGeneration),reset 后旧链 pending 任务自动跳过 set - 新增 restoreTransaction — 撤销删除(重新追加 raw 文本到 mobile.bean) - 删除交易时清除去重缓存(buildTxKeyFromRaw 重建去重键),支持「删了重记」 - editTransaction/deleteTransaction 改用 dr-id 精确定位交易块(避免同名交易定位错误) - appendTransactionsBatch 改从存储直接读取,避免 zustand state 不一致 ### OCR 原生模块增强 - 异步 initEngine 增加 CountDownLatch 等待(最多 15s),解决竞态导致的「引擎未就绪」 - setModelDir 增加去重判断 + file:// 前缀剥离,避免冗余 reload - 推理链路增加分阶段耗时日志(det 推理/det 后处理/rec 识别) - 图片缩放策略重命名(scaleDownForOcr → capLongEdge) ### OCR 模型按需下载 - 移除了启动时自动下载 ~30MB OCR 模型的逻辑 - 改为首次使用 OCR 时才触发下载 ### 设置页重设计 - ScrollView → SectionList 分组卡片布局(iOS 风格分组圆角行+右侧箭头) - 移除 Card 组件包装,直接使用独立分组头+底部关于卡片 ### 首页优化 - ScrollView → FlatList(ListHeaderComponent 承载净资产卡片+待办条) - 日期/金额格式化增加 locale 感知(zh/en) ### 通知管道增强 - NotificationChannel MD5 去重改为批量淘汰(80% 阈值),替代逐个删除 - 增加 debug 日志输出(过滤原因/包名) ### ESLint - 新增 eslint.config.mjs(typescript-eslint + react-hooks + react-native 规则集) - package.json 新增 lint/lint:fix 脚本,引入 5 个 devDependencies ### 文档 - accessibility-wechat-guide.md — 微信无障碍伪装完整方案 - modal-keyboard-guide.md — 弹窗键盘避让方案 - ocr-pipeline-guide.md — OCR 三层层级管线 - OCR及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
# 无障碍服务与微信文本抓取指南 (Accessibility & WeChat Text Extraction)
|
||||
|
||||
本文记录 DriftLedger 无障碍服务(`plugins/accessibility/`)抓取微信账单页面文本的完整方案,重点沉淀**绕过微信 8.0.52+ 节点混淆**的伪装机制、Config Plugin 的落地细节、调试方法论与踩过的坑。
|
||||
|
||||
> 配套:OCR 管线见 `ocr-pipeline-guide.md`;构建/编译陷阱见 `android-build-guide.md`。
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
- 微信 8.0.52+ 对第三方无障碍服务做**节点混淆**:把页面节点的 `text` / `contentDescription` 用其他节点信息随机替换,导致基于节点文本的解析全部失效。微信内部有**白名单**:系统服务(TalkBack、SelectToSpeak)不受影响。
|
||||
- 绕过方案:**伪装成系统 SelectToSpeak 服务**。微信按 `ComponentName`(`包名/类名`)识别白名单,所以必须**包名 + 类名同时伪装**为 `com.google.android.accessibility.selecttospeak.SelectToSpeakService`,只伪装类名无效。
|
||||
- 落地由 Config Plugin(`plugins/accessibility/app.plugin.js`)在 prebuild 时完成:把 8 个 kt 文件整体复制到 `com/google/android/accessibility/selecttospeak/` 目录,`package` 声明 rewrite 为该包,Manifest `android:name` 写成完整全限定名。三者必须严格一致,否则 `ClassNotFoundException`。
|
||||
- 伪装生效后,`triggerManualExtraction` 成为微信账单页的主识别路径,必须用 `dumpAllTexts()`(覆盖所有窗口含 WebView 子窗口),不能用只读 `rootInActiveWindow` 的简化路径。
|
||||
- OCR 截图兜底链路保留,作为伪装失败或极少数空白帧场景的保底。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景:微信 8.0.52+ 节点混淆
|
||||
|
||||
### 现象
|
||||
微信账单详情页(我-服务-钱包-账单-某笔交易)上,点悬浮球「识别账单」后,`billingDebugNodes` 事件里的 `texts` 数组拿到的是乱码、错位文本或空数组。`parseWechatTexts`(`src/services/automation/accessibilityParser.ts`)依赖「标签 + 下一节点为值」的顺序假设,一旦文本被打乱就完全失效。
|
||||
|
||||
### 混淆原理
|
||||
微信在生成无障碍节点时,用 Map 缓存了页面节点信息,混淆时**从 Map 中随机抽取其他节点的信息填入当前节点**。每次页面刷新混淆结果都不同,所以:
|
||||
- 基于固定 `resource-id` 的 `findAccessibilityNodeInfosByViewId` 失效
|
||||
- 基于文本匹配的 `findAccessibilityNodeInfosByText` 失效
|
||||
- 基于顺序假设的扁平数组解析失效
|
||||
- `className` 也被随机化(EditText/TextView 被换成动态生成类名)
|
||||
|
||||
### 白名单机制
|
||||
微信维护了一份系统服务白名单,对白名单内的服务**不下发混淆**。TalkBack、SelectToSpeak 等系统级无障碍服务在白名单中。判断依据是服务的 `ComponentName`(即 `包名/类名`)。
|
||||
|
||||
> 这是整个绕过方案的支点:只要让微信认为我们的服务是系统 SelectToSpeak,就能拿到未混淆的真实节点。
|
||||
|
||||
---
|
||||
|
||||
## 2. 绕过方案:伪装 ComponentName
|
||||
|
||||
### 关键认知:只伪装类名不够
|
||||
早期实现(本仓库 commit `bf04400` 之前)只把类名伪装成 `SelectToSpeakService`,但 Manifest 服务名写的是 `${appId}.accessibility.SelectToSpeakService`(应用包名 + SelectToSpeak 类名)。**包名没伪装**,所以微信仍将其识别为第三方服务,混淆照常下发。这是「伪装做了但没生效」的典型坑。
|
||||
|
||||
### 正确做法:包名 + 类名全限定伪装
|
||||
微信按 `ComponentName` 全字符串匹配白名单,因此:
|
||||
|
||||
| 字段 | 伪装值 |
|
||||
|---|---|
|
||||
| Manifest `android:name` | `com.google.android.accessibility.selecttospeak.SelectToSpeakService` |
|
||||
| Kotlin `package` | `com.google.android.accessibility.selecttospeak` |
|
||||
| Kotlin `class` | `SelectToSpeakService` |
|
||||
| 文件物理路径 | `app/src/main/java/com/google/android/accessibility/selecttospeak/SelectToSpeakService.kt` |
|
||||
|
||||
四者必须严格一致。Android 编译期要求 Manifest 全限定名必须有匹配 `package` + `class` 的真实 Kotlin 源码,否则 `ClassNotFoundException`。
|
||||
|
||||
### 为什么选 SelectToSpeak 而不是 TalkBack
|
||||
早期业界方案伪装的是 `com.google.android.marvin.talkback.TalkBackService`,能成功绕过混淆,但**小米机型会误判 TalkBack 已开启**,屏幕持续显示「TalkBack 已激活」文字,严重干扰用户。SelectToSpeak(随选朗读)没有这个副作用,是更安全的选择。
|
||||
|
||||
---
|
||||
|
||||
## 3. Config Plugin 落地(`plugins/accessibility/app.plugin.js`)
|
||||
|
||||
伪装不是手动改生成的 `android/` 文件(那会被 `expo prebuild --clean` 冲掉),而是通过 Config Plugin 在 prebuild 时自动完成。
|
||||
|
||||
### 三个核心常量
|
||||
```js
|
||||
const FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
|
||||
const FAKE_SERVICE_NAME = `${FAKE_PACKAGE}.SelectToSpeakService`;
|
||||
const FAKE_TILE_SERVICE_NAME = `${FAKE_PACKAGE}.OcrTileService`;
|
||||
```
|
||||
|
||||
### 三个配合改动的环节
|
||||
|
||||
**① 文件复制路径**(`withDangerousMod`)
|
||||
8 个 kt 文件统一复制到 `app/src/main/java/com/google/android/accessibility/selecttospeak/`,而不是应用包名目录。
|
||||
|
||||
**② package 重写**(正则替换)
|
||||
kt 源码里仍写 `package com.beancount.mobile.accessibility`(源码锚点),Config Plugin 用正则替换为 FAKE_PACKAGE:
|
||||
```js
|
||||
content = content.replace(/package\s+com\.beancount\.mobile\.accessibility/g, `package ${FAKE_PACKAGE}`);
|
||||
```
|
||||
⚠️ **关键坑**:正则必须精确匹配到 `.accessibility` 后缀。如果只匹配 `com.beancount.mobile`,替换结果会变成 `com.google.android.accessibility.selecttospeak.accessibility`(多出一段),与 Manifest 不一致 → 编译失败。详见 §5 坑 1。
|
||||
|
||||
**③ Manifest 服务名 + MainApplication import**(`withAndroidManifest` / `withMainApplication`)
|
||||
- Manifest 服务 `android:name` 用 `FAKE_SERVICE_NAME` / `FAKE_TILE_SERVICE_NAME`
|
||||
- MainApplication 里 `import com.google.android.accessibility.selecttospeak.AccessibilityBridgePackage`(注意 import 路径也要用 FAKE_PACKAGE,因为 BridgePackage 跟随其他文件一起伪装了)
|
||||
|
||||
### 「全部跟随伪装」 vs 「仅 Service 伪装」
|
||||
本仓库 8 个 kt 文件(`SelectToSpeakService` / `AccessibilityBridgeModule` / `AccessibilityBridgePackage` / `FloatingHelper` / `FloatingBillView` / `FloatingTip` / `FloatingUiConfigStore` / `OcrTileService`)原本共享同一个 package,彼此通过同 package 直接引用(无显式 import)。
|
||||
|
||||
决策:**全部跟随伪装**到 FAKE_PACKAGE。优点是维持同 package 直接引用的简洁结构,零跨包 import 改动;代价是 `AccessibilityBridgeModule` / `OcrTileService` 这些不暴露给微信的组件也披上了系统服务包名(功能上无影响,它们靠 RN Bridge 和 QS Tile 识别,与微信无关)。
|
||||
|
||||
> 替代方案是只伪装 `SelectToSpeakService`,其余保持在应用包名,但要给 `FloatingHelper` / `OcrTileService` / `AccessibilityBridgeModule` 加跨包 import,改动点和潜在编译错误更多。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据流与触发路径
|
||||
|
||||
完整链路(手动识别路径,伪装生效后是主路径):
|
||||
|
||||
```
|
||||
[微信账单详情页前台]
|
||||
→ onAccessibilityEvent (SelectToSpeakService.kt)
|
||||
→ 记录 topPackage/topActivity + 显示悬浮球 (FloatingHelper)
|
||||
[用户点悬浮球「识别账单」]
|
||||
→ triggerManualExtraction() (SelectToSpeakService.kt)
|
||||
→ dumpAllTexts() ← 关键:覆盖所有窗口含 WebView 子窗口
|
||||
→ rootInActiveWindow + dumpNodeTexts (递归 text + contentDescription)
|
||||
→ 若为空,遍历 windows 兜底
|
||||
→ emit "billingDebugNodes" {package, activity, signature, isManual:true, texts[]}
|
||||
[JS 侧 _layout.tsx 监听 billingDebugNodes]
|
||||
→ 仅处理 isManual=true 的消息
|
||||
→ parseAndProcessAccessibilityTexts(texts, pkg) (automationPipeline.ts)
|
||||
→ parseAccessibilityTexts → parseWechatTexts (accessibilityParser.ts)
|
||||
→ 特征词「交易单号」/「退款单号」/「本服务由财付通提供」定位
|
||||
→ 金额正则 ^([+-])?(\d+(\.\d{1,2})?)$ + 商户取金额前一节点
|
||||
→ 命中 → handleIncomingBillEvent → 分类 + 规则匹配 → 生成 draft
|
||||
→ App 前台:Alert 确认 / App 后台:showFloatingBill 原生浮窗
|
||||
```
|
||||
|
||||
降级路径(兜底,伪装失败时):
|
||||
```
|
||||
[文本为空或解析未命中] → bridge.triggerManualOcr()
|
||||
→ takeScreenshot → bitmapToBase64 (JPEG q60) → emit "billingScreenshot"
|
||||
[JS 侧] → OcrProcessor (Layer1 规则 → Layer2 ONNX OCR → Layer3 AI 视觉)
|
||||
```
|
||||
|
||||
### 关键改造:`triggerManualExtraction` 必须用 `dumpAllTexts`
|
||||
改造前 `triggerManualExtraction` 只读 `rootInActiveWindow`,漏掉 WebView 子窗口。伪装生效后这条路径成为主识别路径,必须保证完整性,所以改用现成的 `dumpAllTexts()`(先试 `rootInActiveWindow`,为空则遍历 `windows`)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 踩过的坑
|
||||
|
||||
### 坑 1:package 重写正则不精确,多出 `.accessibility`
|
||||
**现象**:首次 prebuild 后,kt 文件 package 声明变成 `com.google.android.accessibility.selecttospeak.accessibility`(多了 `.accessibility`),与 Manifest 的 `com.google.android.accessibility.selecttospeak.SelectToSpeakService` 不一致 → 编译期 `ClassNotFoundException`。
|
||||
|
||||
**根因**:源码 package 是 `com.beancount.mobile.accessibility`(有 `.accessibility` 后缀),但正则只写了 `com\.beancount\.mobile`,替换后保留了原有的 `.accessibility`。
|
||||
|
||||
**修复**:正则精确匹配到 `.accessibility`:
|
||||
```js
|
||||
// ❌ 错误:会留下 .accessibility 后缀
|
||||
content.replace(/package\s+com\.beancount\.mobile/g, `package ${FAKE_PACKAGE}`)
|
||||
// ✅ 正确:整体替换
|
||||
content.replace(/package\s+com\.beancount\.mobile\.accessibility/g, `package ${FAKE_PACKAGE}`)
|
||||
```
|
||||
|
||||
**验证方法**:prebuild 后用 `head -1` 检查生成的 kt 文件 package 声明,与 Manifest `android:name` 的包名部分逐字对比。
|
||||
|
||||
### 坑 2:只伪装类名不伪装包名
|
||||
早期实现 Manifest 服务名是 `${appId}.accessibility.SelectToSpeakService`,类名伪装了但包名是应用包名。微信按完整 `ComponentName` 判断,包名不在白名单 → 混淆照常下发。详见 §2。
|
||||
|
||||
### 坑 3:升级后服务标识变化,用户需重新启用
|
||||
伪装改变服务的 `ComponentName`,系统无障碍设置里的服务标识随之变化。升级 APK 后,用户原本启用的服务会「失效」(实际是新服务没启用),需要重新在系统设置里启用「浮记-账单识别」。服务 `label` 保持 `浮记-账单识别` 不变(在 Manifest 硬编码),用户可识别。
|
||||
|
||||
> 这是预期行为,发布说明里需告知。
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证方法论
|
||||
|
||||
代码改动后,按以下顺序验证(沙箱内能做的 vs 真机才能做的分开):
|
||||
|
||||
### 沙箱内(prebuild 后静态检查)
|
||||
1. **kt 文件路径**:`find android/app/src/main/java/com/google/android/accessibility/selecttospeak -type f` 应列出 8 个 kt 文件,且**不应有**应用包名路径下的遗留 kt。
|
||||
2. **package 声明一致性**:`head -1` 每个 kt 文件,都应是 `package com.google.android.accessibility.selecttospeak`。
|
||||
3. **Manifest 服务名**:`grep "android:name=\"com.google.android.accessibility" android/app/src/main/AndroidManifest.xml` 应同时命中 `SelectToSpeakService` 和 `OcrTileService`,且包名部分与 kt 的 package 声明逐字一致。
|
||||
4. **MainApplication import**:`grep "AccessibilityBridgePackage" android/app/src/main/java/*/MainApplication.kt` 应有 `import com.google.android.accessibility.selecttospeak.AccessibilityBridgePackage` 和 `add(AccessibilityBridgePackage())`。
|
||||
5. **typecheck + 测试**:`npm run typecheck` 通过;`npm test` 无新增回归(无障碍改动不涉及 ts,测试应零影响)。
|
||||
|
||||
### 真机(功能验证)
|
||||
1. `npm run android` 构建 APK 安装。
|
||||
2. 系统设置 → 无障碍 → 启用「浮记-账单识别」。
|
||||
3. 打开微信(≥ 8.0.52)→ 账单详情页。
|
||||
4. 点悬浮球「识别账单」。
|
||||
5. 看 Metro 终端的 `billingDebugNodes` 事件:
|
||||
- **成功**:`texts` 数组含真实金额/商户/时间 → `parseWechatTexts` 自动解析入账。
|
||||
- **失败**:`texts` 仍为空或乱码 → 自动降级 OCR 截图(兜底链路未动)。
|
||||
6. 若伪装完全无效(texts 始终空),说明微信检测点不止 ComponentName(可能还查签名/其他特征),需进一步研究;此时 OCR 兜底仍在工作,不影响基础功能。
|
||||
|
||||
---
|
||||
|
||||
## 7. 合规与风险
|
||||
|
||||
- **侧载路线**:本仓库按 `plan.md` 决策 3 走纯开源侧载,伪装机制仅用于非应用商店分发。伪装系统服务包名、绕过微信反自动化检测,可能违反微信用户协议,存在账号风险。
|
||||
- **TalkBack 副作用**:不要回退到伪装 TalkBack 的方案,小米机型会有持续文字干扰(§2)。
|
||||
- **版本适配**:微信持续更新混淆策略。若某次微信升级后伪装失效,先查 AutoJs6 Issue、CSDN/掘金的最新讨论,确认白名单判断逻辑是否变化。
|
||||
- **回退**:伪装失败时,OCR 截图兜底链路(`_layout.tsx` 的 `billingScreenshot` 监听 + `OcrProcessor`)完整保留,最小代价回退。
|
||||
|
||||
---
|
||||
|
||||
## 8. 关键文件清单
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `plugins/accessibility/app.plugin.js` | Config Plugin:文件复制、package rewrite、Manifest 注册、MainApplication import |
|
||||
| `plugins/accessibility/android/SelectToSpeakService.kt` | 主服务:事件监听、`dumpAllTexts`/`dumpNodeTexts` 节点遍历、截图、悬浮球管理 |
|
||||
| `plugins/accessibility/android/AccessibilityBridgeModule.kt` | RN Bridge:14 个 `@ReactMethod`,委托 `SelectToSpeakService.instance` |
|
||||
| `plugins/accessibility/android/FloatingHelper.kt` | 悬浮球 UI,点「识别账单」调 `triggerManualExtraction` |
|
||||
| `plugins/accessibility/android/FloatingBillView.kt` | 账单确认浮窗 |
|
||||
| `plugins/accessibility/android/OcrTileService.kt` | 快速设置磁贴 |
|
||||
| `plugins/accessibility/android/res/xml/accessibility_service_config.xml` | 服务能力声明(`canRetrieveWindowContent` / `canTakeScreenshot` / `canRequestEnhancedWebAccessibility`) |
|
||||
| `src/services/automation/accessibilityParser.ts` | 微信/支付宝/银行文本解析(`parseWechatTexts` 等) |
|
||||
| `src/services/automation/automationPipeline.ts` | 管道协调器(`parseAndProcessAccessibilityTexts`) |
|
||||
| `src/app/_layout.tsx` | `DeviceEventEmitter` 监听 `billingDebugNodes` / `billingScreenshot` |
|
||||
|
||||
---
|
||||
|
||||
## 9. 后续优化方向(未实施)
|
||||
|
||||
- **方案 C:`parseWechatTexts` 鲁棒性增强**。即使伪装生效,微信仍可能对部分节点做轻度扰动。可放弃「标签 + 下一节点」的顺序假设,改用 `getBoundsInScreen()` 的坐标和相对位置识别元素,或用 index 在层级中的位置而非属性。工作量大,建议伪装方案稳定后再立项。
|
||||
- **自动监听路径恢复**。当前因节点混淆,自动监听(`processContentChange`)实质失效,仅手动悬浮球路径有效。伪装生效后可评估是否恢复自动监听,但需注意 `isManual` 标志和页面签名白名单的配合。
|
||||
+288
-18
@@ -6,6 +6,51 @@
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR — 改了原生代码后的完整重建流程
|
||||
|
||||
> 当你修改了 `plugins/*/android/` 下的任何 Kotlin/Java 原生源码、或 `app.plugin.js` 注入逻辑后,`android/` 目录里那份由 prebuild 生成的原生工程**已经过时**,必须重新生成才能让改动生效。这是最常见的「我改了代码但安装后没变化」的根因。
|
||||
|
||||
完整的三步重建命令(Git Bash,工作目录为项目根):
|
||||
|
||||
```bash
|
||||
# 环境变量(每次新开 shell 都要设;可写入 ~/.bashrc 持久化)
|
||||
export JAVA_HOME="/c/Program Files/Java/jdk-21"
|
||||
export ANDROID_HOME="/c/Users/fmq/AppData/Local/Android/Sdk"
|
||||
export PATH="$ANDROID_HOME/platform-tools:$PATH" # 让 adb 可用
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **不想每次 export `ANDROID_HOME`?** 把 SDK 路径写进 `android/local.properties`(`sdk.dir=C\:\\Users\\fmq\\AppData\\Local\\Android\\Sdk`),gradle 会优先读它,前台/后台 shell 都不再依赖环境变量。该文件在 `.gitignore` 内、不进仓库。**后台/自动化编译尤其需要它**——后台新 shell 不继承交互会话里的 `ANDROID_HOME`,缺 `local.properties` 会直接 `SDK location not found` 失败(详见 §7.6)。
|
||||
|
||||
```bash
|
||||
# ① 删除旧的原生工程,强制重新生成(确保原生改动被注入)
|
||||
rm -rf android
|
||||
npx expo prebuild --platform android
|
||||
|
||||
# ② 清理 Gradle 缓存后编译 Release(默认 arm64-v8a,真机最快)
|
||||
cd android
|
||||
./gradlew.bat clean --offline
|
||||
./gradlew.bat :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --offline
|
||||
|
||||
# ③ 通过 adb 覆盖安装到已连接的真机
|
||||
adb install -r -d app/build/outputs/apk/release/app-arm64-v8a-release.apk
|
||||
```
|
||||
|
||||
> **何时需要重新 prebuild?**
|
||||
> - 改了 `plugins/*/android/*.kt`(任何 Config Plugin 的原生源码)
|
||||
> - 改了 `plugins/*/app.plugin.js`(注入逻辑)
|
||||
> - 改了 `app.json`(appId、权限、插件配置)
|
||||
> - 在新机器上首次拉取代码
|
||||
>
|
||||
> **何时只需直接编译(无需 prebuild)?**
|
||||
> - 只改了 `src/` 下的 TS/TSX(JS Bundle 由 Metro/assemble 自动打入)
|
||||
> - 只改了 `android/app/build.gradle`、`gradle.properties` 等已生成的 Gradle 配置
|
||||
>
|
||||
> [!WARNING]
|
||||
> **改了 TS 后,release 包偶尔不会更新**(见 §7):gradle 的 `createBundleReleaseJsAndAssets` 可能因为缓存跳过重新打包,或 Metro 用了 transformer cache。若发现"改了代码但设备上行为没变",**先按 §7.2 验证 bundle 是否真的含新代码**,而不是反复改代码。
|
||||
|
||||
---
|
||||
|
||||
## 1. 体积优化核心原理
|
||||
|
||||
### 1.1 ABI 分包 (ABI Splits)
|
||||
@@ -31,11 +76,14 @@ reactNativeArchitectures=arm64-v8a
|
||||
```
|
||||
* **为什么只包含 arm64-v8a**:目前 99% 的主流现代 Android 实体机都是 64 位 ARM 架构(`arm64-v8a`)。在进行日常分发与本地 Release 测试时,默认只编译 arm64-v8a,能够省去编译另外 3 个架构的机器指令时间,**编译速度提升 3~4 倍**,且输出的包体最小。
|
||||
|
||||
> [!NOTE]
|
||||
> **`-PreactNativeArchitectures` 与 ABI Splits 的关系**:当启用了 §1.1 的 `splits.abi` 后,`assembleRelease` 会**无条件生成所有 `include` 列出的架构分包 + universal 包**,`-PreactNativeArchitectures` 参数只能限制"编译哪几个架构的原生库",并不能减少最终输出的 APK 数量。若想只产出一个 arm64 包、跳过其它架构的编译耗时,最干净的做法是**注释掉 `app/build.gradle` 里的 `splits { abi { ... } }` 块**,让 `reactNativeArchitectures=arm64-v8a` 单独生效。
|
||||
|
||||
### 1.3 Expo 持续原生生成 (Config Plugin) 的自动应用
|
||||
> [!IMPORTANT]
|
||||
> 由于 `android/` 目录被 Git 忽略,**不要直接手动在其他设备上提交原生配置变更**。
|
||||
> 本项目已编写了专门的本地 Config Plugin:[size-optimization](file:///c:/Users/fmq/Documents/work/beancount-mobile/plugins/size-optimization/app.plugin.js)。
|
||||
> 当在新设备上重新 `git clone` 项目后,运行以下指令即可自动拉起 Config Plugin 并在重新生成的 `android/` 目录中完美注入上述所有的体积优化配置(ABI 分包、默认单架构编译):
|
||||
> 由于 `android/` 目录被 Git 忽略(见 `.gitignore`),**不要直接手动修改 `android/` 下的文件并提交**——它们是 prebuild 生成的产物,会在下次重建时丢失。
|
||||
> 本项目已编写了专门的本地 Config Plugin:[size-optimization](file:///c:/Users/fmq/Documents/work/DriftLedger/plugins/size-optimization/app.plugin.js)。
|
||||
> 当在新设备上重新 `git clone` 项目后,运行以下指令即可自动拉起所有 Config Plugin 并在重新生成的 `android/` 目录中完美注入上述所有的体积优化配置(ABI 分包、默认单架构编译、NDK 版本统一):
|
||||
> ```bash
|
||||
> npx expo prebuild --platform android
|
||||
> ```
|
||||
@@ -47,15 +95,24 @@ reactNativeArchitectures=arm64-v8a
|
||||
请在项目的根目录(若已在 `android/` 目录中则不需要前缀 `cd android`)执行以下指令:
|
||||
|
||||
### 2.1 本地测试/实体分发(仅编译 arm64-v8a,最快最推荐)
|
||||
直接运行默认编译,会使用 `gradle.properties` 中配置 of `arm64-v8a`:
|
||||
直接运行默认编译,会使用 `gradle.properties` 中配置的 `arm64-v8a`。
|
||||
|
||||
**Git Bash(推荐,与本文档 §0 的 TL;DR 一致):**
|
||||
```bash
|
||||
export JAVA_HOME="/c/Program Files/Java/jdk-21"
|
||||
export ANDROID_HOME="/c/Users/fmq/AppData/Local/Android/Sdk"
|
||||
cd android
|
||||
./gradlew.bat :app:assembleRelease --offline
|
||||
```
|
||||
|
||||
**PowerShell:**
|
||||
```powershell
|
||||
# 在 Windows Powershell 下执行:
|
||||
$env:JAVA_HOME="C:\Program Files\Java\jdk-21"
|
||||
$env:ANDROID_HOME="C:\Users\fmq\AppData\Local\Android\Sdk"
|
||||
cd android
|
||||
.\gradlew.bat :app:assembleRelease --offline --no-daemon
|
||||
```
|
||||
编译完成后,可在以下路径找到适合真机安装的轻量版 APK(约 37MB):
|
||||
编译完成后,可在以下路径找到适合真机安装的轻量版 APK(开启混淆后约 **24MB**):
|
||||
* `android\app\build\outputs\apk\release\app-arm64-v8a-release.apk`
|
||||
|
||||
---
|
||||
@@ -86,15 +143,228 @@ cd android
|
||||
|
||||
---
|
||||
|
||||
## 3. 高级优化项:Proguard/R8 与混淆 (选填)
|
||||
如需进一步将独立包体积压缩到 25MB 左右,可以考虑在 `gradle.properties` 中开启混淆并做裁剪防御。
|
||||
1. 在 `gradle.properties` 中添加:
|
||||
```properties
|
||||
android.enableMinifyInReleaseBuilds=true
|
||||
android.enableShrinkResourcesInReleaseBuilds=true
|
||||
```
|
||||
2. 注意:由于引入了 `ONNX Runtime` 动态调用,如果运行崩溃,需要在 `android/app/proguard-rules.pro` 中加入如下混淆保留白名单:
|
||||
```proguard
|
||||
-keep class com.microsoft.onnxruntime.** { *; }
|
||||
-dontwarn com.microsoft.onnxruntime.**
|
||||
```
|
||||
## 3. Proguard/R8 混淆(已默认开启)
|
||||
|
||||
本项目的 `gradle.properties` 已**默认启用**代码混淆与资源压缩:
|
||||
```properties
|
||||
android.enableMinifyInReleaseBuilds=true
|
||||
android.enableShrinkResourcesInReleaseBuilds=true
|
||||
```
|
||||
这使得 arm64-v8a 独立包从 ~37MB 压缩到约 **24MB**。无需手动开启。
|
||||
|
||||
> [!WARNING]
|
||||
> 若新增了依赖反射/动态加载的库(如 `ONNX Runtime` 的 JNI 调用),混淆后可能出现运行时 `ClassNotFoundException`。此时需在 `android/app/proguard-rules.pro`(由 ppocr 等 Config Plugin 注入)中补充保留规则,例如:
|
||||
> ```proguard
|
||||
> -keep class com.microsoft.onnxruntime.** { *; }
|
||||
> -dontwarn com.microsoft.onnxruntime.**
|
||||
> ```
|
||||
> 调试混淆问题时,可临时把上述两个属性改为 `false` 排查是否为混淆所致。
|
||||
|
||||
---
|
||||
|
||||
## 4. 通过 adb 安装到真机
|
||||
|
||||
编译产物就绪后,用 adb 覆盖安装到已连接的设备(保留应用数据):
|
||||
|
||||
```bash
|
||||
# 确认设备已连接(USB 调试已开启)
|
||||
adb devices
|
||||
|
||||
# 覆盖安装:-r 保留数据,-d 允许版本号不升(覆盖安装相同/更低 versionCode 时需要)
|
||||
adb install -r -d android/app/build/outputs/apk/release/app-arm64-v8a-release.apk
|
||||
```
|
||||
|
||||
常见问题:
|
||||
| 现象 | 原因与解决 |
|
||||
|---|---|
|
||||
| `adb: command not found` | adb 不在 PATH。Git Bash 下执行 `export PATH="/c/Users/fmq/AppData/Local/Android/Sdk/platform-tools:$PATH"` |
|
||||
| `device offline` / 列表为空 | 手机未授权 USB 调试,或驱动未装;重新插拔并在手机弹窗点「允许」 |
|
||||
| `INSTALL_FAILED_UPDATE_INCOMPATIBLE` | 签名不一致(如之前装的是 debug 版)。先 `adb uninstall com.example.driftledger` 再装 |
|
||||
| 装完打开白屏/闪退 | 多为混淆误删(见 §3)或原生库架构不匹配(模拟器需 x86_64 包) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 原生插件(Config Plugin)开发避坑指南
|
||||
|
||||
本项目通过 7 个 Expo Config Plugin(`plugins/*/app.plugin.js`)在 prebuild 时注入原生代码与配置。以下是踩过的坑:
|
||||
|
||||
### 5.1 跨插件包名一致性陷阱(无障碍伪装包)
|
||||
|
||||
**背景**:为绕过微信 8.0.52+ 的节点混淆,`accessibility` 插件把 7 个 Kotlin 文件整体迁移到伪装包 `com.google.android.accessibility.selecttospeak`(伪装成系统「随说随读」服务),并在注入时用正则把源码里的 `com.beancount.mobile.accessibility` 改写成这个伪装包名。
|
||||
|
||||
**陷阱**:`notification-listener` / `screenshot-monitor` / `sms-receiver` 这三个插件的 Kotlin 源码**也 import 了** `com.beancount.mobile.accessibility.{SelectToSpeakService, ReactContextHolder}`。它们各自的 `app.plugin.js` 有一条「通用包名替换」规则:
|
||||
```js
|
||||
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
|
||||
```
|
||||
这条规则会**无差别**地把 `com.beancount.mobile.accessibility` 也替换成 `appId.accessibility`,导致引用指向一个不存在的包,编译时报:
|
||||
```
|
||||
e: ... BillingNotificationListenerService.kt: Unresolved reference 'accessibility'
|
||||
e: ... BillingNotificationListenerService.kt: Unresolved reference 'SelectToSpeakService'
|
||||
```
|
||||
|
||||
**修复**(已在三个插件中落地):在通用替换**之前**,先把 accessibility 子包引用单独改写到伪装包:
|
||||
```js
|
||||
const ACCESSIBILITY_FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
|
||||
// 必须先改写 accessibility 子包引用,再做通用 com.beancount.mobile → appId 替换
|
||||
content = content.replace(/com\.beancount\.mobile\.accessibility/g, ACCESSIBILITY_FAKE_PACKAGE);
|
||||
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
|
||||
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
|
||||
```
|
||||
|
||||
> **教训**:任何插件如果要用正则做包名改写,必须**先处理跨插件共享的子包引用**(尤其是被「伪装/重命名」过的包),再做通配替换,否则通用规则会破坏跨插件依赖。
|
||||
|
||||
### 5.2 验证 prebuild 注入是否成功
|
||||
|
||||
prebuild 不会因「源码与注入结果不一致」而报错(它只是文件复制 + 字符串替换),所以注入错误只能在 gradle 编译时暴露。快速自查注入结果:
|
||||
```bash
|
||||
# 检查目标包名/类是否被注入到预期路径
|
||||
find android/app/src/main/java -iname "*SelectToSpeak*" -o -iname "*ReactContext*"
|
||||
# 对比源文件与注入文件的差异(应仅 package 声明行不同)
|
||||
diff plugins/accessibility/android/SelectToSpeakService.kt \
|
||||
android/app/src/main/java/com/google/android/accessibility/selecttospeak/SelectToSpeakService.kt
|
||||
```
|
||||
若编译报 `Unresolved reference`,先用上述命令确认类是否被注入、package 是否正确改写。
|
||||
|
||||
### 5.3 编译失败排查清单
|
||||
|
||||
| 错误特征 | 可能原因 | 排查 |
|
||||
|---|---|---|
|
||||
| `Unresolved reference 'XXX'` | 跨插件 import 包名改写不一致(见 §5.1) | 检查注入后文件的 `import` 行 |
|
||||
| `Cannot resolve symbol` / 找不到 R 资源 | res/xml 未随 kt 一起注入 | 检查 `app/src/main/res/xml/` 是否有配置文件 |
|
||||
| 改了 kt 但安装后行为没变 | 忘记重新 prebuild(android/ 是旧的) | `rm -rf android && npx expo prebuild` |
|
||||
| `Execution failed ... mergeReleaseResources` | 资源 ID 冲突 / strings.xml 重复注入 | 检查插件是否做了幂等判断(`if (!content.includes(...))`) |
|
||||
| `Argument type mismatch: 'Float', but 'Double' was expected`(多在 `Math.ceil`/`Math.floor` 处) | `java.lang.Math.ceil`/`floor` **只有 double 重载**,Kotlin 传 Float 不会自动提升 | 改 `x.toDouble()`。注意 `Math.round` 同时有 float/double 两个重载,所以 `Math.round(Float)` 不报错——别误以为 `ceil` 也能直接传 Float |
|
||||
|
||||
### 5.4 只改单个原生源文件时的快速同步(免全量 prebuild)
|
||||
|
||||
§0 的全量重建要 `rm -rf android && prebuild`,慢且扰动整个原生工程。当**只改了某个 `plugins/<x>/android/*.kt` 的内容**(没改 `app.plugin.js` 注入逻辑、没新增/删除文件、没改 assets、没改 app.json)时,可手动把改后的源同步到 android 副本,省去全量 prebuild。Config Plugin 在 prebuild 时对 .kt 做的事 = 复制 + 把 `package`/`import` 里的 `com.beancount.mobile` 替换成 appId,手动等价如下(appId 以 `app.json` 的 `android.package` 为准,本项目为 `com.example.driftledger`):
|
||||
|
||||
```bash
|
||||
# 例:只改了 plugins/ppocr/android/OcrModule.kt
|
||||
python -c "
|
||||
s=open('plugins/ppocr/android/OcrModule.kt',encoding='utf-8').read()
|
||||
s=s.replace('com.beancount.mobile','com.example.driftledger')
|
||||
open('android/app/src/main/java/com/example/driftledger/ppocr/OcrModule.kt','w',encoding='utf-8',newline='\n').write(s)
|
||||
"
|
||||
# 然后直接编译(无需 prebuild;改的是 .kt,Metro bundle 不受影响)
|
||||
cd android && ./gradlew.bat :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --offline --no-daemon
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> 这条捷径**只对「改 .kt 内容」成立**。以下情况**必须**走 §0 全量 prebuild,否则 android/ 副本与注入结果不一致:改了 `app.plugin.js` 注入/复制逻辑;新增或删除 `.kt`/资源文件(手动同步不会更新 `MainApplication` 的 `add(...)` 注入或 res 复制);改了 `assets/`(模型/字典)或 `app.json`。同步后务必 `grep` 副本确认 `package` 行已是 appId、且无残留 `com.beancount.mobile`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 常用速查
|
||||
|
||||
| 目标 | 命令 |
|
||||
|---|---|
|
||||
| 改了 TS 后热更新(无需重编) | `npm run start` → 手机摇一摇 Reload |
|
||||
| 改了原生后重建并安装 | 见 §0 TL;DR 三步 |
|
||||
| 只看编译是否通过(不出 APK) | `./gradlew.bat :app:compileReleaseKotlin --offline` |
|
||||
| 查看连接的设备 | `adb devices -l` |
|
||||
| 查看应用日志 | `adb logcat *:S ReactNativeJS:V ReactNative:V` |
|
||||
| 卸载应用 | `adb uninstall com.example.driftledger` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Release 构建陷阱(改了 TS 却没生效?看这里)
|
||||
|
||||
> 这是项目中最坑、最耗时的问题之一。症状:**改了 `src/` 下的 TS/TSX,编译成功,安装到手机,但行为毫无变化**——仿佛代码没改。这几乎总是 JS Bundle 缓存或 Windows 文件锁导致的。
|
||||
|
||||
### 7.1 根因分类
|
||||
|
||||
| 根因 | 机制 | 表现 |
|
||||
|---|---|---|
|
||||
| **gradle bundle task 缓存** | `:app:createBundleReleaseJsAndAssets` 基于输入快照判定 UP-TO-DATE,即使删了 bundle 输出文件,gradle 的 task snapshot 仍认为"最新",跳过打包 | `./gradlew :app:createBundleReleaseJsAndAssets` 显示 `UP-TO-DATE` |
|
||||
| **Metro transformer cache** | Metro 对每个源文件缓存编译结果,命中就用旧版。Windows 下缓存在 `%LOCALAPPDATA%/Temp/metro-cache` 和 `metro-file-map-*` | bundle 时间戳更新了,但内容不含新代码 |
|
||||
| **Windows 文件锁** | apk/打包中间产物被 adb、杀毒软件、Explorer 预览占用,gradle 无法写入/删除 | `packageRelease FAILED` / `externalNativeBuildCleanRelease FAILED` / "另一个程序正在使用此文件" |
|
||||
| **设备跑旧 APK** | `adb install -r` 时 USB 断开/授权失效,实际没装上 | `adb: no devices` 或 `Success` 但应用没更新 |
|
||||
|
||||
### 7.2 验证 bundle 是否含新代码(关键!)
|
||||
|
||||
**遇到"改了没生效",第一步永远是验证 bundle,而不是反复改代码。** 项目用 Hermes 字节码,bundle 是二进制。
|
||||
|
||||
```bash
|
||||
BUNDLE="android/app/build/generated/assets/createBundleReleaseJsAndAssets/index.android.bundle"
|
||||
# ⚠️ 必须用 grep -a(强制文本模式),因为 Hermes bundle 是二进制,
|
||||
# 默认 grep 会跳过二进制文件,导致永远匹配 0 → 误判"代码没进去"
|
||||
grep -a -c "你新加的字符串常量" "$BUNDLE"
|
||||
# 例:grep -a -c "keyboardDidShow" "$BUNDLE" → 应 ≥1
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **绝对不要用 `grep -c "..."`(不带 -a)验证 Hermes bundle。** 字符串常量(如事件名 `'keyboardDidShow'`、组件名)在字节码里是明文存的,minify 不会改变,用 `grep -a` 能可靠检测。本项目曾因误用 `grep`(不加 -a)反复重编 5 次,浪费大量时间,根因竟是验证方法错了。
|
||||
|
||||
如果 `grep -a` 确认 bundle 含新代码但设备行为没变 → 是「设备跑旧 APK」问题(重装/清数据)。
|
||||
如果 bundle **不含**新代码 → 是「gradle/Metro 缓存」问题,按 §7.3 处理。
|
||||
|
||||
### 7.3 强制 bundle 重新生成的可靠步骤
|
||||
|
||||
按顺序尝试,通常第 1 步即可:
|
||||
|
||||
```bash
|
||||
cd "/c/Users/fmq/Documents/work/DriftLedger"
|
||||
|
||||
# ① 删除 bundle 输出 + sourcemap,让 gradle 的 bundle task 不再 UP-TO-DATE
|
||||
rm -f android/app/build/generated/assets/createBundleReleaseJsAndAssets/index.android.bundle
|
||||
rm -f android/app/build/generated/sourcemaps/react/release/index.android.bundle.map
|
||||
|
||||
# ② 若 ① 无效(task 仍 UP-TO-DATE):手动删除整个 app/build(绕过 gradle clean,
|
||||
# 因为 clean 常因 CMake/文件锁失败而中断,反而没清掉 bundle)
|
||||
rm -rf android/app/build
|
||||
|
||||
# ③ 若仍无效(bundle 生成但内容旧):清 Metro 缓存(Windows 多处)
|
||||
rm -rf node_modules/.cache .expo
|
||||
rm -rf "$LOCALAPPDATA/Temp/metro-cache" "$LOCALAPPDATA/Temp/metro-file-map-"*
|
||||
rm -rf "$LOCALAPPDATA/Temp/1/metro-cache" "$LOCALAPPDATA/Temp/1/metro-file-map-"*
|
||||
|
||||
# ④ 重新编译(带 --no-daemon 可规避 daemon 缓存与锁)
|
||||
cd android
|
||||
export JAVA_HOME="/c/Program Files/Java/jdk-21"
|
||||
./gradlew.bat :app:assembleRelease -PreactNativeArchitectures=arm64-v8a --offline --no-daemon
|
||||
```
|
||||
|
||||
验证打包成功后 bundle 是否更新(§7.2),再安装。
|
||||
|
||||
### 7.4 Windows 文件锁处理
|
||||
|
||||
`packageRelease FAILED` 或 `clean FAILED`("另一个程序正在使用此文件")时:
|
||||
|
||||
```bash
|
||||
# 杀掉占用进程(adb/Explorer 预览/杀毒扫描最常见)
|
||||
taskkill //F //IM adb.exe
|
||||
taskkill //F //IM java.exe
|
||||
sleep 2
|
||||
# 重试对应的失败 task(不必全量重编)
|
||||
./gradlew.bat :app:packageRelease -PreactNativeArchitectures=arm64-v8a --offline
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> - **不要用 `gradlew clean`**:它依赖 `externalNativeBuildCleanRelease`,该 task 在本项目(含原生 CMake 库)极易因文件锁失败,导致 clean 中断、bundle 也没清掉。改用 `rm -rf app/build` 更可靠。
|
||||
> - **不要并发跑多个 gradle 进程**:会互相锁文件。编译前 `taskkill //F //IM java.exe` 清理。
|
||||
> - **`adb install` 前确认设备在线**:`adb devices -l`,列表为空说明 USB 断开或授权失效,`install` 会失败或装到错误的设备。
|
||||
|
||||
### 7.5 排查决策树
|
||||
|
||||
```
|
||||
改了 TS,编译安装后行为没变
|
||||
├─ grep -a 验证 bundle 含新代码?(§7.2)
|
||||
│ ├─ 不含 → gradle/Metro 缓存 → §7.3 强制重生成
|
||||
│ └─ 含 → 设备跑的是旧 APK
|
||||
│ ├─ adb devices 确认在线 → adb install -r -d 重装
|
||||
│ └─ 仍不行 → 卸载重装:adb uninstall com.example.driftledger && adb install <apk>
|
||||
```
|
||||
|
||||
### 7.6 后台 / 自动化编译陷阱
|
||||
|
||||
在 CI、IDE 后台任务、或 agent 的后台 shell 里跑 gradle 时,有几个交互会话遇不到的坑:
|
||||
|
||||
| 陷阱 | 现象 | 解决 |
|
||||
|---|---|---|
|
||||
| **后台 shell 不继承 `ANDROID_HOME`** | `Failed to apply plugin 'com.facebook.react.rootproject'` → `SDK location not found ... local.properties` | 写 `android/local.properties` 的 `sdk.dir`(见 §0 TIP),一劳永逸,不依赖 env |
|
||||
| **命令接 `\| tail`/`\| head` 管道掩盖退出码** | gradle 实际 `BUILD FAILED`,但管道让 shell 退出码 = `tail` 的 0,误判成功;APK 时间戳其实是旧的 | 后台编译**不要接管道**,让退出码真实反映 gradle;判断成败靠读日志的 `BUILD SUCCESSFUL/FAILED` + 核对 APK 时间戳,而非 `$?` |
|
||||
| **Git Bash 下 `grep -E` 报 `conflicting matchers specified`** | 用 `-E` 组合多模式直接报错退出 | 该环境 grep 别名冲突;改用 `grep -e A -e B`、`sed -n` 或多次 `grep` 串联 |
|
||||
| **`adb: command not found`** | 后台/新 shell 的 PATH 没有 platform-tools | 用全路径 `$ANDROID_HOME/platform-tools/adb.exe`,或 `export PATH=...` |
|
||||
|
||||
> **验证后台编译是否真成功的三重核对**:① stdout 含 `BUILD SUCCESSFUL`;② stderr 无 `^e: ` 开头的 Kotlin 编译错误(编译错误走 stderr,stdout 往往只有 `> Task :app:compileReleaseKotlin FAILED`);③ APK 文件时间戳晚于本次编译开始时间。三者缺一即视为失败——尤其别被 `| tail` 的假成功骗到。
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# 弹窗与键盘避让设计指南 (Modal & Keyboard Avoidance)
|
||||
|
||||
本项目(React Native + Expo)所有底部弹窗(FormModal / BottomSheet / NumpadSheet)都基于 RN 原生 `<Modal>`。Modal 会带来两个棘手问题:**底部安全区丢失** 和 **键盘遮挡**。本篇记录反复踩坑后总结的正确模式,避免重蹈覆辙。
|
||||
|
||||
参考实现:`reference_project/BeeCount`(Flutter,`AnimatedPadding + viewInsets.bottom` 方案)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心认知:`<Modal>` 脱离主窗口 context
|
||||
|
||||
> **这是所有问题的总根源,必须牢记。**
|
||||
|
||||
RN 的 `<Modal>` 会在原生层创建一个**独立的新窗口**,它**脱离了主窗口的 React 树**。两个直接后果:
|
||||
|
||||
1. **`react-native-safe-area-context` 失效**:主窗口里 expo-router 自动注入的 `SafeAreaProvider` 不会延伸到 Modal 内部。在 Modal 内直接调 `useSafeAreaInsets()` 会返回 `{ top: 0, bottom: 0, ... }`(全零),拿不到手势条高度。
|
||||
2. **`KeyboardAvoidingView` 不可靠**:在 Modal 的独立窗口里,`behavior="height"` 在 Android 上键盘关闭后可能残留 padding(表现为"关闭键盘后弹窗底部有留白");`behavior="padding"` 对 Modal 内的布局响应也不稳定。
|
||||
|
||||
---
|
||||
|
||||
## 2. 正确模式:Modal 内补 SafeAreaProvider + 响应式 paddingBottom
|
||||
|
||||
### 2.1 SafeAreaProvider 补救(解决底部安全区丢失)
|
||||
|
||||
每个 `<Modal>` 内部**重新包一层 `<SafeAreaProvider>`**,让 context 在弹窗内恢复有效:
|
||||
|
||||
```tsx
|
||||
// ❌ 错误:Modal 内直接用 useSafeAreaInsets(),返回全零
|
||||
export function FormModal(props) {
|
||||
const insets = useSafeAreaInsets(); // bottom === 0 永远
|
||||
return <Modal>...</Modal>;
|
||||
}
|
||||
|
||||
// ✅ 正确:Modal 内补 SafeAreaProvider,拆成外层 + Content
|
||||
export function FormModal(props) {
|
||||
return (
|
||||
<Modal visible={props.visible} transparent animationType="slide">
|
||||
<SafeAreaProvider>
|
||||
<FormModalContent {...props} />
|
||||
</SafeAreaProvider>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
function FormModalContent(props) {
|
||||
const insets = useSafeAreaInsets(); // 现在 bottom 是真实手势条高度
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
> **为什么必须拆两个组件?** `useSafeAreaInsets()` 必须在 `<SafeAreaProvider>` 的**子组件**里调用。同一个组件既渲染 Provider 又调 hook 会拿到全零(hook 在 Provider 上方执行)。
|
||||
|
||||
### 2.2 响应式 paddingBottom(解决键盘遮挡,绝不残留)
|
||||
|
||||
**核心思路(学 BeeCount 的 `AnimatedPadding + viewInsets.bottom`):键盘高度做成 paddingBottom,而不是位移 sheet。**
|
||||
|
||||
```tsx
|
||||
// useKeyboardHeight hook(见 src/hooks/useKeyboardAvoiding.ts)
|
||||
const kbHeight = useKeyboardHeight();
|
||||
|
||||
// sheet 的 paddingBottom = 基础留白 + 键盘高度
|
||||
<Pressable style={{
|
||||
paddingBottom: Math.max(insets.bottom, 24) + kbHeight,
|
||||
}}>
|
||||
{/* 内容(含确认/取消按钮)会被 padding 顶起,始终在键盘上方 */}
|
||||
</Pressable>
|
||||
```
|
||||
|
||||
**为什么这个模式正确:**
|
||||
- 键盘弹出 → `paddingBottom` 增大 → 内容被推到键盘上方,**且 sheet 仍贴底,不会露出遮罩缝**
|
||||
- 键盘关闭 → `paddingBottom` 精确回到 `Math.max(insets.bottom, 24)` → **无残留**
|
||||
- 初始(键盘未弹)→ 只有基础留白 → **不会一打开就有大留白**
|
||||
|
||||
---
|
||||
|
||||
## 3. ❌ 已验证的失败方案(不要再尝试)
|
||||
|
||||
### 3.1 `KeyboardAvoidingView` 包裹 sheet
|
||||
|
||||
```tsx
|
||||
// ❌ Modal 内 KAV 不可靠
|
||||
<Modal>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
||||
<Sheet/>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaProvider>
|
||||
</Modal>
|
||||
```
|
||||
**问题**:Android `behavior="height"` 键盘关闭后残留 padding("关闭键盘后有留白");`behavior="position"` 在 Modal 内也不稳定。
|
||||
|
||||
### 3.2 `Animated` translateY 位移整个 sheet
|
||||
|
||||
```tsx
|
||||
// ❌ 位移会露出遮罩缝 + Modal 内键盘事件易误触发
|
||||
const translateY = useKeyboardAvoiding(); // 返回 Animated.Value
|
||||
<Animated.View style={{ transform: [{ translateY }] }}>
|
||||
<Sheet/>
|
||||
</Animated.View>
|
||||
```
|
||||
**两个致命问题**:
|
||||
1. 位移后 sheet 原位置空出来,露出遮罩色 → **"键盘和弹窗之间有留白"**
|
||||
2. Modal 首次渲染时若 `keyboardDidShow` 被残留焦点事件误触发,translateY 变负 → **"一打开就有留白"**
|
||||
|
||||
> 这正是本项目踩过的坑:translateY 方案让用户看到"所有弹窗底部都有留白"。改用响应式 padding 后彻底解决。
|
||||
|
||||
---
|
||||
|
||||
## 4. 底部留白值的正确计算
|
||||
|
||||
```tsx
|
||||
// ❌ 错误:安全区 + 固定值叠加(会过大)
|
||||
paddingBottom: 36 + insets.bottom // 全面屏手势机 bottom≈48 → 总 84px,太多
|
||||
|
||||
// ✅ 正确:取较大值,不叠加
|
||||
paddingBottom: Math.max(insets.bottom, 24)
|
||||
```
|
||||
|
||||
**为什么不能叠加?** 安全区(`insets.bottom`)本身已经覆盖了手势条区域。再叠加一个固定的 36,会在底部堆出 80+px 的空白,视觉上是"一大块留白"。两者应取**较大值**——安全区大时取安全区,安全区为 0(如 MIUI 全面屏手势机)时取最小视觉留白。
|
||||
|
||||
---
|
||||
|
||||
## 5. 项目中三个公共弹窗的实现
|
||||
|
||||
| 组件 | 文件 | 键盘避让 | 备注 |
|
||||
|---|---|---|---|
|
||||
| `FormModal` | `src/components/form/FormModal.tsx` | 响应式 paddingBottom(含 TextInput) | 所有管理页 CRUD 复用 |
|
||||
| `BottomSheet` | `src/components/ui/BottomSheet.tsx` | 响应式 paddingBottom + 手势下滑 Animated | 手势 `translateY` 仅用于下滑关闭,与键盘 padding 独立 |
|
||||
| `NumpadSheet` | `src/components/form/NumpadSheet.tsx` | 内部 ScrollView | 记一笔主面板,主要用自定义 NumpadKeyboard,系统键盘场景少 |
|
||||
|
||||
`BottomSheet` 的特殊点:它同时有**手势下滑关闭**(用 `Animated.Value` 做 translateY)和**键盘避让**(用响应式 paddingBottom)。两者必须独立——手势位移走 Animated,键盘避让走 padding,不要合并成一个位移值(`Animated.add` 合并会导致手势和键盘互相干扰)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 居中弹窗(ConfirmDialog)无需处理
|
||||
|
||||
`ConfirmDialog`(删除确认)是居中浮层(`justifyContent: 'center'`),无 TextInput、不贴底、无键盘,**不受安全区/键盘影响,不需要上述任何处理**。
|
||||
|
||||
---
|
||||
|
||||
## 7. 验证清单(修弹窗后必测)
|
||||
|
||||
1. **底部留白**:打开弹窗(不点输入框),底部只有正常小留白,无"一大块空白"。
|
||||
2. **键盘上移**:点输入框唤起键盘 → 确认/取消按钮在键盘上方可见,且弹窗与键盘之间无缝隙。
|
||||
3. **关闭键盘无残留**:收起键盘 → 弹窗精确回到初始状态,底部留白和打开时一致。
|
||||
4. **全面屏手势机**:在 MIUI 等全面屏手势设备上(`insets.bottom = 0`)也正常。
|
||||
|
||||
---
|
||||
|
||||
## 8. 经验教训
|
||||
|
||||
1. **RN `<Modal>` 是独立窗口**——这是 RN 的设计,不是 bug。任何依赖主窗口 context 的 API(SafeArea、某些 Keyboard 行为)在 Modal 内都要重新建立。
|
||||
2. **键盘避让用 padding,不用位移**——这是 Flutter/RN 通用共识(BeeCount 的 `AnimatedPadding + viewInsets`)。位移方案虽然直觉上像"弹窗上移",但会露出原位置,制造视觉缝隙。
|
||||
3. **Hermes bundle 是二进制**——验证 release 包代码时必须 `grep -a`,否则永远误判"代码没进去"。详见 `android-build-guide.md §7.2`。
|
||||
@@ -0,0 +1,124 @@
|
||||
# OCR 推理管线指南 (OCR Pipeline Guide)
|
||||
|
||||
本文记录原生 OCR 引擎(`plugins/ppocr/android/OcrModule.kt`,PP-OCRv6 / ONNX Runtime)的推理管线设计、一次「金额丢小数点」问题的根因与修复,以及**与 PaddleOCR 官方管线的差异和裁剪理由**。同时沉淀一套可复用的「OCR 识别错误诊断方法论」。
|
||||
|
||||
> 配套:构建/编译陷阱见 `android-build-guide.md`;Modal/键盘见 `modal-keyboard-guide.md`。
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
- 管线:`整图 cap 长边 → det 检测文本框 → 逐框 crop → rec 识别 → CTC 解码`,全部在 Kotlin 原生侧,JS 层只透传 base64。
|
||||
- **det 后处理必须用「连通域法」**(4-连通 BFS + 官方 unclip 外扩 + box_score_fast 过滤)。**禁止回退到旧的「水平/垂直投影切行」**——它会把基线上的孤立小数点切到文本框外,导致 `¥143.97` 被识别成 `¥14397`。
|
||||
- 怀疑「模型识别能力不足」之前,**先用官方 PaddleOCR 跑同一对模型做对照**:官方能认出来 → 是我们的预处理/后处理代码问题;官方也认不出 → 才是模型边界。这条纪律避免误判。
|
||||
- 我们用 Python + onnxruntime **逐行镜像** Kotlin 管线做离线验证(同一对 onnx 模型 + 真实截图),算法正确性在移植到 Kotlin 前就锁死,原生改动只是「翻译」。
|
||||
|
||||
---
|
||||
|
||||
## 1. 管线总览与参数
|
||||
|
||||
实现位置:`plugins/ppocr/android/OcrModule.kt`。常量在 `companion object`。
|
||||
|
||||
| 环节 | 函数 | 关键参数 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 整图缩放 | `capLongEdge` | `CAP_LONG_EDGE=3000` | 仅超大图降采样防 OOM;手机截图(≤2400)不预压,使 rec 的 crop 源为高清原图 |
|
||||
| det resize | `resizeForDet` | `DET_LIMIT_MAX_SIDE=1600`,half-up,**无条件对齐 32** | 旧值 960 会让小数点仅 1–2px 而糊掉;无条件对齐 32 防止非法尺寸喂入 det 触发广播报错 |
|
||||
| det 前处理 | `preprocessDet` | mean/std=ImageNet,**通道序 BGR** | `(px shr (8*c)) and 0xFF`(c=0→B),对齐官方训练约定 |
|
||||
| det 后处理 | `dbPostprocess` | `DET_THRESH=0.2`、`BOX_THRESH=0.6`、`UNCLIP_RATIO=1.5`、`MIN_SIZE=5` | 连通域法,见 §3 |
|
||||
| crop | `cropBox` | paddingX=4/paddingY=2,**h≥1.5w 时 rot90** | 竖排文本旋转,对齐官方 `get_rotate_crop_image` |
|
||||
| rec 前处理 | `preprocessRec` | 高 `REC_IMAGE_HEIGHT=48`,宽 **ceil** 上限 `REC_MAX_WIDTH=1280`,**不 pad** | 旧宽上限 320 把长商户名压扁 5×+;不 pad 见 §6 |
|
||||
| 解码 | `ctcGreedyDecode` | blank=0,去重,置信度=非 blank 步均值 | 与官方 `CTCLabelDecode` 逻辑一致 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 丢小数点根因:投影切行把基线点切到框外
|
||||
|
||||
### 现象
|
||||
招行一笔 `GOOGLE*ChatGPT` 交易,金额 `¥143.97` 被 OCR 识别成 `¥14397`;而同一页的 `交易地金额 21.19` 小数点却正常。
|
||||
|
||||
### 根因(已用官方同模型对照坐实)
|
||||
旧的 `dbPostprocess` 用**水平投影切行**:一行活跃像素 ≥ `w/20` 才算文本行。小数点是基线上孤立的 1–2 像素宽,它所在那一行的水平投影**远低于阈值**,被判成「非文本行」→ `rowRange` 的 `y1` 停在数字主体底部,**基线行被排除** → `cropBox` 裁出的图**不含小数点** → rec 看不到点 → `14397`。
|
||||
|
||||
官方 PaddleOCR 的 det 后处理是**轮廓/连通域法**:连通域把「数字 + 基线点」归为同一个域,外接框天然含基线,所以点保住了。
|
||||
|
||||
> 关键证据:把旧管线切出的金额框在原图上**纵向下扩 10px** 再送 rec,小数点立刻以 0.99 置信度回来——证明点一直在二值图里,只是被切行排除在 crop 之外。
|
||||
|
||||
### 为什么前两轮误判(教训)
|
||||
- 第一轮怀疑 **JPEG q60 / scaleDown 降采样**抹掉了点 → 用**原图**裁剪送 rec 仍 `14397`,证伪。
|
||||
- 第二轮怀疑**模型能力边界**(大字号下点太小,rec 不认)→ 用**官方 PaddleOCR 跑同一对模型**,官方正确输出 `¥143.97`,**反转结论**:模型完全能认,问题 100% 在我们的后处理代码。
|
||||
|
||||
**纪律**:在归咎「模型不行」或「图像预处理丢信息」之前,先做官方同模型对照。它能一锤定音区分「模型边界」与「我们的代码 bug」。
|
||||
|
||||
---
|
||||
|
||||
## 3. 修复:连通域法 + 官方 unclip + box_score
|
||||
|
||||
`dbPostprocess` 重写为(与官方轮廓法等价、但纯 Kotlin 零新依赖):
|
||||
|
||||
1. **二值化**:`sig > DET_THRESH(0.2)`,同时保留 `sigMap`(概率图)供后续 box_score 用。状态栏/导航栏(顶/底 8%)清零 + 垂直干扰线(列活跃 >30%)清零保留。
|
||||
2. **连通域标记**:4-连通、**迭代 BFS**(用 `ArrayDeque`,防递归栈溢出),每域记录 bbox `(x0,y0,x1,y1)`、真实像素面积 `area`、域内 `sig` 累加 `sum`。
|
||||
3. **官方 unclip 外扩**:`d = area × UNCLIP_RATIO(1.5) / 周长`,`d` 下限 1,bbox 四向各扩 `d`(half-up 取整)。水平文本下这与官方多边形外扩在结果上几乎等价,把基线标点纳入框。
|
||||
4. **过滤**:扩后短边 `< MIN_SIZE(5)` 丢弃;**box_score_fast** = `sum / area`(**域内文本像素 prob 均值**,见 §5 口径)`< BOX_THRESH(0.6)` 丢弃。
|
||||
5. 映射回原图坐标(×ratioX/ratioY)。
|
||||
|
||||
> 旧的「水平投影切行 + 垂直投影切列 + 临时纵向膨胀」已**整体删除**,不要复活。
|
||||
|
||||
---
|
||||
|
||||
## 4. 诊断方法论(可复用范式)
|
||||
|
||||
当某张图 OCR 结果错误时,按以下顺序定位,**不要直接改 Kotlin 猜**:
|
||||
|
||||
1. **Python 复现管线**:用 onnxruntime 加载同一对 `ppocrv6_det.onnx` / `ppocrv6_rec.onnx` + `ppocrv6_dict.txt`,**逐行镜像** Kotlin 的缩放/前处理/后处理/crop/解码(脚本见仓库根 `.ocr-test/`,未跟踪,验证完可删)。先确认能复现错误。
|
||||
2. **逐环节隔离**:对出错文本框做对照实验——分别用「降采样图 / 原图」裁剪、放开 rec 宽上限、纵向放大 crop 等,看哪一步改变结果,缩小嫌疑环。
|
||||
3. **官方同模型对照**:`pip install paddleocr` 后用官方 `PaddleOCR(... engine="onnxruntime")` 跑同一张图。**官方能认 → 我们代码 bug;官方也认不出 → 模型边界。** 这一步是判读的分水岭。
|
||||
4. **dump 逐时间步 logits**:对 rec 输出看「出错字符位置」那个时间步,目标字符类的概率是多少、argmax 是什么。能区分「crop 没含该字符(概率≈0)」还是「含了但模型判错」。
|
||||
5. **修复先在 Python 验证台改对、端到端跑通**,再 1:1 翻译到 Kotlin——原生端跑不了 onnx 离线验证,靠这层把算法正确性前置锁死。
|
||||
|
||||
---
|
||||
|
||||
## 5. 跨语言复现的两个对齐坑
|
||||
|
||||
### 5.1 舍入语义必须一致
|
||||
det 尺寸对齐 32 时,Python 内置 `round` 是**银行家舍入**(22.5→22),Kotlin/Java `Math.round` 是 **half-up**(22.5→23)。两者会让 det 输入差 32 像素列。若验证台用银行家、Kotlin 用 half-up,则 Kotlin 实际跑的是**验证台没验证过的尺寸**,成为盲点。
|
||||
|
||||
**做法**:验证台显式用 half-up(`int(math.floor(x+0.5))`)镜像 `Math.round`,使两边输入逐像素一致;Kotlin 侧 `Math.ceil` 注意只有 `double` 重载(传 Float 编译失败,需 `toDouble()`,详见 `android-build-guide.md §5.3`)。
|
||||
|
||||
### 5.2 box_score_fast 的口径
|
||||
`box_score_fast` 是**文本像素(连通域 mask 内)的 prob 均值**,**不是整个 bbox 矩形的均值**。后者含大量 0 值背景,会把分数稀释到阈值以下、把所有框误杀(实测曾出现「连通域 24 个、保留 0 个」)。用 `scipy.ndimage.mean(sig, labels)` / Kotlin 的 `sum/area` 取域内均值才对。
|
||||
|
||||
---
|
||||
|
||||
## 6. 与官方差异的裁剪清单(为什么不做某些官方能力)
|
||||
|
||||
两条标尺贯穿全部判断:① 我们是 **Android ONNX CPU**,无 GPU、无 OpenCV、包体/内存敏感;② 输入几乎全是 **App 截图**——水平文本、无旋转、无镜头畸变、无弯曲。官方很多重型能力是为「拍照/扫描/任意文档」的宽分布设计的,对我们的窄分布是 over-engineering。
|
||||
|
||||
| 项 | 类别 | 不做/保留现状的主因 | 重新评估的触发条件 |
|
||||
|---|---|---|---|
|
||||
| 透视矫正 `warpPerspective` | P2 | 截图无透视;需引 OpenCV(~30MB so) 或自写 warp | 上「拍照记账」 |
|
||||
| det 通道序 BGR | **已做** | 零成本对齐训练约定,顺手做了 | — |
|
||||
| 顶/底 8% + 列 30% 硬清零 | 保留 | 截图利>弊;无贴边/JS 预裁需求 | 出现贴边误删 或 JS 预裁状态栏 |
|
||||
| `textline_orientation` 行方向模型 | 不做 | 截图恒 0°,每框白跑一次 ONNX 推理 | 上「拍照记账」 |
|
||||
| `doc_orientation` + `UVDoc` 去弯 | 不做 | 截图无旋转/弯曲;移动端最贵的两个模型 | 产品转向拍纸质账本(基本不会) |
|
||||
| rec 右侧 pad 到 320 | 不做 | 逐行 + ONNX 动态宽,pad 只增算力无收益(**少数「与官方不同但我们更对」的点**) | 改做 rec 批推理(大概率不做) |
|
||||
| pyclipper 精确多边形膨胀 | 不做 | 水平文本 bbox 近似等价;需引 Clipper2 新依赖 | 支持倾斜/拍照文本 |
|
||||
|
||||
> 一句话:官方「完整管线」为宽分布 + 强算力调;我们为窄分布 + 弱算力,正确做法是**按输入分布裁掉用不上的重型环节**,只移植真正提升截图质量的部分(连通域取框、提分辨率、rec 放宽、参数对齐)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 参数对照表(旧 → 新 → 官方)
|
||||
|
||||
| 参数 | 旧 | 新 | 官方(OCR 管线生效值) |
|
||||
|---|---|---|---|
|
||||
| 整图缩放 | 短边压 720 | 长边 cap 3000 | 不降采样(max_side_limit=4000) |
|
||||
| det 长边 | 960 / floor | **1600** / half-up / 无条件对齐 32 | 不降采样 / round 对齐 32 |
|
||||
| det thresh | 0.3 | **0.2** | 0.3(模型 yml 0.2) |
|
||||
| det box_thresh | 无 | **0.6** | 0.6(模型 yml 0.45) |
|
||||
| det unclip_ratio | 无(临时 0.4 行高) | **1.5**(area×ratio/周长) | 1.5 |
|
||||
| det 取框法 | 投影切行 | **连通域** | 轮廓 findContours+unclip |
|
||||
| rec 宽 cap | 320 / floor | **1280** / ceil | 3200 / ceil |
|
||||
| det 通道序 | RGB | **BGR** | BGR |
|
||||
| crop 竖排 | 无 | **rot90** | rot90 + 透视 |
|
||||
|
||||
> 移动端折中说明:det 长边取 1600(非官方全分辨率)是为 CPU 性能;box_thresh 取 0.6(非模型 yml 的 0.45)是实测 0.45 仅多收噪声无额外召回。两者都是「在官方语义下向移动端性能倾斜」的有意识折中,非疏漏。
|
||||
@@ -0,0 +1,147 @@
|
||||
# 硅基流动与智谱 AI 账单 OCR 及 JSON 结构化提取实测报告
|
||||
|
||||
本报告针对测试图片(`20260725-142945.jpg` 信用卡账单截图),对 **硅基流动 (SiliconFlow)** 与 **智谱 AI (BigModel.cn)** 平台的视觉大模型、OCR 引擎及多模型组合进行了多轮实测基准对比。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
1. [测试结论与终极选型建议](#一-测试结论与终极选型建议)
|
||||
2. [实测性能对比总表](#二-实测性能对比总表)
|
||||
3. [硅基流动 (SiliconFlow) 平台测试详解](#三-硅基流动-siliconflow-平台测试详解)
|
||||
4. [智谱 AI (BigModel.cn) 平台测试详解](#四-智谱-ai-bigmodelcn-平台测试详解)
|
||||
5. [免费模型配额与 Rate Limits 规则](#五-免费模型配额与-rate-limits-规则)
|
||||
6. [DriftLedger (浮记) 架构与账户分配流程](#六-driftledger-浮记-架构与账户分配流程)
|
||||
|
||||
---
|
||||
|
||||
## 一、 测试结论与终极选型建议
|
||||
|
||||
> [!TIP]
|
||||
> **最佳单 API 直出方案**:智谱 **`glm-4v-flash`**
|
||||
> - **耗时仅 3.22 秒**,单次 API 请求即可直接输出包含商户、金额、日期、卡号、原币的**完美 JSON**,且 100% 免费。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **最佳双阶段流水线方案**:硅基流动 **`DeepSeek-OCR` + `THUDM/GLM-4-9B-0414`**
|
||||
> - **总耗时仅 4.36 秒**(阶段一 OCR 1.17s + 阶段二 文本提 JSON 3.19s),全免费,OCR 文字识别准确率 100%。
|
||||
|
||||
> [!NOTE]
|
||||
> **最佳跨国/外币高精度方案**:**`DeepSeek-OCR` + `deepseek-ai/DeepSeek-V3`**
|
||||
> - **总耗时 5.39 秒**,具备强大的语义推理能力,不仅提取出原币数字 `21.19`,还智能推断并补充了单位 `USD`。每次调用费用仅约 0.0008 分钱。
|
||||
|
||||
---
|
||||
|
||||
## 二、 实测性能对比总表
|
||||
|
||||
测试图片:`20260725-142945.jpg`(招商银行信用卡消费通知,含商户 `GOOGLE *ChatGPT`,金额 `¥143.97`,信用卡 `3315`,原币 `21.19`,时间 `2026-07-23 00:00:00`)。
|
||||
|
||||
| 平台 | 模型 / 组合名称 | 架构类型 | 阶段1 耗时 | 阶段2 耗时 | **总耗时** | 提取准确度与 JSON 质量 | 资费类型 |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **智谱 AI** | **`glm-4v-flash`** | 单阶段 VLM | - | - | **3.22s** | **100% 完美** (提取极精准,结构清晰) | **100% 免费** |
|
||||
| **硅基流动** | **`DeepSeek-OCR` + `GLM-4-9B-0414`** | 双阶段流水线 | 1.17s | 3.19s | **4.36s** | **100% 准确** (结构化规范,无幻觉) | **100% 免费** |
|
||||
| **硅基流动** | **`DeepSeek-OCR` + `DeepSeek-V3`** | 双阶段流水线 | 1.17s | 4.22s | **5.39s** | **智能推导** (自动补全原币单位 `USD`) | 低成本计费 (~0.0008分/次) |
|
||||
| **智谱 AI** | **`glm-4.1v-thinking-flash`** | 思维链 VLM | - | - | **7.03s** | 带有 `<think>` 推理链,易超长截断 | **100% 免费** |
|
||||
| **硅基流动** | **`Qwen/Qwen3-VL-8B-Instruct`** | 单阶段 VLM | - | - | **10.34s** | **100% 准确** (一次生成完成) | **100% 免费** |
|
||||
| **硅基流动** | **`PaddleOCR-VL-1.5`** | 坐标类 OCR | - | - | **62s ~ 114s** | 包含大量 `<\|LOC_xxx\|>` 点位标签与杂音 | **100% 免费** |
|
||||
|
||||
---
|
||||
|
||||
## 三、 硅基流动 (SiliconFlow) 平台测试详解
|
||||
|
||||
### 1. `deepseek-ai/DeepSeek-OCR`
|
||||
- **定位**:端到端纯文档/图片至 Markdown 识别模型。
|
||||
- **优点**:速度极快(**1.17s ~ 1.48s**),完美还原表格与富文本结构。
|
||||
- **限制**:不支持通用大语言模型的指令遵循(无法直接提示词输出 JSON,强制开启 `json_object` 会陷入空格生成死循环)。
|
||||
|
||||
### 2. `PaddlePaddle/PaddleOCR-VL-1.5`
|
||||
- **定位**:带坐标识别的文档/版面分析大模型。
|
||||
- **缺点**:缺少张量并行加速,单次生成生成耗时高达 60~110 秒,输出结果混杂大量的点位 Token。
|
||||
|
||||
### 3. 双阶段流水线提取结果(实际返回 JSON)
|
||||
```json
|
||||
{
|
||||
"occurredAt": "2026-07-23 00:00:00",
|
||||
"amount": 143.97,
|
||||
"currency": "CNY",
|
||||
"direction": "expense",
|
||||
"counterparty": "GOOGLE *ChatGPT",
|
||||
"memo": "信用卡尾号: 3315, 原币金额: 21.19 USD"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、 智谱 AI (BigModel.cn) 平台测试详解
|
||||
|
||||
### 1. 智谱免费 Flash 模型分类与特性
|
||||
|
||||
- **`glm-4v-flash`**:智谱基础免费视觉模型,**实测表现最稳定、速度最快 (3.22s)**,原生支持 `json_object`。
|
||||
- **`glm-4.6v-flash`**:最新轻量多模态模型,支持原生工具调用(Tool Calling),但免费接口频控较严(并发时易触发 HTTP 429)。
|
||||
- **`glm-4.1v-thinking-flash`**:具备 Thinking 思维链机制,回答前会在 `<think>` 中展开多步骤思考逻辑。
|
||||
- **`glm-4-flash-250414`**:纯文本/代码轻量旗舰模型,适合放在双阶段流水线的 Stage 2。
|
||||
- **`CogView-3-Flash` / `CogVideoX-Flash`**:分别用于文生图与视频生成。
|
||||
|
||||
### 2. `glm-4v-flash` 实测输出数据
|
||||
```json
|
||||
{
|
||||
"occurredAt": "2026-07-23 00:00:00",
|
||||
"amount": 143.97,
|
||||
"currency": "CNY",
|
||||
"direction": "expense",
|
||||
"counterparty": "GOOGLE *ChatGPT",
|
||||
"memo": {
|
||||
"card_last_4_digits": "3315",
|
||||
"original_amount": 21.19,
|
||||
"country_or_region": "美国"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、 免费模型配额与 Rate Limits 规则
|
||||
|
||||
### 1. 硅基流动 (SiliconFlow)
|
||||
- **门槛**:需完成账户实名认证。
|
||||
- **配额**:
|
||||
- **RPM (Requests Per Minute)**:100 ~ 1,000 RPM(中小模型 500~1000 RPM)。
|
||||
- **TPM (Tokens Per Minute)**:50,000 ~ 100,000 TPM。
|
||||
- **Pro/ 专线**:带 `Pro/` 前缀的模型(如 `Pro/deepseek-ai/DeepSeek-V3`)为付费独占集群,无免费版的固定并发上限。
|
||||
|
||||
### 2. 智谱开放平台 (BigModel.cn)
|
||||
- **免费规则**:所有的 Flash 命名系列(`GLM-4-Flash`、`GLM-4V-Flash` 等)API 均免费开放。
|
||||
- **并发控制**:对高频连续调用设置了 RPM 阈值(触发时返回 `HTTP 429 Too Many Requests`),代码中需配置指数退避或 1~2 秒重试间隔。
|
||||
|
||||
---
|
||||
|
||||
## 六、 DriftLedger (浮记) 架构与账户分配流程
|
||||
|
||||
针对识别结果中“为什么只包含时间、金额、商户名,而没有 Beancount 账户”的说明:
|
||||
|
||||
### 1. 职责解耦设计
|
||||
识图/OCR 模块只负责提取客观的**原始交易事件 (`ImportedEvent`)**,定义于 [types.ts](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/core/types.ts#L31-L38)。由于每个用户的 Beancount 账户名(如 `Assets:招商银行:信用卡3315`)是高度个性化的,模型无法预知用户本地账本结构。
|
||||
|
||||
### 2. 账本账户决定流程
|
||||
账户映射是在 **`BillPipeline` 责任链** 中完成的:
|
||||
|
||||
```text
|
||||
[账单截图]
|
||||
│
|
||||
▼ 1. 图像解析 (AiVisionProcessor.ts)
|
||||
[ImportedEvent] ─── (仅含时间、金额、商户 GOOGLE *ChatGPT、备注 3315)
|
||||
│
|
||||
▼ 2. 进入流水线 BillPipeline.process() ─── [billPipeline.ts]
|
||||
├──> ① 转账识别 (recognizeTransfers)
|
||||
├──> ② 批次去重与历史去重 (dedup)
|
||||
└──> ③ 规则匹配与账户分类 (rules.ts)
|
||||
├── 匹配商户/卡号 "3315" ──> 资金来源账户 (sourceAccount): Assets:招商银行:信用卡3315
|
||||
└── 匹配商户 "GOOGLE *ChatGPT" ──> 支出分类账户 (categoryAccount): Expenses:订阅服务:AI
|
||||
│
|
||||
▼ 3. 输出标准交易草稿 (TransactionDraft)
|
||||
[TransactionDraft] ─── 包含标准的双式记账 Postings 分录
|
||||
```
|
||||
|
||||
### 3. 相关代码位置
|
||||
- **原始事件类型定义**:[src/domain/core/types.ts:L31-L38](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/core/types.ts#L31-L38)
|
||||
- **账单流水线责任链**:[src/domain/pipeline/billPipeline.ts:L94-L180](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/pipeline/billPipeline.ts#L94-L180)
|
||||
- **规则分类与账户映射引擎**:[src/domain/rules/rules.ts:L1-L60](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/rules/rules.ts#L1-L60)
|
||||
- **AI 识图处理器服务**:[src/services/ocr/AiVisionProcessor.ts](file:///C:/Users/fmq/Documents/work/DriftLedger/src/services/ocr/AiVisionProcessor.ts)
|
||||
@@ -0,0 +1,155 @@
|
||||
# 账单识别与 Beancount 账户分类:合并 vs 解耦架构对比文档
|
||||
|
||||
在双式记账(Beancount / DriftLedger)离线移动客户端开发中,**“原始账单识别(Bill Recognition)”** 与 **“Beancount 账户分类(Account Classification)”** 是两个核心处理阶段。
|
||||
|
||||
本文档深度对比分析将这两个阶段进行 **合并(单步一体求值)** 与 **解耦(双阶段责任链)** 的架构差异,并结合 DriftLedger 源码实现,分别涵盖 **传统规则渠道(Rule-based)** 与 **大模型/AI 渠道(LLM/VLM)** 的表现。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
1. [架构定义与处理模式](#一-架构定义与处理模式)
|
||||
2. [传统规则渠道(Rule-based Channel)对比](#二-传统规则渠道-rule-based-channel-对比)
|
||||
3. [大模型/AI 渠道(AI/LLM Channel)对比](#三-大模型ai-渠道-aillm-channel-对比)
|
||||
4. [多维性能与工程指标全景对比表](#四-多维性能与工程指标全景对比表)
|
||||
5. [DriftLedger (浮记) 混合架构落地推荐](#五-driftledger-浮记-混合架构落地推荐)
|
||||
|
||||
---
|
||||
|
||||
## 一、 架构定义与处理模式
|
||||
|
||||
在多通道系统架构中,无论采用合并还是解耦,**多通道原始内容采集(Ingestion Adapters)** 始终是前置的:
|
||||
- **通道 1:拍照/图库 OCR**(获取账单图像或 OCR Markdown 文本)
|
||||
- **通道 2:无障碍服务 UI 节点提取**(解析微信/支付宝支付成功页的 node 文本,参见 [accessibilityParser.ts](file:///C:/Users/fmq/Documents/work/DriftLedger/src/services/automation/accessibilityParser.ts))
|
||||
- **通道 3:短信与系统通知监控**(解析银行/支付软件推送通知字符串)
|
||||
|
||||
合并与解耦的核心区别,在于拿到**账单原始内容 (Raw Bill Text)** 之后,**“事实字段识别”** 与 **“Beancount 账户分配”** 是在单次操作中完成,还是拆分为多个阶段:
|
||||
|
||||
```text
|
||||
========================================================================================
|
||||
【多通道多源输入】
|
||||
(通道A: 拍照OCR文本 / 通道B: 无障碍UI节点文本 / 通道C: 短信通知文本)
|
||||
│
|
||||
▼
|
||||
【合并架构 (Merged Mode / 一体化文本求值)】
|
||||
单次求值 (AI Prompt 或 规则引擎):
|
||||
输入: 账单原始内容 + 用户 Beancount 动态账户列表
|
||||
输出: 直接一步得到【Beancount 交易草稿 TransactionDraft】(含时间、金额、商户、资金账户、支出账户)
|
||||
========================================================================================
|
||||
|
||||
========================================================================================
|
||||
【多通道多源输入】
|
||||
(通道A: 拍照OCR文本 / 通道B: 无障碍UI节点文本 / 通道C: 短信通知文本)
|
||||
│
|
||||
▼
|
||||
【解耦架构 (Decoupled Mode / 责任链流水线)】
|
||||
阶段 1(事实识别): 仅识别事实,输出无账户关联的【ImportedEvent】(时间、金额、商户、备注)
|
||||
│
|
||||
▼
|
||||
阶段 2(账户分类): 送入 BillPipeline,由规则引擎 RuleEngine 或轻量 AI 匹配 Beancount 账户
|
||||
========================================================================================
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、 传统规则渠道(Rule-based Channel)对比
|
||||
|
||||
在规则匹配渠道下,账户名均来自用户动态配置的规则库 (`Rule[]`),绝非代码硬编码:
|
||||
|
||||
### 1. 动态规则合并模式(Single-Pass Rule Evaluation / 一体化动态匹配)
|
||||
- **处理逻辑**:
|
||||
拿到多通道的原始内容后,解析器直接调用用户配置的动态规则库 `Rule[]`。一条规则同时包含了**事实判定条件**与**双向账户分配**:
|
||||
```typescript
|
||||
// 用户在 APP 中配置的动态规则对象(非代码硬编码)
|
||||
const rule: Rule = {
|
||||
id: "rule-101",
|
||||
counterpartyContains: "星巴克",
|
||||
sourceAccount: "Assets:Alipay:Balance", // 动态资金来源账户
|
||||
categoryAccount: "Expenses:Food:Coffee", // 动态分类支出账户
|
||||
narration: "星巴克咖啡"
|
||||
};
|
||||
|
||||
// 单次求值:一步构造出带有账户的完整 TransactionDraft 草稿
|
||||
if (matches(rawText, rule)) {
|
||||
return createDraftDirectly(rawText, rule.sourceAccount, rule.categoryAccount);
|
||||
}
|
||||
```
|
||||
- **特点**:
|
||||
单次求值即产生最终 `TransactionDraft`。如果入口预填了 `sourceAccount` / `categoryAccount`(参考 DriftLedger 代码 [billPipeline.ts:L169-L180](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/pipeline/billPipeline.ts#L169-L180)),流水线直接接受该草稿。
|
||||
|
||||
### 2. 规则解耦模式(Two-Stage Evaluation / 责任链流水线匹配)
|
||||
- **处理逻辑**:
|
||||
1. **阶段 1 (事实化)**:所有通道(通知、无障碍、短信、账单 CSV)统一输出仅包含事实的 `ImportedEvent`(无账户关联)。
|
||||
2. **阶段 2 (责任链评估)**:`BillPipeline` 依次执行:转账识别 ➔ 批次去重 ➔ 历史去重 ➔ 送入 `RuleEngine` 匹配规则库中的账户。
|
||||
- **特点**:
|
||||
解析器只需关心文本事实提取,账户分配归口给 `BillPipeline` 与 `RuleEngine` 统一调度。在分配账户前可先过滤重复事件,避免无效计算。
|
||||
|
||||
---
|
||||
|
||||
## 三、 大模型/AI 渠道(AI/LLM Channel)对比
|
||||
|
||||
利用大语言模型(LLM)或多模态视觉模型(VLM)处理多通道获取的原始内容:
|
||||
|
||||
### 1. AI 文本级合并模式(单次 LLM 提示词一步直出草稿)
|
||||
- **处理逻辑**:
|
||||
多通道(拍照 OCR / 无障碍节点文本 / 短信通知)提取到**账单原始内容字符串**后,**在单次 LLM 请求中**将“账单原始内容”与“用户本地 Beancount 候选账户列表”一同作为 Prompt 提交给大模型(如 `glm-4-flash-250414` 或 `Qwen2.5-7B-Instruct`)。
|
||||
- **流程**:
|
||||
```text
|
||||
[多通道原始内容文本] + [用户动态账户列表] ───(单次 LLM 求值)───> [Beancount TransactionDraft]
|
||||
```
|
||||
- **优势**:
|
||||
- **通道统一**:不管是图片 OCR、微信支付成功的节点文本、还是银行扣款短信,都使用同一种“文本级合并 Prompt”,直接返回填充好 `sourceAccount` 和 `categoryAccount` 的 JSON 草稿。
|
||||
- **响应极快**:纯文本大模型(如 `glm-4-flash-250414`)处理文本合并请求耗时仅 **~2.2 秒**。
|
||||
- **劣势**:
|
||||
- **Token 开销**:每次请求都需携带用户账户列表。
|
||||
- **确定性风险**:可能偶发生成不存在的账户名。
|
||||
|
||||
### 2. AI 解耦模式(内容识别提取事实 ➔ 独立分类器)
|
||||
- **处理逻辑**:
|
||||
1. **阶段 1(事实识别)**:模型仅负责解析多通道原始内容,输出不含账户的 `ImportedEvent`(时间、金额、商户、备注)。
|
||||
2. **阶段 2(账户分类)**:优先走本地 `RuleEngine`;若未命中,再调用轻量 LLM(耗时 ~2.2 秒)或向量 Embedding 模型(如 `bge-m3`,耗时 **0.3 秒**)在单独的 Prompt / 向量空间中挑选账户。
|
||||
- **优势**:
|
||||
- **绝对确定性**:已知商户 100% 走本地规则引擎(0 延迟、0 错误率)。
|
||||
- **离线优先 (Offline-first)**:本地识别出事实后,断网状态下本地规则引擎依然能完成账户分类。
|
||||
|
||||
---
|
||||
|
||||
## 四、 多维性能与工程指标全景对比表
|
||||
|
||||
| 评估维度 | **规则合并模式 (单次一体匹配)** | **规则解耦模式 (责任链流水线)** | **AI 文本级合并模式 (单次LLM求值)** | **AI 解耦模式 (双阶段链式)** |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **原始内容来源** | 多通道 (OCR/无障碍/短信) | 多通道 (OCR/无障碍/短信) | 多通道 (OCR/无障碍/短信) | 多通道 (OCR/无障碍/短信) |
|
||||
| **识别与分类点** | 文本解析时同步求值 | 分两阶段:解析事实 ➔ 匹配账户 | **单次 LLM 同时提取事实+账户** | 分两阶段:提取事实 ➔ LLM/向量选账户 |
|
||||
| **响应延迟 (Latency)** | **< 1ms** | **< 1ms** | **~2.2s** (`glm-4-flash-250414`) | **~4.3s** (OCR + 分类) |
|
||||
| **API 调用次数** | 0 次 | 0 次 | **1 次** | 1 ~ 2 次 |
|
||||
| **断网/离线鲁棒性** | 完全支持 | 完全支持 | 不支持 (需在线 LLM) | **半支持** (文本提取后离线规则分类) |
|
||||
| **准确率与确定性** | **100%** | **100%** | 高 (约 95%,受 Prompt 引导) | **100%** (已知规则优先覆写) |
|
||||
| **代码可维护性** | 良好 | **极佳** (领域分层极清) | 优秀 (通道无缝复用 Prompt) | **极佳** (管道可随意组装) |
|
||||
|
||||
---
|
||||
|
||||
## 五、 DriftLedger (浮记) 混合架构落地推荐
|
||||
|
||||
结合多通道采集(OCR / 无障碍 / 短信通知)与 DriftLedger 的代码实现 [billPipeline.ts](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/pipeline/billPipeline.ts#L169-L180),推荐采取 **“多通道输入 ➔ AI 文本级合并直出 ➔ 本地流水线预填覆写”** 的最佳落地架构:
|
||||
|
||||
```text
|
||||
【通道 A: 拍照 OCR 文本】 【通道 B: 无障碍 UI 文本】 【通道 C: 短信通知文本】
|
||||
│ │ │
|
||||
└────────────────────────────┼────────────────────────────┘
|
||||
▼
|
||||
【AI 文本级合并求值 (glm-4-flash-250414)】
|
||||
单次 LLM 传入多通道文本 + 用户 Beancount 动态账户列表
|
||||
2.2 秒内一步生成带预填账户的 `ImportedEvent` (含 sourceAccount/categoryAccount)
|
||||
│
|
||||
▼
|
||||
【领域核心层 / BillPipeline 责任链】
|
||||
┌────────────────────────────────┴────────────────────────────────┐
|
||||
▼ ▼
|
||||
【1: 本地规则覆写 (RuleEngine)】 【2: 多通道去重 (Dedup)】
|
||||
若匹配到用户定义的 100% 精确 Rule 规则, 自动过滤历史账本已存在的重复交易,
|
||||
本地规则强行覆写 AI 预测的账户,保障零差错。 避免多次拍照或无障碍重复记账。
|
||||
```
|
||||
|
||||
### 总结:
|
||||
1. **多通道采集(OCR / 无障碍 / 短信)** 是解耦的前置适配层。
|
||||
2. **AI 合并模式** 指的是在获取到账单文本后,**用单次 LLM 请求同时完成事实提取与账户选择**(2.2 秒极速返回)。
|
||||
3. **架构落地**:多通道文本输入 ➔ AI 文本级合并一步预填 ➔ 本地流水线校验覆写。
|
||||
@@ -0,0 +1,195 @@
|
||||
# 硅基流动与智谱 AI 账单 OCR、JSON 提取及 AI 账户自动分类实测报告
|
||||
|
||||
本报告针对测试图片(`20260725-142945.jpg` 信用卡账单截图),对 **硅基流动 (SiliconFlow)** 与 **智谱 AI (BigModel.cn)** 平台的视觉大模型、OCR 引擎、免费文本大模型及向量 Embedding 模型进行了全流程实测基准对比。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
1. [测试结论与终极选型建议](#一-测试结论与终极选型建议)
|
||||
2. [实测性能对比总表](#二-实测性能对比总表)
|
||||
3. [硅基流动 (SiliconFlow) 平台测试详解](#三-硅基流动-siliconflow-平台测试详解)
|
||||
4. [智谱 AI (BigModel.cn) 平台测试详解](#四-智谱-ai-bigmodelcn-平台测试详解)
|
||||
5. [免费模型配额与 Rate Limits 规则](#五-免费模型配额与-rate-limits-规则)
|
||||
6. [DriftLedger (浮记) 架构与账户分配流程](#六-driftledger-浮记-架构与账户分配流程)
|
||||
7. [AI 账户自动分类实测 (免费 LLM vs 路径 B 向量检索)](#七-ai-账户自动分类实测-免费-llm-vs-路径-b-向量检索)
|
||||
|
||||
---
|
||||
|
||||
## 一、 测试结论与终极选型建议
|
||||
|
||||
> [!TIP]
|
||||
> **最佳单 API 识图直出方案**:智谱 **`glm-4v-flash`**
|
||||
> - **耗时仅 3.22 秒**,单次 API 请求即可直接输出包含商户、金额、日期、卡号、原币的**完美 JSON**,且 100% 免费。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **最佳双阶段识图流水线方案**:硅基流动 **`DeepSeek-OCR` + `THUDM/GLM-4-9B-0414`**
|
||||
> - **总耗时仅 4.36 秒**(阶段一 OCR 1.17s + 阶段二 文本提 JSON 3.19s),全免费,OCR 文字识别准确率 100%。
|
||||
|
||||
> [!NOTE]
|
||||
> **最佳 AI 账户自动分类方案**:智谱 **`glm-4-flash-250414`** (免费 LLM) / 硅基 **`BAAI/bge-m3`** (向量路径 B)
|
||||
> - **免费 LLM 直选 (`glm-4-flash-250414`)**:耗时仅 **2.22 秒**,100% 精确推导出 `Liabilities:CreditCard:CMB:3315` 与 `Expenses:Software:Subscription`。
|
||||
> - **向量路径 B (`bge-m3`)**:耗时仅 **0.32 秒 (320 毫秒)**,亚秒级定位匹配目标卡片。
|
||||
|
||||
---
|
||||
|
||||
## 二、 实测性能对比总表
|
||||
|
||||
测试图片:`20260725-142945.jpg`(招商银行信用卡消费通知,含商户 `GOOGLE *ChatGPT`,金额 `¥143.97`,信用卡 `3315`,原币 `21.19`,时间 `2026-07-23 00:00:00`)。
|
||||
|
||||
| 阶段 / 任务 | 平台 | 模型 / 组合名称 | 架构类型 | 阶段耗时 | **总耗时** | 提取准确度与 JSON 质量 | 资费类型 |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **识图 JSON 提取** | **智谱 AI** | **`glm-4v-flash`** | 单阶段 VLM | - | **3.22s** | **100% 完美** (提取极精准,结构清晰) | **100% 免费** |
|
||||
| **识图 JSON 提取** | **硅基流动** | **`DeepSeek-OCR` + `GLM-4-9B-0414`** | 双阶段流水线 | 1.17s + 3.19s | **4.36s** | **100% 准确** (结构化规范,无幻觉) | **100% 免费** |
|
||||
| **识图 JSON 提取** | **硅基流动** | **`DeepSeek-OCR` + `DeepSeek-V3`** | 双阶段流水线 | 1.17s + 4.22s | **5.39s** | **智能推导** (自动补全原币单位 `USD`) | 低成本 (~0.0008分/次) |
|
||||
| **识图 JSON 提取** | **智谱 AI** | **`glm-4.1v-thinking-flash`** | 思维链 VLM | - | **7.03s** | 带有 `<think>` 推理链,易超长截断 | **100% 免费** |
|
||||
| **账户智能分类** | **智谱 AI** | **`glm-4-flash-250414`** | 免费 LLM 分类 | - | **2.22s** | **100% 精确** (同时给出资金与支出账户及理由) | **100% 免费** |
|
||||
| **账户智能分类** | **硅基流动** | **`THUDM/GLM-4-9B-0414`** | 免费 LLM 分类 | - | **3.45s** | **100% 精确** | **100% 免费** |
|
||||
| **账户智能分类** | **硅基流动** | **`BAAI/bge-m3`** | 向量路径 B 检索 | - | **0.32s** | **亚秒级最快** (Top 1 相似度 0.5662 精准命中) | **100% 免费** |
|
||||
|
||||
---
|
||||
|
||||
## 三、 硅基流动 (SiliconFlow) 平台测试详解
|
||||
|
||||
### 1. `deepseek-ai/DeepSeek-OCR`
|
||||
- **定位**:端到端纯文档/图片至 Markdown 识别模型。
|
||||
- **优点**:速度极快(**1.17s ~ 1.48s**),完美还原表格与富文本结构。
|
||||
- **限制**:不支持通用大语言模型的指令遵循(无法直接提示词输出 JSON,强制开启 `json_object` 会陷入空格生成死循环)。
|
||||
|
||||
### 2. `PaddlePaddle/PaddleOCR-VL-1.5`
|
||||
- **定位**:带坐标识别的文档/版面分析大模型。
|
||||
- **缺点**:缺少张量并行加速,单次生成生成耗时高达 60~110 秒,输出结果混杂大量的点位 Token。
|
||||
|
||||
### 3. 双阶段流水线提取结果(实际返回 JSON)
|
||||
```json
|
||||
{
|
||||
"occurredAt": "2026-07-23 00:00:00",
|
||||
"amount": 143.97,
|
||||
"currency": "CNY",
|
||||
"direction": "expense",
|
||||
"counterparty": "GOOGLE *ChatGPT",
|
||||
"memo": "信用卡尾号: 3315, 原币金额: 21.19 USD"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、 智谱 AI (BigModel.cn) 平台测试详解
|
||||
|
||||
### 1. 智谱免费 Flash 模型分类与特性
|
||||
|
||||
- **`glm-4v-flash`**:智谱基础免费视觉模型,**实测表现最稳定、速度最快 (3.22s)**,原生支持 `json_object`。
|
||||
- **`glm-4.6v-flash`**:最新轻量多模态模型,支持原生工具调用(Tool Calling),但免费接口频控较严(并发时易触发 HTTP 429)。
|
||||
- **`glm-4.1v-thinking-flash`**:具备 Thinking 思维链机制,回答前会在 `<think>` 中展开多步骤思考逻辑。
|
||||
- **`glm-4-flash-250414`**:纯文本/代码轻量旗舰模型,适合放在双阶段流水线的 Stage 2 以及账户分类。
|
||||
- **`CogView-3-Flash` / `CogVideoX-Flash`**:分别用于文生图与视频生成。
|
||||
|
||||
### 2. `glm-4v-flash` 实测输出数据
|
||||
```json
|
||||
{
|
||||
"occurredAt": "2026-07-23 00:00:00",
|
||||
"amount": 143.97,
|
||||
"currency": "CNY",
|
||||
"direction": "expense",
|
||||
"counterparty": "GOOGLE *ChatGPT",
|
||||
"memo": {
|
||||
"card_last_4_digits": "3315",
|
||||
"original_amount": 21.19,
|
||||
"country_or_region": "美国"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、 免费模型配额与 Rate Limits 规则
|
||||
|
||||
### 1. 硅基流动 (SiliconFlow)
|
||||
- **门槛**:需完成账户实名认证。
|
||||
- **配额**:
|
||||
- **RPM (Requests Per Minute)**:100 ~ 1,000 RPM(中小模型 500~1000 RPM)。
|
||||
- **TPM (Tokens Per Minute)**:50,000 ~ 100,000 TPM。
|
||||
- **Pro/ 专线**:带 `Pro/` 前缀的模型(如 `Pro/deepseek-ai/DeepSeek-V3`)为付费独占集群,无免费版的固定并发上限。
|
||||
|
||||
### 2. 智谱开放平台 (BigModel.cn)
|
||||
- **免费规则**:所有的 Flash 命名系列(`GLM-4-Flash`、`GLM-4V-Flash` 等)API 均免费开放。
|
||||
- **并发控制**:对高频连续调用设置了 RPM 阈值(触发时返回 `HTTP 429 Too Many Requests`),代码中需配置指数退避或 1~2 秒重试间隔。
|
||||
|
||||
---
|
||||
|
||||
## 六、 DriftLedger (浮记) 架构与账户分配流程
|
||||
|
||||
针对识别结果中“为什么只包含时间、金额、商户名,而没有 Beancount 账户”的说明:
|
||||
|
||||
### 1. 职责解耦设计
|
||||
识图/OCR 模块只负责提取客观的**原始交易事件 (`ImportedEvent`)**,定义于 [types.ts](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/core/types.ts#L31-L38)。由于每个用户的 Beancount 账户名(如 `Assets:招商银行:信用卡3315`)是高度个性化的,模型无法预知用户本地账本结构。
|
||||
|
||||
### 2. 账本账户决定流程
|
||||
账户映射是在 **`BillPipeline` 责任链** 中完成的:
|
||||
|
||||
```text
|
||||
[账单截图]
|
||||
│
|
||||
▼ 1. 图像解析 (AiVisionProcessor.ts)
|
||||
[ImportedEvent] ─── (仅含时间、金额、商户 GOOGLE *ChatGPT、备注 3315)
|
||||
│
|
||||
▼ 2. 进入流水线 BillPipeline.process() ─── [billPipeline.ts]
|
||||
├──> ① 转账识别 (recognizeTransfers)
|
||||
├──> ② 批次去重与历史去重 (dedup)
|
||||
└──> ③ 规则匹配与账户分类 (rules.ts)
|
||||
├── 匹配商户/卡号 "3315" ──> 资金来源账户 (sourceAccount): Assets:招商银行:信用卡3315
|
||||
└── 匹配商户 "GOOGLE *ChatGPT" ──> 支出分类账户 (categoryAccount): Expenses:订阅服务:AI
|
||||
│
|
||||
▼ 3. 输出标准交易草稿 (TransactionDraft)
|
||||
[TransactionDraft] ─── (产生符合 Beancount 规范的两笔双式记账 Postings)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、 AI 账户自动分类实测 (免费 LLM vs 路径 B 向量检索)
|
||||
|
||||
实测验证:能否利用 AI 模型根据用户本地的 Beancount 候选账户列表自动完成账户匹配?
|
||||
|
||||
### 1. 实测结果展示
|
||||
|
||||
#### 路径 A:免费 LLM 直选(智谱 `glm-4-flash-250414`,耗时 2.22 秒)
|
||||
传入 9 个 Beancount 候选账户 + 交易事实,模型 100% 精确返回:
|
||||
```json
|
||||
{
|
||||
"sourceAccount": "Liabilities:CreditCard:CMB:3315",
|
||||
"categoryAccount": "Expenses:Software:Subscription",
|
||||
"confidence": "high",
|
||||
"reason": "交易备注说明信用卡尾号3315,且根据金额和商户判断为软件订阅支出"
|
||||
}
|
||||
```
|
||||
|
||||
#### 路径 B:向量 Embedding 余弦相似度检索(硅基流动 `BAAI/bge-m3`,耗时 0.32 秒)
|
||||
将交易文本与账户生成 1024 维向量进行余弦相似度计算,点积耗时 `< 1ms`:
|
||||
- **Top 1 命中**:`Liabilities:CreditCard:CMB:3315`(相似度 **0.5662**)
|
||||
- **Top 2 命中**:`Expenses:Shopping:Digital`(相似度 0.5302)
|
||||
|
||||
### 2. 路径 B 的完整实现逻辑拆解
|
||||
|
||||
```text
|
||||
[导入交易 ImportedEvent]
|
||||
商户: GOOGLE *ChatGPT | 备注: 信用卡尾号3315
|
||||
│
|
||||
├───> 资金搜索文本 ──> BAAI/bge-m3 ──> 向量对比 ──> 提取最高分: Liabilities:CreditCard:CMB:3315
|
||||
│
|
||||
└───> 分类搜索文本 ──> BAAI/bge-m3 ──> 向量对比 ──> 提取最高分: Expenses:Software:Subscription
|
||||
```
|
||||
|
||||
1. **账户隔离与语义增强 (离线缓存)**:
|
||||
- 将账户拆分为【资金空间 Assets/Liabilities】与【分类空间 Expenses/Income】。
|
||||
- 为账号补充别名描述(如 `Liabilities:CreditCard:CMB:3315` ➔ `"招商银行 信用卡 尾号3315 掌上生活"`)。
|
||||
2. **构建向量索引**:使用 `BAAI/bge-m3` 将所有账户转为向量缓存在本地内存/SQLite 中。
|
||||
3. **实时查询向量化**:收到交易时,分别生成资金查询与分类查询,耗时约 150 毫秒。
|
||||
4. **双路余弦相似度检索**:通过点积公式计算相似度,并提取最高分。
|
||||
5. **阈值判定与推荐**:高分自动填充,低分弹出 Top 3 快捷芯片让用户点选。
|
||||
|
||||
---
|
||||
|
||||
### 三、 相关代码位置
|
||||
- **原始事件类型定义**:[src/domain/core/types.ts:L31-L38](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/core/types.ts#L31-L38)
|
||||
- **账单流水线责任链**:[src/domain/pipeline/billPipeline.ts:L94-L180](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/pipeline/billPipeline.ts#L94-L180)
|
||||
- **规则分类与账户映射引擎**:[src/domain/rules/rules.ts:L1-L60](file:///C:/Users/fmq/Documents/work/DriftLedger/src/domain/rules/rules.ts#L1-L60)
|
||||
- **AI 识图处理器服务**:[src/services/ocr/AiVisionProcessor.ts](file:///C:/Users/fmq/Documents/work/DriftLedger/src/services/ocr/AiVisionProcessor.ts)
|
||||
Reference in New Issue
Block a user