第16章:综合项目实战

从零构建一个完整的待办事项应用 —— 将前十五章所学融会贯通

🏛️

本章导师:狄仁杰

核心方法论:统揽全局,从线索到结案

「诸位,一桩大案的侦破,靠的不是单一线索,而是将所有证据串联成一条完整的证据链。构建一个完整的应用亦是如此——从数据层的基石,到架构层的桥梁,再到界面层的呈现,每一层都是不可或缺的环节。今日,让我们将前十五章积累的所有'线索',汇聚成一个完整的'结案报告'。」

16.1 项目概述与需求分析

诸位,经过前十五章的勘查与历练,我们已经掌握了 Kotlin 语言基础、Jetpack Compose UI、Room 数据库、Hilt 依赖注入、协程与 Flow、MVVM 架构以及单元测试等核心技能——这些就是我们手中的「物证」。正如侦破一桩悬案需要将零散的线索串联成完整的证据链,现在正是将这些知识整合到一个真实项目中、写出最终「结案报告」的时候了。

我们要侦办的这桩「案件」,就是构建一个功能完整的待办事项(TODO)应用。且看本案的「案情摘要」——它需要具备以下核心功能:

技术栈总览

一桩大案的侦破,仰赖各类专业手段的配合。下表便是我们为此案准备的全部「侦查工具」:

技术领域 选型 对应章节
UI 框架 Jetpack Compose 第6-7章
架构模式 MVVM + Clean Architecture 第9章
本地数据库 Room 第11章
依赖注入 Hilt 第12章
异步编程 Kotlin 协程 + Flow 第8章
页面导航 Navigation Compose 第7章
动画 Compose Animation API 第13章
测试 JUnit + Compose Testing 第15章

项目结构

破案讲究条理分明、各司其职。我们采用按功能分包的方式组织代码,正如大理寺中各科各房各有分工,每一处文件的位置都经过审慎安排:

com.example.todoapp/
├── TodoApplication.kt            // Application 类,Hilt 入口
├── MainActivity.kt               // 主 Activity
├── data/
│   ├── local/
│   │   ├── Todo.kt               // Entity 实体类
│   │   ├── TodoDao.kt            // 数据访问对象
│   │   └── TodoDatabase.kt       // Room 数据库
│   └── repository/
│       ├── TodoRepository.kt     // Repository 接口
│       └── TodoRepositoryImpl.kt // Repository 实现
├── di/
│   ├── DatabaseModule.kt         // 数据库依赖注入模块
│   └── RepositoryModule.kt       // Repository 依赖注入模块
├── ui/
│   ├── navigation/
│   │   └── TodoNavGraph.kt       // 导航图
│   ├── list/
│   │   ├── TodoListViewModel.kt  // 列表页 ViewModel
│   │   └── TodoListScreen.kt     // 列表页 UI
│   ├── addedit/
│   │   ├── AddEditViewModel.kt   // 添加/编辑页 ViewModel
│   │   └── AddEditScreen.kt      // 添加/编辑页 UI
│   └── components/
│       ├── TodoItem.kt           // 待办事项卡片组件
│       └── FilterChips.kt        // 筛选标签组件
└── util/
    └── DateUtils.kt              // 日期工具类
架构原则

本案——不,本项目——严格遵循单向数据流原则,正如案情只能从线索汇聚到结论,绝不允许反向推演、捏造证据:UI 层只负责展示和发送事件,ViewModel 处理业务逻辑并暴露状态,Repository 管理数据源。每一层只依赖它下面的层,绝不反向依赖。如此,整条「证据链」方能环环相扣、无懈可击。

16.2 数据层:Entity 与 Database

诸位,侦办大案,首先要做的便是勘察现场、固定物证。数据层就是我们整个应用的「案发现场」,是一切线索的起点和归宿。我们首先要定义 Room 实体类——这便是案件中最基本的「物证」,然后创建 DAO 接口和数据库类,作为保管和查阅物证的「证据库」。

Todo 实体类

package com.example.todoapp.data.local

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "todos")
data class Todo(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val title: String,
    val description: String = "",
    val isCompleted: Boolean = false,
    val createdAt: Long = System.currentTimeMillis()
)

且看这份「物证登记表」中的几项关键设计——每一处细节都经过我的审慎推敲:

TodoDao 数据访问对象

有了物证,便需要一套严谨的「查阅与管理规程」。DAO 便是我们的证据管理制度——增、删、改、查,每一步都有据可依:

package com.example.todoapp.data.local

import androidx.room.*
import kotlinx.coroutines.flow.Flow

@Dao
interface TodoDao {

    // 获取所有待办,按创建时间倒序
    @Query("SELECT * FROM todos ORDER BY createdAt DESC")
    fun getAllTodos(): Flow<List<Todo>>

    // 获取未完成的待办
    @Query("SELECT * FROM todos WHERE isCompleted = 0 ORDER BY createdAt DESC")
    fun getActiveTodos(): Flow<List<Todo>>

    // 获取已完成的待办
    @Query("SELECT * FROM todos WHERE isCompleted = 1 ORDER BY createdAt DESC")
    fun getCompletedTodos(): Flow<List<Todo>>

    // 根据 ID 获取单个待办
    @Query("SELECT * FROM todos WHERE id = :todoId")
    suspend fun getTodoById(todoId: Long): Todo?

    // 插入待办
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertTodo(todo: Todo): Long

    // 更新待办
    @Update
    suspend fun updateTodo(todo: Todo)

    // 删除待办
    @Delete
    suspend fun deleteTodo(todo: Todo)

    // 根据 ID 删除
    @Query("DELETE FROM todos WHERE id = :todoId")
    suspend fun deleteTodoById(todoId: Long)

    // 切换完成状态
    @Query("UPDATE todos SET isCompleted = NOT isCompleted WHERE id = :todoId")
    suspend fun toggleComplete(todoId: Long)
}
为什么查询返回 Flow?

返回 Flow<List<Todo>> 而不是 List<Todo>,这正是我狄仁杰办案的精髓——不能只查一次就收工。Flow 能让我们实时监听数据变化,正如在案发现场布下眼线:当数据库中的数据发生任何修改时,Room 会自动发射新的列表,UI 会自动更新,无需手动刷新。任何风吹草动,都逃不过我们的法眼。

TodoDatabase 数据库类

package com.example.todoapp.data.local

import androidx.room.Database
import androidx.room.RoomDatabase

@Database(
    entities = [Todo::class],
    version = 1,
    exportSchema = false
)
abstract class TodoDatabase : RoomDatabase() {
    abstract fun todoDao(): TodoDao
}

