Skip to content

Latest commit

 

History

History
562 lines (406 loc) · 46.6 KB

requirements.md

File metadata and controls

562 lines (406 loc) · 46.6 KB

X-Record 记账 App 需求规格

状态:需求已对齐,P0~P3 完成,P4 进行中(账户/分类/外观/清空已完成,应用锁与备份待做) 最后更新:2026-09-18

1. 项目定位

一款纯本地的个人记账 App。所有数据(含登录注册)保存在设备本地数据库中,不依赖服务端、不申请网络权限。设计目标是在保证数据准确性的前提下,把"记一笔"的路径压缩到最短。

2. 已确认的技术决策

项目 选型
语言 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 层不手写刷新逻辑,一切由数据库变更驱动。

3. 已确认的产品决策

  1. 支持多账户与转账,转账是独立的交易类型,不是特殊的支出/收入。
  2. "账单"页 = 账本切换 + 当前账本月度汇总 + 预算进度,三者合一。
  3. 每月起始日可配置(默认 1 号,支持 1~28),影响所有月度统计、预算周期和图表。
  4. 不做多币种,全局单一货币,仅货币符号可配置。
  5. 图表使用 Vico。

待确认的假设

  • 假设 A:支持同一台设备上注册多个本地账号,账号之间数据完全隔离,但不做多人共享账本、不做账号间数据合并。
  • 假设 B:应用不申请 INTERNET 权限,纯离线;备份与迁移通过系统文件选择器(SAF)导出导入文件完成。
  • 假设 C:金额使用 Long 存储"分",展示层负责格式化为"元"。

以上三条如果与预期不符,需在开工前修正。

4. 数据模型

4.1 实体表

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 — 流水附件(第三阶段),存本地文件路径。

4.2 不进 Room 的数据

主题模式、主题色、货币符号、每月起始日、当前登录用户 id、当前账本 id、锁屏与生物识别开关 → DataStore Preferences。

4.3 账期工具

"每月起始日"统一由一个 PeriodCalculator 承担:输入时间戳 + periodStartDay,输出所属账期标识(如 2026-09-05 ~ 2026-10-04)。明细分组、图表聚合、预算周期必须全部走这个工具,禁止各页面自行计算,否则会出现统计口径不一致。

5. 页面与功能规格

5.1 整体结构

启动 → 检查登录态
       ├─ 未登录 → 登录 / 注册
       └─ 已登录 → 主界面
                     ├─ NavigationBar:明细 | 图表 | 账单 | 我的
                     └─ 悬浮 FAB(+)→ 快速记账

采用单 Activity + Navigation Compose 嵌套导航图,四个一级页面各自持有独立返回栈,切换 Tab 时保留各自的滚动位置与筛选状态。

5.2 明细

  • 顶部:当前月收支结余卡片,支持左右切换月份。
  • 列表:按日分组,每组带日期头与当日小计;单条显示分类图标、分类名、备注、账户、金额(支出红/收入绿,颜色可在设置中关闭)。
  • 筛选:时间范围、分类(多选)、账户(多选)、收支类型、金额区间、关键词(备注/商家)。
  • 搜索入口独立于筛选,随时可唤起。
  • 长按进入多选模式,支持批量删除、批量改分类、批量改账户、批量加标签。
  • 左滑删除,删除后 Snackbar 提供撤销。
  • 点击进入详情,详情页可编辑、复制为一笔新流水、删除。

5.3 图表

  • 时间维度切换:周 / 月 / 年 / 自定义区间,与"每月起始日"设置联动。
  • 收支趋势:柱状或折线对比,可选"仅支出 / 仅收入 / 收支对比"。
  • 分类占比:环形图 + 图例,点击某分类下钻到该类流水列表。
  • 分类排行:横向条形榜,显示金额与占比。
  • 资产变化:总资产随时间变化的折线,可按账户拆分。
  • 空数据状态要有明确引导,不能只显示空白图表。

5.4 账单

  • 账本管理:账本列表、新建、重命名、切换、排序、设置默认;当前账本全局生效。
  • 月度汇总:本月收入、支出、结余、日均支出、环比与同比。
  • 预算:总预算 + 分类预算的进度条,剩余额度、已用比例、超支高亮与预警。支持按 MONTH/WEEK/YEAR 周期设置。

