ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android备忘录实战:Room、RecyclerView与增删改查

Android备忘录实战:Room、RecyclerView与增删改查 Android Studio 的安装包刚下完那会儿我也以为做个备忘录就是拖几个控件、写两行点击事件的事。真正动手才发现从环境配置到数据落盘中间隔着一整套需要想清楚的东西数据存哪儿、列表怎么刷新、编辑页面删掉之后回退怎么同步、进程被杀掉之后草稿还在不在。这也是我一直推荐用简单备忘录当第一个完整 Android 项目的原因——它的功能边界小到能一次做完但麻雀虽小数据库、列表、页面跳转、权限、生命周期这些 Android 开发里的主线知识一个都跑不掉。这篇内容就是把我从零搭一个备忘录的完整过程摊开讲Android Studio 怎么装、项目怎么建、Room 怎么用、RecyclerView 适配器怎么写、增删改查的链路怎么串以及那些只有真机上跑过才会撞见的坑。适合刚学完 Kotlin 语法、想找个东西练手的入门者也适合以前写 Java 老项目、想快速对齐现代写法的朋友。1. 为什么我把备忘录定为第一个完整实战项目1.1 需求小但技术链路是完整的做过十几个练手项目之后我越来越觉得选入门项目的标准不是功能多而是链路全。一个计算器 App 逻辑再绕它也就一个页面、一份内存数据做完你对 Android 的理解还是停留在 UI 层一个天气 App 要联网要解析 JSON但网络请求的坑太多很容易第一天就卡在接口上劝退。备忘录刚好卡在一个很舒服的位置它有明确的持久化需求逼着你去接触数据库它有列表和详情两个页面逼着你去理解 Activity 跳转和 Intent 传值它有增删改查四种操作逼着你去处理数据变更和界面刷新的同步问题。更关键的是这个需求的完整度很高。你可以只做一版能存文字的最小可用版本两天跑通也可以往上叠加置顶、搜索、多选删除、深色模式、自动草稿一直加到自己不想加为止。我见过太多人卡在项目做完了但不知道下一步干什么备忘录这种可以无限纵向加深的题材正好解决了这个问题。从求职角度看这东西也不是完全拿不出手。面试官看的是你能不能把一件事讲清楚为什么用 Room 不用裸 SQLite、列表用 DiffUtil 刷新是为了解决什么、数据库操作放主线程会怎样。这些问题你在备忘录项目里都真实遇到过讲起来是有细节的比背八股要扎实得多。1.2 技术选型上我做的三个取舍第一个取舍是数据库裸 SQLite 还是 Room。裸写SQLiteOpenHelper能让你理解建表、升级、游标的完整过程但写起来是真的啰嗦一个查询要拼 SQL 字符串、要手动关 Cursor一个字段改名就要处理版本升级。Room 把这些包了一层用注解写实体和查询编译期就能检查 SQL 语法错误——这个特性非常值钱意味着你写错字段名的时候是编译不过而不是运行到那一行才崩。我的建议是第一次做备忘录先用 Room 把流程跑通回头再补一篇文章专门讲裸 SQLite 的底层实现理解 Room 到底帮你做了什么。第二个取舍是列表RecyclerView 还是 ListView。现在没有任何理由在新项目里用 ListViewRecyclerView 的 ViewHolder 复用机制更彻底支持局部刷新配合 DiffUtil 还能自动算差异做动画。代价是代码量确实多一些要写 Adapter、ViewHolder、有时还要写 ItemDecoration。但这是值得的因为 RecyclerView 的这套思路在后面的 ViewPager2、Compose 的 LazyColumn 里都是相通的。第三个取舍是数据观察方式LiveData 还是自己发广播、回调。裸写回调最容易出的问题是内存泄漏和忘了通知界面刷新删了一条数据结果列表还在。LiveData 把数据变了自动通知这件事做成了默认行为配合 Room 的返回值包装几乎不用手写刷新逻辑。代价是要理解生命周期感知这个概念但一旦理解就回不去了。2. 开发环境搭建地基没打稳后面全是坑2.1 Android Studio 安装与几个真实的踩坑点安装包去官网下就行不要贪图各种绿色版精简版那些版本砍掉的组件你在后面某个环节一定会需要。下载的时候注意区分版本通道稳定版和预览版的表现差异挺大入门阶段一律选稳定版。安装过程本身没什么好说的一路下一步真正的坑都在第一次启动之后。第一个坑是 SDK 下载。首次启动会让你选 SDK 路径和下载组件这里我建议至少装三个东西目标版本的 SDK Platform、对应版本的 Build-Tools、以及 Android SDK Command-line Tools。命令行工具很多人会忽略觉得用不上但后面配环境变量、用 adb 直接操作设备、跑一些脚本化任务的时候没它真的会抓瞎。SDK 路径强烈建议不要放在系统盘默认位置也别放在带中文或空格的目录里Gradle 在解析路径时对这类字符的处理历史上一向不太稳报错信息还特别含糊。第二个坑是 Gradle 同步慢。第一次建项目Gradle 要下载一堆依赖速度取决于网络。这里我的经验是不要频繁点Sync Now打断它同步过程被中断过一次之后缓存状态可能就乱了表现出来的症状是明明什么都没改却一直报依赖找不到。真遇到这种情况可以去项目根目录把.gradle和build目录删掉重新同步比反复点重试有效得多。第三个坑是语言。Android Studio 本身是可以装中文语言包的装完菜单变成中文对刚上手的人确实友好一些。我的态度是可以用但别依赖。因为大量的报错信息、StackOverflow 上的答案、官方文档全是英文你要是只认中文菜单遇到报错还是要回去查英文。折中方案是菜单用中文但遇到任何术语都顺手记一下它的英文原文两边都认识后面查资料会顺很多。还有一个容易被忽略的点是内存。Android Studio 跑起来很吃内存如果你机器是 8G 内存同时开着模拟器和浏览器基本就是卡到怀疑人生。可以去设置里调整 IDE 的最大堆内存一般给到 2G 到 4G 之间别给太大给大了反而会因为频繁 GC 卡顿。真机调试会比模拟器流畅很多后面会讲到怎么连。2.2 新建项目时那几个选项到底该怎么选打开新建项目向导选模板这一步选Empty Views Activity不要选带 Compose 的那个。原因不是 Compose 不好而是入门阶段你需要先理解 View 体系知道一个界面是由 XML 描述、由 Activity 加载的这个过程很直观。等你把 View 玩明白了再去学 Compose会更容易理解它在解决什么问题。接下来几个参数逐个说。Name 就是项目名随便起但别用中文。Package name 是包名全局唯一一般用反着的域名格式比如com.example.memo。包名一旦确定后面改动成本很高因为它是 Application ID、签名、数据目录名的一部分所以第一次就起得规范一点。Language 选 Kotlin这个没什么好犹豫的。Minimum SDK 这一项是新手最容易纠结的选低了担心用不了新 API选高了担心老设备装不上。我的经验是看 Android Studio 给出的覆盖率提示选一个覆盖率在 90% 以上的版本就够了。因为低版本设备的保有量其实远没有想象中大为了那 5% 的老设备牺牲大量新 API 的便利性不划算。而且真需要新 API 的时候还有兼容库可以兜底。Build configuration language 选 Kotlin DSL也就是build.gradle.kts。Groovy 写法的教程满大街都是但你新开的项目默认就是 Kotlin DSL 了语法提示和类型检查都更好与其以后被迫迁移不如一开始就用新的。2.3 项目目录结构每个文件夹是干什么的项目建好之后左侧面板切换到 Android 视图默认就是这个你会看到几个分组的目录。这个视图是把真实目录做了映射的看起来清爽但和磁盘上的结构不完全一样想看清楚真实结构可以切到 Project 视图。manifests目录下是AndroidManifest.xml这是应用的身份证声明了应用有哪些 Activity、需要哪些权限、图标和名称是什么。备忘录项目里你至少要在这里注册编辑页的 Activity如果要加分享功能还得声明对应的组件。java目录按包名分层你的 Kotlin 代码都放这里。我习惯在里面再分几个子包data放实体、DAO、数据库ui放 Activity 和 Adapterutil放工具类。这样项目长大后不会所有文件堆在一个平铺的列表里。res目录是资源的集合展开后你会看到好几类。layout放 XML 布局values放字符串、颜色、尺寸、主题drawable放图片和矢量图mipmap放应用图标。这里有个好习惯要早点养成所有界面上出现的文字都抽到strings.xml里不要硬编码在布局中。这不只是为了以后做多语言更重要的时候以后改文案的时候只用改一处不用全局搜替换。!-- res/values/strings.xml -- resources string nameapp_name备忘录/string string namehint_title标题/string string namehint_content写点什么…/string string nameaction_save保存/string string nameempty_tip还没有备忘录点右下角新建/string /resourcesGradle 文件在Gradle Scripts分组下build.gradle.kts有两个一个是项目级的一个是模块级的。真正需要你经常改的是模块级那个加依赖、配编译版本都在这里。依赖不要一股脑全塞进去能不加就不加每多一个库就多一份编译时间和潜在的版本冲突。3. 数据层设计备忘录的核心其实是一张表3.1 表结构怎么设计才不容易返工备忘录的表看起来简单但字段设计有几处值得琢磨。最基本的是自增主键、标题、内容、创建时间、更新时间。这里有个细节时间不要存成格式化好的字符串存成毫秒时间戳Long。因为字符串格式的时间没法排序、没法比较、没法做范围查询你想按时间倒序排列表的时候就傻眼了。显示的时候再格式化成今天 14:30这种人类可读的形式展示和存储分开。第二个细节是标题和内容要不要分两张表。我建议不分。因为备忘录里标题和内容是一体的一条记录就是一个整体拆表只会让查询和事务变复杂没有任何收益。第三个细节是删除怎么做。物理删除直接DELETE掉简单直接逻辑删除加一个isDeleted标记位好处是可以做回收站和撤销。入门版本我建议先做物理删除把流程跑通之后想加回收站了再加字段。顺序反了容易一开始就设计过度。再往后加置顶、加分类、加提醒时间都是往这张表里加字段的事。加字段就必然涉及数据库版本升级这是另一个话题下面单独讲。字段名类型用途备注idINTEGER主键自增Room 里加 autoGeneratetitleTEXT标题可空取内容首行兜底contentTEXT正文可空createdAtINTEGER创建时间毫秒时间戳updatedAtINTEGER更新时间毫秒时间戳列表按它排序isPinnedINTEGER是否置顶入门版可以先不加3.2 用 Room 写实体、DAO 和数据库Room 的用法就三块实体类描述表结构DAO 描述能做什么操作数据库类把两者串起来。实体类就是普通的 Kotlin 数据类加上注解。表名我用note因为 SQLite 里notes和note都行但保持单数是个习惯问题主要是别和 SQL 关键字撞名。// data/Note.kt Entity(tableName note) data class Note( PrimaryKey(autoGenerate true) val id: Long 0L, ColumnInfo(name title) val title: String , ColumnInfo(name content) val content: String , ColumnInfo(name createdAt) val createdAt: Long System.currentTimeMillis(), ColumnInfo(name updatedAt) val updatedAt: Long System.currentTimeMillis(), ColumnInfo(name isPinned) val isPinned: Boolean false )注意主键我这里给了默认值 0。这是 Room 的惯例插入的时候传 0数据库会自动分配查询出来的时候是真实 id。如果你不给默认值每次构造对象都要显式传 id写起来很烦。DAO 是个接口用注解写查询。返回值这里我用Flow这样数据一变界面自动收到通知。如果你对 Flow 还不熟用LiveData也可以效果类似Flow是更新的做法。// data/NoteDao.kt Dao interface NoteDao { Query(SELECT * FROM note ORDER BY isPinned DESC, updatedAt DESC) fun observeAll(): FlowListNote Query(SELECT * FROM note WHERE id :id LIMIT 1) suspend fun findById(id: Long): Note? Query(SELECT * FROM note WHERE title LIKE % || :kw || % OR content LIKE % || :kw || % ORDER BY updatedAt DESC) fun search(kw: String): FlowListNote Insert(onConflict OnConflictStrategy.REPLACE) suspend fun upsert(note: Note): Long Delete suspend fun delete(note: Note) Query(DELETE FROM note WHERE id IN (:ids)) suspend fun deleteByIds(ids: ListLong) }排序这里有个小技巧ORDER BY isPinned DESC, updatedAt DESC。SQLite 里布尔值存成 0 和 1DESC让 1 排在前面正好实现置顶。这样写的好处是排序逻辑在数据库层完成界面层拿到就是有序的不用在适配器里再排一次。数据库类用RoomDatabase抽象类注意它是单例一定要做双重检查加锁或者用companion object里的懒加载否则每个页面各建一个实例数据会出现这边删了那边还在的诡异现象。// data/NoteDatabase.kt Database(entities [Note::class], version 1, exportSchema false) abstract class NoteDatabase : RoomDatabase() { abstract fun noteDao(): NoteDao companion object { Volatile private var INSTANCE: NoteDatabase? null fun get(context: Context): NoteDatabase INSTANCE ?: synchronized(this) { INSTANCE ?: Room.databaseBuilder( context.applicationContext, NoteDatabase::class.java, note.db ).build().also { INSTANCE it } } } }exportSchema false是关掉 schema 导出入门阶段用不上。但如果以后团队协作或者要写数据库迁移测试把它打开并把导出的 json 提交到版本控制里是很有价值的做法。3.3 数据库版本升级这件事别等出事才想第一版跑通之后你大概率会想加字段比如加个置顶、加个颜色标签。这时候如果直接改实体类不改版本号运行会崩报的是 schema 不一致的错误。正确的做法是把version加一然后提供Migration。val MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(ALTER TABLE note ADD COLUMN isPinned INTEGER NOT NULL DEFAULT 0) } }然后在databaseBuilder后面.addMigrations(MIGRATION_1_2)挂上去。我踩过的坑是一开始图省事用了fallbackToDestructiveMigration()也就是版本对不上就把表删了重建。开发阶段这么干确实爽不用写迁移但一旦这个 App 装到别人手机上一次版本更新就把人家所有备忘录清空了。所以这个方法的适用范围只有一个——本地开发、数据随时可以丢的阶段。上架之前一定要换成真正的迁移。4. 界面层实现列表页和编辑页怎么串起来4.1 主界面布局的写法主界面就三样东西一个列表一个悬浮的新建按钮一个空状态提示。根布局我一般用ConstraintLayout因为它的定位能力最强一层就能搞定复杂布局减少嵌套层级。层级浅的好处是测量和绘制快界面复杂起来之后这个差距很明显。!-- res/layout/activity_main.xml -- androidx.constraintlayout.widget.ConstraintLayout xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:apphttp://schemas.android.com/apk/res-auto android:layout_widthmatch_parent android:layout_heightmatch_parent androidx.recyclerview.widget.RecyclerView android:idid/recyclerView android:layout_width0dp android:layout_height0dp android:clipToPaddingfalse android:paddingBottom88dp app:layout_constraintTop_toTopOfparent app:layout_constraintBottom_toBottomOfparent app:layout_constraintStart_toStartOfparent app:layout_constraintEnd_toEndOfparent / TextView android:idid/tvEmpty android:layout_widthwrap_content android:layout_heightwrap_content android:textstring/empty_tip android:visibilitygone app:layout_constraintTop_toTopOfparent app:layout_constraintBottom_toBottomOfparent app:layout_constraintStart_toStartOfparent app:layout_constraintEnd_toEndOfparent / com.google.android.material.floatingactionbutton.FloatingActionButton android:idid/fabAdd android:layout_widthwrap_content android:layout_heightwrap_content android:layout_margin20dp android:contentDescriptionstring/action_save android:srcdrawable/ic_add app:layout_constraintBottom_toBottomOfparent app:layout_constraintEnd_toEndOfparent / /androidx.constraintlayout.widget.ConstraintLayoutclipToPaddingfalse加上底部 padding是为了让列表滚动到底部时最后一条不被悬浮按钮挡住同时滚出屏幕的内容可以延伸到 padding 区域之外视觉上更舒服。这是个很实用的小细节很多人第一次做列表都会被悬浮按钮遮挡问题困扰。Activity 里的初始化逻辑不复杂设置布局管理器、绑定适配器、订阅数据、处理按钮点击。class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var adapter: NoteAdapter private val viewModel: NoteViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) adapter NoteAdapter { note - startActivity(Intent(this, EditActivity::class.java) .putExtra(note_id, note.id)) } binding.recyclerView.layoutManager LinearLayoutManager(this) binding.recyclerView.adapter adapter binding.recyclerView.setHasFixedSize(true) binding.fabAdd.setOnClickListener { startActivity(Intent(this, EditActivity::class.java)) } lifecycleScope.launch { viewModel.notes.collect { list - adapter.submitList(list) binding.tvEmpty.visibility if (list.isEmpty()) View.VISIBLE else View.GONE } } } }setHasFixedSize(true)的意思是列表项尺寸固定这样 RecyclerView 在数据变化时可以跳过重新测量滚动更流畅。前提是你的 item 高度确实不随内容变如果内容行数不定就老老实实去掉这行。4.2 适配器DiffUtil 是必须的适配器是 RecyclerView 里代码量最大也最容易写错的地方。核心是 ViewHolder 复用思路是屏幕只显示十来条你滑动的时候回收离开屏幕的 View复用来显示新进来的数据。所以千万不要在onBindViewHolder里做耗时操作也别在里面根据 position 做条件判断后不重置状态——那样会出现这条本来该显示灰色滑出去再滑回来变成白色的经典 bug。列表刷新我强烈建议用ListAdapter配合DiffUtil而不是每次notifyDataSetChanged()。原因很直接notifyDataSetChanged会让所有 item 都重新绑定并播放淡入淡出看不出动画滑动位置还可能跳。ListAdapter会自动算出哪几条新增、哪几条删除、哪几条内容变了只刷新变化的部分还自带交互动画。class NoteAdapter( private val onClick: (Note) - Unit ) : ListAdapterNote, NoteAdapter.VH(Diff) { object Diff : DiffUtil.ItemCallbackNote() { override fun areItemsTheSame(o: Note, n: Note) o.id n.id override fun areContentsTheSame(o: Note, n: Note) o n } class VH(val binding: ItemNoteBinding) : RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { val b ItemNoteBinding.inflate( LayoutInflater.from(parent.context), parent, false) return VH(b) } override fun onBindViewHolder(holder: VH, position: Int) { val note getItem(position) holder.binding.tvTitle.text note.title.ifBlank { note.content.lineSequence().firstOrNull().orEmpty() } holder.binding.tvContent.text note.content holder.binding.tvTime.text formatTime(note.updatedAt) holder.binding.root.setOnClickListener { onClick(note) } } }这里有个逻辑值得说一下标题为空的时候用内容首行兜底。因为用户写备忘录经常只写正文不写标题列表里如果标题是空白看起来就像一条坏数据。areItemsTheSame比 idareContentsTheSame直接用数据类的因为数据类的 equals 是编译器自动生成的逐字段比较省事又准确。时间格式化我放在工具类里用SimpleDateFormat或者新版的DateTimeFormatter都行。一个小优化是今天的显示HH:mm昨天的显示昨天更早的显示MM-dd跨年的显示yyyy-MM-dd。这种细节用户不一定说得出好在哪但体感上会觉得这个 App 很懂他。4.3 编辑页新建、修改、删除的完整链路编辑页承担两种职责新建和修改。区分方式就是一个 Intent 参数。有 note_id 就是修改没有就是新建。这个模式简单有效比搞两个 Activity 省事得多。里面有几个关键点。第一读取旧数据必须放在子线程Room 的suspend方法天然要求你在协程里调用。第二自动保存不能每次输入都写库那样数据库压力大而且频繁更新 UI 状态也浪费要做防抖比如停留 800 毫秒没有新输入才落盘。第三返回时要注意保存还是丢弃。class EditActivity : AppCompatActivity() { private var noteId: Long 0L private var loaded: Note? null private var saveJob: Job? null private val binding by lazy { ActivityEditBinding.inflate(layoutInflater) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) noteId intent.getLongExtra(note_id, 0L) if (noteId ! 0L) { lifecycleScope.launch { loaded viewModel.find(noteId) loaded?.let { binding.etTitle.setText(it.title) binding.etContent.setText(it.content) } } } binding.etContent.doAfterTextChanged { scheduleSave() } binding.etTitle.doAfterTextChanged { scheduleSave() } } private fun scheduleSave() { saveJob?.cancel() saveJob lifecycleScope.launch { delay(800) persist() } } private suspend fun persist() { val title binding.etTitle.text?.toString().orEmpty() val content binding.etContent.text?.toString().orEmpty() if (title.isBlank() content.isBlank()) return val now System.currentTimeMillis() val note loaded?.copy( title title, content content, updatedAt now ) ?: Note(title title, content content, createdAt now, updatedAt now) noteId viewModel.save(note) loaded note.copy(id noteId) } }doAfterTextChanged是 AndroidX 的 KTX 扩展不用自己写 TextWatcher 那三个回调省事很多。防抖这里用了协程的取消机制每次输入先把上一次的延迟任务取消重新起一个只有停手 800 毫秒之后才会真正执行。这个技巧在处理搜索联想、实时保存这类场景时非常好用。删除我没放在编辑页而是放在列表页的长按菜单里因为删除是个破坏性操作混在编辑页里容易误触。删除时除了删数据最好加一个撤销提示用 Snackbar 做用户点了撤销就把数据插回去。做这个的前提是删除前把整条数据留在内存里撤销时用同样的 id 插回去。注意删除后撤销时如果重新插入用自增主键id 会变如果其他地方有引用这个 id 就会失效。所以要么插入时显式指定原来的 id要么接受 id 变化并确保没有外部引用。4.4 搜索、置顶和空状态让项目不那么像作业基础功能跑通之后加这三个东西的性价比最高。搜索我做成顶部的一个搜索框输入时实时过滤。实现上就在 DAO 里开一个search查询输入内容变化时切换订阅的数据源。这里同样用防抖因为每次按键都查库没必要。搜索完成后如果结果为空要显示没找到相关备忘录不能一片空白让用户以为卡了。置顶的做法比想象中简单给实体加isPinned字段长按菜单里加置顶把isPinned取反因为排序里已经按它优先排了界面自动就变了。收藏和置顶本质上是一回事一个布尔字段加排序就能搞定不需要额外设计什么复杂结构。空状态是最容易被忽略但体验提升最明显的。列表为空时显示一句引导文案加一个淡淡的插图比纯白页面友好太多。实现就是监听数据列表空则显示tvEmpty非空则隐藏。注意空状态的判断要在数据真正加载完之后再做否则冷启动瞬间会闪一下空状态文案看起来像 bug。5. 把项目做得更完整几项值得投入的增强5.1 长按多选与批量删除单条删除用久了会觉得烦尤其是清空一批测试数据的时候。多选删除的逻辑是长按进入多选模式顶部工具栏变成已选 N 项点击 item 切换选中状态选中集合用一个MutableSetLong维护。这里要注意的是状态要在适配器里同步否则滚动之后选中状态会错乱。做法是在onBindViewHolder里根据集合决定 item 的选中背景和勾选框显不显示每次状态变化就notifyItemChanged对应位置而不是全量刷新。全量刷新在多选模式下会导致滚动位置跳动体验很差。批量删除直接调 DAO 里的DELETE FROM note WHERE id IN (:ids)一条 SQL 搞定比循环单删快得多而且如果包在事务里还能保证要么全成要么全不成。5.2 数据库操作千万别放主线程这是新手最容易犯也最难查的错误。在主线程做数据库读写数据量小的时候看起来没事数据一多就掉帧再严重一点直接 ANR界面卡住五秒然后系统弹应用无响应。而且这种问题在模拟器上往往看不出来一到真机、一到数据量上来就暴露。Room 对这个问题做了强制约束suspend方法必须在协程里调Query返回Flow或LiveData时查询会自动在后台线程执行。你要做的是把insert、update、delete都写成suspend在viewModelScope或lifecycleScope里调用。ViewModel 里管数据操作Activity 只负责显示这个分层习惯早点养成后面项目大了会省很多事。class NoteViewModel(app: Application) : AndroidViewModel(app) { private val dao NoteDatabase.get(app).noteDao() val notes: StateFlowListNote dao.observeAll().stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList() ) suspend fun find(id: Long) dao.findById(id) suspend fun save(note: Note): Long dao.upsert(note) suspend fun remove(note: Note) dao.delete(note) }SharingStarted.WhileSubscribed(5000)的意思是界面订阅时才开始收集数据界面完全离开 5 秒后停止。这个 5 秒的缓冲是为了应对屏幕旋转这种短暂的生命周期变化避免旋转一次就重新查一遍数据库。5.3 深色模式和主题适配的省事做法深色模式如果你一开始就用Theme.Material3.DayNight这类主题并且颜色都从colors.xml取、不在布局里硬编码色值那基本上不用额外做什么。系统切换深色模式的时候会自动换用values-night目录下的颜色定义。我踩过的坑是在布局里直接写了#FFFFFF当背景结果深色模式下整个页面白得刺眼。后来把这些色值全部抽成colorSurface、colorOnSurface这类语义化的颜色名values和values-night各定义一套切换就自动生效了。用语义化命名的好处是以后要换配色方案改一处就行不用全局搜色值。状态栏的处理稍微麻烦一点。深色模式下状态栏图标的颜色要变浅否则黑底黑字看不见。可以在themes.xml里配windowLightStatusBar或者用 WindowInsetsController 动态设置。这块各个系统版本表现有差异建议在几台不同版本的机器上都测一下。6. 常见问题与排查实录6.1 报错速查我真实撞过的那几个报错信息关键词可能原因处理方式Cannot access database on the main thread主线程直接调了同步 DAO 方法改成 suspend放协程里调Schema export directory is not provided开了 exportSchema 但没配路径配 room.schemaLocation或直接关掉Expected schema version X, found Y改了实体没升版本号升 version 并写 MigrationClass not found / NoSuchMethod依赖版本冲突或没同步成功清 build 目录重新同步RecyclerView has no LayoutManager忘了设 layoutManager初始化时补上java.lang.IllegalStateException: Fragment not attached异步回调回来时页面已销毁检查生命周期用 viewLifecycleOwnerANR in MainActivity主线程有耗时操作用 Profiler 定位挪到后台线程这份表我建议你自己也维护一份每解决一个问题就记一条。Android 的报错信息重复率很高记录下来之后第二次遇到能省很多时间。6.2 数据不刷新、列表错乱这类玄学问题有段时间我删了一条数据列表刷新了但顶部的统计数字还是旧的。查了半天发现是两个地方各自订阅了数据一个用了collect一个用了collectLatest其中一个在页面暂停时被取消了。这类问题的排查思路是先确认数据源是不是同一个实例再确认订阅的生命周期是不是对的。还有一种是列表数据错乱A 的内容显示在 B 的位置上。这个几乎百分百是 ViewHolder 复用没处理好在onBindViewHolder里做了某个设置但没在 else 分支里复位。比如选中状态只在选中时改了背景色没在未选中时改回来那这条 View 被复用到未选中的位置时还带着选中背景。解决办法就是保证onBindViewHolder里对每个可变属性都做完整赋值不留分支漏洞。第三种是操作了但没生效。这种情况先看是不是在数据库里其实生效了但界面没订阅到再去看是不是写库的时候事务没提交。我遇到过一次是自己在协程里起了个GlobalScopeActivity 销毁了协程还在跑写完了但没人接收结果。换成viewModelScope之后就好了。6.3 真机调试与签名相关的实务模拟器方便但慢而且有些问题只在真机上出现比如输入法行为、通知权限、深色模式跟随系统设置的细节。真机调试有两种连法USB 线和无线。USB 需要在手机的开发者选项里打开调试开关第一次连会弹授权框记得勾选始终允许。无线调试在较新的系统版本里已经原生支持在开发者选项里找到配对码用命令行或者 IDE 里的配对功能连上就行好处是手不用被线拴着测试的时候拿着手机走来走去很方便。这里要注意一点无线调试依赖同一个局域网换网络之后要重新配对。签名这块入门阶段不用太操心debug 签名就够用。但如果你想给朋友装一个或者想固定签名以便后续覆盖安装就要建一个自己的签名的 keystore。生成方式在 Android Studio 的构建菜单里有向导跟着走就行。有一件事要提前注意keystore 文件和密码一定要备份并且不要提交到公开的代码仓库里。丢了签名文件后续版本的覆盖安装就没法做只能让用户卸载重装数据全没。还有个容易忽略的是设备上那个Android/data/包名目录。调试期间经常需要看数据库文件确认数据对不对但这个目录在新版本系统上访问受限普通文件管理器打不开。想直接看数据库要么用 Android Studio 里的设备文件浏览器要么在调试构建里加一个导出功能。这也是为什么我建议 DAO 层写几个临时的调试查询方法能在应用内直接把数据打日志出来比翻文件方便。6.4 几条能省下不少时间的实操心得第一从第一天开始就用 Git。哪怕只有你一个人每天提交一次。Android 项目里一次误操作改坏布局或者删错文件是很常见的有版本历史就能安心回退。.gitignore一定要包含build、.gradle、.idea里的本地配置不然仓库会又大又乱。第二输入框的键盘遮挡问题早点处理。编辑页如果内容长键盘弹起来会挡住输入区域。在 manifest 里给编辑页的 Activity 配windowSoftInputModeadjustResize配合可滚动的根布局就能自动上推。这个配置不改的话用户体验差得非常明显。第三别小看字符串资源。所有硬编码文案都抽出来这件事做的越早越省事。等你有两百条硬编码文案再去抽那工作量能让人崩溃。第四测试数据要批量生成。手动敲十几条备忘录来测列表滚动和搜索效率极低。可以在数据库初始化回调里插入一批假数据只在 debug 构建里生效这样每装一次就有数据可测。// 仅在调试构建里预置数据 if (BuildConfig.DEBUG) { Room.databaseBuilder(...) .addCallback(object : RoomDatabase.Callback() { override fun onCreate(db: SupportSQLiteDatabase) { super.onCreate(db) // 插入若干条示例数据 } }) .build() }说到最后我自己在这个项目上最大的体会是功能写得出来不算本事把边界情况想全才算。比如用户只写了空白字符算不算一条记录、编辑过程中按返回键要不要保存、连续快速点击保存按钮会不会插入两条、数据库升级会不会清空用户数据。这些问题在教程里通常不会讲但恰恰是它们决定了你做出来的是个能跑的作业还是个能用的东西。我现在回头看第一版备忘录留下的那些坑基本都是这几个方向的。你要是有兴趣继续往下做下一步可以试试加个回收站把删除改成标记删除加上定时清理这个过程会把事务和数据一致性的问题都带你走一遍收获比单纯加界面功能大得多。
返回列表