25、实战项目一:待办事项 App(CRUD + 状态管理 + 列表 + 导航)

终于到了第一个实战项目了。说实话,每次带新人入门 Compose,我都会让他们先做这个待办事项 App。为什么?因为它麻雀虽小五脏俱全——增删改查、状态管理、列表渲染、页面跳转,这些核心技能全都能练到。

我个人习惯把这种小项目当作「练手沙盒」。你想想看,一个待办事项 App 虽然简单,但它的数据流模型和大型应用其实是一样的。今天我们就从零开始,把它完整地搭出来。

项目结构概览

先看看我们要做什么。整个 App 分为两个页面:

  • 主列表页:展示所有待办事项,支持添加、删除、切换完成状态
  • 详情/编辑页:查看或编辑某个事项的具体内容

页面之间通过 Navigation 组件跳转,数据通过 ViewModel 统一管理。嗯,这里要注意,我们不会用 Room 数据库,而是用内存中的 MutableList 来存储数据——先跑通流程,后面再升级持久化方案。

核心知识点一览

  • ViewModel + StateFlow 管理状态
  • LazyColumn 渲染列表
  • NavHost 实现页面导航
  • Composable 函数拆分与复用
待办事项 App 架构图 UI 层(Composable) TodoListScreen TodoDetailScreen TodoItem(可复用组件) 观察 ViewModel 层 TodoViewModel StateFlow<List<Todo>> 增删改查方法 数据层(内存) MutableList<Todo>(后续可替换为 Room)

第一步:定义数据模型

先写一个简单的 data class。我在项目中遇到过很多次因为模型设计不合理导致后期改得想哭的情况,所以一开始就要想清楚。

data class Todo(
    val id: String = UUID.randomUUID().toString(),
    val title: String,
    val description: String = "",
    val isCompleted: Boolean = false,
    val createdAt: Long = System.currentTimeMillis()
)

这里用 UUID 作为主键,保证每个待办事项的唯一性。你想想看,如果用自增数字,万一删除了中间某个,后面新增的 id 就可能重复——虽然概率低,但没必要冒这个险。

第二步:搭建 ViewModel

ViewModel 是整个 App 的大脑。它持有数据,暴露状态,提供操作方法。

class TodoViewModel : ViewModel() {

    private val _todos = MutableStateFlow<List<Todo>>(emptyList())
    val todos: StateFlow<List<Todo>> = _todos.asStateFlow()

    fun addTodo(title: String, description: String) {
        val newTodo = Todo(title = title, description = description)
        _todos.value = _todos.value + newTodo
    }

    fun deleteTodo(id: String) {
        _todos.value = _todos.value.filter { it.id != id }
    }

    fun toggleTodo(id: String) {
        _todos.value = _todos.value.map {
            if (it.id == id) it.copy(isCompleted = !it.isCompleted) else it
        }
    }

    fun updateTodo(id: String, title: String, description: String) {
        _todos.value = _todos.value.map {
            if (it.id == id) it.copy(title = title, description = description) else it
        }
    }

    fun getTodoById(id: String): Todo? {
        return _todos.value.find { it.id == id }
    }
}

小提示:注意看我是怎么更新列表的——每次都是创建新的 List 对象,而不是修改原来的。这是 Compose 状态管理的核心原则:不可变性。只有 StateFlow 发射新对象,Compose 才能感知到变化并重组 UI。

我曾经见过有人直接在 MutableList 上调用 add(),然后发现 UI 死活不刷新。嗯,这就是没理解「状态不可变」这个坑。

第三步:编写主列表页面

列表页是用户第一眼看到的东西。我们用 LazyColumn 来渲染,配合一个悬浮按钮来添加新事项。

@Composable
fun TodoListScreen(
    viewModel: TodoViewModel,
    onNavigateToDetail: (String) -> Unit
) {
    val todos by viewModel.todos.collectAsState()

    Scaffold(
        floatingActionButton = {
            FloatingActionButton(onClick = { /* 跳转到添加页面 */ }) {
                Icon(Icons.Default.Add, contentDescription = "添加")
            }
        }
    ) { paddingValues ->
        LazyColumn(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues)
                .padding(horizontal = 16.dp)
        ) {
            items(todos, key = { it.id }) { todo ->
                TodoItem(
                    todo = todo,
                    onToggle = { viewModel.toggleTodo(todo.id) },
                    onDelete = { viewModel.deleteTodo(todo.id) },
                    onClick = { onNavigateToDetail(todo.id) }
                )
            }
        }
    }
}

这里有个细节:key = { it.id }。为什么要加这个?说白了就是告诉 LazyColumn 每个 item 的唯一标识。如果不加,列表在增删时可能会发生奇怪的动画错乱——我踩过这个坑,后来老老实实加上了。

第四步:编写可复用的 TodoItem 组件