数据库类简洁明了——它只声明了版本号、包含的实体和对应的 DAO,如同大理寺的证据库只需记录其编目规则与管理人,具体的实例创建工作则交由 Hilt 依赖注入来安排,各司其职,井然有序。

16.3 数据层:Repository

诸位,在大理寺的办案体制中,主审官从不直接翻阅原始卷宗——他只需向书吏索要整理好的案情摘要即可。Repository 模式的道理与此相同:它为数据访问提供了一层抽象。ViewModel 不需要知道数据是来自 Room、网络还是内存缓存——它只需要调用 Repository 提供的方法。这也让后续的测试验证变得极为便利,因为我们可以轻松地伪造一个 Repository 实现,就像在模拟审讯中安排假证人一样。

Repository 接口

package com.example.todoapp.data.repository

import com.example.todoapp.data.local.Todo
import kotlinx.coroutines.flow.Flow

interface TodoRepository {

    fun getAllTodos(): Flow<List<Todo>>

    fun getActiveTodos(): Flow<List<Todo>>

    fun getCompletedTodos(): Flow<List<Todo>>

    suspend fun getTodoById(id: Long): Todo?

    suspend fun addTodo(todo: Todo): Long

    suspend fun updateTodo(todo: Todo)

    suspend fun deleteTodo(todo: Todo)

    suspend fun toggleComplete(todoId: Long)
}

Repository 实现

package com.example.todoapp.data.repository

import com.example.todoapp.data.local.Todo
import com.example.todoapp.data.local.TodoDao
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class TodoRepositoryImpl @Inject constructor(
    private val todoDao: TodoDao
) : TodoRepository {

    override fun getAllTodos(): Flow<List<Todo>> =
        todoDao.getAllTodos()

    override fun getActiveTodos(): Flow<List<Todo>> =
        todoDao.getActiveTodos()

    override fun getCompletedTodos(): Flow<List<Todo>> =
        todoDao.getCompletedTodos()

    override suspend fun getTodoById(id: Long): Todo? =
        todoDao.getTodoById(id)

    override suspend fun addTodo(todo: Todo): Long =
        todoDao.insertTodo(todo)

    override suspend fun updateTodo(todo: Todo) =
        todoDao.updateTodo(todo)

    override suspend fun deleteTodo(todo: Todo) =
        todoDao.deleteTodo(todo)

    override suspend fun toggleComplete(todoId: Long) =
        todoDao.toggleComplete(todoId)
}

TodoRepositoryImpl 目前只是将调用委托给 TodoDao——如同书吏暂时只从一处卷宗调取材料。但在实际的大案中,Repository 可能还会从朝廷邸报(网络数据源)和地方衙门(缓存)整合情报。即便当前只有单一来源,这层抽象仍然至关重要——它确保 ViewModel 完全不知晓 Room 的存在,正如主审官不必亲赴证据库翻箱倒柜。

16.4 依赖注入配置

诸位,一个运转良好的衙门,靠的是明确的职责分工与人员调配。使用 Hilt 进行依赖注入,便如同为这座「大理寺」安排了一位精干的管家——他负责将合适的人安排到合适的位置,各模块之间保持松耦合,互不越权。我们需要配置两个模块:一个负责提供数据库相关依赖(证据库的钥匙),另一个负责绑定 Repository 接口与实现(指定哪位书吏负责调卷)。

Application 类

package com.example.todoapp

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class TodoApplication : Application()

DatabaseModule

package com.example.todoapp.di

import android.content.Context
import androidx.room.Room
import com.example.todoapp.data.local.TodoDatabase
import com.example.todoapp.data.local.TodoDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

    @Provides
    @Singleton
    fun provideTodoDatabase(
        @ApplicationContext context: Context
    ): TodoDatabase {
        return Room.databaseBuilder(
            context,
            TodoDatabase::class.java,
            "todo_database"
        ).build()
    }

    @Provides
    @Singleton
    fun provideTodoDao(
        database: TodoDatabase
    ): TodoDao {
        return database.todoDao()
    }
}

RepositoryModule

package com.example.todoapp.di

import com.example.todoapp.data.repository.TodoRepository
import com.example.todoapp.data.repository.TodoRepositoryImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    @Binds
    @Singleton
    abstract fun bindTodoRepository(
        impl: TodoRepositoryImpl
    ): TodoRepository
}
@Provides 与 @Binds 的区别

二者的区别,正如「亲自委任」与「按名册调拨」的不同。@Provides 用于需要手动创建实例的情况(如 Room 数据库),方法体包含具体的构造逻辑——这如同你需要亲手打造一把特殊的锁。@Binds 则用于将接口绑定到已有实现,它更高效——Hilt 不需要生成额外的工厂类。当实现类已经通过 @Inject constructor 标注时,优先使用 @Binds,这便是最省力、最精准的「人事安排」。

16.5 ViewModel 层

诸位,在一桩大案中,主审官居中指挥,承上启下——他从证据库中取得物证与供词,经过缜密推理,最终向堂上呈报案情结论。ViewModel 正是这位「主审官」。它持有 UI 需要展示的状态,处理用户操作,并通过 StateFlow 将案情的最新进展通知给 Compose UI——也就是那面向天下公布的堂审大屏。

UI 状态定义

一桩案件在审理过程中,有几种截然不同的阶段:正在调查、尚无线索、已有眉目、出了岔子。我们的 UI 状态亦是如此:

package com.example.todoapp.ui.list

import com.example.todoapp.data.local.Todo

// 筛选类型
enum class TodoFilter {
    ALL,        // 全部
    ACTIVE,     // 进行中
    COMPLETED   // 已完成
}

// UI 状态
sealed class TodoListUiState {
    object Loading : TodoListUiState()

    data class Success(
        val todos: List<Todo>,
        val filter: TodoFilter
    ) : TodoListUiState()

    object Empty : TodoListUiState()

    data class Error(
        val message: String
    ) : TodoListUiState()
}

TodoListViewModel

