第三十节:综合实战项目——笔记App开发、需求分析、架构设计、完整实现、发布准备

终于到了这一步。说实话,每次我带团队做项目,最兴奋的就是这个阶段——把所有零散的知识点串起来,做一个真正能跑、能用的东西。笔记App这个选题,我选了很久。为什么?因为它够典型,够实用,而且几乎涵盖了Room数据库开发的所有核心场景。

你想想看,一个笔记App需要什么?增删改查、搜索、排序、数据同步……这些恰恰是我们在实际开发中最常遇到的需求。我个人习惯把这类项目叫做“试金石”——能把这个做明白,其他数据库项目基本都能拿下。

需求分析:别急着写代码

很多新手一上来就开干,结果做到一半发现需求没想清楚,回头改代码,那叫一个痛苦。我曾经在一个项目里吃过这个亏,后来学乖了——先花半小时把需求理清楚。

我们的笔记App,核心功能就这几个:

  • 笔记列表:按时间倒序展示,支持搜索
  • 笔记编辑:标题+内容,支持保存草稿
  • 笔记删除:单条删除和批量删除
  • 笔记分类:给笔记打标签,按标签筛选
  • 数据导出:把笔记导出为文本文件

嗯,这里要注意——功能不是越多越好。我见过有人给笔记App加了语音输入、图片插入、Markdown渲染……结果项目拖了三个月还没上线。MVP(最小可行产品)思维,在个人项目里同样适用。

核心原则:先跑通核心流程,再考虑锦上添花。

架构设计:MVVM + Repository 模式

我个人比较推崇MVVM架构,配合Repository模式。为什么?因为职责清晰,测试方便,而且Google官方也在推这套。

来看一下整体架构图:

UI层(Activity / Fragment) 观察ViewModel中的LiveData/Flow,驱动界面更新 用户操作 → 调用ViewModel中的方法 ViewModel层 持有Repository引用,管理UI状态 处理业务逻辑,暴露LiveData/StateFlow Repository层 协调本地数据源(Room)和远程数据源(可选) 提供统一的增删改查接口 数据层(Room Database + DAO) Entity定义表结构,DAO封装SQL操作

这张图我建议你保存下来。每次写代码前看一眼,问问自己:我当前写的这个类,属于哪一层?职责是否清晰?

数据库设计:Entity 和 DAO

先定义笔记的实体类。我记得第一次设计数据库时,把字段写得特别复杂,结果查询性能一塌糊涂。后来学乖了——够用就好。

@Entity(tableName = "notes")
data class Note(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val title: String,
    val content: String,
    val category: String = "默认",
    val createdAt: Long = System.currentTimeMillis(),
    val updatedAt: Long = System.currentTimeMillis(),
    val isDeleted: Boolean = false  // 软删除标记
)

这里有个小细节——isDeleted字段。为什么要做软删除?说白了,就是给用户一个“后悔药”。我曾经在项目里直接物理删除,结果用户误删了重要笔记,找不回来,那叫一个崩溃。

再来看DAO接口:

@Dao
interface NoteDao {
    @Query("SELECT * FROM notes WHERE isDeleted = 0 ORDER BY updatedAt DESC")
    fun getAllNotes(): LiveData<List<Note>>

    @Query("SELECT * FROM notes WHERE isDeleted = 0 AND (title LIKE '%' || :query || '%' OR content LIKE '%' || :query || '%')")
    fun searchNotes(query: String): LiveData<List<Note>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertNote(note: Note): Long

    @Update
    suspend fun updateNote(note: Note)

    @Query("UPDATE notes SET isDeleted = 1 WHERE id = :noteId")
    suspend fun softDeleteNote(noteId: Long)

    @Query("DELETE FROM notes WHERE isDeleted = 1")
    suspend fun cleanDeletedNotes()
}

小提示:搜索功能用LIKE语句实现,虽然简单,但数据量大时性能会下降。如果笔记超过1万条,建议考虑FTS4全文搜索。

ViewModel 实现:状态管理

ViewModel的核心职责,就是管理UI状态。我个人习惯用一个密封类来表示不同的加载状态:

sealed class UiState<out T> {
    object Loading : UiState<Nothing>()
    data class Success<T>(val data: T) : UiState<T>()
    data class Error(val message: String) : UiState<Nothing>()
}

class NoteViewModel(private val repository: NoteRepository) : ViewModel() {

    private val _notes = MutableLiveData<UiState<List<Note>>>()
    val notes: LiveData<UiState<List<Note>>> = _notes

    fun loadNotes() {
        viewModelScope.launch {
            _notes.value = UiState.Loading
            try {
                repository.getAllNotes().collect { noteList ->
                    _notes.value = UiState.Success(noteList)
                }
            } catch (e: Exception) {
                _notes.value = UiState.Error(e.message ?: "未知错误")
            }
        }
    }
}

你可能会问:为什么不用StateFlow?嗯,都可以。LiveData在Android生态里兼容性更好,StateFlow在Kotlin原生场景下更灵活。我个人偏好——如果项目是纯Kotlin,用StateFlow;如果还要跟Java代码混编,用LiveData。

完整实现:从数据库到界面

接下来是数据库的初始化。我建议用Application类来做:

class NoteApplication : Application() {
    val database: NoteDatabase by lazy {
        Room.databaseBuilder(
            applicationContext,
            NoteDatabase::class.java,
            "note_database"
        ).build()
    }
}

然后在Activity里获取ViewModel:

class MainActivity : AppCompatActivity() {
    private val viewModel: NoteViewModel by viewModels {
        NoteViewModelFactory((application as NoteApplication).database.noteDao())
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        viewModel.notes.observe(this) { state ->
            when (state) {
                is UiState.Loading -> showLoading()
                is UiState.Success -> showNotes(state.data)
                is UiState.Error -> showError(state.message)
            }
        }
    }
}

注意:千万不要在Activity里直接操作数据库。我曾经见过有人直接在UI线程调用Room的插入操作,结果ANR(应用无响应)频发。Room默认不允许在主线程做数据库操作,除非你调用allowMainThreadQueries()——但千万别这么干。

发布准备:别忘了这些事

代码写完了,离发布还差几步。我整理了一个清单,每次发布前对照检查:

检查项 说明 优先级
数据库迁移 如果修改了Entity,必须写Migration,否则用户升级会崩溃
混淆规则 Room的Entity和DAO不能被混淆,需要添加proguard规则
权限声明 如果涉及文件导出,需要声明存储权限
日志清理 发布前去掉所有Log.d/e,避免泄露信息
版本号更新 versionCode和versionName要递增

说到混淆规则,我踩过一个坑——忘了给Room的Entity加@Keep注解,结果发布后用户反馈笔记打不开。查了半天,发现是混淆把字段名改了,Room反序列化时找不到对应的列。从那以后,我养成了习惯:所有Entity类都加上@Keep。

避坑指南:那些年我踩过的坑

最后分享几个实战中容易忽略的点:

  • LiveData的粘性事件:如果你用LiveData传递一次性事件(比如Snackbar提示),可能会遇到重复消费的问题。我建议用EventWrapper包装一下。
  • 数据库版本升级:测试阶段可以fallbackToDestructiveMigration(),但发布前一定要写正式的Migration。
  • 协程作用域:在ViewModel里用viewModelScope,在Activity里用lifecycleScope,别混用。

嗯,差不多就这些了。笔记App这个项目,说难不难,说简单也不简单。关键是把每一步想清楚,代码写扎实。你按照这个思路走一遍,Room数据库开发这块基本就稳了。

公众号:蓝海资料掘金营,微信deep3321