Android Jetpack Compose 实战:从零构建一个完整的 ToDo 应用

Jetpack Compose 已经从“尝鲜”阶段进入了主流 Android 开发的视野。越来越多的团队开始在新项目中使用 Compose,但很多开发者在真正上手时才发现:看懂文档是一回事,写出能跑的完整应用又是另一回事。
本文用一个真实的 ToDo 应用作为载体,从项目搭建到最终运行,完整走一遍 Compose 开发流程,包括状态管理、数据持久化、MVVM 架构、UI 交互等核心知识点,并附上踩坑记录。
一、项目结构与依赖配置
先看整体项目结构,我们按照标准 MVVM 分层:
app/ ├── data/ │ ├── Task.kt │ ├── TaskDao.kt │ └── TaskDatabase.kt ├── repository/ │ └── TaskRepository.kt ├── viewmodel/ │ └── TaskViewModel.kt └── ui/ ├── MainActivity.kt ├── TaskListScreen.kt └── AddTaskScreen.kt
在 build.gradle.kts 中添加依赖:
android {
compileSdk = 34
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
buildFeatures { compose = true }
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.02.00")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
implementation("androidx.navigation:navigation-compose:2.7.6")
}踩坑提示:Compose 编译器版本必须与 Kotlin 版本严格对应。
二、数据层:Room 数据库实现
@Entity(tableName = "tasks")
data class Task(
@PrimaryKey(autoGenerate = true)
val id: Int = 0,
val title: String,
val description: String = "",
val isCompleted: Boolean = false,
val createdAt: Long = System.currentTimeMillis()
)
@Dao
interface TaskDao {
@Query("SELECT * FROM tasks ORDER BY createdAt DESC")
fun getAllTasks(): Flow>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTask(task: Task)
@Update
suspend fun updateTask(task: Task)
@Delete
suspend fun deleteTask(task: Task)
}三、Repository 与 ViewModel
class TaskRepository(private val taskDao: TaskDao) {
val allTasks: Flow> = taskDao.getAllTasks()
suspend fun insertTask(task: Task) = taskDao.insertTask(task)
suspend fun updateTask(task: Task) = taskDao.updateTask(task)
suspend fun deleteTask(task: Task) = taskDao.deleteTask(task)
}
class TaskViewModel(application: Application) : AndroidViewModel(application) {
private val repository: TaskRepository
val allTasks: StateFlow>
var newTaskTitle by mutableStateOf("")
private set
init {
val db = TaskDatabase.getDatabase(application)
repository = TaskRepository(db.taskDao())
allTasks = repository.allTasks
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
fun addTask() {
if (newTaskTitle.isBlank()) return
viewModelScope.launch {
repository.insertTask(Task(title = newTaskTitle.trim()))
newTaskTitle = ""
}
}
fun toggleTaskCompletion(task: Task) {
viewModelScope.launch { repository.updateTask(task.copy(isCompleted = !task.isCompleted)) }
}
fun deleteTask(task: Task) {
viewModelScope.launch { repository.deleteTask(task) }
}
}踩坑提示:在 Compose 里推荐用 StateFlow + collectAsStateWithLifecycle(),能感知生命周期,避免在后台仍然收集数据浪费资源。
四、UI 层:任务列表页面
@Composable
fun TaskListScreen(viewModel: TaskViewModel = viewModel(), onNavigateToAdd: () -> Unit) {
val tasks by viewModel.allTasks.collectAsStateWithLifecycle()
Scaffold(
topBar = { TopAppBar(title = { Text("我的待办") }) },
floatingActionButton = {
FloatingActionButton(onClick = onNavigateToAdd) {
Icon(Icons.Default.Add, contentDescription = "添加任务")
}
}
) { paddingValues ->
if (tasks.isEmpty()) {
Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center) {
Text("暂无任务,点击右下角添加")
}
} else {
LazyColumn(modifier = Modifier.padding(paddingValues), contentPadding = PaddingValues(16.dp)) {
items(tasks, key = { it.id }) { task ->
TaskItem(task = task, onToggle = { viewModel.toggleTaskCompletion(task) }, onDelete = { viewModel.deleteTask(task) })
}
}
}
}
}
@Composable
fun TaskItem(task: Task, onToggle: () -> Unit, onDelete: () -> Unit) {
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() })
Text(task.title, modifier = Modifier.weight(1f),
textDecoration = if (task.isCompleted) TextDecoration.LineThrough else TextDecoration.None)
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "删除")
}
}
}
}五、添加任务页面与导航配置
@Composable
fun AddTaskScreen(viewModel: TaskViewModel = viewModel(), onTaskAdded: () -> Unit) {
Scaffold(topBar = { TopAppBar(title = { Text("添加任务") }) }) { paddingValues ->
Column(modifier = Modifier.fillMaxSize().padding(paddingValues).padding(16.dp)) {
OutlinedTextField(
value = viewModel.newTaskTitle,
onValueChange = { viewModel.newTaskTitle = it },
label = { Text("任务名称") },
modifier = Modifier.fillMaxWidth()
)
Button(onClick = { viewModel.addTask(); onTaskAdded() },
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
enabled = viewModel.newTaskTitle.isNotBlank()) {
Text("保存任务")
}
}
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TodoAppTheme {
val navController = rememberNavController()
NavHost(navController, startDestination = "list") {
composable("list") { TaskListScreen(onNavigateToAdd = { navController.navigate("add") }) }
composable("add") { AddTaskScreen(onTaskAdded = { navController.popBackStack() }) }
}
}
}
}
}六、常见踩坑总结
remember vs rememberSaveable:remember 在重组时保持状态,横竖屏切换后会丢失;需要跨配置变更保留状态用 rememberSaveable。
LazyColumn key 参数:一定要给 items 传 key 参数,否则列表更新时动画不正确。
Modifier 顺序很重要:.padding().clickable() 和 .clickable().padding() 效果完全不同。
AndroidViewModel 与 ViewModel:需要访问 Context 时用 AndroidViewModel,否则普通 ViewModel 即可。
七、扩展方向
优先级与分类:给 Task 增加 priority 和 category 字段,UI 上用颜色区分
截止日期提醒:集成 WorkManager 设置本地通知
Widget 支持:使用 Glance API 在桌面展示待办列表
深色模式:Material3 自动支持动态主题,只需在 Theme.kt 中开启 dynamicColor = true
如果在实际项目中遇到其他 Compose 相关问题,欢迎留言交流!
发布评论
热门评论区: