30、实战项目:搭建一个完整的 Compose 应用(如待办事项 App)

终于到了实战环节。说实话,前面讲了那么多理论,我自己都觉得有点干。今天咱们就用 Compose 从头搭一个待办事项 App,把声明式 UI 的流程走一遍。你想想看,学完这个,你就能独立写一个完整应用了。

项目结构设计

我个人习惯先把架子搭好。一个标准的 Compose 项目,我会分成这几层:

  • 数据层:负责存储和提供数据。这里我们用 Room 数据库。
  • ViewModel 层:管理 UI 状态,处理业务逻辑。
  • UI 层:纯 Compose 组件,只负责渲染和接收事件。

我在项目中遇到过不少新手把业务逻辑直接写在 Composable 函数里,结果一重构就崩。记住,Composable 只做一件事:把状态映射成界面

核心原则:数据向下流动,事件向上传递。

数据层:定义实体与 DAO

先定义数据模型。我们用 Room 的 @Entity 注解:

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

然后写 DAO 接口。这里我习惯用 Flow 做响应式查询,这样 UI 能自动更新:

@Dao
interface TodoDao {
    @Query("SELECT * FROM todos ORDER BY createdAt DESC")
    fun getAllTodos(): Flow<List<Todo>>

    @Insert
    suspend fun insert(todo: Todo)

    @Update
    suspend fun update(todo: Todo)

    @Delete
    suspend fun delete(todo: Todo)
}

嗯,这里要注意:suspend 函数必须在协程里调用。ViewModel 里我们会用 viewModelScope 来处理。

ViewModel:状态管理

ViewModel 是整个应用的「大脑」。它持有 UI 状态,并对外暴露方法让 UI 层触发事件。

class TodoViewModel(private val dao: TodoDao) : ViewModel() {

    // 从数据库获取实时数据
    val todos: StateFlow<List<Todo>> = dao.getAllTodos()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    private val _showDialog = MutableStateFlow(false)
    val showDialog: StateFlow<Boolean> = _showDialog.asStateFlow()

    fun addTodo(title: String) {
        viewModelScope.launch {
            dao.insert(Todo(title = title))
            _showDialog.value = false
        }
    }

    fun toggleTodo(todo: Todo) {
        viewModelScope.launch {
            dao.update(todo.copy(isCompleted = !todo.isCompleted))
        }
    }

    fun deleteTodo(todo: Todo) {
        viewModelScope.launch {
            dao.delete(todo)
        }
    }

    fun showAddDialog() { _showDialog.value = true }
    fun hideAddDialog() { _showDialog.value = false }
}

为什么用 stateIn?说白了,就是把 Flow 转成 StateFlow,这样 Compose 才能观察到变化。我曾经踩过一个坑:直接用 collectAsState() 在 Composable 里收集 Flow,结果每次重组都重新订阅,导致数据重复。后来改用 stateIn 就稳了。

UI 层:Composable 组件

UI 层我拆成三个组件:主列表、单个待办项、添加对话框。

主界面

@Composable
fun TodoScreen(viewModel: TodoViewModel) {
    val todos by viewModel.todos.collectAsState()
    val showDialog by viewModel.showDialog.collectAsState()

    Scaffold(
        topBar = { TopAppBar(title = { Text("待办事项") }) },
        floatingActionButton = {
            FloatingActionButton(onClick = { viewModel.showAddDialog() }) {
                Icon(Icons.Default.Add, contentDescription = "添加")
            }
        }
    ) { padding ->
        LazyColumn(modifier = Modifier.padding(padding)) {
            items(todos, key = { it.id }) { todo ->
                TodoItem(
                    todo = todo,
                    onToggle = { viewModel.toggleTodo(todo) },
                    onDelete = { viewModel.deleteTodo(todo) }
                )
            }
        }
    }

    if (showDialog) {
        AddTodoDialog(
            onConfirm = { title -> viewModel.addTodo(title) },
            onDismiss = { viewModel.hideAddDialog() }
        )
    }
}

注意 key = { it.id } 这个参数。不加的话,列表增删时 Compose 会全部重组,性能很差。我刚开始写 Compose 时没加这个,滑动列表卡得不行,后来才发现是 key 的问题。

单个待办项

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

添加对话框

@Composable
fun AddTodoDialog(
    onConfirm: (String) -> Unit,
    onDismiss: () -> Unit
) {
    var text by remember { mutableStateOf("") }

    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("添加待办") },
        text = {
            OutlinedTextField(
                value = text,
                onValueChange = { text = it },
                label = { Text("输入内容") },
                singleLine = true
            )
        },
        confirmButton = {
            TextButton(
                onClick = { if (text.isNotBlank()) onConfirm(text) },
                enabled = text.isNotBlank()
            ) {
                Text("确定")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text("取消")
            }
        }
    )
}

依赖注入与 Application 类

最后,我们需要把 Room 数据库和 ViewModel 串起来。我习惯用 ViewModelProvider.Factory 来注入 DAO:

class TodoApplication : Application() {
    val database by lazy { AppDatabase.getDatabase(this) }
    val dao by lazy { database.todoDao() }
}

// 在 Activity 中获取 ViewModel
val viewModel: TodoViewModel = viewModel(
    factory = object : ViewModelProvider.Factory {
        override fun  create(modelClass: Class): T {
            return TodoViewModel(dao) as T
        }
    }
)

小技巧:如果你用 Hilt 或 Koin,可以省掉 Factory 的样板代码。但小项目手动注入反而更清晰,我建议你先理解原理再上框架。

整体架构图

下面这张图展示了数据在应用中的流动路径。你可以看到,所有数据都从 Room 出发,经过 ViewModel,最终到达 UI。用户操作则反向传递回去。

待办事项 App 数据流架构 Room 数据库 数据持久化层 Flow 查询 ViewModel 状态管理 + 业务逻辑 StateFlow 订阅 Compose UI 声明式渲染 用户事件(点击/输入) 数据流向说明 • 实线:数据向下流动 • 虚线:事件向上传递 • 单向数据流,避免混乱

运行效果与避坑指南

把上面代码拼起来,你就能得到一个完整的待办事项 App。支持增删改查,数据持久化,界面自动刷新。

避坑指南

  • 别忘了在 AndroidManifest.xml 里注册 TodoApplication
  • Room 的数据库版本升级要写 migration,否则会 crash。我曾经上线前忘了加 migration,结果用户更新后全崩了,被老板骂了一下午。
  • Compose 里不要用 remember 存大对象,比如整个列表。它只适合存 UI 状态。

好了,这个项目虽然简单,但涵盖了 Compose 开发的完整流程。你把它跑通,再自己加点功能(比如分类、搜索),基本就能上手实际项目了。

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