每个列表项都是一个独立的 Composable。我习惯把这种小组件单独抽出来,方便复用和测试。

@Composable
fun TodoItem(
    todo: Todo,
    onToggle: () -> Unit,
    onDelete: () -> Unit,
    onClick: () -> Unit
) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 4.dp)
            .clickable { onClick() },
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(12.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Checkbox(
                checked = todo.isCompleted,
                onCheckedChange = { onToggle() }
            )
            Column(modifier = Modifier.weight(1f)) {
                Text(
                    text = todo.title,
                    style = MaterialTheme.typography.titleMedium,
                    textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else null
                )
                if (todo.description.isNotBlank()) {
                    Text(
                        text = todo.description,
                        style = MaterialTheme.typography.bodySmall,
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis
                    )
                }
            }
            IconButton(onClick = onDelete) {
                Icon(Icons.Default.Delete, contentDescription = "删除")
            }
        }
    }
}

设计要点:已完成的事项标题加删除线,这是用户最直观的视觉反馈。描述只显示一行,超出部分用省略号——列表页不需要展示全部内容,详情页才是干这个的。

第五步:详情/编辑页面

点击某个待办事项后,跳转到详情页。这里我们同时支持查看和编辑。

@Composable
fun TodoDetailScreen(
    todoId: String,
    viewModel: TodoViewModel,
    onNavigateBack: () -> Unit
) {
    val todo = viewModel.getTodoById(todoId)
    var title by remember { mutableStateOf(todo?.title ?: "") }
    var description by remember { mutableStateOf(todo?.description ?: "") }

    if (todo == null) {
        Text("待办事项不存在")
        return
    }

    Column(modifier = Modifier.padding(16.dp)) {
        OutlinedTextField(
            value = title,
            onValueChange = { title = it },
            label = { Text("标题") },
            modifier = Modifier.fillMaxWidth()
        )
        Spacer(modifier = Modifier.height(8.dp))
        OutlinedTextField(
            value = description,
            onValueChange = { description = it },
            label = { Text("描述") },
            modifier = Modifier.fillMaxWidth(),
            minLines = 3
        )
        Spacer(modifier = Modifier.height(16.dp))
        Button(
            onClick = {
                viewModel.updateTodo(todoId, title, description)
                onNavigateBack()
            },
            modifier = Modifier.align(Alignment.End)
        ) {
            Text("保存")
        }
    }
}

注意:这里的 title 和 description 用了 remember + mutableStateOf 来管理局部状态。为什么不用 ViewModel?因为这只是编辑过程中的临时状态,不需要跨页面共享。记住一个原则:全局状态放 ViewModel,局部状态放 Composable。

第六步:用 Navigation 串联起来

最后一步,用 NavHost 把两个页面串起来。这是 Compose 中页面跳转的标准做法。

@Composable
fun TodoApp() {
    val viewModel: TodoViewModel = viewModel()
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = "list"
    ) {
        composable("list") {
            TodoListScreen(
                viewModel = viewModel,
                onNavigateToDetail = { id ->
                    navController.navigate("detail/$id")
                }
            )
        }
        composable(
            route = "detail/{todoId}",
            arguments = listOf(navArgument("todoId") { type = NavType.StringType })
        ) { backStackEntry ->
            val todoId = backStackEntry.arguments?.getString("todoId") ?: return@composable
            TodoDetailScreen(
                todoId = todoId,
                viewModel = viewModel,
                onNavigateBack = { navController.popBackStack() }
            )
        }
    }
}

这里有个小技巧:路由参数用 {todoId} 占位符,然后在 composable 中通过 backStackEntry 取出来。我刚开始学的时候老忘记加 navArgument 那一段,结果参数死活传不过去——嗯,这种细节最容易翻车。

完整数据流回顾

让我们捋一遍整个流程:

  1. 用户点击添加按钮 → ViewModel.addTodo() → StateFlow 发射新列表 → UI 自动刷新
  2. 用户点击某个事项 → Navigation 跳转到 detail/{id} → ViewModel.getTodoById() 获取数据
  3. 用户编辑后点保存 → ViewModel.updateTodo() → 返回列表页 → 列表自动显示更新后的内容

你看,整个数据流是单向的:UI 触发事件 → ViewModel 修改状态 → 状态驱动 UI 更新。这就是 Compose 推崇的「单向数据流」模式。说白了,只要遵循这个模式,你的代码就不会乱成一锅粥。

扩展思考:如果要把数据持久化到本地,只需要把 ViewModel 中的 MutableList 替换成 Room 数据库的 DAO 调用即可。UI 层完全不需要改动——这就是分层架构的好处。

好了,这个待办事项 App 的核心代码就这些。虽然简单,但它涵盖了 Compose 开发中最常用的几个模式。我个人建议你亲手敲一遍,然后试着加一些功能,比如按日期排序、搜索过滤、主题切换——这些都是很好的练手方向。


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