项目地址:https://github.com/Sirens007/OneLedger-Android
当前版本:v0.1.0
技术栈:Kotlin、Jetpack Compose、Material 3、Room、Coroutines
记录时间:2026 年 7 月
最开始我只是想在 Android 上做一款类似 iCost 的记账软件。
真正开始写以后,我发现"能新增一笔支出"并不难,难的是让账单、账户余额、预算、日历和统计始终对得上;另一个难点是交互,特别是金额键盘、系统输入法和页面高度之间的关系,稍微处理不好就会跳动、闪烁,甚至同时出现两套键盘。
这个项目最后取名为 OneLedger。它目前仍是一个本地 MVP,不是已经完成的商业产品。这篇文章记录一下当前做到了什么、代码怎么组织,以及开发过程中几个比较折腾的问题。
先看现在的界面
OneLedger 的底部导航保留了四个入口:账本、资产、存钱、统计。整体使用深色背景、蓝绿主色和"票据轨道"式卡片,没有直接照搬 iCost 的页面布局。

首页主要回答两个问题:这个月还能花多少,以及最近的钱花到哪里了。账单按日期分组,预算卡可以继续进入总预算和分类预算页面。
快速记账页目前支持支出、收入和转账,也可以选择分类、账户、备注与发生时间。

收支日历会在有账单的日期下方显示红色支出和绿色收入;没有账单时显示中国节日或农历日期。月份可以通过按钮或左右滑动切换,过去和未来月份都能查看。

资产页的余额不是写死在页面上的,而是根据账户期初余额和收入、支出、转账记录聚合出来。

