记账 App 我装了删、删了装,最后自己写了一个:本地语音识别 + R8 踩坑复盘
一个 Android 独立开发的技术复盘:本地语音识别、AI 语义解析,以及 R8 给我上的一课。
一、缘起
我用过的记账 App 大概有七八个。它们的共同问题是:记账这件事本身太麻烦了。
一笔消费要点四下------点加号、选分类、输金额、挑日期,然后才能保存。第一次用还能忍受, 但记账是每天要做三次的事。麻烦累积到一定程度,人就会放弃。我自己的记录从来撑不过两周。
所以我想要的其实很简单:动嘴就行。
"中午吃饭三十五" → 识别 → 解析出「餐饮 / ¥35 / 今天」→ 点一下确认 → 进账本。
不需要选分类(AI 分),不需要输金额(语音里有),不需要挑日期(没说的话就是今天)。
这个想法在技术上恰好卡在一个有意思的位置:语音识别可以完全跑在本地,而语义理解必须联网。 怎么把这两半接起来、怎么让"本地"这件事真的成立,是这篇文章主要想讲的东西。
二、它现在长什么样
- 首页:当月余额、支出、收入一览 + 按日期分组的流水
- 底部居中的麦克风圆钮:按住说话 → 松手自动识别 → 自动发起 AI 解析 → 弹出待确认卡片
- 统计页:当月概览 + 分类排行(带百分比)+ 近 12 个月趋势柱状图
- 设置页:DeepSeek API 配置、数据导出/导入(JSON)、通知保活
技术栈:
| 层 | 选型 |
|---|---|
| 语言 / UI | 全 Kotlin,Jetpack Compose + Material 3 |
| 架构 | MVVM + Clean Architecture(data / domain / ui 三层) |
| 本地存储 | Room 2.7.0 |
| 依赖注入 | Hilt 2.51.1 |
| 导航 | Navigation Compose 2.8.5 |
| 网络 | Retrofit + OkHttp + Moshi |
| 图表 | Vico 2.0.0-beta.2 |
| 语音识别 | ONNX Runtime Android 1.27.0 + JTransforms 3.2 |
| 语义解析 | DeepSeek Chat API |
| 构建 | AGP 8.7.3 / Kotlin 2.0.21 / KSP 2.0.21-1.0.28 / Gradle 8.11.1 / JDK 17 |
| 版本 | compileSdk 36、targetSdk 36、minSdk 26 |
三、为什么语音识别必须放在本地
把语音丢给云端 ASR 是最省事的选择,但我没这么做,原因有三个:
1. 隐私上说不通。 记账内容天然敏感。用户对着手机说"工资到账一万二",这句话如果上传到 某台服务器上,那这个 App 的性质就变了。我想要的是:录音不出手机,只把识别出来的文字发出去。
2. 延迟。 本地识别松手即出字,云端要等一个来回。而"松手就出结果"是这个产品最爽的一瞬间, 值得为它付出代价。
3. 成本。 每一笔记账都调一次云端 ASR,用户越多账单越大。而本地推理的边际成本是零。
代价也很直白:模型体积。 我用的是 FunASR 的 Paraformer 模型导出成 ONNX,paraformer_nano.onnx 有 227 MiB 。它直接打进 APK 的 assets,导致 release 包体积达到 343 MiB。
这是这个项目目前最大的工程缺陷,我在最后一节会老实交代。
推理侧用的是 ONNX Runtime Android,通过一个 @Singleton 的 FunAsrModelManager 在内存中持有模型, App 启动时就在后台线程预热(ensureModel() → 加载 token list → 加载 CMVN → 建 session)。 这样做是因为实测发现:如果等到第一次按住说话才加载,那一次会明显卡顿甚至直接失败------ 而开场那一下的爽感全押在这个瞬间。
四、踩坑一:R8 把 ONNX Runtime 的类改名了,JNI 直接崩
这是整个项目最耗时、也最值得写下来的一个坑。
症状
Debug 版一切正常,Release 版一点语音识别就闪退。
而且是最难查的那种闪退:logcat 里没有任何 Java 异常,没有 stack trace,App 就是突然消失。 你只能看到进程没了。
定位
Android 会为每次原生崩溃留下 tombstone。翻 /data/tombstones/ 或者 adb shell dumpsys dropbox --print, 能看到真相:
arduino
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0000000000000018
Cause: null pointer dereference
backtrace:
#00 libart.so art::JavaVMExt::JniAbort(char const*, char const*)+820
#01 libart.so art::JNI<false>::GetMethodID(_JNIEnv*, _jclass*, char const*, char const*)+740
#02 base.apk convertToTensorInfo+628
#03 base.apk convertOrtValueToONNXValue+436
#04 base.apk Java_ai_onnxruntime_OrtSession_run+836
#13 base.vdex FunAsrVoiceService.a+412
堆栈把问题指得很清楚:崩在 OrtSession.run() 里,具体是 convertToTensorInfo 调用 GetMethodID 时触发了 JniAbort。
原因
ONNX Runtime 的 Java 层不通过 JNI 注册表 (RegisterNatives)绑定 native 方法, 而是让 native 层用 FindClass / GetMethodID 按字符串类名和字段名回查 Java 侧的类。
而 R8 默认会把它们当普通类改名。我从混淆映射里导出来看过,实际被改成了这样:
rust
ai.onnxruntime.OnnxJavaType -> b.a
ai.onnxruntime.OrtEnvironment$OrtEnvCloser -> b.b
ai.onnxruntime.OrtException -> b.d
ai.onnxruntime.OrtException$OrtErrorCode -> b.c
ai.onnxruntime.OrtLoggingLevel -> b.e
ai.onnxruntime.OrtProvider -> b.f
ai.onnxruntime.OrtSession$Result -> b.g
ai.onnxruntime.OrtUtil -> b.h
ai.onnxruntime.TensorInfo$OnnxTensorType -> b.i
ai.onnxruntime.platform.Fp16Conversions -> c.a
native 层拿着字符串 "ai/onnxruntime/TensorInfo" 去找类,当然找不到;GetMethodID 返回 null 并挂起一个待处理异常,紧接着 JNI 检测到异常状态未清理,直接 abort 掉整个进程。
这就是为什么看不到 Java 异常------崩溃发生在 native 层,Java 的异常机制根本来不及介入。
解法
kotlin
-keep class ai.onnxruntime.** { *; }
-keep interface ai.onnxruntime.** { *; }
-dontwarn ai.onnxruntime.**
一行 keep 就够了。但代价是排查过程:因为没有任何 Java 侧线索,很容易误判成模型文件损坏、 线程问题、或者 native 库 ABI 不匹配,在错误方向上耗掉大把时间。
这类坑的通用特征 (值得记住):Debug 正常 + Release 崩 + logcat 无异常 = 优先怀疑 R8 混淆了 被反射或 JNI 按名字查找的类。
同一类坑还有一个:Moshi 反射序列化
同一个项目里还有一个同源问题。我用的是 Moshi 的 KotlinJsonAdapterFactory(反射式), 不是 codegen,所以 JSON 的 key 直接取构造参数名。R8 把参数名混淆成 a/b/c 之后:
ExpenseParseResult字段全对不上 → release 版 AI 解析必然失败ExportData字段被改名 → release 导出的备份和 debug 版互不兼容
这两个问题在 debug 版完全看不出来。所以:
kotlin
-keep class com.example.expensetracker.domain.model.ExpenseParseResult { *; }
-keep class com.example.expensetracker.data.repository.ExportData { *; }
一条经验:任何"按名字查找"的东西------反射、JNI、序列化、依赖注入的字符串 key------ 都是 R8 的天然敌人。项目里每引入一个这样的库,就该同步问一句:"它的 keep 规则写了吗?"
五、踩坑二:录屏和语音识别抢同一个麦克风
这个坑不是代码问题,是 Android 的硬件互斥,而且非常反直觉。
我做演示视频的时候,用手机自带的录屏录下整个"按住说话"的过程。视频画面完全正常: 麦克风按钮高亮、浮层显示"正在聆听"、实时转写文字一个字一个字蹦出来、松手后解析卡片弹出。 App 的语音识别是工作的。
但录完一放,没有人声。
我拿素材做了逐 2 秒的音量扫描,结果一目了然:
arduino
8s max -26.6 dB
10s max -91.0 dB ← 数字静音,完全无信号
12s max -91.0 dB ← 数字静音
14s max -91.0 dB ← 数字静音
16s max -35.6 dB
10--14 秒正好是 App 占用麦克风录音的那 7 秒。在这一段时间里,系统录屏收到的音轨是纯数字静音 (-91 dB 意味着采样点全是 0,连底噪都没有)。
也就是说:当 App 持有麦克风时,录屏拿不到任何音频。 这不是谁配置错了,是两个 AudioRecord 会话对同一路输入源的竞争,系统把优先级给了前台正在录音的应用。
结论 :这种"语音识别 + 屏幕录制"的演示视频,音频必须后期配,重拍多少次都没用。 想清楚这一点能省掉一整个下午。
六、踩坑三:导入备份是"追加",不是"覆盖"
这个 bug 有点危险,因为它会静默地污染数据。
导出逻辑没问题:把 expenses 和 categories 序列化成 JSON。问题在导入:
kotlin
// ExportImportUseCase.kt
val newExpenses = data.expenses.map { it.copy(id = 0) }
id = 0 的用意是让 Room 的 autoGenerate 重新分配主键,避免和现有记录冲突。 但这样一来,导入就变成了"把所有记录重新插一遍",而不是"恢复备份"。
后果:我拿一个 302 条记录的备份去恢复,导完变成 604 条,支出金额直接翻倍:
支出 ¥6903.15 → ¥11863.53
收入 ¥24695.67 → ¥45195.67
而且界面不会报任何错,你会以为恢复成功了。
正确做法应该是先清空再插入,包在一个事务里:
kotlin
@Transaction
suspend fun restoreFrom(expenses: List<ExpenseEntity>, categories: List<CategoryEntity>) {
expenseDao.deleteAll()
categoryDao.deleteAll()
expenseDao.insertAll(expenses)
categoryDao.insertAll(categories)
}
教训 :涉及数据恢复的功能,"追加"和"覆盖"必须是一个显式的产品决策, 而不是实现细节的副作用。更稳妥的做法是在导入前弹窗问清楚,或者干脆提供"合并 / 覆盖"两个选项。
七、其他几个值得记的小问题
1. 消费日期的时间语义。 AI 解析出的日期只到"天",没有时刻。如果用户说的是今天, 直接取当天 00:00 会导致界面上显示"00:00",和"此刻刚记的账"严重不符。 最终规则是:AI 返回的就是今天 → 用当前时刻;返回的是别的日期 → 用那天 00:00(补记历史消费)。
2. 数据来源标记被覆盖。 解析结果本身标了 source = VOICE,但 ViewModel 里紧接着 .copy(source = ExpenseSource.MANUAL) 把它覆盖掉了。结果是:首页语音记账的账, 来源永远被记成"手动",那个语音标记永远不会出现。
3. 规划文档比代码跑得快。 PLAN.md 里写了"双击电源键唤起记账",但代码里 AccessibilityService、电源键监听一个都没有。README 里写的"分类占比饼图", 代码里 PieChart、Canvas、drawArc 零匹配。文档承诺了不存在的东西,比没有文档更糟。
八、已知不足(老实清单)
既然是复盘,把问题摊开说:
| 问题 | 现状 | 打算怎么改 |
|---|---|---|
| APK 343 MiB | 227 MiB 模型直接打进 assets | 改成首次启动下载模型,或做 ABI/模型分包 |
| 导入是追加 | 会静默污染数据 | 改成事务内先清空,或提供"合并/覆盖"选项 |
| 没有清空数据入口 | 换账本只能靠 adb shell pm clear |
设置页加"清空所有数据"(带二次确认) |
| Release 版会把 API Key 写进日志 | OkHttp 的 Level.BODY 没有 BuildConfig.DEBUG 判断,请求头和请求体(含 Authorization: Bearer sk-...)会全部打进 logcat |
加 debug 判断 |
| 零测试 | 没有一行单元测试 | 至少把日期解析、导入导出、统计口径补上 |
| 依赖有 alpha/beta | security-crypto 1.1.0-alpha06、vico 2.0.0-beta.2 |
跟进稳定版 |
| 仓库没有 gradlew | 构建依赖本机 Gradle | 补 wrapper |
| 文档与实现不符 | README / PLAN.md 有不存在功能 | 改成如实描述 |
其中我最想先解决的是 APK 体积。343 MiB 的分发成本太高了------用户下载意愿、 应用商店的体积限制、更新流量,每一条都是硬伤。
九、写在最后
做这个小项目最大的收获不是"学会了 Compose"或者"跑通了 ONNX",而是:
当你知道一个东西为什么难用之后,你才有可能把它做对。
我用了七八个记账 App,抱怨过它们的每一处麻烦,但直到自己动手才发现------有些麻烦是产品设计 可以避免的(比如选分类),有些是根本绕不开的技术约束(比如 227 MiB 的模型)。
代码全部开源,MIT 协议:
arduino
https://github.com/airliu23/ExpenseTracker
Release 页有打包好的 APK,Android 8.0 以上直接装。
如果这个思路对你有用,或者你在自己的项目里也踩过 R8 混淆的坑,欢迎在评论区聊聊。