第一章:实战项目一 — 待办事项App(CRUD操作、Room数据库、Compose UI、导航、主题切换)

各位同学,欢迎来到第一个实战项目。

说实话,学了一大堆理论,如果不亲手撸一个完整的App,总觉得心里没底。我自己带团队的时候,最怕的就是新人面试时背得头头是道,一上手写代码就懵了。

所以这一章,咱们直接干一个「待办事项App」。麻雀虽小,五脏俱全。它会用到:

  • Room数据库 — 本地持久化
  • CRUD操作 — 增删改查
  • Jetpack Compose UI — 声明式界面
  • 导航 — 页面跳转
  • 主题切换 — 白天/黑夜模式

嗯,你想想看,一个App该有的核心功能,它全占了。做完这个,你就能理解Android开发中80%的日常套路。


1.1 项目结构概览

先别急着写代码。我习惯先搭好架子,再往里填肉。

这个项目的包结构大概是这样的:

com.example.todoapp
├── data
│   ├── db
│   │   ├── TodoDatabase.kt
│   │   ├── TodoDao.kt
│   │   └── TodoEntity.kt
│   └── repository
│       └── TodoRepository.kt
├── ui
│   ├── theme
│   │   ├── Theme.kt
│   │   ├── Color.kt
│   │   └── Type.kt
│   ├── navigation
│   │   └── NavGraph.kt
│   ├── screens
│   │   ├── list
│   │   │   ├── TodoListScreen.kt
│   │   │   └── TodoListViewModel.kt
│   │   └── detail
│   │       ├── TodoDetailScreen.kt
│   │       └── TodoDetailViewModel.kt
│   └── components
│       └── CommonComponents.kt
└── MainActivity.kt

你看,data层管数据,ui层管界面,职责分明。我在项目中踩过最大的坑,就是把业务逻辑和UI混在一起写,最后改需求时改到想哭。所以,分层这件事,从一开始就要做好。


2.2 数据层:Room数据库

Room是Google官方推荐的本地数据库方案。说白了,它就是在SQLite上面包了一层,让你用注解就能搞定建表和查询。

2.2.1 定义实体(Entity)

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

这里有个小细节:autoGenerate = true 表示id自增。我刚开始用Room时,忘了加这个,结果每次插入数据都报主键冲突。嗯,这种低级错误,犯过一次就记住了。

2.2.2 定义DAO(Data Access Object)

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

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertTodo(todo: TodoEntity)

    @Update
    suspend fun updateTodo(todo: TodoEntity)

    @Delete
    suspend fun deleteTodo(todo: TodoEntity)

    @Query("SELECT * FROM todos WHERE id = :id")
    suspend fun getTodoById(id: Int): TodoEntity?
}

注意看,getAllTodos()返回的是Flow。为什么?因为这样当数据库数据变化时,UI会自动更新。这就是响应式编程的魅力。我曾经在旧项目里用手动刷新,每次增删改都要手动调用一次查询,烦得很。

2.2.3 定义Database

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

    companion object {
        @Volatile
        private var INSTANCE: TodoDatabase? = null

        fun getDatabase(context: Context): TodoDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    TodoDatabase::class.java,
                    "todo_database"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}
注意:数据库实例要用单例模式。否则每次创建新实例,数据就丢了。我见过有人把getDatabase写在Activity里,结果屏幕旋转一次,数据全没了。别问我怎么知道的。

3.3 Repository层:数据仓库

Repository的作用,就是给ViewModel提供数据。它屏蔽了数据来源的细节——你从Room拿数据,还是从网络拿数据,ViewModel不关心。

class TodoRepository(private val todoDao: TodoDao) {
    val allTodos: Flow<List<TodoEntity>> = todoDao.getAllTodos()

    suspend fun insert(todo: TodoEntity) = todoDao.insertTodo(todo)
    suspend fun update(todo: TodoEntity) = todoDao.updateTodo(todo)
    suspend fun delete(todo: TodoEntity) = todoDao.deleteTodo(todo)
    suspend fun getById(id: Int): TodoEntity? = todoDao.getTodoById(id)
}

你看,代码很简单。但它的价值在于:如果以后你想把数据存到云端,只需要改Repository的实现,ViewModel和UI完全不用动。这就是架构的意义。


4.4 UI层:Compose界面

终于到了写界面的时候了。Compose的写法跟传统XML完全不同,它是用Kotlin函数直接描述UI。

4.4.1 列表页面