5.5 我的

  • 账户管理:增删改、排序、归档、设置初始余额与是否计入总资产;显示各账户当前余额。
  • 分类管理:一级/二级增删改、图标与颜色、排序、隐藏预置分类。
  • 预算设置、定期账单管理、标签管理。
  • 数据:导出(CSV/JSON)、导入、清空数据、数据库版本信息。
  • 安全:修改密码、应用锁(数字密码 / 生物识别)、自动锁定时长。
  • 外观:浅色 / 深色 / 跟随系统、主题色(含 Material You 动态取色)、金额颜色开关。
  • 其他:每月起始日、货币符号、日期格式、关于。

5.6 记账流程(FAB)

默认以 ModalBottomSheet 形式弹出快速记账,可一键展开为全屏编辑页补充更多字段。

快速记账页包含:

  • 类型切换:支出 / 收入 / 转账。
  • 自定义数字键盘,支持 + - × ÷ = 连续运算,支持"等于上一笔金额"。
  • 分类宫格(转账类型下隐藏分类,改为转出/转入账户选择)。
  • 账户选择器,默认使用上次使用的账户。
  • 日期快捷:今天 / 昨天 / 前天 + 日期时间选择器,时间默认当前。
  • 备注输入 + 常用备注标签(点击即填)。
  • 保存后停留在页面并可"再记一笔",适合连续录入多笔。

表单校验:金额必须大于 0;转账必须选择两个不同的账户;账户被归档时不允许新流水。未填必填项时保存按钮置灰而非弹错。

6. 非功能需求

数据正确性(最高优先级)

  • 金额一律用 Long 存分,禁止在计算链路中出现 Double/Float。
  • Room 导出 schema JSON 并纳入版本控制,任何表结构变更必须编写迁移并配套 MigrationTestHelper 测试。
  • 流水写入需在事务中完成,涉及账户余额的批量操作必须保证原子性。

安全

  • 密码使用 PBKDF2WithHmacSHA256 + 随机盐存储,不提供密码找回,忘记密码只能重置(重置前需明确提示将清除该账号数据)。
  • 应用锁使用 BiometricPrompt,锁定时隐藏最近任务列表缩略图。
  • 不申请网络权限,导出的备份文件默认不含密码哈希。

性能

  • 万级流水下明细滚动保持 60fps,统计查询走索引并使用 SQL 聚合而非内存计算。
  • 列表超过约 3 万条时再引入 Paging 3,前期不过度设计。

体验与无障碍

  • 支持深色模式、动态取色、系统大字号、TalkBack 语义标签。
  • 所有危险操作(删除、清空、重置)二次确认,破坏性操作可撤销的尽量提供撤销。

测试

  • DAO 层单元测试(含统计口径与账期计算)。
  • Repository/ViewModel 单元测试(覆盖转账、跨账期、超支等边界)。
  • 关键路径 Compose UI 测试:登录注册、记一笔、编辑流水、筛选。

7. 分期计划

P0 工程骨架(已完成):Gradle 依赖与 Hilt/KSP 配置、主题体系、Navigation 骨架、四个 Tab 与 FAB 的空壳、Room 数据库与实体定义。

P1 账号体系(已完成):注册登录、密码哈希、登录态保持、默认数据初始化(预置分类、默认现金账户、默认账本)、本地多账号数据隔离、退出登录。

P2 记账闭环(已完成):加号快速记账(含转账)、明细列表与分组小计、流水的增删改查。筛选、搜索与批量操作顺延至后续阶段。

P3 统计与预算(已完成):图表页的收支趋势与分类占比/排行、账单页的账本管理与月度汇总、预算设置与进度预警、每月起始日联动。

P4 完善:我的页各二级功能、账户与分类管理、应用锁与生物识别、数据导出导入、主题与外观设置。

P5 增强:定期账单与自动记账、记账模板、附件与图片、批量操作增强、数据导入市面账单 CSV。

