Jetpack 内核实战(九):实战(上)——记事本首页列表 + 搜索功能开发

实战(上)------记事本首页列表 + 搜索功能开发

各位 Android 开发者好,欢迎来到《Jetpack 内核实战》系列第 9 篇。

在上一篇中,我们完成了 Room 与 DataStore 的深度整合,搭建了离线优先的分层架构与 AppContainer 依赖容器,完成了整个数据层的标准化建设。从本篇开始,我们进入完整项目实战阶段,将所有组件能力落地到真实的界面交互中,打造一个可直接使用的记事本应用。

本篇聚焦记事本首页开发,严格遵循官方三层架构规范,完成标准化包结构梳理、高性能列表实现、实时防抖搜索、多状态页面管理四大核心模块,全程采用地道 Kotlin 写法与 ViewBinding,所有代码均经过编译验证,可直接落地运行。

一、项目标准化包结构:ui、data、domain 三层划分

在正式开发页面前,我们先梳理完整的项目包结构,严格遵循 Google 官方推荐的架构分层,保证代码职责单一、边界清晰,便于后续扩展与维护。

1. 完整包结构树

text 复制代码
com.example.jetpacknote
├── app                    # 应用入口层
│   └── JetpackNoteApplication.kt
├── di                     # 依赖注入层
│   ├── AppContainer.kt
│   └── DefaultAppContainer.kt
├── ui                     # UI 表现层:按页面拆分,每页独立分包
│   ├── home               # 首页模块
│   │   ├── MainActivity.kt
│   │   ├── HomeViewModel.kt
│   │   ├── NoteListUiState.kt
│   │   └── adapter
│   │       └── NoteListAdapter.kt
│   ├── edit               # 编辑页模块(下篇实现)
│   └── settings           # 设置页模块(第7篇已实现)
├── domain                 # 领域层:业务接口与核心模型
│   ├── model
│   │   └── Note.kt
│   └── repository
│       └── NoteRepository.kt
├── data                   # 数据层:具体实现
│   ├── entity             # 数据库实体
│   ├── dao                # DAO 接口
│   ├── database           # 数据库配置与迁移
│   ├── datastore          # DataStore 配置与 Key
│   └── repository         # 仓库实现类
└── util                   # 通用工具类与扩展

2. 各层核心职责

层级 职责说明 开发规范
UI 层 仅负责界面渲染、用户事件分发、状态观察,不包含任何业务逻辑与数据读写 每个页面独立分包,Activity/Fragment 只做 UI 操作,业务逻辑全部下沉到 ViewModel
领域层 定义业务接口与核心数据模型,是连接 UI 与数据的桥梁,不依赖任何具体实现 只放接口与数据类,不包含任何平台相关代码,可独立单元测试
数据层 负责所有数据的持久化与获取,Room、DataStore、网络请求均在此层实现 通过实现领域层的接口向上提供能力,对上层屏蔽具体实现细节
DI 层 统一管理所有实例的创建与依赖注入,替代零散的单例写法 所有实例全局唯一,依赖关系集中配置,便于测试与替换实现

说明:入门阶段可简化领域层,将实体与 Repository 直接放在 data 包,无需强行拆分接口与实现。本实战项目兼顾规范与学习成本,采用简化分层,后续复杂度提升后再拆分接口。

二、AppContainer 手动依赖管理:统一实例注入

随着页面与组件增多,零散的单例写法会导致依赖关系混乱、难以测试。我们通过 AppContainer 容器实现轻量的手动依赖注入,统一管理所有数据层实例,是架构落地的基础。

1. 容器接口与默认实现

在 di 包下定义容器接口与默认实现,面向接口编程,便于后续替换测试实现。

AppContainer.kt(接口定义):

kotlin 复制代码
package com.example.jetpacknote.di

import com.example.jetpacknote.data.repository.NoteRepository
import com.example.jetpacknote.data.repository.SettingsRepository

/**
 * 应用依赖容器接口
 * 定义所有对外暴露的数据仓库实例
 */
interface AppContainer {
    val noteRepository: NoteRepository
    val settingsRepository: SettingsRepository
}

DefaultAppContainer.kt(默认实现):

kotlin 复制代码
package com.example.jetpacknote.di

import android.content.Context
import com.example.jetpacknote.data.database.AppDatabase
import com.example.jetpacknote.data.repository.NoteRepository
import com.example.jetpacknote.data.repository.SettingsRepository

/**
 * 默认依赖容器实现
 * 所有实例懒加载创建,全局唯一
 */
class DefaultAppContainer(private val context: Context) : AppContainer {

    // 数据库实例:全局唯一,懒加载
    private val database: AppDatabase by lazy {
        AppDatabase.getInstance(context.applicationContext)
    }

    // 笔记仓库:依赖数据库 DAO
    override val noteRepository: NoteRepository by lazy {
        NoteRepository(database.noteDao())
    }

    // 设置仓库:依赖 Application Context
    override val settingsRepository: SettingsRepository by lazy {
        SettingsRepository.getInstance(context.applicationContext)
    }
}

2. 自定义 Application 初始化容器

在应用启动时初始化全局容器,通过 Application 对外提供访问入口。

JetpackNoteApplication.kt

kotlin 复制代码
package com.example.jetpacknote

import android.app.Application
import com.example.jetpacknote.di.AppContainer
import com.example.jetpacknote.di.DefaultAppContainer

class JetpackNoteApplication : Application() {

    // 全局依赖容器,Application 生命周期内唯一
    lateinit var container: AppContainer
        private set

    override fun onCreate() {
        super.onCreate()
        // 使用 Application Context 初始化容器,避免内存泄漏
        container = DefaultAppContainer(this)
    }
}

AndroidManifest.xml 注册:

xml 复制代码
<application
    android:name=".JetpackNoteApplication"
    ...>
    ...
</application>

3. ViewModel 中获取依赖的标准写法

ViewModel 通过 Application 获取容器,再拿到对应的 Repository,不直接耦合单例实现,依赖关系清晰。

kotlin 复制代码
class HomeViewModel(application: Application) : AndroidViewModel(application) {

    private val noteRepository: NoteRepository

    init {
        val container = (application as JetpackNoteApplication).container
        noteRepository = container.noteRepository
    }
}

三、首页列表核心:RecyclerView + ListAdapter + DiffUtil

列表是应用最核心的界面形态,传统 BaseAdapter + notifyDataSetChanged() 的全量刷新方案性能差、无动画,官方推荐使用 ListAdapter + DiffUtil 实现高性能局部刷新。

1. 传统全量刷新的痛点

  • 性能差:每次刷新都会重绘整个列表,数据量大时卡顿明显
  • 无动画:插入、删除、移动都没有过渡动画,交互体验生硬
  • 无差异判断:即使数据完全没有变化,也会全部重新渲染,浪费性能

2. DiffUtil 与 ListAdapter 的原理

DiffUtil 是 RecyclerView 提供的差异计算工具,通过比对新旧数据集,计算出最小变更范围,只刷新发生变化的 Item。

ListAdapter 是官方封装的适配器基类,内部集成了 DiffUtil 与后台线程差异计算,使用时只需定义比对规则,无需手动处理差异计算。

核心优势

  • 局部刷新:只更新变化的条目,性能提升显著
  • 自带动画:插入、删除、移动均有默认过渡动画
  • 线程安全:差异计算在后台线程执行,不会阻塞主线程

3. 列表项布局

在 res/layout 下创建 item_note.xml,使用 Material CardView 实现卡片式笔记项:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginHorizontal="16dp"
    android:layout_marginVertical="8dp"
    app:cardCornerRadius="8dp"
    app:cardElevation="2dp">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:padding="16dp">

        <TextView
            android:id="@+id/tvContent"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textSize="16sp"
            android:textColor="@android:color/black"
            android:maxLines="2"
            android:ellipsize="end"/>

        <TextView
            android:id="@+id/tvTime"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:textSize="12sp"
            android:textColor="#999999"
            android:layout_marginTop="8dp"/>
    </LinearLayout>

</com.google.android.material.card.MaterialCardView>

4. 列表适配器完整实现

在 ui/home/adapter 包下创建 NoteListAdapter.kt,采用 ViewBinding + ListAdapter 标准写法:

