简介:这是一套基于Android Studio开发的轻量级个人记账应用实战项目,面向Android初学者与移动开发学习者,解决日常收支记录与管理需求,兼顾功能完整性与代码可读性。资源包共70个文件,含16个Java业务逻辑与Activity类、22个XML布局与资源定义、18个PNG图标素材,辅以3个Gradle构建配置及APK安装包、MP4演示视频、DOCX运行说明文档等,整体压缩后仅10.68MB,便于快速下载与本地调试。已有493人学习下载,体现了其在入门级Android项目实践中的高参考价值。用户可直接安装APK体验完整功能,也可导入Android Studio源码学习登录注册流程、SQLite本地数据存储、分类记账UI设计及账单增删改查逻辑实现,配套文档详细说明环境配置与调试步骤,是掌握Android基础开发链路的优质练手案例。
1. 为什么一个“记账本”要专门用 Android Studio 从零写?——它不是备忘录,而是本地持久化+业务逻辑闭环的轻量级财务终端
你打开手机里那个标着“记账”的 App,可能只觉得是随手记几笔收支。但真把它拆开看:每条记录要带时间戳、分类标签(餐饮/交通/娱乐)、金额正负、可选备注、支持按月汇总、能导出 CSV、甚至要响应夜间模式切换——这些加起来,已经是一个典型的本地优先(Local-First)移动业务系统。而 Android Studio 正是唯一能让你完整掌控这个闭环的开发环境:它提供原生 UI 组件(Material Components)、内置 SQLite 支持、生命周期感知(ViewModel + LiveData)、离线可用能力,以及对 Android 11+ 存储访问框架(SAF)和后台限制的精准适配。这不是“做个界面+存个文件”的玩具项目,而是面向真实用户每日高频使用的工具型应用。适合刚学完 Java/Kotlin 基础、想用一个可交付、可演示、可装进自己手机的真实项目来验证工程能力的开发者;也适合需要快速交付内部财务辅助工具的小团队——它不依赖服务器,不走云同步,所有数据留在设备本地,启动快、无网络抖动、隐私可控。下面我就带你从零开始,用 Android Studio 2023.1.1(稳定版)搭出一个真正能每天用、不翻车、不丢数据的安卓记账本系统。
2. 用 Android Studio 创建最小可运行记账本:从 New Project 到首次安装 APK 的四步闭环
2.1 创建项目时必须选对模板与最低 SDK 版本
新建项目时,不要选 “Empty Activity”—— 它默认不带 Navigation 和 Fragment 架构,后期加 Tab 或分类页会返工。正确做法是选择“Bottom Navigation Activity”模板(Android Studio ≥ 2022.1.1),它自动生成带底部导航栏的三 Tab 结构(首页/统计/我的),天然契合记账本的「记一笔 / 看汇总 / 设定」三大主路径。Minimum SDK 必须设为API 21(Android 5.0):低于此版本无法使用 ConstraintLayout 2.0+ 的链式布局和 Material Design 2 组件;高于 API 30(Android 11)则需额外处理分区存储(Scoped Storage)权限,对新手属于过早引入的复杂度。我们先跑通核心链路,再升级适配。
# 创建后立即执行(在项目根目录下) ./gradlew clean && ./gradlew build提示:
clean清掉旧构建缓存,build触发完整编译。别跳过这步——很多“运行报错”其实只是 Gradle 缓存脏了。
2.2 替换默认 Activity 为记账主界面:用 Fragment + ViewModel 重构导航流
Bottom Navigation 模板生成的MainActivity是FragmentContainerView+NavHostFragment结构。我们要把默认的HomeFragment替换为RecordFragment(记账页),并确保其 ViewModel 能跨配置变更(如横竖屏切换)保持状态:
// app/src/main/java/com/example/accountbook/RecordFragment.kt class RecordFragment : Fragment() { private var _binding: FragmentRecordBinding? = null private val binding get() = _binding!! private val viewModel: RecordViewModel by viewModels() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentRecordBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // 绑定 ViewModel 到 UI binding.viewModel = viewModel binding.lifecycleOwner = this } }逻辑说明:
FragmentRecordBinding是 Android Studio 自动生成的视图绑定类,避免findViewById;viewModels()委托由androidx.fragment:fragment-ktx提供,确保 ViewModel 生命周期与 Fragment 绑定;binding.lifecycleOwner = this是关键——没有它,LiveData 不会自动观察,UI 不会响应数据变化。
2.3 配置 Gradle 依赖:只加真正需要的库,拒绝“全家桶”式引入
app/build.gradle中删掉模板自带的implementation 'com.google.android.material:material:1.10.0'(版本太新,部分组件在 API 21 上有兼容问题),改用稳定版:
// app/build.gradle dependencies { implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.9.0' // 1.9.0 是 API 21 兼容的最后一个稳定版 implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.navigation:navigation-fragment-ktx:2.7.2' implementation 'androidx.navigation:navigation-ui-ktx:2.7.2' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2' implementation 'androidx.room:room-runtime:2.6.1' implementation 'androidx.room:room-ktx:2.6.1' kapt 'androidx.room:room-compiler:2.6.1' }参数说明:Room 2.6.1 是当前(2024 年中)最稳定的版本,支持 Kotlin 协程
suspend函数,且@Database注解在 API 21 上无 crash;kapt是 Kotlin Annotation Processing Tool,必须显式声明才能生成 DAO 实现类;所有版本号统一用.x形式(如2.6.1),避免2.6.0-alpha01这类不稳定预发布版。
2.4 运行前必做三件事:检查模拟器 ABI、启用 USB 调试、验证 APK 安装路径
- 模拟器 ABI:在 AVD Manager 中创建设备时,CPU/ABI 必须选 x86_64(不是 ARM64)。ARM64 模拟器在 Windows 上启动慢、GPU 渲染易黑屏,x86_64 兼容性好、启动快,且 Android Studio 自带的系统镜像对其优化充分。
- USB 调试:真机调试前,在手机设置 → 关于手机 → 连续点击“版本号”7 次激活开发者选项;再进入开发者选项 → 开启“USB 调试”。
- APK 安装路径验证:运行后,Android Studio 底部
Build窗口会显示类似Installation did not succeed. The application could not be installed: INSTALL_FAILED_CONFLICTING_PROVIDER的错误——这通常是因为你之前装过同包名未签名的测试版。解决方案:在终端执行
再点 Run。这是新手最常卡住的一步,不是代码问题,是环境残留。adb uninstall com.example.accountbook
3. 用 Room 实现本地记账数据持久化:建表、增删改查、事务回滚全链路
3.1 定义 Entity:一条记账记录的字段设计必须覆盖真实场景
记账不是简单存“金额+备注”,要考虑:
- 时间必须精确到秒(非仅日期),因为同一日多笔消费很常见;
- 分类不能是字符串硬编码(如
"餐饮"),而应引用独立Category表 ID,方便后期扩展图标、颜色、预算限额; - 金额用
Long类型存“分”(如 1999 表示 ¥19.99),规避浮点数精度丢失; - 是否为收入(
isIncome: Boolean)比用正负号更语义清晰,且避免amount < 0的歧义判断。
// app/src/main/java/com/example/accountbook/data/Transaction.kt @Entity(tableName = "transactions") data class Transaction( @PrimaryKey(autoGenerate = true) val id: Long = 0, val categoryId: Long, // 外键,关联 Category 表 val amount: Long, // 单位:分 val note: String = "", val timestamp: Long, // Unix timestamp 毫秒值 val isIncome: Boolean = false ) // app/src/main/java/com/example/accountbook/data/Category.kt @Entity(tableName = "categories") data class Category( @PrimaryKey(autoGenerate = true) val id: Long = 0, val name: String, val iconResId: Int = R.drawable.ic_category_default, val color: Int = Color.parseColor("#6200EE") // Material Purple 700 )逻辑说明:
@Entity标注类为数据库表;autoGenerate = true让 Room 自动分配主键,避免手动维护 ID 冲突;timestamp用Long存毫秒值,比Date对象更轻量、序列化更稳定;iconResId存资源 ID 整数,而非字符串路径,避免资源加载失败导致崩溃。
3.2 编写 DAO 接口:用 suspend 函数实现协程异步操作
DAO(Data Access Object)是 Room 的核心抽象层。所有数据库操作必须声明为suspend函数,由 Room 在后台线程自动调度,严禁在主线程直接调用.insert():
// app/src/main/java/com/example/accountbook/data/TransactionDao.kt @Dao interface TransactionDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(transaction: Transaction): Long @Query("SELECT * FROM transactions WHERE timestamp BETWEEN :start AND :end ORDER BY timestamp DESC") suspend fun getTransactionsByTimeRange(start: Long, end: Long): List<Transaction> @Query("SELECT SUM(amount) FROM transactions WHERE isIncome = 1 AND timestamp BETWEEN :start AND :end") suspend fun getTotalIncome(start: Long, end: Long): Long? @Query("SELECT SUM(amount) FROM transactions WHERE isIncome = 0 AND timestamp BETWEEN :start AND :end") suspend fun getTotalExpense(start: Long, end: Long): Long? @Query("DELETE FROM transactions WHERE id = :id") suspend fun deleteById(id: Long) @Query("UPDATE transactions SET note = :note WHERE id = :id") suspend fun updateNote(id: Long, note: String) }参数说明:
OnConflictStrategy.REPLACE表示插入时若主键冲突,则替换整行(适合编辑后保存);SUM(amount)返回Long?(可空),因为当无数据时返回null,避免NullPointerException;所有@Query必须显式指定ORDER BY,否则 Android 12+ 可能返回无序结果,导致 UI 显示错乱。
3.3 构建 Database 类:单例 + 创建时预置基础分类
Room Database 是整个持久化层的入口。必须用@Database注解,且version从 1 开始,后续升级时才需Migration:
// app/src/main/java/com/example/accountbook/data/AppDatabase.kt @Database( entities = [Transaction::class, Category::class], version = 1, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { abstract fun transactionDao(): TransactionDao abstract fun categoryDao(): CategoryDao companion object { @Volatile private var INSTANCE: AppDatabase? = null fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { INSTANCE ?: buildDatabase(context).also { INSTANCE = it } } } private fun buildDatabase(context: Context): AppDatabase { return Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "accountbook_database" ).addCallback(object : Callback() { override fun onCreate(db: SupportSQLiteDatabase) { super.onCreate(db) // 预置常用分类:餐饮、交通、购物、工资、红包 val categories = listOf( Category(name = "餐饮", iconResId = R.drawable.ic_food), Category(name = "交通", iconResId = R.drawable.ic_transport), Category(name = "购物", iconResId = R.drawable.ic_shopping), Category(name = "工资", iconResId = R.drawable.ic_salary, isIncome = true), Category(name = "红包", iconResId = R.drawable.ic_redpacket, isIncome = true) ) // 批量插入,避免逐条 insert 性能损耗 val categoryDao = INSTANCE?.categoryDao() ?: return CoroutineScope(Dispatchers.IO).launch { categories.forEach { categoryDao.insert(it) } } } }).build() } } }逻辑说明:
exportSchema = false关闭 Schema 导出(减少 APK 体积);addCallback在数据库首次创建时执行,预置分类让用户打开 App 就能记账,无需先手动添加;CoroutineScope(Dispatchers.IO).launch确保插入在 IO 线程执行,不阻塞主线程;synchronized+@Volatile是 Kotlin 单例的标准双重检查锁写法,比by lazy更安全。
3.4 在 ViewModel 中封装业务逻辑:把“记一笔”变成可测试的纯函数
ViewModel 不该直接操作 UI 或调用Toast,而应暴露LiveData或StateFlow供 UI 观察。记账动作的业务逻辑应抽离为 suspend 函数:
// app/src/main/java/com/example/accountbook/viewmodel/RecordViewModel.kt class RecordViewModel(private val transactionDao: TransactionDao) : ViewModel() { private val _uiState = MutableStateFlow(RecordUiState()) val uiState: StateFlow<RecordUiState> = _uiState.asStateFlow() fun onRecordClick(amount: Long, categoryId: Long, note: String, isIncome: Boolean) { viewModelScope.launch { try { val transaction = Transaction( categoryId = categoryId, amount = amount, note = note, timestamp = System.currentTimeMillis(), isIncome = isIncome ) transactionDao.insert(transaction) _uiState.value = _uiState.value.copy(isSuccess = true, message = "记账成功") // 3 秒后自动清除提示 delay(3000) _uiState.value = _uiState.value.copy(isSuccess = false, message = "") } catch (e: Exception) { _uiState.value = _uiState.value.copy(isSuccess = false, message = "记账失败:${e.message}") } } } } data class RecordUiState( val isSuccess: Boolean = false, val message: String = "" )参数说明:
viewModelScope是 ViewModel 自带的协程作用域,Activity 销毁时自动取消其中所有协程,防止内存泄漏;delay(3000)是 UI 提示的典型时长,比 Handler.postDelayed 更简洁;copy()是 Kotlin data class 的不可变更新方式,确保 StateFlow 发送新实例而非修改旧对象。
4. 记账本 UI 实现细节:Material 主题、输入校验、夜间模式适配、导出 CSV
4.1 使用 Material 3 主题统一视觉语言,禁用系统默认暗色主题干扰
在res/values/themes.xml中定义主题,关闭isLightTheme的自动推断,强制控制明暗:
<!-- res/values/themes.xml --> <style name="Theme.AccountBook" parent="Theme.Material3.DayNight.NoActionBar"> <item name="colorPrimary">@color/md_theme_light_primary</item> <item name="colorOnPrimary">@color/md_theme_light_onPrimary</item> <item name="android:forceDarkAllowed">false</item> <!-- 关键!禁用 Android 系统级暗色推断 --> </style>逻辑说明:
Theme.Material3.DayNight.NoActionBar提供 Material 3 的明暗双模式基础;android:forceDarkAllowed设为false后,App 不会响应系统“深色模式开启”广播,完全由自身AppCompatDelegate.setDefaultNightMode()控制,避免夜间模式下按钮文字消失等玄学问题。
4.2 输入金额时的防呆校验:用 TextWatcher 拦截非法字符,实时格式化
用户输入“12.345”或“-100”必须被拦截——金额只允许一位小数,且不能为负(支出用isIncome=false控制):
// RecordFragment.kt 中 onViewCreated 里 binding.etAmount.addTextChangedListener(object : TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun afterTextChanged(s: Editable?) { s?.let { // 移除所有非数字和小数点 val cleaned = it.toString().replace(Regex("[^0-9.]"), "") // 限制小数点最多一个 val dotCount = cleaned.count { it == '.' } if (dotCount > 1) { it.replace(0, it.length, cleaned.substringBeforeLast('.')) return } // 限制小数位数最多 2 位 if (cleaned.contains('.')) { val decimalPart = cleaned.substringAfter('.') if (decimalPart.length > 2) { it.replace(0, it.length, "${cleaned.substringBefore('.')}.${decimalPart.substring(0, 2)}") return } } // 实时转为“元.分”格式,如输入 123 → 显示 1.23 if (cleaned.isNotEmpty() && cleaned != ".") { val value = cleaned.toDoubleOrNull() ?: 0.0 val formatted = String.format("%.2f", value) if (it.toString() != formatted) { it.replace(0, it.length, formatted) } } } } })参数说明:
TextWatcher是 EditText 的监听器,afterTextChanged在每次输入后触发;String.format("%.2f", value)确保始终显示两位小数;it.replace()直接修改 Editable 内容,比setText()更高效,避免光标跳转。
4.3 夜间模式开关:用 SwitchMaterial 实现一键切换,状态持久化到 SharedPreferences
夜间模式不是“点一下就变”,必须记住用户选择,并在下次启动时恢复:
// RecordFragment.kt private fun setupNightModeSwitch() { val sp = requireContext().getSharedPreferences("settings", Context.MODE_PRIVATE) val isNightMode = sp.getBoolean("night_mode", false) binding.switchNightMode.isChecked = isNightMode binding.switchNightMode.setOnCheckedChangeListener { _, isChecked -> AppCompatDelegate.setDefaultNightMode( if (isChecked) AppCompatDelegate.MODE_NIGHT_YES else AppCompatDelegate.MODE_NIGHT_NO ) sp.edit().putBoolean("night_mode", isChecked).apply() // 重启 Activity 以应用主题变更 activity?.recreate() } }逻辑说明:
AppCompatDelegate.setDefaultNightMode()是 Jetpack 推荐的夜间模式控制方式;activity?.recreate()是必须的——主题变更不会自动重绘现有 View,必须重建 Activity;SharedPreferences存储布尔值,轻量且跨进程安全。
4.4 导出本月账单为 CSV:用 OutputStreamWriter 写入 SD 卡,适配 Android 11+ 分区存储
Android 11(API 30)起,应用无法直接写入外部存储根目录。必须用MediaStore或Storage Access Framework (SAF)。我们选 SAF,用户主动选择导出位置:
// RecordViewModel.kt fun exportThisMonthCsv(context: Context) { viewModelScope.launch { try { val now = Calendar.getInstance() val startOfMonth = Calendar.getInstance().apply { set(Calendar.DAY_OF_MONTH, 1) set(Calendar.HOUR_OF_DAY, 0) set(Calendar.MINUTE, 0) set(Calendar.SECOND, 0) set(Calendar.MILLISECOND, 0) } val endOfMonth = Calendar.getInstance().apply { set(Calendar.DAY_OF_MONTH, getActualMaximum(Calendar.DAY_OF_MONTH)) set(Calendar.HOUR_OF_DAY, 23) set(Calendar.MINUTE, 59) set(Calendar.SECOND, 59) set(Calendar.MILLISECOND, 999) } val transactions = transactionDao.getTransactionsByTimeRange( startOfMonth.timeInMillis, endOfMonth.timeInMillis ) // 触发 SAF 文件选择器 val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "text/csv" putExtra(Intent.EXTRA_TITLE, "accountbook_${now.get(Calendar.YEAR)}_${now.get(Calendar.MONTH) + 1}.csv") } (context as Activity).startActivityForResult(intent, EXPORT_CSV_REQUEST_CODE) } catch (e: Exception) { _uiState.value = _uiState.value.copy(message = "导出失败:${e.message}") } } }参数说明:
Intent.ACTION_CREATE_DOCUMENT是 SAF 标准 Intent,用户选择文件位置后,系统回调onActivityResult;type = "text/csv"告诉系统这是 CSV 文件;EXTRA_TITLE预设文件名,提升用户体验;getActualMaximum(Calendar.DAY_OF_MONTH)获取当月最后一天,避免手动算 30/31 天的边界错误。
5. 记账本开发避坑指南:5 个血泪经验总结,每个都踩过真坑
5.1 现象:App 启动后第一次记账成功,第二次点击无反应,Logcat 显示java.lang.IllegalStateException: Cannot access database on the main thread
原因:在 ViewModel 的onRecordClick中,误将transactionDao.insert(transaction)写在viewModelScope.launch外部,即主线程直接调用 DAO 方法。Room 默认禁止主线程访问数据库,强制要求协程或allowMainThreadQueries()(后者仅用于测试,绝不可上线)。
解决:确认所有 DAO 调用都在viewModelScope.launch { ... }或lifecycleScope.launch { ... }内;检查是否漏掉了suspend关键字——如果 DAO 函数没声明suspend,Room 编译器不会生成协程适配代码。
5.2 现象:夜间模式下,FloatingActionButton(FAB)背景变成黑色,图标看不见
原因:Material 3 的ExtendedFloatingActionButton默认使用colorSurface作为背景色,而夜间模式下colorSurface是深色,与图标颜色(colorOnSurface)对比度不足。
解决:在res/values-night/colors.xml中显式定义colorSurface为较浅的灰(如#121212),并在res/values/colors.xml中定义colorSurface为#FFFFFF;或直接在布局 XML 中给 FAB 设置app:backgroundTint="@color/fab_background",单独控制其夜间色值。
5.3 现象:导出 CSV 后,Excel 打开显示乱码(中文变成方块)
原因:CSV 文件未声明 UTF-8 BOM(Byte Order Mark),Windows Excel 默认用 GBK 解码。
解决:在写入 CSV 时,用OutputStreamWriter指定UTF-8编码,并在首行写入 BOM 字节:
val outputStream = contentResolver.openOutputStream(uri)!! val writer = OutputStreamWriter(outputStream, Charset.forName("UTF-8")) writer.write("\uFEFF") // BOM for UTF-8 writer.write("日期,分类,金额,备注,类型\n") // ... 后续写入数据 writer.close()5.4 现象:Android 12 设备上,Bottom Navigation 标签文字突然变大,挤占图标空间
原因:Android 12 引入fontScale系统设置,用户调高字体缩放比例时,BottomNavigationView默认会放大文字,但未适配图标尺寸。
解决:在res/values/dimens.xml中覆盖默认尺寸:
<dimen name="design_bottom_navigation_text_size" tools:override="true">12sp</dimen> <dimen name="design_bottom_navigation_active_text_size" tools:override="true">12sp</dimen>tools:override="true"确保覆盖系统默认值。
5.5 现象:真机调试时,App 安装后图标不显示,桌面快捷方式为空白
原因:AndroidManifest.xml中<application>标签漏写了android:icon属性,或引用的@mipmap/ic_launcher图标资源不存在(常见于复制项目时未同步mipmap文件夹)。
解决:检查AndroidManifest.xml,确认<application android:icon="@mipmap/ic_launcher">存在;在app/src/main/res/mipmap-*下,确保ic_launcher.png文件存在且尺寸合规(mipmap-hdpi至少 72×72,mipmap-xxhdpi至少 144×144);用 Android Studio 的Image Asset Studio重新生成 Launcher Icon,勾选Legacy选项以兼容旧设备。
6. 让记账本真正“能用”:三个进阶技巧——离线图表、数据备份、多端同步雏形
6.1 用 MPAndroidChart 绘制月度收支折线图:不联网也能看趋势
MPAndroidChart 是 Android 上最成熟的开源图表库,支持离线渲染。添加依赖后,只需几行代码就能把getTotalIncome/getTotalExpense的结果画成折线:
// app/build.gradle implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'// StatisticsFragment.kt private fun setupChart() { binding.chart.apply { description.isEnabled = false setNoDataText("暂无数据") xAxis.position = XAxis.XAxisPosition.BOTTOM axisLeft.axisMinimum = 0f legend.isEnabled = false setDrawGridBackground(false) } // 模拟获取近 6 个月数据(实际从 Room 查询) val entriesIncome = mutableListOf<Entry>() val entriesExpense = mutableListOf<Entry>() val months = listOf("1月", "2月", "3月", "4月", "5月", "6月") // 假设查询结果 val incomeData = listOf(1200f, 1500f, 1300f, 1800f, 1600f, 2000f) val expenseData = listOf(800f, 950f, 1100f, 1200f, 1050f, 1300f) incomeData.forEachIndexed { i, v -> entriesIncome.add(Entry(i.toFloat(), v)) } expenseData.forEachIndexed { i, v -> entriesExpense.add(Entry(i.toFloat(), v)) } val incomeSet = LineDataSet(entriesIncome, "收入").apply { setColors(Color.BLUE) lineWidth = 2f setCircleColor(Color.BLUE) circleRadius = 3f } val expenseSet = LineDataSet(entriesExpense, "支出").apply { setColors(Color.RED) lineWidth = 2f setCircleColor(Color.RED) circleRadius = 3f } val data = LineData(months, incomeSet, expenseSet) binding.chart.data = data binding.chart.invalidate() // 刷新图表 }技巧说明:
LineData构造时传入months作为 X 轴标签,避免用数字索引;setCircleColor和circleRadius让数据点更醒目;invalidate()是必须调用的刷新方法,否则图表不绘制。这个图表完全离线,不依赖任何网络服务,用户打开即见趋势。
6.2 数据备份到本地文件:用 JSON 序列化 + 加密保护隐私
记账数据敏感,不能裸存。用Gson序列化 +AES加密,备份到应用私有目录(无需权限):
// BackupManager.kt class BackupManager(private val context: Context) { private val gson = Gson() private val cipher = Cipher.getInstance("AES/GCM/NoPadding") fun backupToPrivateFile(transactions: List<Transaction>, categories: List<Category>) { val backupData = BackupData(transactions, categories) val json = gson.toJson(backupData) // AES 加密 val key = generateKey() val iv = ByteArray(12) // GCM IV 长度为 12 字节 SecureRandom().nextBytes(iv) cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, iv)) val encrypted = cipher.doFinal(json.toByteArray(Charsets.UTF_8)) val fullData = iv + encrypted // IV + 密文 val file = File(context.filesDir, "backup_${System.currentTimeMillis()}.enc") FileOutputStream(file).use { it.write(fullData) } } private fun generateKey(): SecretKeySpec { val keyBytes = ByteArray(16) SecureRandom().nextBytes(keyBytes) return SecretKeySpec(keyBytes, "AES") } } data class BackupData( val transactions: List<Transaction>, val categories: List<Category> )参数说明:
context.filesDir是应用私有目录,无需申请存储权限;AES/GCM/NoPadding是现代加密标准,GCM 模式自带完整性校验;IV(初始化向量)必须随机生成且随密文一起保存,解密时才能还原;fullData = iv + encrypted是标准拼接方式,解密时先取前 12 字节为 IV,剩余为密文。
6.3 多端同步雏形:用 WorkManager 定期上传增量数据到自有服务器
同步不是“全量上传”,而是只传新增/修改的记录(lastSyncTimestamp)。用WorkManager在后台低功耗执行:
// SyncWorker.kt class SyncWorker( context: Context, params: WorkerParameters ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { val lastSync = getPreferenceLong("last_sync_timestamp", 0L) val newTransactions = transactionDao.getTransactionsByTimeRange(lastSync, Long.MAX_VALUE) if (newTransactions.isEmpty()) return Result.success() // 调用自有 API 上传(此处省略 Retrofit 调用细节) val apiResponse = uploadToServer(newTransactions) if (apiResponse.isSuccessful) { setPreferenceLong("last_sync_timestamp", System.currentTimeMillis()) return Result.success() } else { return Result.retry() // 网络失败则重试 } } } // 在 Application 初始化时启动周期同步 PeriodicWorkRequestBuilder<SyncWorker>() .setIntervalDuration(15, TimeUnit.MINUTES) // 每 15 分钟检查一次 .setConstraints(Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) // 仅在联网时执行 .build()) .build()技巧说明:
PeriodicWorkRequestBuilder设置 15 分钟间隔,平衡及时性与电量消耗;setRequiredNetworkType避免无网时浪费资源;Result.retry()让 WorkManager 自动重试,无需手动管理;last_sync_timestamp存在 SharedPreferences 中,保证只传增量。这已是生产级同步方案的最小可行雏形,比 Firebase Realtime Database 更可控、更省钱。
我做记账本项目时,最初以为“存个数据就行”,结果在 Android 11 分区存储、夜间模式图标、CSV 乱码上反复折腾三天。后来才明白:一个真正能每天用的工具 App,90% 的工作量不在“记账”本身,而在让每一次点击、每一次切换、每一次导出都稳如磐石。现在我的手机里还装着这个版本,它没上架、没广告、不联网,但每月初我都会打开它,看一眼上月收支曲线——那一刻,代码就活了。希望帮到你。
本文还有配套的精品资源,点击获取