记账 App 我装了删、删了装,最后自己写了一个:本地语音识别 + R8 踩坑复盘

记账 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 混淆的坑,欢迎在评论区聊聊。

相关推荐
程序员鱼皮1 小时前
Personal Agent 是什么?为什么大厂都抢着做?连 DeepSeek Bot 都来了。。
前端·后端·ai编程
追光者491 小时前
同一台机器,从"一整天零产出"到"20 分钟发三篇":我给流水线装了个"等窗口"的闸门
ai编程
xn71331 小时前
Gemini 4 Argon API 迁移清单:百万上下文、Thinking 预算与 Agent 验收
aigc·ai编程
每天都是不一样的太阳1 小时前
AI成长系列-09-提示词资产库
ai编程
机器人梦想家1 小时前
【AI工具】Spec Kit 升级教程:specify CLI 从 1.1.2 升到 1.2.0 全流程(含 GitHub 下载慢的镜像解法)
ai编程
hunterandroid2 小时前
Android 运行时权限实战:从声明权限到拒绝后的可用降级
android
a5986522 小时前
docker-android 6080 端口访问失败?
android·docker·容器·云手机
李航19833 小时前
SketchUp国产平替来了,功能非常接近,还有AI版本自动建模
人工智能·python·计算机视觉·ai·ai编程
天意__3 小时前
Android手机上部署Linux服务器 在Termux里安装Ubuntu
android·linux·服务器