package com.example.todoapp.ui.list

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.todoapp.data.local.Todo
import com.example.todoapp.data.repository.TodoRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class TodoListViewModel @Inject constructor(
    private val repository: TodoRepository
) : ViewModel() {

    // 当前筛选条件
    private val _filter = MutableStateFlow(TodoFilter.ALL)
    val filter: StateFlow<TodoFilter> = _filter.asStateFlow()

    // UI 状态:根据筛选条件切换不同的数据流
    val uiState: StateFlow<TodoListUiState> = _filter
        .flatMapLatest { currentFilter ->
            when (currentFilter) {
                TodoFilter.ALL -> repository.getAllTodos()
                TodoFilter.ACTIVE -> repository.getActiveTodos()
                TodoFilter.COMPLETED -> repository.getCompletedTodos()
            }
        }
        .map { todos ->
            if (todos.isEmpty()) {
                TodoListUiState.Empty
            } else {
                TodoListUiState.Success(todos, _filter.value)
            }
        }
        .catch { e ->
            emit(TodoListUiState.Error(e.message ?: "未知错误"))
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = TodoListUiState.Loading
        )

    // 设置筛选条件
    fun setFilter(newFilter: TodoFilter) {
        _filter.value = newFilter
    }

    // 添加待办
    fun addTodo(title: String, description: String = "") {
        viewModelScope.launch {
            val todo = Todo(
                title = title.trim(),
                description = description.trim()
            )
            repository.addTodo(todo)
        }
    }

    // 删除待办
    fun deleteTodo(todo: Todo) {
        viewModelScope.launch {
            repository.deleteTodo(todo)
        }
    }

    // 切换完成状态
    fun toggleComplete(todoId: Long) {
        viewModelScope.launch {
            repository.toggleComplete(todoId)
        }
    }
}

这段代码的精妙之处在于 uiState 的构建——这正是主审官最核心的推理逻辑。通过 flatMapLatest,我们将筛选条件与对应的数据查询关联起来——当侦查方向改变时,旧的调查线路立即撤回,新的调查随即展开。然后通过 map 将数据列表转换为案情结论(UI 状态),最后通过 stateIn 将 Cold Flow 转换为 Hot StateFlow,让「堂审公屏」(Compose)能够实时订阅案件进展。

注意 WhileSubscribed(5000)

SharingStarted.WhileSubscribed(5000) 表示当最后一个订阅者取消订阅后,Flow 会在 5 秒后停止收集。这如同主审官暂时离席——探子们不必急于撤走,等上五秒再说。这样在配置变更(如屏幕旋转)时,ViewModel 不会立即重新查询数据库,提升了性能。

16.6 UI 层:主界面

现在,我们来到了案件的「公堂呈堂」环节——也就是用户直接面对的界面层。主界面是用户看到的第一个页面,正如大理寺的正堂之上,所有案件的进展一目了然。它包含待办列表、筛选标签、浮动按钮和空状态提示。让我们逐一摆设堂上的「证物」。

TodoItem 组件

每一条待办事项,便如同案卷架上的一份卷宗。卡片上清楚标注着案件名目(标题)、案情简述(描述)、是否已结案(完成状态),以及销案按钮(删除):

package com.example.todoapp.ui.components

import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.example.todoapp.data.local.Todo

@Composable
fun TodoItem(
    todo: Todo,
    onToggleComplete: (Long) -> Unit,
    onDelete: (Todo) -> Unit,
    onItemClick: (Long) -> Unit,
    modifier: Modifier = Modifier
) {
    Card(
        modifier = modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp, vertical = 4.dp),
        onClick = { onItemClick(todo.id) }
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(12.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            // 复选框
            Checkbox(
                checked = todo.isCompleted,
                onCheckedChange = { onToggleComplete(todo.id) }
            )

            // 标题和描述
            Column(
                modifier = Modifier
                    .weight(1f)
                    .padding(horizontal = 12.dp)
            ) {
                Text(
                    text = todo.title,
                    style = MaterialTheme.typography.titleMedium,
                    textDecoration = if (todo.isCompleted)
                        TextDecoration.LineThrough else TextDecoration.None,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis
                )
                if (todo.description.isNotBlank()) {
                    Text(
                        text = todo.description,
                        style = MaterialTheme.typography.bodyMedium,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        maxLines = 2,
                        overflow = TextOverflow.Ellipsis
                    )
                }
            }

            // 删除按钮
            IconButton(onClick = { onDelete(todo) }) {
                Icon(
                    imageVector = Icons.Default.Delete,
                    contentDescription = "删除",
                    tint = MaterialTheme.colorScheme.error
                )
            }
        }
    }
}

FilterChips 筛选组件

破案时,我们常常需要从不同角度审视证据——有时需要总览全局,有时只看未解之谜,有时则要回顾已结之案。筛选组件便是我们手中的「分类案卷签」:

package com.example.todoapp.ui.components

import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.todoapp.ui.list.TodoFilter

@Composable
fun FilterChips(
    currentFilter: TodoFilter,
    onFilterSelected: (TodoFilter) -> Unit,
    modifier: Modifier = Modifier
) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp, vertical = 8.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        val filters = listOf(
            TodoFilter.ALL to "全部",
            TodoFilter.ACTIVE to "进行中",
            TodoFilter.COMPLETED to "已完成"
        )

        filters.forEach { (filter, label) ->
            FilterChip(
                selected = currentFilter == filter,
                onClick = { onFilterSelected(filter) },
                label = { Text(label) }
            )
        }
    }
}

TodoListScreen 主页面

这便是大理寺的正堂——所有案件的进展在此一览无余。加载中时,堂上悬挂等候的灯笼;案卷清空时,呈现一片宁静;线索汇聚时,案卷列表整齐排列;出了差错时,红字警示高悬:

package com.example.todoapp.ui.list

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.todoapp.ui.components.*

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TodoListScreen(
    onNavigateToAdd: () -> Unit,
    onNavigateToEdit: (Long) -> Unit,
    viewModel: TodoListViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsState()
    val currentFilter by viewModel.filter.collectAsState()

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("待办事项") },
                colors = TopAppBarDefaults.topAppBarColors(
                    containerColor = MaterialTheme.colorScheme.primaryContainer
                )
            )
        },
        floatingActionButton = {
            FloatingActionButton(onClick = onNavigateToAdd) {
                Icon(Icons.Default.Add, contentDescription = "添加待办")
            }
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
        ) {
            // 筛选标签
            FilterChips(
                currentFilter = currentFilter,
                onFilterSelected = { viewModel.setFilter(it) }
            )

            // 根据状态展示不同内容
            when (val state = uiState) {
                is TodoListUiState.Loading -> {
                    Box(
                        modifier = Modifier.fillMaxSize(),
                        contentAlignment = Alignment.Center
                    ) {
                        CircularProgressIndicator()
                    }
                }

                is TodoListUiState.Empty -> {
                    EmptyState(filter = currentFilter)
                }

                is TodoListUiState.Success -> {
                    LazyColumn {
                        items(
                            items = state.todos,
                            key = { it.id }
                        ) { todo ->
                            TodoItem(
                                todo = todo,
                                onToggleComplete = { viewModel.toggleComplete(it) },
                                onDelete = { viewModel.deleteTodo(it) },
                                onItemClick = { onNavigateToEdit(it) },
                                modifier = Modifier.animateItem()
                            )
                        }
                    }
                }

                is TodoListUiState.Error -> {
                    Box(
                        modifier = Modifier.fillMaxSize(),
                        contentAlignment = Alignment.Center
                    ) {
                        Text(
                            text = "出错了: ${state.message}",
                            color = MaterialTheme.colorScheme.error
                        )
                    }
                }
            }
        }
    }
}