@Composable
fun TodoListScreen(
    viewModel: TodoListViewModel,
    onNavigateToDetail: (Int) -> Unit
) {
    val todos by viewModel.allTodos.collectAsState(initial = emptyList())

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

这里有个关键点:collectAsState()。它把Flow转成了Compose可观察的状态。当数据库变化时,列表会自动刷新。你想想看,以前用RecyclerView,要写Adapter、ViewHolder、DiffUtil...现在几行代码就搞定了。

4.4.2 详情页面

@Composable
fun TodoDetailScreen(
    todoId: Int,
    viewModel: TodoDetailViewModel,
    onNavigateBack: () -> Unit
) {
    val todo by viewModel.todo.collectAsState()

    Column(modifier = Modifier.padding(16.dp)) {
        OutlinedTextField(
            value = todo.title,
            onValueChange = { viewModel.updateTitle(it) },
            label = { Text("标题") }
        )
        Spacer(modifier = Modifier.height(8.dp))
        OutlinedTextField(
            value = todo.description,
            onValueChange = { viewModel.updateDescription(it) },
            label = { Text("描述") }
        )
        Spacer(modifier = Modifier.height(16.dp))
        Row(horizontalArrangement = Arrangement.SpaceEvenly) {
            Button(onClick = {
                viewModel.save()
                onNavigateBack()
            }) { Text("保存") }
            Button(onClick = {
                viewModel.delete()
                onNavigateBack()
            }) { Text("删除") }
        }
    }
}
小技巧:在详情页面,我习惯用LaunchedEffect来加载数据。比如LaunchedEffect(todoId) { viewModel.loadTodo(todoId) }。这样当todoId变化时,会自动重新加载。

5.5 导航

导航用Jetpack Navigation Compose。定义两个目的地:列表页和详情页。

@Composable
fun TodoNavGraph(navController: NavHostController) {
    NavHost(navController, startDestination = "list") {
        composable("list") {
            TodoListScreen(
                viewModel = viewModel(),
                onNavigateToDetail = { id ->
                    navController.navigate("detail/$id")
                }
            )
        }
        composable(
            route = "detail/{todoId}",
            arguments = listOf(navArgument("todoId") { type = NavType.IntType })
        ) { backStackEntry ->
            val todoId = backStackEntry.arguments?.getInt("todoId") ?: -1
            TodoDetailScreen(
                todoId = todoId,
                viewModel = viewModel(),
                onNavigateBack = { navController.popBackStack() }
            )
        }
    }
}

注意,todoId = -1表示新建。这个约定是我自己定的,你也可以用null。我个人习惯用-1,因为Int类型处理起来更简单。


6.6 主题切换

主题切换说白了就是根据一个状态,切换颜色和背景。

@Composable
fun TodoTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) {
        darkColorScheme(
            primary = Color(0xFFBB86FC),
            background = Color(0xFF121212)
        )
    } else {
        lightColorScheme(
            primary = Color(0xFF6200EE),
            background = Color(0xFFFFFBFE)
        )
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

然后在MainActivity里,用一个remember状态来控制:

var isDarkTheme by remember { mutableStateOf(false) }
TodoTheme(darkTheme = isDarkTheme) {
    // 导航和界面
}

你可以在设置页面加一个Switch,切换isDarkTheme的值。嗯,就这么简单。以前用XML实现主题切换,要写两套style文件,还要处理Activity重建。Compose让这件事变得极其自然。


7.7 整体架构图

下面这张图,是我画的项目整体架构。你看一眼,就能明白数据是怎么流动的。

待办事项App — 架构与数据流 UI层 (Compose) TodoListScreen TodoDetailScreen Theme / Navigation ViewModel层 TodoListViewModel TodoDetailViewModel 状态管理 / 业务逻辑 Repository层 TodoRepository 数据统一入口 Data层 (Room) TodoDatabase TodoDao TodoEntity 事件 状态 调用 Flow 读写 Flow 数据单向流动:UI → ViewModel → Repository → Room

数据流是单向的:用户点击UI,事件传到ViewModel,ViewModel调用Repository,Repository操作Room数据库。数据库变化后,通过Flow反向通知到UI。这样每个环节职责清晰,出了问题也容易定位。


8.8 避坑指南

最后,分享几个我踩过的坑:

  • Room数据库升级:如果你改了Entity结构,记得升级数据库版本,并写Migration。我曾经直接删了字段没写Migration,App一启动就崩了。
  • Compose重组:不要在Composable函数里做耗时操作。Compose会频繁重组,如果你在里面做数据库查询,性能会炸。
  • ViewModel作用域:导航到详情页时,每个详情页应该有自己的ViewModel实例。用viewModel()默认是按NavBackStackEntry作用域的,没问题。
  • 主题切换闪烁:如果你用isSystemInDarkTheme(),系统切换主题时会有短暂闪烁。建议把主题状态存在DataStore里,手动控制。

核心要点回顾:

  • Room + Flow 实现数据持久化和自动刷新
  • Repository 隔离数据源
  • ViewModel 管理UI状态
  • Compose 声明式构建界面
  • Navigation Compose 管理页面跳转
  • 主题切换本质是颜色状态的动态变化

好了,这一章的内容就是这些。代码量不大,但每一行都是精华。你跟着敲一遍,绝对能打通Compose开发的任督二脉。


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