每期结束需保证工程可编译、可运行、无遗留崩溃,并补齐该期核心逻辑的单元测试。

8. 验收标准(P2 结束时)

  1. 新用户注册后可直接记一笔支出,并在明细页看到该记录与当日小计。
  2. 记一笔转账后,转出账户减少、转入账户增加相同的金额,总资产不变。
  3. 编辑或删除一笔流水后,明细、账户余额、图表统计三处数据同步更新。
  4. 修改"每月起始日"(如改为 5 号)后,明细分组与月度汇总的账期随之改变且前后一致。
  5. 杀进程重启后,登录态与所有数据完整保留。

9. 工程约定与版本约束

9.1 包结构

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 主题

9.2 已生效的硬性约束

  1. Room schema 已纳入导出:app/schemas/ 下会随版本生成 JSON。任何实体改动都必须提升数据库版本号并编写 Migration,同时补齐迁移测试。
  2. 外键删除策略:transactions.accountId 使用 NO ACTION 而非 RESTRICT。原因是约束在语句结束时才校验,这样"删除整个用户"的级联删除可以顺利完成;而单独删除一个仍有流水的账户依然会被数据库拒绝,符合"账户只归档不删除"的设计。
  3. 枚举以字符串落库,不使用 ordinal,避免枚举顺序调整导致历史数据错位。
  4. 金额一律 Long 存分,transactions.amount 恒为正数。
  5. 账期统一走 PeriodCalculator,起始日上限为 28,shift 必须从账期起点推算,禁止对区间终点调用 plusMonths。
  6. 图标使用工程内矢量资源(res/drawable/ic_tab_*.xml)。material-icons-extended 已停止跟随 Compose BOM 发布,且会把上千个图标类打进 APK——实测替换后 debug 包从 68MB 降到 34.4MB。
  7. 登录名统一小写存储,显示名交给昵称。这样登录名大小写不敏感,也不会出现"Tobin"和"tobin"两个账号。
  8. 密码迭代次数随哈希一起存库。日后提高强度时,老用户仍可用记录里的旧迭代次数校验,登录成功后再按新强度重算,无需强制重置密码。
  9. 注册必须在一个数据库事务内完成:用户、默认账本、默认账户、预置分类要么全部写入,要么全部回滚。留下"有账本没分类"的半成品会让用户第一次记账就卡住。
  10. 登录失败不区分"用户不存在"与"密码错误",统一返回"用户名或密码错误",避免暴露本机已注册的用户名。
  11. 预置数据的 icon 存语义化键(如 restaurant)而非资源 id 数值,否则资源重新排序后历史数据会指向错误图标。
  12. 金额运算全程使用 BigDecimal,任何地方都不要用 Double。用 Double 计算 0.1 + 0.2 会得到 0.30000000000000004,落到账上就是实打实的错账。表达式求值最后统一四舍五入到"分"。
  13. 明细页顶部的收支合计与列表中每一行出自同一份数据。合计直接在内存里对同一列表求和,保证页面上显示的汇总永远等于下面每一行相加,不会出现"列表加起来和顶部对不上"的经典问题。数据量上来后再换 SQL 聚合并需重新验证一致性。
  14. 转账不计入收支统计,只改变账户之间的余额分布;转出账户减少「金额 + 手续费」,转入账户增加金额,两边之和不变。
  15. 日期选择器与业务时间戳必须显式换算时区。Material3 的日期选择器用 UTC 零点毫秒,直接混用会出现"选 9 月 18 日、存成 9 月 17 日"的跨时区错账,统一走 DatePickerBridge。
  16. 删除流水必须提供撤销。本地库是物理删除,没有回收站,误删只能靠撤销找回;撤销时保留原 id 重新插入,避免其它表的引用错位。
  17. 弹层内不要使用主窗口的 Snackbar。ModalBottomSheet 是独立窗口,主窗口的 Snackbar 会被遮罩挡住,用户看不到任何反馈,提示要画在弹层内部。
  18. Vico 的每个系列必须至少有一个数据点。空列表会抛 IllegalArgumentException: Series can't be empty 并让界面崩溃;数据未就绪时先渲染占位,不能把空数据交给图表。
  19. 删除账本前必须查清流水条数并如实告知。账本下的流水通过外键级联删除,且无法恢复;最后一本账本不允许删除,否则用户会失去唯一的记账入口。
  20. 账户归属用户、账本归属用户,二者互不隶属。同一张银行卡在多本账之间共用,切换账本只改变流水的可见范围,不改变账户与余额。
  21. 预算跟着账本走。切到另一本账时看到的是那本账自己的预算,避免"旅行账本的预算在约束日常开销"这种错配。
  22. 日均支出按已过天数计算。当前账期只除以已经过去的天数;月初第 3 天把总支出除以 30 会显著低估,让用户误判还剩多少空间。
  23. 环比在上一账期无支出时不显示数字,用"—"表示无从比较,而不是显示 -100%。
  24. 隐藏的分类不能出现在记账面板。管理页要显示全部(含隐藏),记账面板只能显示可见的;这是两套不同的查询,observeTopLevel 必须带 isHidden = 0,否则用户"隐藏"了分类却发现它还在。此问题正是本轮实测中发现的。
  25. 有流水的账户不允许删除,只能归档。删除会让历史流水失去归属;同时要保证至少留下一个可用账户,否则用户下次记账时无账户可选。
  26. 预置分类只能隐藏,不能删除。它们是统计口径的一部分,删掉会让历史流水与图表对不上;自建分类若已有流水或有子分类,同样拒绝删除。
  27. 清空流水保留账本、账户、分类与预算。清空后用户应能立刻继续记账,而不是面对一个需要重新搭建的空应用。
  28. 信用卡与负债类账户默认不计入总资产,否则总资产会被负债撑高,用户看到的数字比实际乐观。
  29. 主题与动态取色存在 DataStore,在 Activity 层读取并决定 Theme。切换后立即生效且重启保留,不需要重启应用。
  30. 所有全屏页面都必须处理系统栏内边距。应用开启了 edge-to-edge,且测试机型顶部有 152px 的摄像头挖孔。不做内边距时,注册页的返回按钮正好落在状态栏覆盖区内,点击事件被系统截走,表现为"按钮点了没反应、而且离状态栏太近"——两个现象是同一个根因。用 Scaffold 的页面由 innerPadding 自动处理,手写布局的页面(登录、注册)必须显式调用 safeDrawingPadding()。
  31. 单选控件的选中态必须一眼可辨,并且用 Modifier.selectable 而不是 clickable。clickable 不会把选中状态暴露给无障碍服务,读屏用户无法知道当前选了什么;同时选中与未选中不能只靠填充深浅区分——分类宫格最初只有"实心 vs 12% 淡色"的差别,浅色底上用户根本看不出来,误以为点击没生效(数据库里记录的却已经是点击后的分类)。
  32. 记账时间戳不能用 ViewModel 创建时刻。记账面板的 ViewModel 生命周期与主界面一样长,若 occurredAt 只在构造时取值,同一会话里所有流水都会带着最初打开面板那一刻的时间;跨零点使用时甚至会把今天的账记到昨天。改为打开面板时取"此刻"、保存后保留用户选的日期但刷新时间。
  33. 写入流水必须落到"当前账本",编辑时保持原账本不变。新增时若写死默认账本,用户切到旅行账本记的账会悄悄进日常账本;编辑时若重新解析账本,改一笔账会把记录搬回默认账本。两者都是数据被挪走却没有任何提示。
  34. 切换账号后必须重建整棵主界面树。记账面板的 ViewModel 在创建时捕获 userId,不重建就会拿着旧账号的账户与分类去记账。根导航以 user.id 作为 key 即可保证切换后状态彻底重置。
  35. 用 sqlite3 直接改 App 数据库时必须先 PRAGMA foreign_keys=ON。命令行默认关闭外键,删除用户不会级联,会留下无主账本/账户/分类。App 自身的 Room 连接是开启外键的(已实测:删除账本后其流水一并消失,无孤儿数据)。
  36. 删除账号只能删当前登录账号,且必须依赖外键级联。数据层要再校验一次 currentUserId == userId:删除其它账号意味着在不知道对方密码的情况下销毁其数据,与"本地多账号彼此隔离"直接冲突。整个账号名下的账本、账户、分类、流水、标签、预算、定期账单全部靠级联清除,逐表删除既容易遗漏也无法保证原子性。删除后清空登录态,根导航自动回到登录页。
  37. 破坏性操作的确认框必须给出具体数据量,而不是笼统的"将删除全部数据"。删除账号的确认框会实时统计"N 笔流水、N 本账本、N 个账户、N 个分类",用户在按下确认前能确切知道自己要失去什么。
  38. Vico 的坐标轴标签格式化器绝不能返回空串,且必须对任意下标都能算出结果。图表切换数据做动画时,Vico 会查询当前数据集之外的 x 值(从 31 天的月份切到 30 天的月份时仍会问 x=30)。早期实现是"从数据列表按下标取标签",取不到就返回空串,直接导致 IllegalStateException。正确做法是由日期推算标签而不是查表,并在最外层保留一个非空的兜底。
  39. keep 规则写在 src/<变体>/keepRules/**.keep,而不是 proguard-rules.pro。AGP 只收集 .keep 后缀;该目录下出现 .pro 或 .pgcfg 会让构建直接失败。可以按变体分(main / debug / release / flavor),AGP 会全部合并。
  40. 不要照抄网上的通用 keep 规则模板。依赖自带的消费者规则会被 AGP 自动合并——本项目已确认 Room(proguard.txt)、kotlinx-serialization(kotlinx-serialization-common.pro,完整覆盖 @Serializable 的 Companion、$$serializer、INSTANCE.serializer())都自带;Hilt、Navigation Compose、Vico 不需要额外规则。重复手写不仅无益,还可能互相冲突。判断依据应当是"开启 R8 后跑一遍真实流程",而不是抄一份模板。
  41. optimization.enable 是所有收缩与混淆的总开关。它为 false 时,keepRules 目录里的规则完全不生效(实测 release 构建里连 R8 任务都不会出现,只有普通的 dex 任务)。
  42. 发布包必须用自己的签名证书。验证 R8 时用调试签名只是为了本机安装;build.gradle.kts 中 release 未配置签名,正式发布前必须补上,绝不能用调试签名发布。
  43. 备份文件不含任何凭据。备份会被放到云盘或电脑上,带上用户名与密码哈希等于把口令散落到应用之外。恢复时数据落到当前登录的账号下。
  44. 导入必须重映射主键。主键在本库全局自增,沿用备份里的 id 会与目标账号的既有记录撞车。导入前先清空当前账号数据,再按"账本 → 账户 → 分类(父级先于子级)→ 流水 → 预算 → 定期账单"的顺序重建并维护 旧id → 新id 映射。
  45. 导入前必须校验备份内部的引用自洽。导入是"先清空再写入",写到一半发现引用缺失就只能留下残局。因此在动数据库之前先检查流水/预算/定期账单引用的账本、账户、分类是否都存在,并要求至少含一本默认账本与一个账户。
  46. 定期账单必须存储锚定日。只按 nextTriggerAt 递推会让"每月 31 日"退化成 1/31 → 2/28 → 3/28 一路漂移;记住锚定日才能在短月钳位、到大月回到原位。
  47. 定期账单采用"打开应用时追补"而非后台调度。本地应用不在后台运行时生成流水没有意义,打开时一次性追平既简单,也不会在用户不知情的时候写入数据。单次追补有上限,避免长期未打开后一次灌入大量历史记录。
  48. 给已有表加 NOT NULL 列时,实体必须声明 @ColumnInfo(defaultValue = ...) 且与迁移里的 DEFAULT 一致。Room 会拿"实体期望的表结构"和"迁移后的实际结构"比对,两边不一致会导致迁移校验失败。
  49. 应用锁与登录密码是两回事。登录密码区分"本机上的哪个账号",应用锁保护"这台手机上的这个应用",因此锁的配置存在设备级 DataStore 里,与当前登录账号无关。锁密码同样用 PBKDF2 + 随机盐存储,不落明文。
  50. 锁定状态必须是三态(未知 / 锁定 / 已解锁)。配置是异步读出来的,若只有"锁定/已解锁"两态,启用应用锁后冷启动会有几毫秒先把主界面渲染出来再被覆盖——内容已经泄露了一帧。未知态一律渲染占位。
  51. 启用应用锁时必须先标记本次会话已解锁,再写配置。写入"启用"的那一刻 sessionUnlocked 若仍为 null,状态机会判定为锁定,导致根导航切换分支、整个主界面树连同导航栈被销毁重建,用户刚设完密码就被弹回首页。
  52. 应用锁启用期间加 FLAG_SECURE,禁止截屏并让最近任务里的缩略图变空白,避免解锁界面之外泄露内容。
  53. 忘记应用锁密码不提供找回入口。纯本地应用没有可用于验证身份的服务端,界面只能如实说明"需要清除应用数据",而不是给一个做不到的"找回"按钮。
  54. 滚轮日期选择器的"日"必须随年月联动。用户把月份从 3 月滚到 2 月时,"3 月 31 日"这个组合已不存在,必须收拢到 2 月 28(闰年 29)日,否则 LocalDate.of 会直接抛异常。规则集中在 DateSelection.clampToValidDay,日滚轮的取值范围由所选年月实时计算。
  55. 滚轮选中项取"离视口中心最近的一项",而不是 firstVisibleItemIndex:后者在手指停在两项之间时会来回跳,界面与选中值对不上。
  56. 没在用的依赖要及时删掉,它带的权限会算在你头上。WorkManager 从 P0 就声明在依赖里,但最终改用"打开应用时追补"后从未使用;它悄悄合并进 WAKE_LOCK、ACCESS_NETWORK_STATE、RECEIVE_BOOT_COMPLETED、FOREGROUND_SERVICE 四个权限。对一个宣称"纯本地"的记账应用来说,RECEIVE_BOOT_COMPLETED 这类权限会直接引发用户质疑。移除后 APK 权限只剩生物识别两项,体积也从 4.5 MB 降到 4.3 MB。

9.3 版本约束

当前工具链是 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 插件兼容性。这件事值得单独作为一次任务,不要与其他功能改动混在一起。

9.4 P0 验证结果

  • ./gradlew :app:assembleDebug 构建通过,产出 debug APK。
  • ./gradlew :app:testDebugUnitTest 通过,PeriodCalculatorTest 11 项用例全部通过,覆盖起始日为 1/5/28、平年与闰年二月、跨年与连续平移等边界。
  • Room 已生成 XRecordDatabase_Impl,schema 已导出至 app/schemas/。
  • 已在 Pixel 10 Pro Fold 模拟器(Android 17,1080x2364)上安装运行:四个一级页面均可正常切换,悬浮按钮可唤起记账面板,返回键行为正确,运行期间无崩溃日志。

9.5 P1 验证结果

在模拟器上完整走了一遍真实操作路径,并导出数据库逐条核对:

  • 首次启动进入登录页;注册 tobin 后直接进入主界面,数据库生成 1 个用户、1 个"日常账本"(默认账本)、1 个"现金"账户、19 个预置分类(支出 12 + 收入 7)。
  • 密码以 32 字节 PBKDF2 派生结果存储(Base64 后 44 字符),迭代次数 100000,明文未落库。
  • 进程被杀后重启仍是登录态;清除 DataStore 后回到登录页。
  • 错误密码被拒绝并提示"用户名或密码错误";正确密码正常进入主界面。
  • "我的"页显示当前账号,退出登录有二次确认,确认后回到登录页。
  • 注册第二个账号 alice 后,两个账号各自拥有 1 个账本、1 个账户、19 个分类;无主数据、跨账号父子分类、重复用户名均为 0 条。
  • 两个账号使用相同密码 abc123,盐与哈希完全不同,验证了每用户独立随机盐。

9.6 待处理

  • 基线已提交(commit 0ca56f6)。后续每个阶段独立提交,表结构变更必须与迁移脚本在同一次提交内。
  • 悬浮按钮当前位于右下角(Material 3 默认位置)。若要改成底部导航中间"骑跨"的样式,需要自定义底部栏布局。
  • 主题目前是 Material 3 默认紫色 + 动态取色,尚未确定品牌配色,等主要页面成型后统一设计。

9.7 P2 验证结果

单元测试 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 才有界面),这只影响模拟器上的测试数据。

9.8 P3 验证结果

单元测试 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 条约束修复,这条经验也一并记录在案。

9.9 顺延事项

  • 明细页的筛选与搜索、长按多选批量操作尚未实现。
  • 分类图标暂以"分类名首字 + 圆底"呈现,语义化图标键(如 restaurant)还没有对应素材。
  • 转账手续费字段已在数据库中预留,但没有录入入口。
  • 图表页尚未实现资产变化曲线;预算目前只支持月度周期与一级分类。
  • 账本排序、账本图标自定义未实现。

9.10 P4 第一阶段验证结果

本轮完成「我的」页的账户管理、分类管理、外观设置与清空流水,应用锁、数据导出导入、定期账单管理顺延。57 项单元测试通过,全程无崩溃。

  • 「我的」页已重建为设置中心:账号信息、记账(账户/分类/每月起始日)、其他(外观/清空流水/关于)、退出登录。
  • 账户管理:正确显示各账户余额(现金 20,880.00 = 收入 21,000 − 支出 20 − 转出 100);新建信用卡账户后 includeInTotal 自动为 false;对已有流水的账户执行删除被拒绝并给出原因。
  • 分类管理:预置分类删除被拒绝;隐藏"餐饮"后该分类在管理页标记"已隐藏",并且从记账面板中消失(此处修掉了一个真实缺陷:observeTopLevel 漏了 isHidden 过滤)。
  • 外观:切换到深色后设置以 DARK 写入 DataStore,重启后仍保留。主题的视觉效果未能用命令行确证(模拟器存在多个显示器,screencap 抓取的不是主屏,像素对比不可信),需要人工确认。
  • 清空流水:执行后流水表为 0 条,账本 2 本、账户 3 个、分类 19 个、预算 1 条全部保留。

9.11 登录/注册页系统栏缺陷修复

用户反馈注册页"返回按钮点击没反应,而且离状态栏太近"。排查发现两个现象同源:

  • 该机型 DisplayCutout 顶部 inset 为 152px,而注册页的返回按钮图标位于 y 89~148,整个落在状态栏覆盖区域内,触摸由系统消费,应用收不到点击。
  • 页面本身没有做任何系统栏内边距,所以视觉上也贴着状态栏。

修复方式是为登录页与注册页的根布局加上 safeDrawingPadding()(这两个页面是手写布局,没有 Scaffold 兜底)。修复后返回按钮从 y 89148 移到 y 202261,登录页标题移到 y 308,两处返回入口(左上角图标与底部"已有账号?返回登录")点击均正常。

这条经验说明:edge-to-edge 开启后,"控件看起来在屏幕内"和"控件能收到点击"是两回事,状态栏区域内的控件是可见但不可交互的。

9.12 分类选中态与记账时间戳修复

用户反馈"记账时点击分类没有选中状态"。排查过程与结论:

  • 从数据库反查发现,用户点过的分类已经正确存进了流水(其中一笔的分类是"交通",其余是默认的"餐饮"),说明点击逻辑本身没问题。
  • 真正的原因是选中态的表现太弱:只在圆形底色上做"实心 vs 12% 透明度"的区分,在浅色背景上几乎看不出来。
  • 修复:改成"实心圆 + 一圈浅色光晕 + 标签加粗"的双层表达;同时把 clickable 换成 selectable(role = RadioButton),让选中状态能被无障碍服务读取,也便于自动化验证。
  • 验证:点击"交通"后,无障碍树中"餐饮"由 checked=true 变为 false、"交通"由 false 变为 true,状态转移正确。账单页的账本选择同样改成了 selectable。

排查过程中还发现一个独立缺陷:同一会话内连续记账的流水共用同一个时间戳(用户当时的 5 笔记录 occurredAt 完全相同)。原因是记账面板的 ViewModel 生命周期与主界面一样长,而 occurredAt 只在构造时取值。已修复为打开面板时取"此刻"、保存后保留日期并刷新时间。修复后连续两笔记账的时间分别为 19:20:02 与 19:20:59。

9.13 切换账号功能与账本归属修复

切换账号:「我的」页头部新增当前账号展示与"切换账号"入口,弹层列出本机全部账号(当前账号标"当前"),选择其它账号需输入该账号密码。切换必须验密码——本地多账号的意义就是彼此不可见,免密切换会让隔离形同虚设。弹层底部提供"添加账号",复用注册页(隐藏了不适用的"返回登录"文案),注册成功即切到新账号。

实现要点:切换后根导航以 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。

9.14 删除账号

「我的」页在"退出登录"下方新增"删除账号"(危险色)。确认框会实时统计并列出该账号名下的数据量,然后一次性删除整个账号。

端到端验证(用一个临时账号 temp4 走完整流程,它在测试结束时删除了自己):

  • 先给 temp4 造了跨两个账本的数据:2 本账本、2 笔流水(分属两本账)、1 个账户、19 个分类。
  • 确认框正确显示"2 笔流水、2 本账本、1 个账户、19 个分类",与实际数据一致。
  • 确认删除后应用立即回到登录界面,无崩溃。
  • 数据库核对:temp4 的用户、账本、账户、分类、流水全部为 0;全局无主数据与孤儿流水均为 0;其它账号(tobin 7 笔流水、leo 0 笔)数据完好。

9.15 图表切月崩溃修复

用户反馈在图表页点击月份左右箭头时崩溃,堆栈为 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 崩溃,进程持续存活。

9.16 启用 R8 与 keep 规则

此前 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。

9.17 数据导出导入

「我的 → 数据备份」提供导出与恢复。文件为 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,与备份文件逐笔比对(按金额与发生时间排序)完全一致;主键由导入前的 121 变为 2235,证明重映射生效;孤儿流水、无主账本、无主分类均为 0;其它账号数据未受影响。

9.18 定期账单

「我的 → 定期账单」支持新建、编辑、启用/停用与删除,支持支出/收入/转账三种类型、每天/每周/每月/每年与自定义间隔,并可选择是否自动记账。

第一次真实的数据库迁移:为支持"每月 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。

顺带修复:定期账单编辑表单的金额输入框误用了账户页的"初始余额"文案,已改为"金额"。

9.19 应用锁

「我的 → 安全」提供应用锁: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 的那一瞬间状态机判定为锁定,根导航切换分支导致整棵主界面树连同导航栈被重建。改为先标记会话已解锁再写配置。

可用性修正:生物识别原本在设备不支持时整行隐藏,用户只能靠猜"为什么没有这个选项"。改为始终显示该行,不可用时置灰并给出原因("此设备尚未录入指纹或人脸,请先在系统设置中录入")。实测模拟器上有指纹硬件但未录入任何指纹,该行正确显示为禁用并附带说明。

9.20 日期选择改为滚轮

原先使用 Material3 的日历式 DatePicker,改为年 / 月 / 日三列滚轮 + 取消 / 确定,记账面板与定期账单两处同时替换。

实现要点:用 rememberSnapFlingBehavior 做滚动吸附;选中项取离视口中心最近的一项;日滚轮的取值范围由所选年月实时计算,并在年月变化时把越界的日期收拢到月末。原先为换算 Material3 日期选择器 UTC 毫秒而写的 DatePickerBridge 已无引用,一并删除。

验证(模拟器实测):

  • 打开选择器即定位到当前日期(2026年 / 9月 / 18日),三列均正确高亮中间行,下方为取消与确定。
  • 日滚轮冲到末尾停在 30 日——9 月只有 30 天,滚不过去。
  • 把月份滚到 2 月,原本选中的 30 日被收拢为 28 日,未出现非法日期导致的崩溃。
  • 切到 2 月后把日滚轮冲到末尾,停在 28 日,说明日滚轮取值范围随月份联动。
  • 点确定后记账面板的日期标签变为"2月28日";关闭面板未产生多余流水(仍为 14 笔)。
  • 新增 5 项 DateSelection 单元测试,覆盖平年/闰年二月与 30 天月份的收拢。