@Composable
private fun EmptyState(filter: TodoFilter) {
    val message = when (filter) {
        TodoFilter.ALL -> "还没有待办事项\n点击右下角按钮添加一个吧!"
        TodoFilter.ACTIVE -> "没有进行中的待办事项"
        TodoFilter.COMPLETED -> "还没有完成任何待办事项"
    }
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = message,
            style = MaterialTheme.typography.bodyLarge,
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )
    }
}
关于 Modifier.animateItem()

LazyColumnitems 中使用 Modifier.animateItem(),可以为列表项的添加、移除和重新排列自动添加平滑动画——正如案卷在架上的增减移动应当有条不紊、从容不迫。前提是必须为每个列表项提供唯一的 key,这里我们使用 todo.id,就如同每份卷宗的编号绝不可重复。

16.7 UI 层:添加/编辑界面

接下来便是「立案」与「修档」的环节。添加和编辑共用同一个页面——正如新案件的登记与旧案件的补充调查,用的是同一张案卷格式。如果导航时传入了 todoId 参数,便是重新审视旧案(编辑模式);否则便是新开一桩(添加模式)。

AddEditViewModel

package com.example.todoapp.ui.addedit

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.todoapp.data.local.Todo
import com.example.todoapp.data.repository.TodoRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class AddEditViewModel @Inject constructor(
    private val repository: TodoRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val todoId: Long? = savedStateHandle.get<Long>("todoId")
    val isEditMode: Boolean = todoId != null && todoId != -1L

    private val _title = MutableStateFlow("")
    val title: StateFlow<String> = _title.asStateFlow()

    private val _description = MutableStateFlow("")
    val description: StateFlow<String> = _description.asStateFlow()

    private val _titleError = MutableStateFlow<String?>(null)
    val titleError: StateFlow<String?> = _titleError.asStateFlow()

    private val _saveCompleted = MutableSharedFlow<Unit>()
    val saveCompleted: SharedFlow<Unit> = _saveCompleted.asSharedFlow()

    init {
        // 编辑模式:加载已有数据
        if (isEditMode) {
            viewModelScope.launch {
                repository.getTodoById(todoId!!)?.let { todo ->
                    _title.value = todo.title
                    _description.value = todo.description
                }
            }
        }
    }

    fun onTitleChange(newTitle: String) {
        _title.value = newTitle
        _titleError.value = null  // 清除错误提示
    }

    fun onDescriptionChange(newDescription: String) {
        _description.value = newDescription
    }

    fun saveTodo() {
        val currentTitle = _title.value.trim()

        // 验证:标题不能为空
        if (currentTitle.isEmpty()) {
            _titleError.value = "标题不能为空"
            return
        }

        viewModelScope.launch {
            if (isEditMode) {
                val existingTodo = repository.getTodoById(todoId!!)
                existingTodo?.let {
                    repository.updateTodo(
                        it.copy(
                            title = currentTitle,
                            description = _description.value.trim()
                        )
                    )
                }
            } else {
                repository.addTodo(
                    Todo(
                        title = currentTitle,
                        description = _description.value.trim()
                    )
                )
            }
            _saveCompleted.emit(Unit)
        }
    }
}

AddEditScreen 界面

package com.example.todoapp.ui.addedit

import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddEditScreen(
    onNavigateBack: () -> Unit,
    viewModel: AddEditViewModel = hiltViewModel()
) {
    val title by viewModel.title.collectAsState()
    val description by viewModel.description.collectAsState()
    val titleError by viewModel.titleError.collectAsState()

    // 监听保存完成事件,返回上一页
    LaunchedEffect(Unit) {
        viewModel.saveCompleted.collect {
            onNavigateBack()
        }
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(if (viewModel.isEditMode) "编辑待办" else "新建待办")
                },
                navigationIcon = {
                    IconButton(onClick = onNavigateBack) {
                        Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回")
                    }
                }
            )
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            // 标题输入框
            OutlinedTextField(
                value = title,
                onValueChange = { viewModel.onTitleChange(it) },
                label = { Text("标题") },
                isError = titleError != null,
                supportingText = {
                    titleError?.let { Text(it) }
                },
                singleLine = true,
                modifier = Modifier.fillMaxWidth()
            )

            // 描述输入框
            OutlinedTextField(
                value = description,
                onValueChange = { viewModel.onDescriptionChange(it) },
                label = { Text("描述(可选)") },
                minLines = 3,
                maxLines = 5,
                modifier = Modifier.fillMaxWidth()
            )

            // 保存按钮
            Button(
                onClick = { viewModel.saveTodo() },
                modifier = Modifier.fillMaxWidth()
            ) {
                Text(if (viewModel.isEditMode) "保存修改" else "添加待办")
            }
        }
    }
}

导航配置

两个厅堂——案卷大厅(列表页)与受理窗口(添加/编辑页)——之间需要有通畅的廊道。Navigation Compose 就是这条廊道,它确保我们可以在正堂与后堂之间自如穿行:

package com.example.todoapp.ui.navigation

import androidx.compose.runtime.Composable
import androidx.navigation.NavType
import androidx.navigation.compose.*
import androidx.navigation.navArgument
import com.example.todoapp.ui.list.TodoListScreen
import com.example.todoapp.ui.addedit.AddEditScreen

@Composable
fun TodoNavGraph() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = "todo_list"
    ) {
        // 待办列表页
        composable("todo_list") {
            TodoListScreen(
                onNavigateToAdd = {
                    navController.navigate("add_edit/-1")
                },
                onNavigateToEdit = { todoId ->
                    navController.navigate("add_edit/$todoId")
                }
            )
        }

        // 添加/编辑页
        composable(
            route = "add_edit/{todoId}",
            arguments = listOf(
                navArgument("todoId") {
                    type = NavType.LongType
                    defaultValue = -1L
                }
            )
        ) {
            AddEditScreen(
                onNavigateBack = { navController.popBackStack() }
            )
        }
    }
}

MainActivity

万事俱备,只欠升堂。MainActivity 便是那一声「升堂!」的惊堂木——整个应用由此启动:

package com.example.todoapp

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import com.example.todoapp.ui.navigation.TodoNavGraph
import dagger.hilt.android.AndroidEntryPoint

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                TodoNavGraph()
            }
        }
    }
}
别忘了 @AndroidEntryPoint

