跳转至

案例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. 创建项目

Bash
# 使用Android Studio创建新项目
# 选择 "Empty Activity" 模板
# 语言选择 Kotlin
# 最低SDK: API 24 (Android 7.0)

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

🎓 学习要点

通过本案例,你将掌握:

  1. MVVM架构的完整实现
  2. Jetpack Compose声明式UI开发
  3. Room数据库的CRUD操作
  4. Hilt依赖注入的配置和使用
  5. Flow响应式数据流
  6. Compose Navigation页面导航
  7. Material Design 3组件使用
  8. 单元测试UI测试

🔗 相关资源


下一步: 案例2:天气查询App


案例完成时间:预计5-7天