尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android Studio开发本地记账App:Room持久化+Material UI实战

Android Studio开发本地记账App:Room持久化+Material UI实战 简介这是一套基于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 21Android 5.0低于此版本无法使用 ConstraintLayout 2.0 的链式布局和 Material Design 2 组件高于 API 30Android 11则需额外处理分区存储Scoped Storage权限对新手属于过早引入的复杂度。我们先跑通核心链路再升级适配。# 创建后立即执行在项目根目录下 ./gradlew clean ./gradlew build提示clean清掉旧构建缓存build触发完整编译。别跳过这步——很多“运行报错”其实只是 Gradle 缓存脏了。2.2 替换默认 Activity 为记账主界面用 Fragment ViewModel 重构导航流Bottom Navigation 模板生成的MainActivity是FragmentContainerViewNavHostFragment结构。我们要把默认的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 自动生成的视图绑定类避免findViewByIdviewModels()委托由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 上无 crashkapt是 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的错误——这通常是因为你之前装过同包名未签名的测试版。解决方案在终端执行adb uninstall com.example.accountbook再点 Run。这是新手最常卡住的一步不是代码问题是环境残留。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 函数实现协程异步操作DAOData 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): ListTransaction 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 线程执行不阻塞主线程synchronizedVolatile是 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: StateFlowRecordUiState _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 主题、输入校验、夜间模式适配、导出 CSV4.1 使用 Material 3 主题统一视觉语言禁用系统默认暗色主题干扰在res/values/themes.xml中定义主题关闭isLightTheme的自动推断强制控制明暗!-- res/values/themes.xml -- style nameTheme.AccountBook parentTheme.Material3.DayNight.NoActionBar item namecolorPrimarycolor/md_theme_light_primary/item item namecolorOnPrimarycolor/md_theme_light_onPrimary/item item nameandroid:forceDarkAllowedfalse/item !-- 关键禁用 Android 系统级暗色推断 -- /style逻辑说明Theme.Material3.DayNight.NoActionBar提供 Material 3 的明暗双模式基础android:forceDarkAllowed设为false后App 不会响应系统“深色模式开启”广播完全由自身AppCompatDelegate.setDefaultNightMode()控制避免夜间模式下按钮文字消失等玄学问题。4.2 输入金额时的防呆校验用 TextWatcher 拦截非法字符实时格式化用户输入“12.345”或“-100”必须被拦截——金额只允许一位小数且不能为负支出用isIncomefalse控制// 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必须重建 ActivitySharedPreferences存储布尔值轻量且跨进程安全。4.4 导出本月账单为 CSV用 OutputStreamWriter 写入 SD 卡适配 Android 11 分区存储Android 11API 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用户选择文件位置后系统回调onActivityResulttype 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 函数没声明suspendRoom 编译器不会生成协程适配代码。5.2 现象夜间模式下FloatingActionButtonFAB背景变成黑色图标看不见原因Material 3 的ExtendedFloatingActionButton默认使用colorSurface作为背景色而夜间模式下colorSurface是深色与图标颜色colorOnSurface对比度不足。解决在res/values-night/colors.xml中显式定义colorSurface为较浅的灰如#121212并在res/values/colors.xml中定义colorSurface为#FFFFFF或直接在布局 XML 中给 FAB 设置app:backgroundTintcolor/fab_background单独控制其夜间色值。5.3 现象导出 CSV 后Excel 打开显示乱码中文变成方块原因CSV 文件未声明 UTF-8 BOMByte Order MarkWindows 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 namedesign_bottom_navigation_text_size tools:overridetrue12sp/dimen dimen namedesign_bottom_navigation_active_text_size tools:overridetrue12sp/dimentools:overridetrue确保覆盖系统默认值。5.5 现象真机调试时App 安装后图标不显示桌面快捷方式为空白原因AndroidManifest.xml中application标签漏写了android:icon属性或引用的mipmap/ic_launcher图标资源不存在常见于复制项目时未同步mipmap文件夹。解决检查AndroidManifest.xml确认application android:iconmipmap/ic_launcher存在在app/src/main/res/mipmap-*下确保ic_launcher.png文件存在且尺寸合规mipmap-hdpi至少 72×72mipmap-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 mutableListOfEntry() val entriesExpense mutableListOfEntry() 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: ListTransaction, categories: ListCategory) { 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: ListTransaction, val categories: ListCategory )参数说明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 初始化时启动周期同步 PeriodicWorkRequestBuilderSyncWorker() .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 乱码上反复折腾三天。后来才明白一个真正能每天用的工具 App90% 的工作量不在“记账”本身而在让每一次点击、每一次切换、每一次导出都稳如磐石。现在我的手机里还装着这个版本它没上架、没广告、不联网但每月初我都会打开它看一眼上月收支曲线——那一刻代码就活了。希望帮到你。本文还有配套的精品资源点击获取
返回列表