此处必须格外警惕——这是一个隐藏极深的「嫌疑犯」。使用 Hilt 的 Activity 必须添加 @AndroidEntryPoint 注解,Application 类必须添加 @HiltAndroidApp。遗漏了这些注解,就如同升堂时忘了带官印——Hilt 无法注入依赖,应用会在运行时崩溃。我见过无数开发者栽在此处,诸位切记、切记。

16.8 添加动画效果

诸位,一桩精彩的堂审,不仅仅是宣读卷宗——它还需要起承转合、抑扬顿挫,方能让旁听者心领神会。动画在应用中扮演的正是这个角色:它让界面的状态变化有迹可循,让用户自然而然地理解正在发生什么。让我们在几个关键的「审案节点」加入动效。

AnimatedVisibility:空状态动画

当案卷从有到无、从无到有时,堂上的陈设不应骤然出现或消失,而应如帷幕缓缓拉开。使用 AnimatedVisibility 实现淡入淡出过渡:

import androidx.compose.animation.*

@Composable
fun TodoContent(
    uiState: TodoListUiState,
    filter: TodoFilter,
    // ... 其他参数
) {
    val isEmpty = uiState is TodoListUiState.Empty

    // 空状态:淡入淡出 + 上滑进入
    AnimatedVisibility(
        visible = isEmpty,
        enter = fadeIn() + slideInVertically(),
        exit = fadeOut() + slideOutVertically()
    ) {
        EmptyState(filter = filter)
    }

    // 列表:淡入淡出
    AnimatedVisibility(
        visible = !isEmpty && uiState is TodoListUiState.Success,
        enter = fadeIn(),
        exit = fadeOut()
    ) {
        // LazyColumn ...
    }
}

animateColorAsState:完成状态颜色变化

当一桩案件从「侦办中」变为「已结案」,卷宗封面的颜色也随之改变。用户点击复选框标记待办完成时,卡片背景色应当平滑过渡,如同案情水落石出、尘埃落定:

@Composable
fun TodoItem(
    todo: Todo,
    // ... 其他参数
) {
    // 根据完成状态动画过渡背景色
    val containerColor by animateColorAsState(
        targetValue = if (todo.isCompleted)
            MaterialTheme.colorScheme.surfaceVariant
        else
            MaterialTheme.colorScheme.surface,
        animationSpec = tween(durationMillis = 300),
        label = "todoCardColor"
    )

    Card(
        colors = CardDefaults.cardColors(
            containerColor = containerColor
        ),
        // ...
    ) {
        // 卡片内容
    }
}

Crossfade:筛选切换过渡

在不同的侦查方向之间切换——从全局总览到聚焦未结之案,再到回顾已结旧案——使用 Crossfade 实现内容交叉淡入淡出,如同在不同的案卷册之间从容翻阅:

Crossfade(
    targetState = uiState,
    animationSpec = tween(300),
    label = "contentCrossfade"
) { state ->
    when (state) {
        is TodoListUiState.Loading -> { /* 加载状态 */ }
        is TodoListUiState.Empty -> { EmptyState(filter = currentFilter) }
        is TodoListUiState.Success -> { /* 列表 */ }
        is TodoListUiState.Error -> { /* 错误状态 */ }
    }
}

SwipeToDismiss:滑动删除

有些案件需要被销案——向左一拂,红色警示浮现,案卷便从架上消失。这便是滑动删除功能,它赋予用户一种果断而优雅的「结案」体验:

import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.rememberSwipeToDismissBoxState

@Composable
fun SwipeableTodoItem(
    todo: Todo,
    onDelete: (Todo) -> Unit,
    onToggleComplete: (Long) -> Unit,
    onItemClick: (Long) -> Unit
) {
    val dismissState = rememberSwipeToDismissBoxState(
        confirmValueChange = { dismissValue ->
            if (dismissValue == SwipeToDismissBoxValue.EndToStart) {
                onDelete(todo)
                true
            } else {
                false
            }
        }
    )

    SwipeToDismissBox(
        state = dismissState,
        enableDismissFromStartToEnd = false,
        backgroundContent = {
            // 红色删除背景
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .background(MaterialTheme.colorScheme.error)
                    .padding(horizontal = 20.dp),
                contentAlignment = Alignment.CenterEnd
            ) {
                Icon(
                    Icons.Default.Delete,
                    contentDescription = "删除",
                    tint = MaterialTheme.colorScheme.onError
                )
            }
        }
    ) {
        TodoItem(
            todo = todo,
            onToggleComplete = onToggleComplete,
            onDelete = onDelete,
            onItemClick = onItemClick
        )
    }
}
动画的适度原则

我狄仁杰断案,讲究张弛有度、点到为止。动画亦然——它应当服务于用户体验,而非炫技。一个好的动画应该让用户自然地理解界面状态的变化,而不是让用户眼花缭乱。保持动画简洁、时长合理(200-400 毫秒通常是最佳范围),在关键交互节点使用动画即可。过犹不及,适可而止。

16.9 编写测试

诸位,狄仁杰办案有一条铁律:每一项指控都必须经得起反复推敲,每一条证据链都必须严丝合缝。写代码亦是如此——一个真正可靠的应用离不开测试。测试就是我们的「复查制度」,是防止冤假错案的最后一道防线。我们为核心逻辑编写单元测试,为 UI 编写集成测试。

FakeTodoRepository

首先,我们需要一位「替身证人」——一个假的 Repository 实现,用于在测试中替代真实的数据库操作。这样我们便可以在不依赖真实证据库的情况下,独立验证主审官(ViewModel)的推理逻辑是否正确:

package com.example.todoapp.data.repository

import com.example.todoapp.data.local.Todo
import kotlinx.coroutines.flow.*

class FakeTodoRepository : TodoRepository {

    private val todos = MutableStateFlow<List<Todo>>(emptyList())
    private var nextId = 1L

    override fun getAllTodos(): Flow<List<Todo>> =
        todos.asStateFlow()

    override fun getActiveTodos(): Flow<List<Todo>> =
        todos.map { list -> list.filter { !it.isCompleted } }

    override fun getCompletedTodos(): Flow<List<Todo>> =
        todos.map { list -> list.filter { it.isCompleted } }

    override suspend fun getTodoById(id: Long): Todo? =
        todos.value.find { it.id == id }

    override suspend fun addTodo(todo: Todo): Long {
        val id = nextId++
        val newTodo = todo.copy(id = id)
        todos.value = todos.value + newTodo
        return id
    }

    override suspend fun updateTodo(todo: Todo) {
        todos.value = todos.value.map {
            if (it.id == todo.id) todo else it
        }
    }

