第一章:实战项目一 — 待办事项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 整体架构图
下面这张图,是我画的项目整体架构。你看一眼,就能明白数据是怎么流动的。
数据流是单向的:用户点击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开发的任督二脉。