案例1:待办事项App完整实现¶
难度:⭐⭐⭐ 初级到中级 预计完成时间:5-7天 技术栈:Kotlin + Compose + MVVM + Room + Hilt
📱 应用预览¶
功能特性¶
- ✅ 创建、编辑、删除待办事项
- ✅ 标记完成/未完成
- ✅ 按类别筛选
- ✅ 搜索功能
- ✅ 数据本地持久化
- ✅ 暗黑模式支持
- ✅ 动画效果
界面预览¶
Text Only
┌─────────────────────────────────┐
│ 待办事项 + │
├─────────────────────────────────┤
│ ┌─────────────────────────────┐ │
│ │ 🔍 搜索待办事项... │ │
│ └─────────────────────────────┘ │
├─────────────────────────────────┤
│ 全部 | 工作 | 个人 | 购物 │
├─────────────────────────────────┤
│ ┌─────────────────────────────┐ │
│ │ ☐ 完成项目文档 🗑️ │ │
│ │ 📁 工作 📅 今天 │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ ☑ 购买 groceries 🗑️ │ │
│ │ 📁 购物 📅 昨天 │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ ☐ 预约牙医 🗑️ │ │
│ │ 📁 个人 📅 明天 │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────┘
🏗️ 架构设计¶
项目结构¶
Text Only
todo-app/
├── app/
│ ├── src/
│ │ ├── main/
│ │ │ ├── java/com/example/todo/
│ │ │ │ ├── data/
│ │ │ │ │ ├── local/
│ │ │ │ │ │ ├── TodoDao.kt
│ │ │ │ │ │ ├── TodoDatabase.kt
│ │ │ │ │ │ └── entity/
│ │ │ │ │ │ └── TodoEntity.kt
│ │ │ │ │ ├── repository/
│ │ │ │ │ │ └── TodoRepository.kt
│ │ │ │ │ └── mapper/
│ │ │ │ │ └── TodoMapper.kt
│ │ │ │ ├── domain/
│ │ │ │ │ ├── model/
│ │ │ │ │ │ ├── Todo.kt
│ │ │ │ │ │ └── TodoCategory.kt
│ │ │ │ │ └── usecase/
│ │ │ │ │ ├── AddTodoUseCase.kt
│ │ │ │ │ ├── GetTodosUseCase.kt
│ │ │ │ │ ├── UpdateTodoUseCase.kt
│ │ │ │ │ └── DeleteTodoUseCase.kt
│ │ │ │ ├── presentation/
│ │ │ │ │ ├── home/
│ │ │ │ │ │ ├── HomeScreen.kt
│ │ │ │ │ │ ├── HomeViewModel.kt
│ │ │ │ │ │ ├── HomeUiState.kt
│ │ │ │ │ │ └── components/
│ │ │ │ │ │ ├── TodoItem.kt
│ │ │ │ │ │ ├── TodoInput.kt
│ │ │ │ │ │ └── FilterChips.kt
│ │ │ │ │ └── addedit/
│ │ │ │ │ ├── AddEditScreen.kt
│ │ │ │ │ └── AddEditViewModel.kt
│ │ │ │ ├── di/
│ │ │ │ │ └── AppModule.kt
│ │ │ │ └── MainActivity.kt
│ │ │ └── res/
│ │ └── test/
│ └── build.gradle.kts
└── build.gradle.kts
架构图¶
Text Only
┌─────────────────────────────────────────────────────────────┐
│ UI Layer (Compose) │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ HomeScreen │ │ AddEditScreen │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ └────────────────────┘ │
│ │ │
├────────────────────┼─────────────────────────────────────────┤
│ Presentation Layer (ViewModel) │
│ ┌─────────────────┴─────────────────┐ │
│ │ HomeViewModel │ │
│ │ - StateFlow<HomeUiState> │ │
│ │ - processIntent() │ │
│ └─────────────────┬─────────────────┘ │
│ │ │
├────────────────────┼─────────────────────────────────────────┤
│ Domain Layer │
│ ┌─────────────────┴─────────────────┐ │
│ │ UseCases │ │
│ │ ┌─────────┐ ┌─────────┐ │ │
│ │ │GetTodos │ │AddTodo │ │ │
│ │ └────┬────┘ └────┬────┘ │ │
│ └───────┼───────────┼───────────────┘ │
│ │ │ │
├──────────┼───────────┼───────────────────────────────────────┤
│ │ Data Layer │
│ ┌───────┴───────────┴───────┐ │
│ │ Repository │ │
│ │ ┌─────────────────┐ │ │
│ │ │ TodoRepository │ │ │
│ │ └────────┬────────┘ │ │
│ └────────────┼──────────────┘ │
│ │ │
│ ┌────────────┴────────────┐ │
│ │ Local Data (Room) │ │
│ │ ┌─────────────────┐ │ │
│ │ │ TodoDatabase │ │ │
│ │ └─────────────────┘ │ │
│ └─────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘
🚀 快速开始¶
1. 创建项目¶
2. 配置依赖¶
Kotlin
// build.gradle.kts (Project)
plugins {
id("com.android.application") version "8.7.0" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("com.google.dagger.hilt.android") version "2.51.1" apply false
id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false
}
// build.gradle.kts (App)
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp")
}
android {
namespace = "com.example.todo"
compileSdk = 35
defaultConfig {
applicationId = "com.example.todo"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
buildFeatures {
compose = true
}
// Kotlin 2.0+ 使用 org.jetbrains.kotlin.plugin.compose 插件,无需 composeOptions
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
// Compose BOM
val composeBom = platform("androidx.compose:compose-bom:2024.02.01")
implementation(composeBom)
androidTestImplementation(composeBom)
// Core
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.activity:activity-compose:1.8.1")
// Compose
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
// Navigation
implementation("androidx.navigation:navigation-compose:2.7.5")
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
// Room
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// Hilt
implementation("com.google.dagger:hilt-android:2.51.1")
ksp("com.google.dagger:hilt-compiler:2.51.1")
// Testing
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.8")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
// Debug
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
📦 核心代码实现¶
1. 数据模型¶
Kotlin
// domain/model/Todo.kt
data class Todo(
val id: String = UUID.randomUUID().toString(),
val title: String,
val description: String = "",
val category: TodoCategory = TodoCategory.PERSONAL,
val isCompleted: Boolean = false,
val createdAt: Long = System.currentTimeMillis(),
val dueDate: Long? = null
)
enum class TodoCategory {
WORK, PERSONAL, SHOPPING, HEALTH, OTHER;
fun getDisplayName(): String = when (this) {
WORK -> "工作"
PERSONAL -> "个人"
SHOPPING -> "购物"
HEALTH -> "健康"
OTHER -> "其他"
}
}
// data/local/entity/TodoEntity.kt
@Entity(tableName = "todos")
data class TodoEntity(
@PrimaryKey val id: String,
val title: String,
val description: String,
val category: String,
val isCompleted: Boolean,
val createdAt: Long,
val dueDate: Long?
)
// data/mapper/TodoMapper.kt
// 扩展函数实现数据层 Entity 与领域层 Domain 模型的双向转换
fun TodoEntity.toDomain(): Todo = Todo(
id = id,
title = title,
description = description,
category = TodoCategory.valueOf(category), // 字符串转枚举
isCompleted = isCompleted,
createdAt = createdAt,
dueDate = dueDate
)
fun Todo.toEntity(): TodoEntity = TodoEntity(
id = id,
title = title,
description = description,
category = category.name, // 枚举转字符串(Room 不直接支持枚举存储)
isCompleted = isCompleted,
createdAt = createdAt,
dueDate = dueDate
)
2. 数据库和DAO¶
Kotlin
// data/local/TodoDao.kt
@Dao
interface TodoDao {
// 返回 Flow:数据变更时自动推送最新列表(响应式查询)
@Query("SELECT * FROM todos ORDER BY createdAt DESC")
fun getAllTodos(): Flow<List<TodoEntity>>
@Query("SELECT * FROM todos WHERE category = :category ORDER BY createdAt DESC")
fun getTodosByCategory(category: String): Flow<List<TodoEntity>>
// 模糊搜索:标题或描述包含关键词(|| 是 SQLite 字符串拼接操作符)
@Query("SELECT * FROM todos WHERE title LIKE '%' || :query || '%' OR description LIKE '%' || :query || '%'")
fun searchTodos(query: String): 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: String): TodoEntity?
}
// data/local/TodoDatabase.kt
@Database(entities = [TodoEntity::class], version = 1)
abstract class TodoDatabase : RoomDatabase() {
abstract fun todoDao(): TodoDao
}
3. Repository¶
Kotlin
// data/repository/TodoRepository.kt
interface TodoRepository {
fun getTodos(): Flow<List<Todo>>
fun getTodosByCategory(category: TodoCategory): Flow<List<Todo>>
fun searchTodos(query: String): Flow<List<Todo>>
suspend fun addTodo(todo: Todo)
suspend fun updateTodo(todo: Todo)
suspend fun deleteTodo(todo: Todo)
suspend fun getTodoById(id: String): Todo?
}
class TodoRepositoryImpl @Inject constructor(
private val todoDao: TodoDao
) : TodoRepository {
override fun getTodos(): Flow<List<Todo>> {
return todoDao.getAllTodos()
.map { entities -> entities.map { it.toDomain() } }
}
override fun getTodosByCategory(category: TodoCategory): Flow<List<Todo>> {
return todoDao.getTodosByCategory(category.name)
.map { entities -> entities.map { it.toDomain() } }
}
override fun searchTodos(query: String): Flow<List<Todo>> {
return if (query.isBlank()) {
getTodos()
} else {
todoDao.searchTodos(query)
.map { entities -> entities.map { it.toDomain() } }
}
}
override suspend fun addTodo(todo: Todo) {
todoDao.insertTodo(todo.toEntity())
}
override suspend fun updateTodo(todo: Todo) {
todoDao.updateTodo(todo.toEntity())
}
override suspend fun deleteTodo(todo: Todo) {
todoDao.deleteTodo(todo.toEntity())
}
override suspend fun getTodoById(id: String): Todo? {
return todoDao.getTodoById(id)?.toDomain()
}
}
4. UseCase¶
Kotlin
// domain/usecase/GetTodosUseCase.kt
class GetTodosUseCase @Inject constructor(
private val repository: TodoRepository
) {
operator fun invoke(category: TodoCategory? = null): Flow<List<Todo>> {
return if (category != null) {
repository.getTodosByCategory(category)
} else {
repository.getTodos()
}
}
}
// domain/usecase/AddTodoUseCase.kt
class AddTodoUseCase @Inject constructor(
private val repository: TodoRepository
) {
suspend operator fun invoke(title: String, description: String, category: TodoCategory) {
val todo = Todo(
title = title,
description = description,
category = category
)
repository.addTodo(todo)
}
}
// domain/usecase/UpdateTodoUseCase.kt
class UpdateTodoUseCase @Inject constructor(
private val repository: TodoRepository
) {
suspend operator fun invoke(todo: Todo) {
repository.updateTodo(todo)
}
}
// domain/usecase/DeleteTodoUseCase.kt
class DeleteTodoUseCase @Inject constructor(
private val repository: TodoRepository
) {
suspend operator fun invoke(todo: Todo) {
repository.deleteTodo(todo)
}
}
// domain/usecase/SearchTodosUseCase.kt
class SearchTodosUseCase @Inject constructor(
private val repository: TodoRepository
) {
operator fun invoke(query: String): Flow<List<Todo>> {
return repository.searchTodos(query)
}
}
5. ViewModel和UI State¶
Kotlin
// presentation/home/HomeUiState.kt
data class HomeUiState(
val todos: List<Todo> = emptyList(),
val searchQuery: String = "",
val selectedCategory: TodoCategory? = null,
val isLoading: Boolean = false,
val error: String? = null
)
// presentation/home/HomeViewModel.kt
@HiltViewModel
class HomeViewModel @Inject constructor(
private val getTodosUseCase: GetTodosUseCase,
private val addTodoUseCase: AddTodoUseCase,
private val updateTodoUseCase: UpdateTodoUseCase,
private val deleteTodoUseCase: DeleteTodoUseCase,
private val searchTodosUseCase: SearchTodosUseCase
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
private var searchJob: Job? = null
init {
loadTodos()
}
fun onSearchQueryChange(query: String) {
_uiState.update { it.copy(searchQuery = query) }
searchJob?.cancel() // 取消上一次搜索,避免频繁请求
searchJob = viewModelScope.launch {
delay(300) // 防抖:用户停止输入 300ms 后才执行搜索
searchTodosUseCase(query)
.collect { todos ->
_uiState.update { it.copy(todos = todos) }
}
}
}
fun onCategorySelected(category: TodoCategory?) {
_uiState.update { it.copy(selectedCategory = category) }
loadTodos()
}
fun addTodo(title: String, description: String, category: TodoCategory) {
viewModelScope.launch {
try {
addTodoUseCase(title, description, category)
} catch (e: Exception) {
_uiState.update { it.copy(error = e.message) }
}
}
}
fun toggleTodoCompletion(todo: Todo) {
viewModelScope.launch {
updateTodoUseCase(todo.copy(isCompleted = !todo.isCompleted))
}
}
fun deleteTodo(todo: Todo) {
viewModelScope.launch {
deleteTodoUseCase(todo)
}
}
private fun loadTodos() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
getTodosUseCase(_uiState.value.selectedCategory)
.onStart { _uiState.update { it.copy(isLoading = true) } } // Flow 开始收集时显示加载状态
.catch { e -> _uiState.update { it.copy(error = e.message, isLoading = false) } } // 异常处理
.collect { todos ->
_uiState.update { it.copy(todos = todos, isLoading = false) } // 数据到达后更新 UI 状态
}
}
}
}
6. UI组件¶
Kotlin
// presentation/home/HomeScreen.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel(),
onNavigateToAddEdit: (String?) -> Unit = {}
) {
val uiState by viewModel.uiState.collectAsState()
var showAddDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("待办事项") },
actions = {
IconButton(onClick = { showAddDialog = true }) {
Icon(Icons.Default.Add, contentDescription = "添加")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// 搜索栏
SearchBar(
query = uiState.searchQuery,
onQueryChange = viewModel::onSearchQueryChange
)
// 分类筛选
CategoryFilterChips(
selectedCategory = uiState.selectedCategory,
onCategorySelected = viewModel::onCategorySelected
)
// 待办列表
if (uiState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (uiState.todos.isEmpty()) {
EmptyState()
} else {
TodoList(
todos = uiState.todos,
onToggleComplete = viewModel::toggleTodoCompletion,
onDelete = viewModel::deleteTodo,
onEdit = { onNavigateToAddEdit(it.id) }
)
}
}
}
if (showAddDialog) {
AddTodoDialog(
onDismiss = { showAddDialog = false },
onConfirm = { title, description, category ->
viewModel.addTodo(title, description, category)
showAddDialog = false
}
)
}
}
// presentation/home/components/TodoList.kt
@Composable
fun TodoList(
todos: List<Todo>,
onToggleComplete: (Todo) -> Unit,
onDelete: (Todo) -> Unit,
onEdit: (Todo) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = todos,
key = { it.id }
) { todo ->
TodoItem(
todo = todo,
onToggleComplete = { onToggleComplete(todo) },
onDelete = { onDelete(todo) },
onEdit = { onEdit(todo) }
)
}
}
}
// presentation/home/components/TodoItem.kt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TodoItem(
todo: Todo,
onToggleComplete: () -> Unit,
onDelete: () -> Unit,
onEdit: () -> Unit
) {
var showDeleteConfirm by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth(),
onClick = onEdit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = todo.isCompleted,
onCheckedChange = { onToggleComplete() }
)
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp)
) {
Text(
text = todo.title,
style = MaterialTheme.typography.titleMedium,
textDecoration = if (todo.isCompleted)
TextDecoration.LineThrough
else
TextDecoration.None,
color = if (todo.isCompleted)
MaterialTheme.colorScheme.onSurfaceVariant
else
MaterialTheme.colorScheme.onSurface
)
if (todo.description.isNotBlank()) {
Text(
text = todo.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Row(
modifier = Modifier.padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
CategoryChip(category = todo.category)
todo.dueDate?.let {
DueDateChip(timestamp = it)
}
}
}
IconButton(onClick = { showDeleteConfirm = true }) {
Icon(
Icons.Default.Delete,
contentDescription = "删除",
tint = MaterialTheme.colorScheme.error
)
}
}
}
if (showDeleteConfirm) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
title = { Text("确认删除") },
text = { Text("确定要删除\"${todo.title}\"吗?") },
confirmButton = {
TextButton(
onClick = {
onDelete()
showDeleteConfirm = false
}
) {
Text("删除", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) {
Text("取消")
}
}
)
}
}
// presentation/home/components/CategoryFilterChips.kt
@Composable
fun CategoryFilterChips(
selectedCategory: TodoCategory?,
onCategorySelected: (TodoCategory?) -> Unit
) {
val categories = listOf(null) + TodoCategory.values().toList()
LazyRow(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(categories) { category ->
FilterChip(
selected = selectedCategory == category,
onClick = { onCategorySelected(category) },
label = {
Text(
category?.getDisplayName() ?: "全部"
)
}
)
}
}
}
7. 依赖注入模块¶
Kotlin
// di/AppModule.kt
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton // 数据库实例全局唯一,避免重复创建连接
fun provideTodoDatabase(@ApplicationContext context: Context): TodoDatabase {
return Room.databaseBuilder(
context,
TodoDatabase::class.java,
"todo_database" // SQLite 数据库文件名
).build()
}
@Provides // 每次注入时从 Database 获取 DAO(轻量操作,无需 Singleton)
fun provideTodoDao(database: TodoDatabase): TodoDao {
return database.todoDao()
}
@Provides
@Singleton
fun provideTodoRepository(
todoDao: TodoDao
): TodoRepository {
return TodoRepositoryImpl(todoDao) // 绑定接口与实现
}
}
8. MainActivity和Application¶
Kotlin
// MainActivity.kt
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TodoTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
val navController = rememberNavController()
// Compose Navigation:声明式路由配置
NavHost(
navController = navController,
startDestination = "home"
) {
composable("home") {
HomeScreen(
onNavigateToAddEdit = { todoId ->
navController.navigate("addEdit?$todoId")
}
)
}
// 可选参数路由:todoId 为空时表示新增,非空时表示编辑
composable(
"addEdit?{todoId}",
arguments = listOf(
navArgument("todoId") {
type = NavType.StringType
nullable = true // 参数可为空
}
)
) { backStackEntry ->
val todoId = backStackEntry.arguments?.getString("todoId")
AddEditScreen(
todoId = todoId,
onNavigateBack = { navController.popBackStack() }
)
}
}
}
}
}
}
}
// TodoApplication.kt
@HiltAndroidApp
class TodoApplication : Application()
🧪 测试¶
单元测试¶
Kotlin
// HomeViewModelTest.kt
@ExperimentalCoroutinesApi
class HomeViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var viewModel: HomeViewModel
private lateinit var getTodosUseCase: GetTodosUseCase
private lateinit var addTodoUseCase: AddTodoUseCase
private lateinit var updateTodoUseCase: UpdateTodoUseCase
private lateinit var deleteTodoUseCase: DeleteTodoUseCase
private lateinit var searchTodosUseCase: SearchTodosUseCase
@Before
fun setup() {
getTodosUseCase = mockk()
addTodoUseCase = mockk()
updateTodoUseCase = mockk()
deleteTodoUseCase = mockk()
searchTodosUseCase = mockk()
viewModel = HomeViewModel(
getTodosUseCase,
addTodoUseCase,
updateTodoUseCase,
deleteTodoUseCase,
searchTodosUseCase
)
}
@Test
fun `loadTodos success updates uiState`() = runTest {
// Given
val todos = listOf(
Todo("1", "Task 1", category = TodoCategory.WORK),
Todo("2", "Task 2", category = TodoCategory.PERSONAL)
)
every { getTodosUseCase(any()) } returns flowOf(todos) // mockk 模拟 UseCase 返回值
// When — 执行被测方法
viewModel.loadTodos()
// Then — 使用 Turbine 库的 test{} 验证 StateFlow 的值
viewModel.uiState.test {
assertEquals(HomeUiState(isLoading = false, todos = todos), awaitItem())
cancel()
}
}
}
📱 运行应用¶
Bash
# 构建应用
./gradlew assembleDebug
# 安装到设备
./gradlew installDebug
# 运行测试
./gradlew test
# 运行UI测试
./gradlew connectedAndroidTest
🎓 学习要点¶
通过本案例,你将掌握:
- ✅ MVVM架构的完整实现
- ✅ Jetpack Compose声明式UI开发
- ✅ Room数据库的CRUD操作
- ✅ Hilt依赖注入的配置和使用
- ✅ Flow响应式数据流
- ✅ Compose Navigation页面导航
- ✅ Material Design 3组件使用
- ✅ 单元测试和UI测试
🔗 相关资源¶
下一步: 案例2:天气查询App
案例完成时间:预计5-7天