    override suspend fun deleteTodo(todo: Todo) {
        todos.value = todos.value.filter { it.id != todo.id }
    }

    override suspend fun toggleComplete(todoId: Long) {
        todos.value = todos.value.map {
            if (it.id == todoId) it.copy(isCompleted = !it.isCompleted)
            else it
        }
    }
}

ViewModel 单元测试

现在,让我们对主审官的每一步推理进行严格的复核——初始状态是否正确?立案后卷宗是否入库?结案标记是否生效?销案是否彻底?筛选是否精准?每一项都要验证:

package com.example.todoapp.ui.list

import com.example.todoapp.data.repository.FakeTodoRepository
import kotlinx.coroutines.test.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.junit.*
import org.junit.Assert.*

@OptIn(ExperimentalCoroutinesApi::class)
class TodoListViewModelTest {

    private lateinit var viewModel: TodoListViewModel
    private lateinit var repository: FakeTodoRepository
    private val testDispatcher = UnconfinedTestDispatcher()

    @Before
    fun setup() {
        Dispatchers.setMain(testDispatcher)
        repository = FakeTodoRepository()
        viewModel = TodoListViewModel(repository)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun `initial state is Empty`() = runTest {
        // 收集一次状态
        val state = viewModel.uiState.value
        assertTrue(state is TodoListUiState.Empty)
    }

    @Test
    fun `addTodo updates state to Success`() = runTest {
        viewModel.addTodo("买牛奶", "去超市买两盒")

        // 等待状态更新
        advanceUntilIdle()

        val state = viewModel.uiState.value
        assertTrue(state is TodoListUiState.Success)
        val todos = (state as TodoListUiState.Success).todos
        assertEquals(1, todos.size)
        assertEquals("买牛奶", todos[0].title)
    }

    @Test
    fun `toggleComplete changes todo status`() = runTest {
        viewModel.addTodo("写报告")
        advanceUntilIdle()

        val todoId = (viewModel.uiState.value as TodoListUiState.Success)
            .todos[0].id
        viewModel.toggleComplete(todoId)
        advanceUntilIdle()

        val updatedTodo = (viewModel.uiState.value as TodoListUiState.Success)
            .todos[0]
        assertTrue(updatedTodo.isCompleted)
    }

    @Test
    fun `deleteTodo removes item from list`() = runTest {
        viewModel.addTodo("任务A")
        viewModel.addTodo("任务B")
        advanceUntilIdle()

        val todoToDelete = (viewModel.uiState.value as TodoListUiState.Success)
            .todos[0]
        viewModel.deleteTodo(todoToDelete)
        advanceUntilIdle()

        val remaining = (viewModel.uiState.value as TodoListUiState.Success)
            .todos
        assertEquals(1, remaining.size)
    }

    @Test
    fun `setFilter to Active shows only active todos`() = runTest {
        viewModel.addTodo("进行中的任务")
        viewModel.addTodo("已完成的任务")
        advanceUntilIdle()

        // 完成第二个任务
        val todos = (viewModel.uiState.value as TodoListUiState.Success).todos
        viewModel.toggleComplete(todos[1].id)
        advanceUntilIdle()

        // 切换到"进行中"筛选
        viewModel.setFilter(TodoFilter.ACTIVE)
        advanceUntilIdle()

        val activeTodos = (viewModel.uiState.value as TodoListUiState.Success)
            .todos
        assertEquals(1, activeTodos.size)
        assertFalse(activeTodos[0].isCompleted)
    }
}

Compose UI 测试

验证完主审官的推理逻辑后,还要检验公堂上的呈现是否合规——卷宗的内容是否正确显示?点击复选框时回调是否被正确触发?删除按钮是否如实执行了销案?

package com.example.todoapp.ui.list

import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import com.example.todoapp.data.local.Todo
import com.example.todoapp.ui.components.TodoItem
import org.junit.Rule
import org.junit.Test

class TodoListScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun todoItem_displaysCorrectContent() {
        val todo = Todo(
            id = 1,
            title = "写代码",
            description = "完成第16章示例"
        )

        composeTestRule.setContent {
            TodoItem(
                todo = todo,
                onToggleComplete = {},
                onDelete = {},
                onItemClick = {}
            )
        }

        // 验证标题和描述正确显示
        composeTestRule.onNodeWithText("写代码").assertIsDisplayed()
        composeTestRule.onNodeWithText("完成第16章示例").assertIsDisplayed()
    }

    @Test
    fun todoItem_checkboxTogglesOnClick() {
        var toggledId: Long? = null
        val todo = Todo(
            id = 42,
            title = "测试任务"
        )

        composeTestRule.setContent {
            TodoItem(
                todo = todo,
                onToggleComplete = { toggledId = it },
                onDelete = {},
                onItemClick = {}
            )
        }

        // 点击复选框
        composeTestRule
            .onNodeWithTag("checkbox", useUnmergedTree = true)
            .performClick()

        // 验证回调被调用,传入正确的 ID
        assertEquals(42L, toggledId)
    }

    @Test
    fun todoItem_deleteButtonTriggersCallback() {
        var deletedTodo: Todo? = null
        val todo = Todo(id = 1, title = "要删除的任务")

        composeTestRule.setContent {
            TodoItem(
                todo = todo,
                onToggleComplete = {},
                onDelete = { deletedTodo = it },
                onItemClick = {}
            )
        }

        // 点击删除按钮
        composeTestRule
            .onNodeWithContentDescription("删除")
            .performClick()

        assertEquals(todo, deletedTodo)
    }
}
测试驱动信心

通过 FakeTodoRepository,我们的 ViewModel 测试完全不依赖 Room 数据库,运行速度极快——正如在模拟审讯中,无需动用真正的刑部大牢。在真实项目中,还应该为 DAO 编写 instrumented test(使用内存数据库),验证 SQL 查询的正确性。测试覆盖了核心路径后,你便可以放心大胆地重构代码,因为测试会替你守住每一道防线——正如我狄仁杰从不在没有把握的情况下提审嫌犯,有了测试覆盖,重构便再无后顾之忧。

16.10 项目回顾与扩展方向

诸位,至此,这桩「大案」的侦办已经完成。让我们退后一步,从全局审视这条完整的「证据链」——从最底层的数据基石,到中间的逻辑枢纽,再到最上层的堂审呈堂,每一个环节是如何环环相扣的。

数据流架构图

整个应用的数据流如下所示,严格遵循单向数据流原则——证据只从现场汇聚到结论,绝不反向捏造:

┌─────────────────────────────────────────────────────┐
│                      UI 层                          │
│  ┌─────────────┐         ┌─────────────────────┐    │
│  │  Compose UI  │ ──────→ │  用户事件 (点击等)   │    │
│  │  (展示状态)  │ ←────── │                     │    │
│  └─────────────┘         └──────────┬──────────┘    │
│         ↑ StateFlow                 │ 调用方法      │
├─────────┼───────────────────────────┼───────────────┤
│         │           ViewModel 层    │               │
│  ┌──────┴──────────────────────────┐│               │
│  │     TodoListViewModel           ││               │
│  │  - 管理 UI 状态                 ││               │
│  │  - 处理业务逻辑                 │←               │
│  │  - 暴露 StateFlow               │                │
│  └──────────────┬──────────────────┘                │
│                 │ 调用 Repository                    │
├─────────────────┼───────────────────────────────────┤
│                 │       数据层                       │
│  ┌──────────────▼──────────────────┐                │
│  │     TodoRepository              │                │
│  │  - 抽象数据访问                 │                │
│  │  - 暴露 Flow                    │                │
│  └──────────────┬──────────────────┘                │
│                 │                                    │
│  ┌──────────────▼──────────────────┐                │
│  │     Room Database               │                │
│  │  - Entity / DAO / Database      │                │
│  │  - SQLite 持久化                │                │
│  └─────────────────────────────────┘                │
└─────────────────────────────────────────────────────┘

知识点回顾

让我们像整理结案卷宗一样,将本案中动用的全部侦查手段逐一登记在案:

项目功能 使用的技术 来自章节
数据类 / 密封类定义 data class, sealed class, enum class 第3-4章
集合操作与高阶函数 map, filter, forEach, let 第5章
声明式 UI 布局 Compose: Column, Row, LazyColumn 第6章
页面导航 Navigation Compose, NavHost 第7章
异步数据加载 协程, Flow, StateFlow 第8章
MVVM 架构分层 ViewModel, UiState, Repository 第9章
本地数据持久化 Room: Entity, Dao, Database 第11章
依赖注入 Hilt: @Module, @Provides, @Inject 第12章
列表与删除动画 AnimatedVisibility, animateColorAsState 第13章
单元测试与 UI 测试 JUnit, Compose Testing, Fake 第15章

扩展方向

一桩案件虽已结案,但由此牵出的线索往往指向更多值得深究的方向。这个 TODO 应用已经具备了完整的基础功能,但作为一位有追求的「捕快」,你还可以沿着以下线索继续侦查:

持续学习建议

诸位,Android 开发的江湖风云变幻,技术的更迭如同朝代的兴替。掌握了本教程涵盖的基础知识后,建议你关注以下方向:Kotlin Multiplatform(跨平台开发)、Compose Multiplatform(跨平台 UI)、以及 AI 辅助开发工具的使用。我狄仁杰虽身处大唐,但若有新的断案之术传入中原,亦必虚心研习。保持对新技术的好奇心,在实际项目中不断实践,你就会从初出茅庐的新手捕快成长为一名独当一面的断案能手。

诸位,恭喜你完成了整个教程的学习!从第 1 章的 Kotlin 基础语法到如今的综合项目实战——这桩横跨十六章的「大案」终于画上了圆满的句号。正如我狄仁杰常言:天下没有破不了的案,亦没有学不会的技术。关键在于——条理清晰地分析每一条线索,坚持不懈地追踪每一个细节,最终将它们串联成一个完整的全貌。编程是一项需要持续精进的技艺——最好的修炼方式就是亲手办案。现在,去构建属于你自己的应用吧!

本章练习

练习 1:为待办添加优先级字段 入门

Todo 实体类中新增一个 priority 字段(可使用枚举类型:LOW、MEDIUM、HIGH),并在 TodoItem 卡片中以不同颜色的小圆点显示优先级。提示:这好比为每份卷宗加上紧急程度的标签——红色为急案,黄色为常案,绿色为缓案。

提示

1. 创建一个 enum class Priority { LOW, MEDIUM, HIGH }

2. 在 Todo data class 中添加 val priority: Priority = Priority.MEDIUM 字段。

3. 注意:修改 Entity 后需要升级数据库版本或使用 fallbackToDestructiveMigration()

4. 在 TodoItem 的 Row 中添加一个 BoxCanvas 绘制小圆点,根据 priority 值选择不同颜色。

参考答案
// 1. 定义优先级枚举
enum class Priority {
    LOW, MEDIUM, HIGH
}

// 2. 修改 Todo 实体
@Entity(tableName = "todos")
data class Todo(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val title: String,
    val description: String = "",
    val isCompleted: Boolean = false,
    val priority: Priority = Priority.MEDIUM,
    val createdAt: Long = System.currentTimeMillis()
)

// 3. 在 TodoItem 中显示优先级圆点
val priorityColor = when (todo.priority) {
    Priority.HIGH -> Color.Red
    Priority.MEDIUM -> Color.Yellow
    Priority.LOW -> Color.Green
}
Box(
    modifier = Modifier
        .size(12.dp)
        .background(priorityColor, CircleShape)
)

练习 2:实现搜索功能 进阶

在待办列表页面的顶部添加一个搜索框,允许用户按标题关键字搜索待办事项。搜索应该是实时的(用户每输入一个字便立即过滤结果)。提示:这如同在堆积如山的案卷中按关键线索快速检索——你需要在 DAO 层添加一个带 LIKE 查询的方法,然后在 ViewModel 中用 debounce 配合 flatMapLatest 实现实时搜索。

提示

1. 在 TodoDao 中添加:@Query("SELECT * FROM todos WHERE title LIKE '%' || :query || '%'")

2. 在 TodoListViewModel 中添加 MutableStateFlow<String> 保存搜索关键字。

3. 使用 combine 将搜索关键字与筛选条件合并,再用 flatMapLatest 切换数据源。

4. 使用 debounce(300) 避免用户每敲一个字都触发查询。

参考答案
// DAO 层新增搜索方法
@Query("SELECT * FROM todos WHERE title LIKE '%' || :query || '%' ORDER BY createdAt DESC")
fun searchTodos(query: String): Flow<List<Todo>>

// ViewModel 中添加搜索逻辑
private val _searchQuery = MutableStateFlow("")

val uiState = combine(_filter, _searchQuery) { filter, query ->
    Pair(filter, query)
}
.debounce(300)
.flatMapLatest { (filter, query) ->
    if (query.isNotBlank()) {
        repository.searchTodos(query)
    } else {
        when (filter) {
            TodoFilter.ALL -> repository.getAllTodos()
            TodoFilter.ACTIVE -> repository.getActiveTodos()
            TodoFilter.COMPLETED -> repository.getCompletedTodos()
        }
    }
}
.map { todos ->
    if (todos.isEmpty()) TodoListUiState.Empty
    else TodoListUiState.Success(todos, _filter.value)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TodoListUiState.Loading)

fun onSearchQueryChange(query: String) {
    _searchQuery.value = query
}

练习 3:添加撤销删除功能 进阶

当用户滑动删除一条待办后,在屏幕底部显示一个 Snackbar,提示「已删除:XXX」,并提供一个「撤销」按钮。用户点击撤销后,被删除的待办应当恢复。提示:这如同判决后的复审机制——给当事人一次申诉的机会。你需要在删除时先将待办暂存,然后在撤销时重新插入。

提示

1. 使用 SnackbarHostState 并将其传入 ScaffoldsnackbarHost 参数。

2. 在 ViewModel 中新增一个 SharedFlow 用于通知 UI 显示 Snackbar。

3. 删除时先保存被删除的 Todo 副本,然后执行删除。

4. 调用 snackbarHostState.showSnackbar() 并检查返回值是否为 SnackbarResult.ActionPerformed

5. 如果用户点击了撤销,调用 repository.addTodo() 将备份重新插入。

参考答案
// ViewModel 中
private var _lastDeletedTodo: Todo? = null

private val _showUndoSnackbar = MutableSharedFlow<Todo>()
val showUndoSnackbar = _showUndoSnackbar.asSharedFlow()

fun deleteTodo(todo: Todo) {
    viewModelScope.launch {
        _lastDeletedTodo = todo
        repository.deleteTodo(todo)
        _showUndoSnackbar.emit(todo)
    }
}

fun undoDelete() {
    viewModelScope.launch {
        _lastDeletedTodo?.let { repository.addTodo(it) }
        _lastDeletedTodo = null
    }
}

// Screen 中
val snackbarHostState = remember { SnackbarHostState() }

LaunchedEffect(Unit) {
    viewModel.showUndoSnackbar.collect { todo ->
        val result = snackbarHostState.showSnackbar(
            message = "已删除: ${todo.title}",
            actionLabel = "撤销",
            duration = SnackbarDuration.Short
        )
        if (result == SnackbarResult.ActionPerformed) {
            viewModel.undoDelete()
        }
    }
}

练习 4:实现数据库迁移 挑战

假设应用已经发布了第一个版本(数据库版本 1),现在需要为 Todo 实体新增 dueDate: Long?(截止日期)和 categoryId: Long?(分类 ID)两个可空字段。请编写一个 Room 数据库迁移(Migration),将数据库从版本 1 升级到版本 2,同时保留用户的已有数据。提示:这如同在不打乱已有卷宗顺序的情况下,为案卷架增设新的分类标签栏位——任何已有案卷不得丢失。

提示

1. 在 Todo 实体中添加两个新字段:val dueDate: Long? = nullval categoryId: Long? = null

2. 将 @Databaseversion 改为 2

3. 创建 Migration(1, 2) 对象,在 migrate() 方法中执行 ALTER TABLE 语句。

4. 在 DatabaseModule 中通过 .addMigrations() 注册迁移。

参考答案
// 定义迁移
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE todos ADD COLUMN dueDate INTEGER DEFAULT NULL")
        db.execSQL("ALTER TABLE todos ADD COLUMN categoryId INTEGER DEFAULT NULL")
    }
}

// 在 DatabaseModule 中注册
@Provides
@Singleton
fun provideTodoDatabase(
    @ApplicationContext context: Context
): TodoDatabase {
    return Room.databaseBuilder(
        context,
        TodoDatabase::class.java,
        "todo_database"
    )
    .addMigrations(MIGRATION_1_2)
    .build()
}

// 更新 Entity
@Entity(tableName = "todos")
data class Todo(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val title: String,
    val description: String = "",
    val isCompleted: Boolean = false,
    val createdAt: Long = System.currentTimeMillis(),
    val dueDate: Long? = null,
    val categoryId: Long? = null
)

// 更新 Database 版本
@Database(
    entities = [Todo::class],
    version = 2,
    exportSchema = false
)
abstract class TodoDatabase : RoomDatabase() {
    abstract fun todoDao(): TodoDao
}

练习 5:编写端到端集成测试 挑战

编写一个完整的 Compose UI 集成测试,模拟以下用户操作流程:启动应用 -> 看到空状态 -> 点击添加按钮 -> 输入标题和描述 -> 点击保存 -> 返回列表页 -> 验证新待办显示在列表中 -> 点击复选框标记完成 -> 验证待办显示删除线样式。提示:这是一次完整的「模拟审案演练」,从立案到结案全程走一遍,确保每一个环节都经得起考验。

提示

1. 使用 createAndroidComposeRule<MainActivity>() 启动完整的 Activity。

2. 使用 Hilt 的测试支持:@HiltAndroidTest 注解和 HiltAndroidRule

3. 提供一个使用内存数据库的测试模块,替换生产环境的数据库模块。

4. 按照操作流程依次调用 onNodeWithText()performClick()performTextInput() 等方法。

5. 使用 waitUntil() 等待异步操作完成后再进行断言。

参考答案
@HiltAndroidTest
class TodoAppE2ETest {

    @get:Rule(order = 0)
    val hiltRule = HiltAndroidRule(this)

    @get:Rule(order = 1)
    val composeRule = createAndroidComposeRule<MainActivity>()

    @Before
    fun setup() { hiltRule.inject() }

    @Test
    fun fullUserFlow_addAndCompleteTodo() {
        // 1. 验证空状态
        composeRule.onNodeWithText("还没有待办事项")
            .assertIsDisplayed()

        // 2. 点击添加按钮
        composeRule.onNodeWithContentDescription("添加待办")
            .performClick()

        // 3. 输入标题和描述
        composeRule.onNodeWithText("标题")
            .performTextInput("买菜")
        composeRule.onNodeWithText("描述(可选)")
            .performTextInput("西红柿、鸡蛋、青菜")

        // 4. 保存
        composeRule.onNodeWithText("添加待办")
            .performClick()

        // 5. 回到列表,验证新待办存在
        composeRule.waitUntil(3000) {
            composeRule.onAllNodesWithText("买菜")
                .fetchSemanticsNodes().isNotEmpty()
        }
        composeRule.onNodeWithText("买菜")
            .assertIsDisplayed()

        // 6. 点击复选框标记完成
        composeRule.onNode(
            hasClickAction() and isToggleable()
        ).performClick()

        // 7. 验证文字带删除线(已完成状态)
        composeRule.onNodeWithText("买菜")
            .assertIsDisplayed()
    }
}
« 上一章:测试入门 目录