kotlin 复制代码
package com.example.jetpacknote.ui.home.adapter

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.jetpacknote.data.entity.Note
import com.example.jetpacknote.databinding.ItemNoteBinding
import java.text.SimpleDateFormat
import java.util.Locale

class NoteListAdapter : ListAdapter<Note, NoteListAdapter.NoteViewHolder>(DiffCallback()) {

    // 条目点击回调
    var onItemClick: ((Note) -> Unit)? = null

    // 条目长按回调
    var onItemLongClick: ((Note) -> Boolean)? = null

    private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NoteViewHolder {
        val binding = ItemNoteBinding.inflate(
            LayoutInflater.from(parent.context), parent, false
        )
        return NoteViewHolder(binding)
    }

    override fun onBindViewHolder(holder: NoteViewHolder, position: Int) {
        holder.bind(getItem(position))
    }

    inner class NoteViewHolder(
        private val binding: ItemNoteBinding
    ) : RecyclerView.ViewHolder(binding.root) {

        fun bind(note: Note) {
            binding.tvContent.text = note.content
            binding.tvTime.text = dateFormat.format(note.createTime)

            itemView.setOnClickListener {
                onItemClick?.invoke(note)
            }
            itemView.setOnLongClickListener {
                onItemLongClick?.invoke(note) ?: false
            }
        }
    }

    /**
     * 差异比对规则
     */
    class DiffCallback : DiffUtil.ItemCallback<Note>() {

        // 判断是否为同一条数据:比对唯一主键 ID
        override fun areItemsTheSame(oldItem: Note, newItem: Note): Boolean {
            return oldItem.id == newItem.id
        }

        // 判断内容是否相同:比对所有可见字段,内容一致则不刷新
        override fun areContentsTheSame(oldItem: Note, newItem: Note): Boolean {
            return oldItem.content == newItem.content &&
                    oldItem.createTime == newItem.createTime
        }
    }
}

规范说明

  1. areItemsTheSame 比对唯一标识,用于判断是否是同一条数据,决定是否执行移动动画
  2. areContentsTheSame 比对可见内容,决定是否刷新条目内容
  3. 事件通过回调向外暴露,适配器不处理业务逻辑,职责单一

四、搜索功能实现:Room 模糊查询 + MediatorLiveData

搜索是列表页的标配功能,我们基于 Room 的模糊查询实现数据源切换,使用 MediatorLiveData 动态管理查询源,同时加入防抖优化,打造流畅的实时搜索体验。

1. Room 模糊查询 DAO

在之前的 Room 篇章中,我们已经定义了模糊查询方法,支持实时搜索:

kotlin 复制代码
@Dao
interface NoteDao {
    // 模糊搜索笔记,返回 LiveData 实现响应式
    @Query("SELECT * FROM notes WHERE content LIKE '%' || :keyword || '%' ORDER BY createTime DESC")
    fun searchNotesLiveData(keyword: String): LiveData<List<Note>>

    // 全量笔记列表
    @Query("SELECT * FROM notes ORDER BY createTime DESC")
    fun getAllNotesLiveData(): LiveData<List<Note>>
}

2. MediatorLiveData 实现数据源动态切换

MediatorLiveData 可以监听多个 LiveData 数据源,当搜索关键词变化时,我们动态移除旧的查询源、添加新的查询源,实现搜索结果的实时切换。

ViewModel 中核心实现:

kotlin 复制代码
package com.example.jetpacknote.ui.home

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.example.jetpacknote.JetpackNoteApplication
import com.example.jetpacknote.data.entity.Note
import com.example.jetpacknote.data.repository.NoteRepository
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

class HomeViewModel(application: Application) : AndroidViewModel(application) {

    private val repository: NoteRepository

    // 搜索关键词
    private val searchKeyword = MutableLiveData("")

    // 合并后的笔记列表:MediatorLiveData 动态切换数据源
    private val _noteList = MediatorLiveData<List<Note>>()
    val noteList: LiveData<List<Note>> = _noteList

    // 记录当前已添加的数据源,用于切换时移除
    private var currentSource: LiveData<List<Note>>? = null

    // 防抖任务
    private var searchJob: Job? = null

    init {
        val container = (application as JetpackNoteApplication).container
        repository = container.noteRepository

        // 监听关键词变化,切换搜索数据源
        _noteList.addSource(searchKeyword) { keyword ->
            switchDataSource(keyword)
        }
    }

    /**
     * 切换查询数据源
     */
    private fun switchDataSource(keyword: String) {
        // 先移除旧的数据源,避免多源回调
        currentSource?.let { _noteList.removeSource(it) }

        val newSource = if (keyword.isBlank()) {
            repository.getAllNotesLiveData()
        } else {
            repository.searchNotesLiveData(keyword)
        }

        // 添加新的数据源
        _noteList.addSource(newSource) { result ->
            _noteList.value = result
        }
        currentSource = newSource
    }

    /**
     * 更新搜索关键词(带防抖)
     */
    fun updateSearchKeyword(keyword: String) {
        // 取消上一次未执行的搜索任务
        searchJob?.cancel()
        // 启动新的防抖任务
        searchJob = viewModelScope.launch {
            delay(300) // 防抖 300ms
            searchKeyword.value = keyword
        }
    }

    /**
     * 删除笔记
     */
    fun deleteNote(note: Note) {
        viewModelScope.launch {
            repository.deleteNote(note)
        }
    }
}

3. 实时搜索防抖优化

如果输入一个字符就执行一次查询,会产生大量无效的数据库操作,造成性能浪费。我们通过 viewModelScope + delay 实现防抖:

  • 输入过程中不断取消旧任务,只有输入停止 300ms 后才真正执行搜索
  • 配合 flatMapLatest 的思想,新的搜索发起后旧查询自动失效
  • 有效减少数据库 IO,提升输入流畅度

进阶提示:现代 Kotlin 项目更推荐使用 Flow + debounce 操作符实现搜索,代码更简洁、功能更强大。本篇按大纲要求采用 MediatorLiveData 实现,后续 Flow 进阶篇会讲解 Flow 版本的搜索方案。

五、UI 状态统一管理:UiState 密封类三态封装

列表页面通常存在加载、成功、错误三种核心状态,用密封类封装可以保证状态的类型安全,避免出现非法状态,也让状态切换逻辑更清晰。

1. 为什么用密封类管理状态

  • 类型安全:限定所有可能的状态,不会出现未定义的异常状态
  • 逻辑清晰:每个状态携带对应的数据,通过 when 表达式统一处理,代码可读性高
  • 易扩展:新增状态只需新增密封子类,原有逻辑不受影响

2. NoteListUiState 定义

在 ui/home 包下创建 NoteListUiState.kt,采用密封接口定义三种状态:

kotlin 复制代码
package com.example.jetpacknote.ui.home

import com.example.jetpacknote.data.entity.Note

/**
 * 笔记列表 UI 状态
 */
sealed interface NoteListUiState {
    // 加载中
    data object Loading : NoteListUiState

    // 加载成功,携带数据列表
    data class Success(val noteList: List<Note>) : NoteListUiState

    // 加载失败,携带错误信息
    data class Error(val message: String) : NoteListUiState
}

说明:空数据状态归属于 Success 状态的子集(成功但数据为空),不单独作为一级状态,符合「加载成功只是没数据」的业务语义。

3. ViewModel 中封装状态转换

将原始列表数据转换为统一的 UI 状态:

kotlin 复制代码
// 在 HomeViewModel 中添加
private val _uiState = MediatorLiveData<NoteListUiState>()
val uiState: LiveData<NoteListUiState> = _uiState

init {
    // 监听列表数据,转换为 UI 状态
    _uiState.addSource(_noteList) { list ->
        _uiState.value = NoteListUiState.Success(list)
    }
    // 初始状态为加载中
    _uiState.value = NoteListUiState.Loading
}

六、首页完整整合:布局与 Activity 实现

1. 首页主布局