上面的图片来自 Compose 截图回归测试,里面使用的是固定测试数据,因此金额不代表应用首次安装后的默认数据。目前新账本的总预算默认是 ¥0.00。
项目现在用的技术
截至本文记录时,仓库中的主要版本如下:
| 项目 | 版本或方案 |
|---|---|
| Kotlin | 2.3.21 |
| Android Gradle Plugin | 9.3.0 |
| Jetpack Compose | BOM 2026.06.01 |
| Material | Material 3 |
| Room | 2.8.4,schema v3 |
| Coroutines | 1.10.2 |
| minSdk / targetSdk | 23 / 36 |
| Java | 17 |
工程仍然是单 app 模块。现在项目规模还没有大到必须拆成十几个 Gradle module,先按功能分包更容易维护:
text
com.oneledger.app
├── data/local Room Entity、DAO、Database、Migration
├── data/repository Repository 接口与本地实现
├── domain/model 跨层使用的业务模型
├── ui/components 快速记账、键盘、通用组件
├── ui/screens 账本、预算、日历、资产、存钱、统计
├── ui/theme 颜色、排版和主题
└── util 金额、日期等纯函数
数据流保持得比较简单:
text
Compose UI
↓ 用户操作
ViewModel / immutable UiState
↓
LedgerRepository
↓
Room DAO
↓
SQLite
UI 不直接调用 DAO。Repository 维护当前 activeBookId,账户、分类、账单、预算和存钱计划的查询都需要带上账本边界,避免以后增加多账本界面时出现串账。
本地优先不是一句口号
OneLedger 当前没有登录、云同步和网络请求,Manifest 中也没有申请 INTERNET 权限。应用的 allowBackup 目前设为 false,核心数据只保存在本机 Room 数据库。
数据库现在有六张核心表:
ledger_books:账本;
accounts:现金、储蓄卡、信用账户;
categories:收入和支出分类;
transactions:支出、收入和转账;
budgets:总预算和分类预算;
savings_plans:存钱计划。
我把金额统一保存为"分",类型使用 Long:
kotlin
data class TransactionEntity(
val amountMinor: Long,
// ...
)
例如 12.34 元 在数据库中保存为 1234。金融金额如果直接用 Float 或 Double,迟早会遇到精度问题;展示时再把分值格式化成人民币字符串会稳妥很多。
账单和账户、分类之间也设置了外键。删除账单没有直接物理删除,而是写入 deletedAt 做软删除,这样才能实现 Snackbar 撤销。
最费时间的三个地方
1. 金额输入不是普通计算器
第一版数字键盘使用了常见的 previousValue + pendingOperator + currentValue 结构。问题是输入:
text
12 + 1 -
程序会在点击减号时先算出 13,再进入下一次运算。记账场景下我更希望它像一个可编辑表达式:只有点击等号才计算。
后来我把金额状态改成 Token:
kotlin
sealed interface ExpressionToken {
data class NumberToken(val raw: String) : ExpressionToken
data class OperatorToken(val operator: AmountOperator) : ExpressionToken
}
现在可以先输入:
text
12 + 1 - 2
点击 = 后再得到 11,随后按钮才恢复为"完成"。连续点击运算符会替换末尾运算符,删除键也会按 Token 和字符逐级删除。真正计算时使用 BigDecimal,避免重新引入浮点误差。
这部分目前有 16 个 JVM 单元测试,覆盖小数、负数、连续运算符、删除、长表达式和跨越原九位数限制等情况。
2. 自定义数字键盘和系统输入法切换
金额使用自定义键盘,备注使用系统输入法。最初两套键盘分别控制显示和隐藏,结果出现过这些问题:
- 点击备注后,自定义键盘还留在底部;
- Activity 已经因为 IME 缩小,Compose 又额外添加
imePadding,键盘高度被计算两次; - 页面先经历一次"没有任何键盘"的布局,再弹出新键盘;
- 分类区域被压扁,中间出现很大的空白;
- 快速来回点击金额和备注时,页面上下抖动。
最后没有靠增加延迟遮住问题,而是把键盘状态收敛到一个互斥状态机:
kotlin
enum class KeyboardMode {
NONE,
CUSTOM_NUMBER,
SYSTEM_IME,
CUSTOM_TO_SYSTEM,
SYSTEM_TO_CUSTOM,
}
Activity 使用 adjustResize 处理系统 IME 高度,Compose 不再重复预留 IME 空间。自定义键盘保持固定完整高度,只做整体 translationY + alpha,不使用会逐行裁切按键的展开动画。
这部分最重要的经验是:输入焦点、键盘模式和底部占用高度必须由同一套状态驱动 。如果分别维护 showCustomKeyboard、isImeVisible 和 focusedField,很容易出现状态互相打架。
3. 日历要跟手,不能在每一帧做重活
收支日历一开始用自定义拖动和位移动画实现分页。慢慢拖还能用,快速连续滑动时就会有分段推进、标题与页面不同步的问题。
现在改成了 Compose 原生 HorizontalPager:
currentPage驱动月份标题;settledPage只在页面停稳后提交选中月份;- 相邻月份提前加载一页;
- 每个月固定 42 个日期格,也就是六行,避免月份高度变化;
- 日期格使用稳定 key;
- 月份数组、节日和农历结果按
YearMonth缓存; - 农历计算和交易汇总放到
Dispatchers.Default,不堵住拖动帧。
切换月份时会尽量保留原来的日号,例如从 1 月 31 日切到 2 月会选中 2 月最后一天,再切到 3 月仍可回到 31 日,而不是每次重置为 1 号。
数据库升级也需要认真对待
项目目前是 Room schema v3,并保留了显式迁移:
kotlin
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
v1 到 v2 增加了交易/预算关系约束和预算周期唯一索引。v2 到 v3 则处理了一个很小但实际会影响用户的细节:早期演示数据把月预算默认设成了 ¥5,000,后来恢复为 ¥0。
迁移没有简单地把所有 ¥5,000 预算清零,而是只更新"默认账本、旧演示 ID、总预算、金额为 500000 分、创建时间和更新时间相同"的记录。只要用户编辑过预算,updatedAt 就会变化,迁移会保留这条数据。
这种修改看上去很小,但如果直接写一句:
sql
UPDATE budgets SET limitMinor = 0 WHERE limitMinor = 500000;
就会把用户自己设置的 ¥5,000 预算一起改掉。
目前做了哪些测试
我不敢说现在测试已经很完整,但项目不是只靠手点:
- 38 个 JVM 单元测试,当前失败数为 0;
- 14 个 Compose 截图回归场景,覆盖四个主页面、预算、日历、快速记账、表达式、深浅主题和日期时间选择;
- Room Migration 和 DAO 使用 Android instrumentation test;
- Lint、Debug 构建和 Android test 源码编译作为提交前门禁。
JVM 测试现在主要覆盖:
- 金额格式化和表达式状态机;
- 自定义键盘与系统 IME 的互斥状态;
- 日期、跨月日历和农历标签;
- 净资产是否排除不计入净值的账户;
- 账单修改、转账和数据校验。
截图测试使用固定数据,所以界面变化会直接反映在基准图中。只有确认视觉修改正确后才更新基准,不会为了让测试通过直接覆盖图片。
如何运行
克隆仓库:
bash
git clone https://github.com/Sirens007/OneLedger-Android.git
cd OneLedger-Android
用 Android Studio 打开,等待 Gradle Sync 完成后运行 app。项目要求 JDK 17。
Windows 下可以执行:
powershell
.\gradlew.bat assembleDebug testDebugUnitTest lintDebug validateDebugScreenshotTest compileDebugAndroidTestKotlin
涉及 Room Migration 或 DAO 时,还需要连接允许安装测试 APK 的设备:
powershell
.\gradlew.bat connectedDebugAndroidTest
还没有做完的部分
这部分我想写得直接一点。当前 v0.1 已经形成记账、编辑、删除、预算、日历、资产和统计的基本闭环,但离可以长期替代成熟记账软件还有距离:
- 账户新增、编辑、归档和账户详情还没有完整做完;
- 分类管理 UI 还没有开放;
- 首页虽然有搜索入口,完整筛选查询还在后续计划中;
- 存钱页目前更接近可交互原型,还缺少每次存款的流水表;
- 多账本的数据边界已经预留,但创建和切换 UI 尚未完成;
- CSV/JSON 导入导出、加密备份、应用锁还没有实现;
- 统计还有一部分聚合需要继续下沉到 DAO;
- 真机性能、TalkBack、平板和横屏适配仍需系统测试;
- 当前没有云同步、OCR 和 AI 自动入账。
下一阶段会先把核心回归测试和性能门禁补齐,再按独立分支完成账户、分类、搜索、导入导出和存钱流水,不准备一次塞进一个大分支。
最后
OneLedger 最开始确实参考了 iCost 的功能思路,但做了一段时间后,我更在意的是数据边界、输入效率和 Android 自己的交互体验,而不是把另一个产品的页面复制出来。
这个项目现在大约有 29 个主源码 Kotlin 文件、6800 多行 Kotlin 代码,规模不算大,但已经碰到了金额精度、数据库迁移、输入法、分页性能、截图回归这些真实问题。对我来说,它比再写一个简单 CRUD Demo 更有学习价值。
仓库使用 Apache-2.0 License。如果你也在学习 Kotlin、Jetpack Compose 或 Room,欢迎查看代码、提交 Issue,或者一起讨论更合适的实现方式: