从零构建一个完整的待办事项应用 —— 将前十五章所学融会贯通
核心方法论:统揽全局,从线索到结案
「诸位,一桩大案的侦破,靠的不是单一线索,而是将所有证据串联成一条完整的证据链。构建一个完整的应用亦是如此——从数据层的基石,到架构层的桥梁,再到界面层的呈现,每一层都是不可或缺的环节。今日,让我们将前十五章积累的所有'线索',汇聚成一个完整的'结案报告'。」
诸位,经过前十五章的勘查与历练,我们已经掌握了 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 管理数据源。每一层只依赖它下面的层,绝不反向依赖。如此,整条「证据链」方能环环相扣、无懈可击。
诸位,侦办大案,首先要做的便是勘察现场、固定物证。数据层就是我们整个应用的「案发现场」,是一切线索的起点和归宿。我们首先要定义 Room 实体类——这便是案件中最基本的「物证」,然后创建 DAO 接口和数据库类,作为保管和查阅物证的「证据库」。
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()
)
且看这份「物证登记表」中的几项关键设计——每一处细节都经过我的审慎推敲:
id 使用 autoGenerate = true,让 Room 自动生成主键——正如每件物证都需要唯一的编号,不容混淆description 默认为空字符串,允许用户只填写标题——有时一条线索只有名目,尚无详细描述,这是合理的isCompleted 默认为 false,新建的待办都是未完成状态——新案件入卷时,自然尚未结案createdAt 使用时间戳记录创建时间,方便排序——案发时间是最重要的线索之一,务必精确记录有了物证,便需要一套严谨的「查阅与管理规程」。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<List<Todo>> 而不是 List<Todo>,这正是我狄仁杰办案的精髓——不能只查一次就收工。Flow 能让我们实时监听数据变化,正如在案发现场布下眼线:当数据库中的数据发生任何修改时,Room 会自动发射新的列表,UI 会自动更新,无需手动刷新。任何风吹草动,都逃不过我们的法眼。
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 依赖注入来安排,各司其职,井然有序。
诸位,在大理寺的办案体制中,主审官从不直接翻阅原始卷宗——他只需向书吏索要整理好的案情摘要即可。Repository 模式的道理与此相同:它为数据访问提供了一层抽象。ViewModel 不需要知道数据是来自 Room、网络还是内存缓存——它只需要调用 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)
}
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 的存在,正如主审官不必亲赴证据库翻箱倒柜。
诸位,一个运转良好的衙门,靠的是明确的职责分工与人员调配。使用 Hilt 进行依赖注入,便如同为这座「大理寺」安排了一位精干的管家——他负责将合适的人安排到合适的位置,各模块之间保持松耦合,互不越权。我们需要配置两个模块:一个负责提供数据库相关依赖(证据库的钥匙),另一个负责绑定 Repository 接口与实现(指定哪位书吏负责调卷)。
package com.example.todoapp
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class TodoApplication : Application()
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()
}
}
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 用于需要手动创建实例的情况(如 Room 数据库),方法体包含具体的构造逻辑——这如同你需要亲手打造一把特殊的锁。@Binds 则用于将接口绑定到已有实现,它更高效——Hilt 不需要生成额外的工厂类。当实现类已经通过 @Inject constructor 标注时,优先使用 @Binds,这便是最省力、最精准的「人事安排」。
诸位,在一桩大案中,主审官居中指挥,承上启下——他从证据库中取得物证与供词,经过缜密推理,最终向堂上呈报案情结论。ViewModel 正是这位「主审官」。它持有 UI 需要展示的状态,处理用户操作,并通过 StateFlow 将案情的最新进展通知给 Compose 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()
}
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)能够实时订阅案件进展。
SharingStarted.WhileSubscribed(5000) 表示当最后一个订阅者取消订阅后,Flow 会在 5 秒后停止收集。这如同主审官暂时离席——探子们不必急于撤走,等上五秒再说。这样在配置变更(如屏幕旋转)时,ViewModel 不会立即重新查询数据库,提升了性能。
现在,我们来到了案件的「公堂呈堂」环节——也就是用户直接面对的界面层。主界面是用户看到的第一个页面,正如大理寺的正堂之上,所有案件的进展一目了然。它包含待办列表、筛选标签、浮动按钮和空状态提示。让我们逐一摆设堂上的「证物」。
每一条待办事项,便如同案卷架上的一份卷宗。卡片上清楚标注着案件名目(标题)、案情简述(描述)、是否已结案(完成状态),以及销案按钮(删除):
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
)
}
}
}
}
破案时,我们常常需要从不同角度审视证据——有时需要总览全局,有时只看未解之谜,有时则要回顾已结之案。筛选组件便是我们手中的「分类案卷签」:
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) }
)
}
}
}
这便是大理寺的正堂——所有案件的进展在此一览无余。加载中时,堂上悬挂等候的灯笼;案卷清空时,呈现一片宁静;线索汇聚时,案卷列表整齐排列;出了差错时,红字警示高悬:
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
)
}
}
在 LazyColumn 的 items 中使用 Modifier.animateItem(),可以为列表项的添加、移除和重新排列自动添加平滑动画——正如案卷在架上的增减移动应当有条不紊、从容不迫。前提是必须为每个列表项提供唯一的 key,这里我们使用 todo.id,就如同每份卷宗的编号绝不可重复。
接下来便是「立案」与「修档」的环节。添加和编辑共用同一个页面——正如新案件的登记与旧案件的补充调查,用的是同一张案卷格式。如果导航时传入了 todoId 参数,便是重新审视旧案(编辑模式);否则便是新开一桩(添加模式)。
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)
}
}
}
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 便是那一声「升堂!」的惊堂木——整个应用由此启动:
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()
}
}
}
}
此处必须格外警惕——这是一个隐藏极深的「嫌疑犯」。使用 Hilt 的 Activity 必须添加 @AndroidEntryPoint 注解,Application 类必须添加 @HiltAndroidApp。遗漏了这些注解,就如同升堂时忘了带官印——Hilt 无法注入依赖,应用会在运行时崩溃。我见过无数开发者栽在此处,诸位切记、切记。
诸位,一桩精彩的堂审,不仅仅是宣读卷宗——它还需要起承转合、抑扬顿挫,方能让旁听者心领神会。动画在应用中扮演的正是这个角色:它让界面的状态变化有迹可循,让用户自然而然地理解正在发生什么。让我们在几个关键的「审案节点」加入动效。
当案卷从有到无、从无到有时,堂上的陈设不应骤然出现或消失,而应如帷幕缓缓拉开。使用 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 ...
}
}
当一桩案件从「侦办中」变为「已结案」,卷宗封面的颜色也随之改变。用户点击复选框标记待办完成时,卡片背景色应当平滑过渡,如同案情水落石出、尘埃落定:
@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(
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 -> { /* 错误状态 */ }
}
}
有些案件需要被销案——向左一拂,红色警示浮现,案卷便从架上消失。这便是滑动删除功能,它赋予用户一种果断而优雅的「结案」体验:
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 毫秒通常是最佳范围),在关键交互节点使用动画即可。过犹不及,适可而止。
诸位,狄仁杰办案有一条铁律:每一项指控都必须经得起反复推敲,每一条证据链都必须严丝合缝。写代码亦是如此——一个真正可靠的应用离不开测试。测试就是我们的「复查制度」,是防止冤假错案的最后一道防线。我们为核心逻辑编写单元测试,为 UI 编写集成测试。
首先,我们需要一位「替身证人」——一个假的 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
}
}
}
现在,让我们对主审官的每一步推理进行严格的复核——初始状态是否正确?立案后卷宗是否入库?结案标记是否生效?销案是否彻底?筛选是否精准?每一项都要验证:
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)
}
}
验证完主审官的推理逻辑后,还要检验公堂上的呈现是否合规——卷宗的内容是否正确显示?点击复选框时回调是否被正确触发?删除按钮是否如实执行了销案?
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 查询的正确性。测试覆盖了核心路径后,你便可以放心大胆地重构代码,因为测试会替你守住每一道防线——正如我狄仁杰从不在没有把握的情况下提审嫌犯,有了测试覆盖,重构便再无后顾之忧。
诸位,至此,这桩「大案」的侦办已经完成。让我们退后一步,从全局审视这条完整的「证据链」——从最底层的数据基石,到中间的逻辑枢纽,再到最上层的堂审呈堂,每一个环节是如何环环相扣的。
整个应用的数据流如下所示,严格遵循单向数据流原则——证据只从现场汇聚到结论,绝不反向捏造:
┌─────────────────────────────────────────────────────┐
│ 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 应用已经具备了完整的基础功能,但作为一位有追求的「捕快」,你还可以沿着以下线索继续侦查:
dueDate 字段,支持日期选择器,按截止日期排序和提醒——如同为每桩案件设定最后审限Category 实体,实现多对多关系,让用户按类别管理待办——正如大理寺按案件类型分科归档LIKE 查询实现模糊搜索——让你能在堆积如山的卷宗中迅速定位目标WorkManager 在截止日期前发送本地通知——如同安排衙役按时提醒主审官开堂MaterialTheme 的 darkColorScheme 实现深色主题切换——夜审模式,保护双眼诸位,Android 开发的江湖风云变幻,技术的更迭如同朝代的兴替。掌握了本教程涵盖的基础知识后,建议你关注以下方向:Kotlin Multiplatform(跨平台开发)、Compose Multiplatform(跨平台 UI)、以及 AI 辅助开发工具的使用。我狄仁杰虽身处大唐,但若有新的断案之术传入中原,亦必虚心研习。保持对新技术的好奇心,在实际项目中不断实践,你就会从初出茅庐的新手捕快成长为一名独当一面的断案能手。
诸位,恭喜你完成了整个教程的学习!从第 1 章的 Kotlin 基础语法到如今的综合项目实战——这桩横跨十六章的「大案」终于画上了圆满的句号。正如我狄仁杰常言:天下没有破不了的案,亦没有学不会的技术。关键在于——条理清晰地分析每一条线索,坚持不懈地追踪每一个细节,最终将它们串联成一个完整的全貌。编程是一项需要持续精进的技艺——最好的修炼方式就是亲手办案。现在,去构建属于你自己的应用吧!
在 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 中添加一个 Box 或 Canvas 绘制小圆点,根据 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)
)
在待办列表页面的顶部添加一个搜索框,允许用户按标题关键字搜索待办事项。搜索应该是实时的(用户每输入一个字便立即过滤结果)。提示:这如同在堆积如山的案卷中按关键线索快速检索——你需要在 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
}
当用户滑动删除一条待办后,在屏幕底部显示一个 Snackbar,提示「已删除:XXX」,并提供一个「撤销」按钮。用户点击撤销后,被删除的待办应当恢复。提示:这如同判决后的复审机制——给当事人一次申诉的机会。你需要在删除时先将待办暂存,然后在撤销时重新插入。
1. 使用 SnackbarHostState 并将其传入 Scaffold 的 snackbarHost 参数。
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()
}
}
}
假设应用已经发布了第一个版本(数据库版本 1),现在需要为 Todo 实体新增 dueDate: Long?(截止日期)和 categoryId: Long?(分类 ID)两个可空字段。请编写一个 Room 数据库迁移(Migration),将数据库从版本 1 升级到版本 2,同时保留用户的已有数据。提示:这如同在不打乱已有卷宗顺序的情况下,为案卷架增设新的分类标签栏位——任何已有案卷不得丢失。
1. 在 Todo 实体中添加两个新字段:val dueDate: Long? = null 和 val categoryId: Long? = null。
2. 将 @Database 的 version 改为 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
}
编写一个完整的 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()
}
}