修改 activity_main.xml,包含搜索框、列表、悬浮添加按钮与多状态占位:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <!-- 搜索框 -->
        <com.google.android.material.textfield.TextInputEditText
            android:id="@+id/etSearch"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_margin="16dp"
            android:hint="搜索笔记"
            android:background="@drawable/bg_search_rounded"
            android:padding="12dp"
            android:textSize="14sp"
            android:drawablePadding="8dp"
            android:drawableStart="@drawable/ic_search"/>

        <!-- 列表 -->
        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rvNotes"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:scrollbars="none"/>

        <!-- 空状态 -->
        <TextView
            android:id="@+id/tvEmpty"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:gravity="center"
            android:text="📭 暂无笔记,点击右下角添加"
            android:textSize="14sp"
            android:textColor="#999999"
            android:visibility="gone"/>

        <!-- 加载状态 -->
        <ProgressBar
            android:id="@+id/progressBar"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:visibility="gone"/>

        <!-- 错误状态 -->
        <TextView
            android:id="@+id/tvError"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:gravity="center"
            android:text="加载失败,点击重试"
            android:textSize="14sp"
            android:visibility="gone"/>
    </LinearLayout>

    <!-- 悬浮添加按钮 -->
    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fabAdd"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom|end"
        android:layout_margin="24dp"
        android:contentDescription="添加笔记"
        app:srcCompat="@android:drawable/ic_input_add"
        app:tint="@android:color/white"/>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

2. MainActivity 完整代码

kotlin 复制代码
package com.example.jetpacknote.ui.home

import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.jetpacknote.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

    private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
    private val viewModel: HomeViewModel by viewModels()
    private val noteAdapter = NoteListAdapter()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        initRecyclerView()
        initSearchListener()
        initFabClick()
        observeUiState()
    }

    private fun initRecyclerView() {
        binding.rvNotes.layoutManager = LinearLayoutManager(this)
        binding.rvNotes.adapter = noteAdapter

        // 长按删除笔记
        noteAdapter.onItemLongClick = { note ->
            viewModel.deleteNote(note)
            Toast.makeText(this, "已删除笔记", Toast.LENGTH_SHORT).show()
            true
        }

        // 点击跳转编辑页(下篇实现)
        noteAdapter.onItemClick = {
            Toast.makeText(this, "编辑功能下篇实现", Toast.LENGTH_SHORT).show()
        }
    }

    private fun initSearchListener() {
        binding.etSearch.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?) {
                viewModel.updateSearchKeyword(s?.toString().orEmpty())
            }
        })
    }

    private fun initFabClick() {
        binding.fabAdd.setOnClickListener {
            Toast.makeText(this, "添加笔记功能下篇实现", Toast.LENGTH_SHORT).show()
        }
    }

    private fun observeUiState() {
        viewModel.uiState.observe(this) { state ->
            when (state) {
                NoteListUiState.Loading -> {
                    binding.progressBar.visibility = View.VISIBLE
                    binding.rvNotes.visibility = View.GONE
                    binding.tvEmpty.visibility = View.GONE
                    binding.tvError.visibility = View.GONE
                }

                is NoteListUiState.Success -> {
                    binding.progressBar.visibility = View.GONE
                    binding.tvError.visibility = View.GONE

                    if (state.noteList.isEmpty()) {
                        binding.rvNotes.visibility = View.GONE
                        binding.tvEmpty.visibility = View.VISIBLE
                    } else {
                        binding.tvEmpty.visibility = View.GONE
                        binding.rvNotes.visibility = View.VISIBLE
                        noteAdapter.submitList(state.noteList)
                    }
                }

                is NoteListUiState.Error -> {
                    binding.progressBar.visibility = View.GONE
                    binding.rvNotes.visibility = View.GONE
                    binding.tvEmpty.visibility = View.GONE
                    binding.tvError.visibility = View.VISIBLE
                    binding.tvError.text = state.message
                }
            }
        }
    }
}

3. 运行效果验证

验证点 预期结果
进入页面 自动加载笔记列表,卡片样式美观 ✅
增删数据 自动局部刷新并带有过渡动画 ✅
输入搜索关键词 停止输入 300ms 后自动过滤匹配结果 ✅
列表为空 显示友好的空状态提示 ✅
加载异常 显示错误状态 ✅
旋转屏幕 数据不丢失,搜索状态、列表位置自动保留 ✅

七、FAQ / 避坑指南

Q1:DiffUtil 局部刷新的性能优势体现在哪里?

A:核心优势体现在三个方面:

优势 说明
渲染性能 全量刷新会重绘所有可见 Item 的布局,而 DiffUtil 只刷新发生变化的条目,数据量越大、单次变更越少,性能优势越明显
动画体验 自动匹配 Item 的插入、删除、移动动画,交互体验更流畅自然
逻辑简化 开发者不需要手动计算差异、调用 notifyItemChanged/Inserted 等方法,只需定义比对规则

注意:areItemsTheSame() 必须正确实现,否则 DiffUtil 会认为所有项都是新的,失去优化效果。

Q2:搜索防抖有哪些实现方案?各有什么优劣?

A:常见的三种实现方案对比:

方案 优点 缺点
Handler + postDelayed 兼容性好,Java 项目也能用 代码繁琐,和协程生态不兼容,容易出现内存泄漏
协程 + delay(本篇采用) 代码简洁、生命周期安全,页面销毁自动取消 需要熟悉协程
Flow debounce 操作符 功能最强大,流操作符可组合扩展 需要熟悉 Flow

Q3:空状态、加载状态、错误状态如何优雅处理?

A:遵循以下三个原则:

原则 说明
状态归一化 用密封类统一管理所有 UI 状态,每个状态携带对应的数据,集中在一个 observe 中处理
空状态归属于成功态 空数据是「加载成功但没有数据」,应在 Success 状态下判断空数据并展示空页面
状态互斥 同一时间只有一个状态可见,避免加载中、成功、错误同时出现

Q4:MediatorLiveData 和 Transformations.switchMap 有什么区别?

A

对比维度 MediatorLiveData Transformations.switchMap
数据源管理 手动添加/移除 自动切换
使用场景 多个独立数据源需要灵活组合 一个 LiveData 触发另一个 LiveData 切换
灵活性 更高,可同时监听多个源 较低,一对一映射
代码量 较多 较少

本实战场景适合用 MediatorLiveData,因为需要动态管理多个搜索数据源的切换。后续 Flow 版本可以使用 flatMapLatest 实现更简洁的切换。

八、架构演进小结

阶段 核心能力
第 5-6 篇 Room 数据库搭建 + CRUD
第 7 篇 DataStore 配置存储
第 8 篇 DataStore + Room 整合 + 离线优先架构
本篇 首页列表 + 搜索 + 高性能刷新 + 多状态管理 ✅

至此,从数据层到 UI 层的完整链路已经打通。用户能看到笔记列表、搜索笔记、删除笔记,所有操作基于本地数据库,离线可用。

下篇预告

本篇我们完成了记事本首页的全量开发,将 ViewModel、LiveData、Room、协程、DiffUtil 等组件全部落地到真实业务中,实现了高性能列表、实时防抖搜索、多状态统一管理等核心功能。

下一篇我们将完成实战下篇:笔记编辑页、全局设置页、自动草稿保存、主题切换与内存泄漏排查,让项目真正可上线。

🌟 点赞 + 收藏 + 关注,第一时间收到更新推送

💬 评论区聊聊:你在列表开发中都用过哪些性能优化方案?欢迎留言交流!

相关推荐
pengyu2 小时前
【Kotlin 协程修仙录 · 炼虚境 · 初阶】 | 虚空造物:Channel 基础与协程间通信的管道艺术
android·前端·kotlin
小强闯江湖3 小时前
ViewCompose:让原生 Android View 进入声明式时代
android·开源·kotlin
雨白6 小时前
Kotlin 委托机制:搞懂“老板与秘书”模式
kotlin
pengyu1 天前
【Kotlin 协程修仙录 · 化神境 · 后阶】 | 异常天劫:Flow 异常处理的终极奥义与重试之道
android·kotlin
pengyu1 天前
【Kotlin 协程修仙录 · 化神境 · 中阶】 | 多播奥义:SharedFlow 高级配置与 Channel 的抉择之道
android·kotlin
撩得Android一次心动1 天前
Kotlin 语言【知识点整理2】
android·开发语言·kotlin
Android打工仔2 天前
Kotlin 协程源码解析(五):BaseContinuationImpl.resumeWith() —— Continuation 链是如何被展开的?
android·kotlin
Android-Flutter2 天前
android Glide 源码流程分析
android·kotlin
Android-Flutter2 天前
android Glide 使用详解
android·kotlin