状态:需求已对齐,P0~P3 完成,P4 进行中(账户/分类/外观/清空已完成,应用锁与备份待做) 最后更新:2026-09-18
一款纯本地的个人记账 App。所有数据(含登录注册)保存在设备本地数据库中,不依赖服务端、不申请网络权限。设计目标是在保证数据准确性的前提下,把"记一笔"的路径压缩到最短。
| 项目 | 选型 |
|---|---|
| 语言 | Kotlin |
| UI | Jetpack Compose + Material 3 |
| 数据库 | Room(KSP 注解处理) |
| 导航 | Navigation Compose(type-safe route) |
| 依赖注入 | Hilt |
| 偏好存储 | DataStore Preferences |
| 图表 | Vico(原生 Compose 实现) |
| 定时任务 | WorkManager |
| 生物识别 | BiometricPrompt |
| 异步 | Coroutines + Flow |
| 构建 | AGP 9.4 / Kotlin 2.2 / compileSdk 37 / minSdk 28 |
架构采用 MVVM + Repository:Room 返回 Flow → Repository 做聚合与领域映射 → ViewModel 用 stateIn 转 StateFlow → Compose 直接 collectAsStateWithLifecycle。UI 层不手写刷新逻辑,一切由数据库变更驱动。
- 支持多账户与转账,转账是独立的交易类型,不是特殊的支出/收入。
- "账单"页 = 账本切换 + 当前账本月度汇总 + 预算进度,三者合一。
- 每月起始日可配置(默认 1 号,支持 1~28),影响所有月度统计、预算周期和图表。
- 不做多币种,全局单一货币,仅货币符号可配置。
- 图表使用 Vico。
- 假设 A:支持同一台设备上注册多个本地账号,账号之间数据完全隔离,但不做多人共享账本、不做账号间数据合并。
- 假设 B:应用不申请
INTERNET权限,纯离线;备份与迁移通过系统文件选择器(SAF)导出导入文件完成。 - 假设 C:金额使用
Long存储"分",展示层负责格式化为"元"。
以上三条如果与预期不符,需在开工前修正。
users — 本地账号
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Long PK | 自增 |
| username | String | 登录名,唯一索引 |
| nickname | String | 昵称 |
| avatarPath | String? | 头像文件路径 |
| passwordHash | String | PBKDF2 哈希结果,禁止明文 |
| passwordSalt | String | 每用户独立随机盐 |
| passwordIterations | Int | 迭代次数,便于后续升级算法 |
| createdAt / lastLoginAt | Long | 时间戳 |
books — 账本
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Long PK | |
| userId | Long FK | 数据隔离键 |
| name / icon | String | |
| sortOrder | Int | |
| isDefault | Boolean | 新建用户默认账本 |
| createdAt | Long |
accounts — 资产账户(归属用户,跨账本共享)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Long PK | |
| userId | Long FK | |
| name / icon / color | String | |
| type | Enum | 现金 / 储蓄卡 / 信用卡 / 支付宝 / 微信 / 投资 / 负债 / 其他 |
| initialBalance | Long | 初始余额(分),信用卡等可为负 |
| includeInTotal | Boolean | 是否计入总资产 |
| cardTailNumber / remark | String? | |
| sortOrder | Int | |
| isArchived | Boolean | 归档而非删除,保留历史流水 |
categories — 分类
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Long PK | |
| userId | Long FK | |
| parentId | Long? | null 为一级分类,非空为二级 |
| name / icon / color | String | |
| type | Enum | EXPENSE / INCOME(二级继承一级方向) |
| sortOrder | Int | 支持自定义排序 |
| isPreset | Boolean | 系统预置只能隐藏,不能删除 |
| isHidden | Boolean |
transactions — 流水(核心表)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Long PK | |
| userId / bookId | Long FK | |
| type | Enum | EXPENSE / INCOME / TRANSFER |
| amount | Long | 金额(分),恒为正数,方向由 type 决定 |
| categoryId | Long? | 转账时为 null |
| accountId | Long | 支出/收入的所属账户;转账的转出账户 |
| toAccountId | Long? | 仅转账:转入账户 |
| transferFee | Long? | 仅转账:手续费 |
| occurredAt | Long | 发生时间,统计与排序依据 |
| createdAt / updatedAt | Long | 创建与修改时间,补记账时二者不同 |
| remark | String? | 备注 |
| payee | String? | 商家/交易对象 |
| excludedFromStats | Boolean | 不计入收支统计(如待报销) |
| recurringRuleId | Long? | 由定期账单生成时记录来源 |
索引:(userId, bookId, occurredAt DESC)、(categoryId)、(accountId)。
tags / transaction_tags — 标签与多对多关联
budgets — 预算
| 字段 | 类型 | 说明 |
|---|---|---|
| id / userId | Long | |
| bookId | Long? | null 表示全账本通用 |
| categoryId | Long? | null 表示总预算 |
| period | Enum | MONTH / WEEK / YEAR |
| amount | Long | 预算额度(分) |
| isEnabled | Boolean |
recurring_rules — 定期账单(房租、订阅、工资)
字段含:名称、类型、金额、分类、账户、周期(日/周/月/年)、间隔、触发日、nextTriggerAt、autoCreate(自动记账 or 仅提醒)、启用状态。
templates — 记账模板(第二阶段),用于一键复用高频记账。
attachments — 流水附件(第三阶段),存本地文件路径。
主题模式、主题色、货币符号、每月起始日、当前登录用户 id、当前账本 id、锁屏与生物识别开关 → DataStore Preferences。
"每月起始日"统一由一个 PeriodCalculator 承担:输入时间戳 + periodStartDay,输出所属账期标识(如 2026-09-05 ~ 2026-10-04)。明细分组、图表聚合、预算周期必须全部走这个工具,禁止各页面自行计算,否则会出现统计口径不一致。
启动 → 检查登录态
├─ 未登录 → 登录 / 注册
└─ 已登录 → 主界面
├─ NavigationBar:明细 | 图表 | 账单 | 我的
└─ 悬浮 FAB(+)→ 快速记账
采用单 Activity + Navigation Compose 嵌套导航图,四个一级页面各自持有独立返回栈,切换 Tab 时保留各自的滚动位置与筛选状态。
- 顶部:当前月收支结余卡片,支持左右切换月份。
- 列表:按日分组,每组带日期头与当日小计;单条显示分类图标、分类名、备注、账户、金额(支出红/收入绿,颜色可在设置中关闭)。
- 筛选:时间范围、分类(多选)、账户(多选)、收支类型、金额区间、关键词(备注/商家)。
- 搜索入口独立于筛选,随时可唤起。
- 长按进入多选模式,支持批量删除、批量改分类、批量改账户、批量加标签。
- 左滑删除,删除后 Snackbar 提供撤销。
- 点击进入详情,详情页可编辑、复制为一笔新流水、删除。
- 时间维度切换:周 / 月 / 年 / 自定义区间,与"每月起始日"设置联动。
- 收支趋势:柱状或折线对比,可选"仅支出 / 仅收入 / 收支对比"。
- 分类占比:环形图 + 图例,点击某分类下钻到该类流水列表。
- 分类排行:横向条形榜,显示金额与占比。
- 资产变化:总资产随时间变化的折线,可按账户拆分。
- 空数据状态要有明确引导,不能只显示空白图表。
- 账本管理:账本列表、新建、重命名、切换、排序、设置默认;当前账本全局生效。
- 月度汇总:本月收入、支出、结余、日均支出、环比与同比。
- 预算:总预算 + 分类预算的进度条,剩余额度、已用比例、超支高亮与预警。支持按 MONTH/WEEK/YEAR 周期设置。
- 账户管理:增删改、排序、归档、设置初始余额与是否计入总资产;显示各账户当前余额。
- 分类管理:一级/二级增删改、图标与颜色、排序、隐藏预置分类。
- 预算设置、定期账单管理、标签管理。
- 数据:导出(CSV/JSON)、导入、清空数据、数据库版本信息。
- 安全:修改密码、应用锁(数字密码 / 生物识别)、自动锁定时长。
- 外观:浅色 / 深色 / 跟随系统、主题色(含 Material You 动态取色)、金额颜色开关。
- 其他:每月起始日、货币符号、日期格式、关于。
默认以 ModalBottomSheet 形式弹出快速记账,可一键展开为全屏编辑页补充更多字段。
快速记账页包含:
- 类型切换:支出 / 收入 / 转账。
- 自定义数字键盘,支持
+ - × ÷ =连续运算,支持"等于上一笔金额"。 - 分类宫格(转账类型下隐藏分类,改为转出/转入账户选择)。
- 账户选择器,默认使用上次使用的账户。
- 日期快捷:今天 / 昨天 / 前天 + 日期时间选择器,时间默认当前。
- 备注输入 + 常用备注标签(点击即填)。
- 保存后停留在页面并可"再记一笔",适合连续录入多笔。
表单校验:金额必须大于 0;转账必须选择两个不同的账户;账户被归档时不允许新流水。未填必填项时保存按钮置灰而非弹错。
数据正确性(最高优先级)
- 金额一律用
Long存分,禁止在计算链路中出现Double/Float。 - Room 导出 schema JSON 并纳入版本控制,任何表结构变更必须编写迁移并配套
MigrationTestHelper测试。 - 流水写入需在事务中完成,涉及账户余额的批量操作必须保证原子性。
安全
- 密码使用 PBKDF2WithHmacSHA256 + 随机盐存储,不提供密码找回,忘记密码只能重置(重置前需明确提示将清除该账号数据)。
- 应用锁使用 BiometricPrompt,锁定时隐藏最近任务列表缩略图。
- 不申请网络权限,导出的备份文件默认不含密码哈希。
性能
- 万级流水下明细滚动保持 60fps,统计查询走索引并使用 SQL 聚合而非内存计算。
- 列表超过约 3 万条时再引入 Paging 3,前期不过度设计。
体验与无障碍
- 支持深色模式、动态取色、系统大字号、TalkBack 语义标签。
- 所有危险操作(删除、清空、重置)二次确认,破坏性操作可撤销的尽量提供撤销。
测试
- DAO 层单元测试(含统计口径与账期计算)。
- Repository/ViewModel 单元测试(覆盖转账、跨账期、超支等边界)。
- 关键路径 Compose UI 测试:登录注册、记一笔、编辑流水、筛选。
P0 工程骨架(已完成):Gradle 依赖与 Hilt/KSP 配置、主题体系、Navigation 骨架、四个 Tab 与 FAB 的空壳、Room 数据库与实体定义。
P1 账号体系(已完成):注册登录、密码哈希、登录态保持、默认数据初始化(预置分类、默认现金账户、默认账本)、本地多账号数据隔离、退出登录。
P2 记账闭环(已完成):加号快速记账(含转账)、明细列表与分组小计、流水的增删改查。筛选、搜索与批量操作顺延至后续阶段。
P3 统计与预算(已完成):图表页的收支趋势与分类占比/排行、账单页的账本管理与月度汇总、预算设置与进度预警、每月起始日联动。
P4 完善:我的页各二级功能、账户与分类管理、应用锁与生物识别、数据导出导入、主题与外观设置。
P5 增强:定期账单与自动记账、记账模板、附件与图片、批量操作增强、数据导入市面账单 CSV。
每期结束需保证工程可编译、可运行、无遗留崩溃,并补齐该期核心逻辑的单元测试。
- 新用户注册后可直接记一笔支出,并在明细页看到该记录与当日小计。
- 记一笔转账后,转出账户减少、转入账户增加相同的金额,总资产不变。
- 编辑或删除一笔流水后,明细、账户余额、图表统计三处数据同步更新。
- 修改"每月起始日"(如改为 5 号)后,明细分组与月度汇总的账期随之改变且前后一致。
- 杀进程重启后,登录态与所有数据完整保留。
top.tobin.xrecord
├── XRecordApplication.kt Hilt 入口
├── MainActivity.kt 单 Activity
├── core/util/ 跨模块纯逻辑(PeriodCalculator)
├── data/local/
│ ├── entity/ Room 实体与 TypeConverter
│ ├── dao/ 数据访问接口
│ ├── XRecordDatabase.kt 数据库定义
│ └── di/DatabaseModule.kt Hilt 提供数据库与 DAO
└── ui/
├── navigation/ 类型安全路由与一级页面定义
├── components/ 可复用 UI 组件
├── feature/<页面>/ 按页面分包
└── theme/ Material 3 主题
- Room schema 已纳入导出:
app/schemas/下会随版本生成 JSON。任何实体改动都必须提升数据库版本号并编写Migration,同时补齐迁移测试。 - 外键删除策略:
transactions.accountId使用NO ACTION而非RESTRICT。原因是约束在语句结束时才校验,这样"删除整个用户"的级联删除可以顺利完成;而单独删除一个仍有流水的账户依然会被数据库拒绝,符合"账户只归档不删除"的设计。 - 枚举以字符串落库,不使用
ordinal,避免枚举顺序调整导致历史数据错位。 - 金额一律
Long存分,transactions.amount恒为正数。 - 账期统一走
PeriodCalculator,起始日上限为 28,shift必须从账期起点推算,禁止对区间终点调用plusMonths。 - 图标使用工程内矢量资源(
res/drawable/ic_tab_*.xml)。material-icons-extended已停止跟随 Compose BOM 发布,且会把上千个图标类打进 APK——实测替换后 debug 包从 68MB 降到 34.4MB。 - 登录名统一小写存储,显示名交给昵称。这样登录名大小写不敏感,也不会出现"Tobin"和"tobin"两个账号。
- 密码迭代次数随哈希一起存库。日后提高强度时,老用户仍可用记录里的旧迭代次数校验,登录成功后再按新强度重算,无需强制重置密码。
- 注册必须在一个数据库事务内完成:用户、默认账本、默认账户、预置分类要么全部写入,要么全部回滚。留下"有账本没分类"的半成品会让用户第一次记账就卡住。
- 登录失败不区分"用户不存在"与"密码错误",统一返回"用户名或密码错误",避免暴露本机已注册的用户名。
- 预置数据的
icon存语义化键(如restaurant)而非资源 id 数值,否则资源重新排序后历史数据会指向错误图标。 - 金额运算全程使用
BigDecimal,任何地方都不要用Double。用 Double 计算0.1 + 0.2会得到0.30000000000000004,落到账上就是实打实的错账。表达式求值最后统一四舍五入到"分"。 - 明细页顶部的收支合计与列表中每一行出自同一份数据。合计直接在内存里对同一列表求和,保证页面上显示的汇总永远等于下面每一行相加,不会出现"列表加起来和顶部对不上"的经典问题。数据量上来后再换 SQL 聚合并需重新验证一致性。
- 转账不计入收支统计,只改变账户之间的余额分布;转出账户减少「金额 + 手续费」,转入账户增加金额,两边之和不变。
- 日期选择器与业务时间戳必须显式换算时区。Material3 的日期选择器用 UTC 零点毫秒,直接混用会出现"选 9 月 18 日、存成 9 月 17 日"的跨时区错账,统一走
DatePickerBridge。 - 删除流水必须提供撤销。本地库是物理删除,没有回收站,误删只能靠撤销找回;撤销时保留原 id 重新插入,避免其它表的引用错位。
- 弹层内不要使用主窗口的 Snackbar。
ModalBottomSheet是独立窗口,主窗口的 Snackbar 会被遮罩挡住,用户看不到任何反馈,提示要画在弹层内部。 - Vico 的每个系列必须至少有一个数据点。空列表会抛
IllegalArgumentException: Series can't be empty并让界面崩溃;数据未就绪时先渲染占位,不能把空数据交给图表。 - 删除账本前必须查清流水条数并如实告知。账本下的流水通过外键级联删除,且无法恢复;最后一本账本不允许删除,否则用户会失去唯一的记账入口。
- 账户归属用户、账本归属用户,二者互不隶属。同一张银行卡在多本账之间共用,切换账本只改变流水的可见范围,不改变账户与余额。
- 预算跟着账本走。切到另一本账时看到的是那本账自己的预算,避免"旅行账本的预算在约束日常开销"这种错配。
- 日均支出按已过天数计算。当前账期只除以已经过去的天数;月初第 3 天把总支出除以 30 会显著低估,让用户误判还剩多少空间。
- 环比在上一账期无支出时不显示数字,用"—"表示无从比较,而不是显示 -100%。
- 隐藏的分类不能出现在记账面板。管理页要显示全部(含隐藏),记账面板只能显示可见的;这是两套不同的查询,
observeTopLevel必须带isHidden = 0,否则用户"隐藏"了分类却发现它还在。此问题正是本轮实测中发现的。 - 有流水的账户不允许删除,只能归档。删除会让历史流水失去归属;同时要保证至少留下一个可用账户,否则用户下次记账时无账户可选。
- 预置分类只能隐藏,不能删除。它们是统计口径的一部分,删掉会让历史流水与图表对不上;自建分类若已有流水或有子分类,同样拒绝删除。
- 清空流水保留账本、账户、分类与预算。清空后用户应能立刻继续记账,而不是面对一个需要重新搭建的空应用。
- 信用卡与负债类账户默认不计入总资产,否则总资产会被负债撑高,用户看到的数字比实际乐观。
- 主题与动态取色存在 DataStore,在 Activity 层读取并决定 Theme。切换后立即生效且重启保留,不需要重启应用。
- 所有全屏页面都必须处理系统栏内边距。应用开启了 edge-to-edge,且测试机型顶部有 152px 的摄像头挖孔。不做内边距时,注册页的返回按钮正好落在状态栏覆盖区内,点击事件被系统截走,表现为"按钮点了没反应、而且离状态栏太近"——两个现象是同一个根因。用
Scaffold的页面由innerPadding自动处理,手写布局的页面(登录、注册)必须显式调用safeDrawingPadding()。 - 单选控件的选中态必须一眼可辨,并且用
Modifier.selectable而不是clickable。clickable不会把选中状态暴露给无障碍服务,读屏用户无法知道当前选了什么;同时选中与未选中不能只靠填充深浅区分——分类宫格最初只有"实心 vs 12% 淡色"的差别,浅色底上用户根本看不出来,误以为点击没生效(数据库里记录的却已经是点击后的分类)。 - 记账时间戳不能用 ViewModel 创建时刻。记账面板的 ViewModel 生命周期与主界面一样长,若
occurredAt只在构造时取值,同一会话里所有流水都会带着最初打开面板那一刻的时间;跨零点使用时甚至会把今天的账记到昨天。改为打开面板时取"此刻"、保存后保留用户选的日期但刷新时间。 - 写入流水必须落到"当前账本",编辑时保持原账本不变。新增时若写死默认账本,用户切到旅行账本记的账会悄悄进日常账本;编辑时若重新解析账本,改一笔账会把记录搬回默认账本。两者都是数据被挪走却没有任何提示。
- 切换账号后必须重建整棵主界面树。记账面板的 ViewModel 在创建时捕获
userId,不重建就会拿着旧账号的账户与分类去记账。根导航以user.id作为key即可保证切换后状态彻底重置。 - 用 sqlite3 直接改 App 数据库时必须先
PRAGMA foreign_keys=ON。命令行默认关闭外键,删除用户不会级联,会留下无主账本/账户/分类。App 自身的 Room 连接是开启外键的(已实测:删除账本后其流水一并消失,无孤儿数据)。 - 删除账号只能删当前登录账号,且必须依赖外键级联。数据层要再校验一次
currentUserId == userId:删除其它账号意味着在不知道对方密码的情况下销毁其数据,与"本地多账号彼此隔离"直接冲突。整个账号名下的账本、账户、分类、流水、标签、预算、定期账单全部靠级联清除,逐表删除既容易遗漏也无法保证原子性。删除后清空登录态,根导航自动回到登录页。 - 破坏性操作的确认框必须给出具体数据量,而不是笼统的"将删除全部数据"。删除账号的确认框会实时统计"N 笔流水、N 本账本、N 个账户、N 个分类",用户在按下确认前能确切知道自己要失去什么。
- Vico 的坐标轴标签格式化器绝不能返回空串,且必须对任意下标都能算出结果。图表切换数据做动画时,Vico 会查询当前数据集之外的 x 值(从 31 天的月份切到 30 天的月份时仍会问 x=30)。早期实现是"从数据列表按下标取标签",取不到就返回空串,直接导致
IllegalStateException。正确做法是由日期推算标签而不是查表,并在最外层保留一个非空的兜底。 - keep 规则写在
src/<变体>/keepRules/**.keep,而不是proguard-rules.pro。AGP 只收集.keep后缀;该目录下出现.pro或.pgcfg会让构建直接失败。可以按变体分(main / debug / release / flavor),AGP 会全部合并。 - 不要照抄网上的通用 keep 规则模板。依赖自带的消费者规则会被 AGP 自动合并——本项目已确认 Room(
proguard.txt)、kotlinx-serialization(kotlinx-serialization-common.pro,完整覆盖@Serializable的 Companion、$$serializer、INSTANCE.serializer())都自带;Hilt、Navigation Compose、Vico 不需要额外规则。重复手写不仅无益,还可能互相冲突。判断依据应当是"开启 R8 后跑一遍真实流程",而不是抄一份模板。 optimization.enable是所有收缩与混淆的总开关。它为false时,keepRules目录里的规则完全不生效(实测 release 构建里连 R8 任务都不会出现,只有普通的 dex 任务)。- 发布包必须用自己的签名证书。验证 R8 时用调试签名只是为了本机安装;
build.gradle.kts中 release 未配置签名,正式发布前必须补上,绝不能用调试签名发布。 - 备份文件不含任何凭据。备份会被放到云盘或电脑上,带上用户名与密码哈希等于把口令散落到应用之外。恢复时数据落到当前登录的账号下。
- 导入必须重映射主键。主键在本库全局自增,沿用备份里的 id 会与目标账号的既有记录撞车。导入前先清空当前账号数据,再按"账本 → 账户 → 分类(父级先于子级)→ 流水 → 预算 → 定期账单"的顺序重建并维护 旧id → 新id 映射。
- 导入前必须校验备份内部的引用自洽。导入是"先清空再写入",写到一半发现引用缺失就只能留下残局。因此在动数据库之前先检查流水/预算/定期账单引用的账本、账户、分类是否都存在,并要求至少含一本默认账本与一个账户。
- 定期账单必须存储锚定日。只按
nextTriggerAt递推会让"每月 31 日"退化成 1/31 → 2/28 → 3/28 一路漂移;记住锚定日才能在短月钳位、到大月回到原位。 - 定期账单采用"打开应用时追补"而非后台调度。本地应用不在后台运行时生成流水没有意义,打开时一次性追平既简单,也不会在用户不知情的时候写入数据。单次追补有上限,避免长期未打开后一次灌入大量历史记录。
- 给已有表加 NOT NULL 列时,实体必须声明
@ColumnInfo(defaultValue = ...)且与迁移里的 DEFAULT 一致。Room 会拿"实体期望的表结构"和"迁移后的实际结构"比对,两边不一致会导致迁移校验失败。 - 应用锁与登录密码是两回事。登录密码区分"本机上的哪个账号",应用锁保护"这台手机上的这个应用",因此锁的配置存在设备级 DataStore 里,与当前登录账号无关。锁密码同样用 PBKDF2 + 随机盐存储,不落明文。
- 锁定状态必须是三态(未知 / 锁定 / 已解锁)。配置是异步读出来的,若只有"锁定/已解锁"两态,启用应用锁后冷启动会有几毫秒先把主界面渲染出来再被覆盖——内容已经泄露了一帧。未知态一律渲染占位。
- 启用应用锁时必须先标记本次会话已解锁,再写配置。写入"启用"的那一刻
sessionUnlocked若仍为 null,状态机会判定为锁定,导致根导航切换分支、整个主界面树连同导航栈被销毁重建,用户刚设完密码就被弹回首页。 - 应用锁启用期间加
FLAG_SECURE,禁止截屏并让最近任务里的缩略图变空白,避免解锁界面之外泄露内容。 - 忘记应用锁密码不提供找回入口。纯本地应用没有可用于验证身份的服务端,界面只能如实说明"需要清除应用数据",而不是给一个做不到的"找回"按钮。
- 滚轮日期选择器的"日"必须随年月联动。用户把月份从 3 月滚到 2 月时,"3 月 31 日"这个组合已不存在,必须收拢到 2 月 28(闰年 29)日,否则
LocalDate.of会直接抛异常。规则集中在DateSelection.clampToValidDay,日滚轮的取值范围由所选年月实时计算。 - 滚轮选中项取"离视口中心最近的一项",而不是
firstVisibleItemIndex:后者在手指停在两项之间时会来回跳,界面与选中值对不上。 - 没在用的依赖要及时删掉,它带的权限会算在你头上。WorkManager 从 P0 就声明在依赖里,但最终改用"打开应用时追补"后从未使用;它悄悄合并进
WAKE_LOCK、ACCESS_NETWORK_STATE、RECEIVE_BOOT_COMPLETED、FOREGROUND_SERVICE四个权限。对一个宣称"纯本地"的记账应用来说,RECEIVE_BOOT_COMPLETED这类权限会直接引发用户质疑。移除后 APK 权限只剩生物识别两项,体积也从 4.5 MB 降到 4.3 MB。
当前工具链是 Kotlin 2.2(AGP 9.4 内置 Kotlin 编译器版本为 2.2.0),这带来两条约束:
- Vico 锁定在 3.2.3。3.3.0 起使用 Kotlin 2.4 编译,其产物元数据超出当前编译器可读范围(上限 2.3.0),会直接编译失败。升级 Kotlin 工具链时才可放开。
gradle.properties需要android.disallowKotlinSourceSets=false。AGP 9 默认禁止通过kotlin.sourceSets追加源码目录,而 KSP(Room、Hilt 的注解处理器)依赖该机制。
后续若要把 Kotlin 升到 2.4+,需要一并处理:显式应用 Kotlin Android 插件、匹配 KSP 版本、放开 Vico 版本,并重新验证 Hilt 插件兼容性。这件事值得单独作为一次任务,不要与其他功能改动混在一起。
./gradlew :app:assembleDebug构建通过,产出 debug APK。./gradlew :app:testDebugUnitTest通过,PeriodCalculatorTest11 项用例全部通过,覆盖起始日为 1/5/28、平年与闰年二月、跨年与连续平移等边界。- Room 已生成
XRecordDatabase_Impl,schema 已导出至app/schemas/。 - 已在 Pixel 10 Pro Fold 模拟器(Android 17,1080x2364)上安装运行:四个一级页面均可正常切换,悬浮按钮可唤起记账面板,返回键行为正确,运行期间无崩溃日志。
在模拟器上完整走了一遍真实操作路径,并导出数据库逐条核对:
- 首次启动进入登录页;注册
tobin后直接进入主界面,数据库生成 1 个用户、1 个"日常账本"(默认账本)、1 个"现金"账户、19 个预置分类(支出 12 + 收入 7)。 - 密码以 32 字节 PBKDF2 派生结果存储(Base64 后 44 字符),迭代次数 100000,明文未落库。
- 进程被杀后重启仍是登录态;清除 DataStore 后回到登录页。
- 错误密码被拒绝并提示"用户名或密码错误";正确密码正常进入主界面。
- "我的"页显示当前账号,退出登录有二次确认,确认后回到登录页。
- 注册第二个账号
alice后,两个账号各自拥有 1 个账本、1 个账户、19 个分类;无主数据、跨账号父子分类、重复用户名均为 0 条。 - 两个账号使用相同密码
abc123,盐与哈希完全不同,验证了每用户独立随机盐。
- 基线已提交(commit
0ca56f6)。后续每个阶段独立提交,表结构变更必须与迁移脚本在同一次提交内。 - 悬浮按钮当前位于右下角(Material 3 默认位置)。若要改成底部导航中间"骑跨"的样式,需要自定义底部栏布局。
- 主题目前是 Material 3 默认紫色 + 动态取色,尚未确定品牌配色,等主要页面成型后统一设计。
单元测试 49 项全部通过(新增金额输入 15 项、金额格式化 7 项、日期工具 7 项)。在模拟器上完成的操作与结果:
- 记一笔支出:键盘输入
12.34 + 10按等号得到22.34,保存后明细出现该行,顶部汇总同步为支出 22.34、结余 -22.34。 - 记一笔收入 21000:切换类型后分类自动换成收入分类(工资/奖金/兼职…),汇总变为收入 21,000.00、支出 22.34、结余 20,977.66,与列表各行一致。
- 记一笔转账 100:明细显示"现金 → 招行卡 100.00",收支汇总完全不受影响。用 SQL 核对账户余额为现金 20,900.00、招行卡 100.00,总资产 21,000.00 与转账前一致。
- 编辑:点击流水进入全屏编辑页,数据正确回填(含转账的两个账户),金额由 100 改为 250 保存后列表同步更新并自动返回。
- 删除与撤销:左滑删除后出现"已删除一笔流水 / 撤销",滑动后该行与汇总立即消失,点击撤销后行与汇总恢复,数据库层面确认记录被真正删除并重新插入。
- 全程无崩溃日志。
测试过程中为了验证转账,曾直接向模拟器数据库插入过一个"招行卡"账户(账户管理功能要到 P4 才有界面),这只影响模拟器上的测试数据。
单元测试 57 项全部通过(新增图表区间与分桶 8 项)。在模拟器上的验证:
- 图表页:周/月/年三个维度、账期标题、收支汇总、趋势柱状图、分类占比环形图、分类排行均正常渲染,无崩溃。
- 账单页:账期标题、收支结余、日均支出、环比、账本列表、预算区均正常。
- 预算:设置总预算 3000 元后显示"剩余 2,980.00",与支出 20.00 对得上。
- 账本隔离:新建"Travel"账本后自动切换过去,汇总归零、预算为空;切回"日常账本"数据恢复。
- 每月起始日联动:把起始日从 1 改为 5 后,明细页、图表页、账单页的标题同时从"2026年9月"变为"9月5日 - 10月4日",日均支出也从 1.11 重算为 1.42(20.00 ÷ 14 天)。三处口径完全一致。
- 数据库核对:日常账本 4 条流水、Travel 0 条;预算记录 bookId 指向日常账本、周期 MONTH、金额 300000 分;账户仍是用户级共用。
开发过程中图表页曾因空数据直接崩溃,堆栈指向 Vico 的 ColumnCartesianLayerModel,已按第 18 条约束修复,这条经验也一并记录在案。
- 明细页的筛选与搜索、长按多选批量操作尚未实现。
- 分类图标暂以"分类名首字 + 圆底"呈现,语义化图标键(如
restaurant)还没有对应素材。 - 转账手续费字段已在数据库中预留,但没有录入入口。
- 图表页尚未实现资产变化曲线;预算目前只支持月度周期与一级分类。
- 账本排序、账本图标自定义未实现。
本轮完成「我的」页的账户管理、分类管理、外观设置与清空流水,应用锁、数据导出导入、定期账单管理顺延。57 项单元测试通过,全程无崩溃。
- 「我的」页已重建为设置中心:账号信息、记账(账户/分类/每月起始日)、其他(外观/清空流水/关于)、退出登录。
- 账户管理:正确显示各账户余额(现金 20,880.00 = 收入 21,000 − 支出 20 − 转出 100);新建信用卡账户后
includeInTotal自动为 false;对已有流水的账户执行删除被拒绝并给出原因。 - 分类管理:预置分类删除被拒绝;隐藏"餐饮"后该分类在管理页标记"已隐藏",并且从记账面板中消失(此处修掉了一个真实缺陷:
observeTopLevel漏了isHidden过滤)。 - 外观:切换到深色后设置以
DARK写入 DataStore,重启后仍保留。主题的视觉效果未能用命令行确证(模拟器存在多个显示器,screencap抓取的不是主屏,像素对比不可信),需要人工确认。 - 清空流水:执行后流水表为 0 条,账本 2 本、账户 3 个、分类 19 个、预算 1 条全部保留。
用户反馈注册页"返回按钮点击没反应,而且离状态栏太近"。排查发现两个现象同源:
- 该机型
DisplayCutout顶部 inset 为 152px,而注册页的返回按钮图标位于 y 89~148,整个落在状态栏覆盖区域内,触摸由系统消费,应用收不到点击。 - 页面本身没有做任何系统栏内边距,所以视觉上也贴着状态栏。
修复方式是为登录页与注册页的根布局加上 safeDrawingPadding()(这两个页面是手写布局,没有 Scaffold 兜底)。修复后返回按钮从 y 89148 移到 y 202261,登录页标题移到 y 308,两处返回入口(左上角图标与底部"已有账号?返回登录")点击均正常。
这条经验说明:edge-to-edge 开启后,"控件看起来在屏幕内"和"控件能收到点击"是两回事,状态栏区域内的控件是可见但不可交互的。
用户反馈"记账时点击分类没有选中状态"。排查过程与结论:
- 从数据库反查发现,用户点过的分类已经正确存进了流水(其中一笔的分类是"交通",其余是默认的"餐饮"),说明点击逻辑本身没问题。
- 真正的原因是选中态的表现太弱:只在圆形底色上做"实心 vs 12% 透明度"的区分,在浅色背景上几乎看不出来。
- 修复:改成"实心圆 + 一圈浅色光晕 + 标签加粗"的双层表达;同时把
clickable换成selectable(role = RadioButton),让选中状态能被无障碍服务读取,也便于自动化验证。 - 验证:点击"交通"后,无障碍树中"餐饮"由
checked=true变为false、"交通"由false变为true,状态转移正确。账单页的账本选择同样改成了selectable。
排查过程中还发现一个独立缺陷:同一会话内连续记账的流水共用同一个时间戳(用户当时的 5 笔记录 occurredAt 完全相同)。原因是记账面板的 ViewModel 生命周期与主界面一样长,而 occurredAt 只在构造时取值。已修复为打开面板时取"此刻"、保存后保留日期并刷新时间。修复后连续两笔记账的时间分别为 19:20:02 与 19:20:59。
切换账号:「我的」页头部新增当前账号展示与"切换账号"入口,弹层列出本机全部账号(当前账号标"当前"),选择其它账号需输入该账号密码。切换必须验密码——本地多账号的意义就是彼此不可见,免密切换会让隔离形同虚设。弹层底部提供"添加账号",复用注册页(隐藏了不适用的"返回登录"文案),注册成功即切到新账号。
实现要点:切换后根导航以 user.id 为 key 重建整棵主界面树,避免记账面板等 ViewModel 继续持有旧账号的 userId。若当前登录账号被删除,会话流会回落到未登录态,应用回到登录页而不是停在空白主界面(已实测)。
顺带查出的严重缺陷:验证切换时发现,在 B2 账本里记的账被写进了默认账本——TransactionRepository.save() 写死使用 bookDao.findDefault(userId),完全忽略当前选中的账本。同一根因还导致编辑流水会把记录从原账本搬到默认账本。这意味着 P3 的账本切换只切换了"看"、没切换"写"。已修复为:新增使用当前账本(与界面显示同一套回落规则),编辑保持原账本。修复后实测在 B2 记账,账单页正确显示 30.00。
外键约束核查:清理测试账号时发现直接删 users 留下了无主账本/账户/分类,一度怀疑外键没生效。实测结论是:命令行 sqlite3 默认不开 PRAGMA foreign_keys,是操作方式的问题;App 自身的 Room 连接开启了外键——通过界面删除 B2 账本后,其下 1 条流水被一并删除,孤儿流水为 0。
「我的」页在"退出登录"下方新增"删除账号"(危险色)。确认框会实时统计并列出该账号名下的数据量,然后一次性删除整个账号。
端到端验证(用一个临时账号 temp4 走完整流程,它在测试结束时删除了自己):
- 先给 temp4 造了跨两个账本的数据:2 本账本、2 笔流水(分属两本账)、1 个账户、19 个分类。
- 确认框正确显示"2 笔流水、2 本账本、1 个账户、19 个分类",与实际数据一致。
- 确认删除后应用立即回到登录界面,无崩溃。
- 数据库核对:temp4 的用户、账本、账户、分类、流水全部为 0;全局无主数据与孤儿流水均为 0;其它账号(tobin 7 笔流水、leo 0 笔)数据完好。
用户反馈在图表页点击月份左右箭头时崩溃,堆栈为
IllegalStateException: CartesianValueFormatter.format returned a blank string。
根因:横轴标签实现为 points.getOrNull(value.toInt())?.label.orEmpty(),即从当前数据列表按下标取。切换月份时桶的数量会变(28~31 天),而 Vico 在动画期间仍会按旧数据集的 x 范围询问标签,越界时返回空串,Vico 直接抛异常。用 Double 的 x 截断成下标也放大了这个问题。
修复:标签改为由日期推算(ChartRangeCalculator.axisLabel),对任意下标都返回非空且合理的结果,并让分桶标签与坐标轴标签共用这一份逻辑,避免两处跑偏。格式化的最外层再加一个非空兜底,防止将来回归时直接崩溃。
验证:
- 新增 3 项回归测试,其中一项专门断言"跨界下标(-1、29、30、31、100)也必须产生非空标签"。
- 在用户真实数据上实测(含 8 月与 9 月两个账期的数据):月维度从 9 月连点 6 次回到 3 月、再连点 7 次前进,跨越 28/30/31 天各种月份;周与年维度各反复前后切换多轮。全程 0 崩溃,进程持续存活。
此前 release 的 optimization.enable = false,R8 完全不运行(assembleRelease --dry-run 里没有任何 R8 任务,只有 dexBuilderRelease),所以 keepRules 目录是一份死配置。本次改为 enable = true,并重写了 rules.keep。
规则内容与依据:先核实了各依赖自带的消费者规则(见第 40 条),确认序列化、Room 都不需要手写,因此只保留项目自身真正需要的一条——保留 SourceFile,LineNumberTable,让混淆后的崩溃堆栈仍有定位价值。
效果:
- 包体积从 debug 的 36.4 MB 降到 release 的 3.6 MB(约 90%),资源收缩也在同一开关下生效(
optimizeReleaseResources)。 - 混淆已生效:
MoneyFormatter → cg1、PasswordHasher → kt1,而MainActivity、XRecordApplication因清单引用保留原名。 - 规则确认被采纳:
app/build/outputs/mapping/release/configuration.txt第 967~968 行即为所写的两条;同一文件中 kotlinx-serialization 规则出现 22 处、Room 规则 7 处,证明消费者规则已自动合并。
运行时验证(把 release 包装到模拟器实测,覆盖 R8 最容易出问题的反射与序列化路径):
- 冷启动正常,启动耗时 111 ms,明细页数据正确。
- 遍历四个一级页面,以及账户管理、分类管理、外观三个二级页面;图表页切换周/月/年维度并反复前后翻页。
- 记账写入正常(1,025.00 → 1,055.00),随后删除该测试记录,数据恢复为 1,025.00;账号切换弹层正常列出本机账号。
- 全程
ClassNotFoundException、NoClassDefFoundError、NoSuchMethodError、NoSuchFieldError、Unable to instantiate均为 0 条,崩溃数为 0。
「我的 → 数据备份」提供导出与恢复。文件为 JSON,通过系统文件选择器(SAF)读写,不申请存储权限。
- 导出在一个数据库事务里读取全部表,避免导出过程中数据被改动导致前后不一致。
- 导入前先解析并校验引用自洽性,再让用户确认覆盖;确认后才清空并重建,全程在一个事务内。
- 恢复时所有主键重新分配,并维护旧 id → 新 id 的映射,避免与目标账号的既有记录冲突。
验证(在用户真实数据上完成一次完整往返):
- 导出文件 12,795 字节,是合法 JSON:账本 1、账户 1、分类 19、流水 14、预算 0、定期账单 0,与数据库一致。
- 备份中
password、hash、salt、username等字段出现次数为 0,确认不含凭据。 - 导入确认框显示"14 笔流水、1 本账本、1 个账户、19 个分类",与文件内容一致。
- 导入后:流水 14 笔、金额合计 348,400,与备份文件逐笔比对(按金额与发生时间排序)完全一致;主键由导入前的 1
21 变为 2235,证明重映射生效;孤儿流水、无主账本、无主分类均为 0;其它账号数据未受影响。
「我的 → 定期账单」支持新建、编辑、启用/停用与删除,支持支出/收入/转账三种类型、每天/每周/每月/每年与自定义间隔,并可选择是否自动记账。
第一次真实的数据库迁移:为支持"每月 N 号"的正确递推,recurring_rules 增加了 anchorDay 列,数据库版本 1 → 2,并编写了 MIGRATION_1_2。原有数据是用户在真实使用中积累的,因此这次迁移直接在生产数据上验证:
- 安装新版本后应用正常打开,原有流水与分类完好,无迁移异常。
PRAGMA user_version为 2,recurring_rules已包含anchorDay列。- 迁移前后用户数据条数一致(tobin 14 笔流水、19 个分类)。
功能验证:创建"每月 15 元"的规则后,锚定日自动取为当日 18 号;退出再进入该页触发追补,生成的流水金额 1500 分、分类正确、recurringRuleId 指向该规则,规则的下次日期推进到 2026-10-18。测试产生的规则与流水均已删除,数据恢复到 1,025.00。
顺带修复:定期账单编辑表单的金额输入框误用了账户页的"初始余额"文案,已改为"金额"。
「我的 → 安全」提供应用锁:4 位数字密码(PBKDF2 + 随机盐存于设备级 DataStore)、生物识别解锁、自动锁定时长(立即 / 30 秒 / 1 分钟 / 5 分钟 / 从不)与「立即锁定」。
解锁界面为数字键盘 + 圆点指示,开启生物识别时会自动弹一次系统验证(用户取消不强制)。
验证:
- 设置密码后冷启动,直接出现"已锁定"界面,不会先闪出主界面。
- 错误密码(9999)提示"密码错误,请重试"并保持锁定;正确密码(1234)解锁进入主界面。
- 自动锁定:设为"立即"后按 HOME 退到后台再返回,立即回到锁定界面;已恢复为默认的 30 秒。
FLAG_SECURE生效:锁定状态下截屏得到的图片从正常的约 60 KB 降到 15 KB,说明窗口内容被系统屏蔽。- 配置确认落库:
app_lock_enabled、app_lock_pin_hash、app_lock_pin_salt、app_lock_timeout_seconds均存在;全程无崩溃。
过程中修复的缺陷:启用应用锁时界面会被弹回首页。原因是写入顺序——先写密码再写"启用",在"启用"落库而 sessionUnlocked 仍为 null 的那一瞬间状态机判定为锁定,根导航切换分支导致整棵主界面树连同导航栈被重建。改为先标记会话已解锁再写配置。
可用性修正:生物识别原本在设备不支持时整行隐藏,用户只能靠猜"为什么没有这个选项"。改为始终显示该行,不可用时置灰并给出原因("此设备尚未录入指纹或人脸,请先在系统设置中录入")。实测模拟器上有指纹硬件但未录入任何指纹,该行正确显示为禁用并附带说明。
原先使用 Material3 的日历式 DatePicker,改为年 / 月 / 日三列滚轮 + 取消 / 确定,记账面板与定期账单两处同时替换。
实现要点:用 rememberSnapFlingBehavior 做滚动吸附;选中项取离视口中心最近的一项;日滚轮的取值范围由所选年月实时计算,并在年月变化时把越界的日期收拢到月末。原先为换算 Material3 日期选择器 UTC 毫秒而写的 DatePickerBridge 已无引用,一并删除。
验证(模拟器实测):
- 打开选择器即定位到当前日期(2026年 / 9月 / 18日),三列均正确高亮中间行,下方为取消与确定。
- 日滚轮冲到末尾停在 30 日——9 月只有 30 天,滚不过去。
- 把月份滚到 2 月,原本选中的 30 日被收拢为 28 日,未出现非法日期导致的崩溃。
- 切到 2 月后把日滚轮冲到末尾,停在 28 日,说明日滚轮取值范围随月份联动。
- 点确定后记账面板的日期标签变为"2月28日";关闭面板未产生多余流水(仍为 14 笔)。
- 新增 5 项
DateSelection单元测试,覆盖平年/闰年二月与 30 天月份的收拢。