@[TOC](Android实战项目③ Room+Clean Architecture开发待办事项App 完整源码详解)# Android实战项目③ Room+Clean Architecture开发待办事项App 完整源码详解> 系列第3篇。学习Room数据库(Entity+DAO+Database)、DataStore偏好存储、Clean Architecture分层架构和Hilt接口绑定。---## 项目结构03-todo/app/src/main/java/├── com/example/todo/MainActivity.kt├── com/example/todo/MainApplication.kt├── com/example/todo/data/local/TodoDao.kt├── com/example/todo/data/local/TodoDatabase.kt├── com/example/todo/data/local/entity/TodoEntity.kt├── com/example/todo/data/repository/TodoRepositoryImpl.kt├── com/example/todo/di/DatabaseModule.kt├── com/example/todo/domain/model/Todo.kt├── com/example/todo/domain/model/TodoCategory.kt├── com/example/todo/domain/model/TodoPriority.kt├── com/example/todo/domain/repository/TodoRepository.kt├── com/example/todo/navigation/NavGraph.kt├── com/example/todo/ui/component/TodoItem.kt├── com/example/todo/ui/screen/AddEditTodoScreen.kt├── com/example/todo/ui/screen/TodoListScreen.kt├── com/example/todo/ui/theme/Theme.kt├── com/example/todo/viewmodel/AddEditTodoViewModel.kt├── com/example/todo/viewmodel/TodoListViewModel.kt文件数 : 18个Kotlin文件---## 完整源码 + 详解### MainActivity.ktkotlinpackage com.example.todoimport android.os.Bundleimport androidx.activity.ComponentActivityimport androidx.activity.compose.setContentimport androidx.activity.enableEdgeToEdgeimport androidx.navigation.compose.rememberNavControllerimport com.example.todo.navigation.TodoNavGraphimport com.example.todo.ui.theme.TodoThemeimport dagger.hilt.android.AndroidEntryPoint@AndroidEntryPointclass MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { TodoTheme { TodoNavGraph(rememberNavController()) } } }}---### MainApplication.ktkotlinpackage com.example.todoimport android.app.Applicationimport dagger.hilt.android.HiltAndroidApp@HiltAndroidAppclass MainApplication : Application()---### NavGraph.ktkotlinpackage com.example.todo.navigationimport androidx.compose.runtime.Composableimport androidx.navigation.NavHostControllerimport androidx.navigation.NavTypeimport androidx.navigation.compose.NavHostimport androidx.navigation.compose.composableimport androidx.navigation.navArgumentimport com.example.todo.ui.screen.AddEditTodoScreenimport com.example.todo.ui.screen.TodoListScreen@Composablefun TodoNavGraph(navController: NavHostController) { NavHost(navController, startDestination = "list") { composable("list") { TodoListScreen(onAddClick = { navController.navigate("add") }, onTodoClick = { id -> navController.navigate("edit/$id") }) } composable("add") { AddEditTodoScreen(onBack = { navController.popBackStack() }) } composable("edit/{todoId}", arguments = listOf(navArgument("todoId") { type = NavType.LongType })) { AddEditTodoScreen(onBack = { navController.popBackStack() }) } }}---### AddEditTodoViewModel.ktkotlinpackage com.example.todo.viewmodelimport androidx.lifecycle.SavedStateHandleimport androidx.lifecycle.ViewModelimport androidx.lifecycle.viewModelScopeimport com.example.todo.domain.model.Todoimport com.example.todo.domain.model.TodoCategoryimport com.example.todo.domain.model.TodoPriorityimport com.example.todo.domain.repository.TodoRepositoryimport dagger.hilt.android.lifecycle.HiltViewModelimport kotlinx.coroutines.flow.MutableStateFlowimport kotlinx.coroutines.flow.asStateFlowimport kotlinx.coroutines.launchimport javax.inject.Inject@HiltViewModelclass AddEditTodoViewModel @Inject constructor( private val repository: TodoRepository, savedStateHandle: SavedStateHandle) : ViewModel() { private val todoId: Long = savedStateHandle.get<Long>("todoId") ?: -1L val title = MutableStateFlow("") val description = MutableStateFlow("") val category = MutableStateFlow(TodoCategory.OTHER) val priority = MutableStateFlow(TodoPriority.MEDIUM) private val _saved = MutableStateFlow(false) val saved = _saved.asStateFlow() init { if (todoId > 0) { viewModelScope.launch { repository.getTodoById(todoId)?.let { todo -> title.value = todo.title description.value = todo.description category.value = todo.category priority.value = todo.priority } } } } fun save() { if (title.value.isBlank()) return viewModelScope.launch { val todo = Todo( id = if (todoId > 0) todoId else 0, title = title.value, description = description.value, category = category.value, priority = priority.value ) if (todoId > 0) repository.updateTodo(todo) else repository.insertTodo(todo) _saved.value = true } }}---### TodoListViewModel.ktkotlinpackage com.example.todo.viewmodelimport androidx.lifecycle.ViewModelimport androidx.lifecycle.viewModelScopeimport com.example.todo.domain.model.Todoimport com.example.todo.domain.model.TodoCategoryimport com.example.todo.domain.repository.TodoRepositoryimport dagger.hilt.android.lifecycle.HiltViewModelimport kotlinx.coroutines.ExperimentalCoroutinesApiimport kotlinx.coroutines.flow.*import kotlinx.coroutines.launchimport javax.inject.Inject@HiltViewModelclass TodoListViewModel @Inject constructor( private val repository: TodoRepository) : ViewModel() { private val _searchQuery = MutableStateFlow("") val searchQuery: StateFlow<String> = _searchQuery.asStateFlow() private val _selectedCategory = MutableStateFlow<TodoCategory?>(null) val selectedCategory: StateFlow<TodoCategory?> = _selectedCategory.asStateFlow() @OptIn(ExperimentalCoroutinesApi::class) val todos: StateFlow<List<Todo>> = _searchQuery.flatMapLatest { query -> if (query.isBlank()) repository.getAllTodos() else repository.searchTodos(query) }.combine(_selectedCategory) { todos, category -> if (category == null) todos else todos.filter { it.category == category } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) fun onSearchChanged(query: String) { _searchQuery.value = query } fun onCategorySelected(category: TodoCategory?) { _selectedCategory.value = category } fun toggleComplete(todo: Todo) { viewModelScope.launch { repository.toggleComplete(todo.id, !todo.isCompleted) } } fun deleteTodo(todo: Todo) { viewModelScope.launch { repository.deleteTodo(todo) } }}---### Todo.ktkotlinpackage com.example.todo.domain.model/** 待办领域模型 */data class Todo( val id: Long = 0, val title: String, val description: String = "", val category: TodoCategory = TodoCategory.OTHER, val priority: TodoPriority = TodoPriority.MEDIUM, val isCompleted: Boolean = false, val dueDate: Long? = null, val createdAt: Long = System.currentTimeMillis())---### TodoCategory.ktkotlinpackage com.example.todo.domain.model/** 待办分类 */enum class TodoCategory(val displayName: String) { WORK("工作"), PERSONAL("个人"), SHOPPING("购物"), STUDY("学习"), OTHER("其他")}---### TodoPriority.ktkotlinpackage com.example.todo.domain.model/** 优先级 */enum class TodoPriority(val displayName: String, val level: Int) { HIGH("高", 3), MEDIUM("中", 2), LOW("低", 1)}---### TodoRepository.ktkotlinpackage com.example.todo.domain.repositoryimport com.example.todo.domain.model.Todoimport kotlinx.coroutines.flow.Flowinterface TodoRepository { fun getAllTodos(): Flow<List<Todo>> fun searchTodos(query: String): Flow<List<Todo>> suspend fun getTodoById(id: Long): Todo? suspend fun insertTodo(todo: Todo): Long suspend fun updateTodo(todo: Todo) suspend fun deleteTodo(todo: Todo) suspend fun toggleComplete(id: Long, completed: Boolean)}---### TodoDao.ktkotlinpackage com.example.todo.data.localimport androidx.room.*import com.example.todo.data.local.entity.TodoEntityimport kotlinx.coroutines.flow.Flow/** DAO --- 数据访问对象 */@Daointerface TodoDao { @Query("SELECT * FROM todos ORDER BY isCompleted ASC, createdAt DESC") fun getAllTodos(): Flow<List<TodoEntity>> @Query("SELECT * FROM todos WHERE title LIKE '%' || :query || '%' OR description LIKE '%' || :query || '%'") fun searchTodos(query: String): Flow<List<TodoEntity>> @Query("SELECT * FROM todos WHERE id = :id") suspend fun getTodoById(id: Long): TodoEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertTodo(todo: TodoEntity): Long @Update suspend fun updateTodo(todo: TodoEntity) @Delete suspend fun deleteTodo(todo: TodoEntity) @Query("UPDATE todos SET isCompleted = :completed WHERE id = :id") suspend fun toggleComplete(id: Long, completed: Boolean)}---### TodoDatabase.ktkotlinpackage com.example.todo.data.localimport androidx.room.Databaseimport androidx.room.RoomDatabaseimport com.example.todo.data.local.entity.TodoEntity@Database(entities = [TodoEntity::class], version = 1, exportSchema = false)abstract class TodoDatabase : RoomDatabase() { abstract fun todoDao(): TodoDao}---### TodoEntity.ktkotlinpackage com.example.todo.data.local.entityimport androidx.room.Entityimport androidx.room.PrimaryKeyimport com.example.todo.domain.model.Todoimport com.example.todo.domain.model.TodoCategoryimport com.example.todo.domain.model.TodoPriority/** Room 数据库实体 */@Entity(tableName = "todos")data class TodoEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, val title: String, val description: String = "", val category: String = "OTHER", val priority: String = "MEDIUM", val isCompleted: Boolean = false, val dueDate: Long? = null, val createdAt: Long = System.currentTimeMillis()) { fun toDomain() = Todo(id, title, description, TodoCategory.valueOf(category), TodoPriority.valueOf(priority), isCompleted, dueDate, createdAt) companion object { fun fromDomain(todo: Todo) = TodoEntity(todo.id, todo.title, todo.description, todo.category.name, todo.priority.name, todo.isCompleted, todo.dueDate, todo.createdAt) }}---### TodoRepositoryImpl.ktkotlinpackage com.example.todo.data.repositoryimport com.example.todo.data.local.TodoDaoimport com.example.todo.data.local.entity.TodoEntityimport com.example.todo.domain.model.Todoimport com.example.todo.domain.repository.TodoRepositoryimport kotlinx.coroutines.flow.Flowimport kotlinx.coroutines.flow.mapimport javax.inject.Injectimport javax.inject.Singleton@Singletonclass TodoRepositoryImpl @Inject constructor(private val dao: TodoDao) : TodoRepository { override fun getAllTodos(): Flow<List<Todo>> = dao.getAllTodos().map { list -> list.map { it.toDomain() } } override fun searchTodos(query: String): Flow<List<Todo>> = dao.searchTodos(query).map { list -> list.map { it.toDomain() } } override suspend fun getTodoById(id: Long): Todo? = dao.getTodoById(id)?.toDomain() override suspend fun insertTodo(todo: Todo): Long = dao.insertTodo(TodoEntity.fromDomain(todo)) override suspend fun updateTodo(todo: Todo) = dao.updateTodo(TodoEntity.fromDomain(todo)) override suspend fun deleteTodo(todo: Todo) = dao.deleteTodo(TodoEntity.fromDomain(todo)) override suspend fun toggleComplete(id: Long, completed: Boolean) = dao.toggleComplete(id, completed)}---### AddEditTodoScreen.ktkotlinpackage com.example.todo.ui.screenimport androidx.compose.foundation.layout.*import androidx.compose.material.icons.Iconsimport androidx.compose.material.icons.automirrored.filled.ArrowBackimport androidx.compose.material.icons.filled.Checkimport androidx.compose.material3.*import androidx.compose.runtime.*import androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport androidx.hilt.navigation.compose.hiltViewModelimport com.example.todo.domain.model.TodoCategoryimport com.example.todo.domain.model.TodoPriorityimport com.example.todo.viewmodel.AddEditTodoViewModel@OptIn(ExperimentalMaterial3Api::class)@Composablefun AddEditTodoScreen(viewModel: AddEditTodoViewModel = hiltViewModel(), onBack: () -> Unit) { val title by viewModel.title.collectAsState() val description by viewModel.description.collectAsState() val category by viewModel.category.collectAsState() val priority by viewModel.priority.collectAsState() val saved by viewModel.saved.collectAsState() LaunchedEffect(saved) { if (saved) onBack() } Scaffold(topBar = { TopAppBar(title = { Text("编辑待办") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回") } }, actions = { IconButton(onClick = { viewModel.save() }) { Icon(Icons.Default.Check, "保存") } }) }) { padding -> Column(Modifier.fillMaxSize().padding(padding).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { OutlinedTextField(value = title, onValueChange = { viewModel.title.value = it }, label = { Text("标题") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(value = description, onValueChange = { viewModel.description.value = it }, label = { Text("描述") }, modifier = Modifier.fillMaxWidth(), minLines = 3) // 分类选择 Text("分类", style = MaterialTheme.typography.labelLarge) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { TodoCategory.entries.forEach { cat -> FilterChip(selected = category == cat, onClick = { viewModel.category.value = cat }, label = { Text(cat.displayName) }) } } // 优先级 Text("优先级", style = MaterialTheme.typography.labelLarge) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { TodoPriority.entries.forEach { p -> FilterChip(selected = priority == p, onClick = { viewModel.priority.value = p }, label = { Text(p.displayName) }) } } } }}---### TodoListScreen.ktkotlinpackage com.example.todo.ui.screenimport androidx.compose.foundation.layout.*import androidx.compose.foundation.lazy.LazyColumnimport androidx.compose.foundation.lazy.LazyRowimport androidx.compose.foundation.lazy.itemsimport androidx.compose.material.icons.Iconsimport androidx.compose.material.icons.filled.Addimport androidx.compose.material3.*import androidx.compose.runtime.*import androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport androidx.hilt.navigation.compose.hiltViewModelimport com.example.todo.domain.model.TodoCategoryimport com.example.todo.ui.component.TodoItemimport com.example.todo.viewmodel.TodoListViewModel@OptIn(ExperimentalMaterial3Api::class)@Composablefun TodoListScreen( viewModel: TodoListViewModel = hiltViewModel(), onAddClick: () -> Unit, onTodoClick: (Long) -> Unit) { val todos by viewModel.todos.collectAsState() val searchQuery by viewModel.searchQuery.collectAsState() val selectedCategory by viewModel.selectedCategory.collectAsState() Scaffold( topBar = { TopAppBar(title = { Text("待办事项") }) }, floatingActionButton = { FloatingActionButton(onClick = onAddClick) { Icon(Icons.Default.Add, "新增") } } ) { padding -> Column(Modifier.fillMaxSize().padding(padding)) { // 搜索 OutlinedTextField(value = searchQuery, onValueChange = { viewModel.onSearchChanged(it) }, placeholder = { Text("搜索...") }, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), singleLine = true) // 分类过滤 LazyRow(contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { item { FilterChip(selected = selectedCategory == null, onClick = { viewModel.onCategorySelected(null) }, label = { Text("全部") }) } items(TodoCategory.entries.toList()) { cat -> FilterChip(selected = selectedCategory == cat, onClick = { viewModel.onCategorySelected(cat) }, label = { Text(cat.displayName) }) } } // 列表 LazyColumn(contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { items(todos, key = { it.id }) { todo -> TodoItem(todo = todo, onToggle = { viewModel.toggleComplete(todo) }, onDelete = { viewModel.deleteTodo(todo) }, onClick = { onTodoClick(todo.id) }) } } } }}---### Theme.ktkotlinpackage com.example.todo.ui.themeimport android.os.Buildimport androidx.compose.foundation.isSystemInDarkThemeimport androidx.compose.material3.*import androidx.compose.runtime.Composableimport androidx.compose.ui.platform.LocalContext@Composablefun TodoTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { val colorScheme = when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val ctx = LocalContext.current if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx) } darkTheme -> darkColorScheme() else -> lightColorScheme() } MaterialTheme(colorScheme = colorScheme, content = content)}---### TodoItem.ktkotlinpackage com.example.todo.ui.componentimport androidx.compose.foundation.clickableimport androidx.compose.foundation.layout.*import androidx.compose.material.icons.Iconsimport androidx.compose.material.icons.filled.Deleteimport androidx.compose.material3.*import androidx.compose.runtime.Composableimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.text.style.TextDecorationimport androidx.compose.ui.text.style.TextOverflowimport androidx.compose.ui.unit.dpimport com.example.todo.domain.model.Todo@Composablefun TodoItem(todo: Todo, onToggle: () -> Unit, onDelete: () -> Unit, onClick: () -> Unit) { Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) { Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = todo.isCompleted, onCheckedChange = { onToggle() }) Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) { Text( todo.title, style = MaterialTheme.typography.titleSmall, textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else null, maxLines = 1, overflow = TextOverflow.Ellipsis ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { AssistChip(onClick = {}, label = { Text(todo.category.displayName) }) AssistChip(onClick = {}, label = { Text(todo.priority.displayName) }) } } IconButton(onClick = onDelete) { Icon(Icons.Default.Delete, "删除") } } }}---### DatabaseModule.ktkotlinpackage com.example.todo.diimport android.content.Contextimport androidx.room.Roomimport com.example.todo.data.local.TodoDaoimport com.example.todo.data.local.TodoDatabaseimport com.example.todo.data.repository.TodoRepositoryImplimport com.example.todo.domain.repository.TodoRepositoryimport dagger.Bindsimport dagger.Moduleimport dagger.Providesimport dagger.hilt.InstallInimport dagger.hilt.android.qualifiers.ApplicationContextimport dagger.hilt.components.SingletonComponentimport javax.inject.Singleton@Module@InstallIn(SingletonComponent::class)object DatabaseModule { @Provides @Singleton fun provideDatabase(@ApplicationContext context: Context): TodoDatabase = Room.databaseBuilder(context, TodoDatabase::class.java, "todo.db").build() @Provides fun provideTodoDao(db: TodoDatabase): TodoDao = db.todoDao()}@Module@InstallIn(SingletonComponent::class)abstract class RepositoryModule { @Binds @Singleton abstract fun bindTodoRepository(impl: TodoRepositoryImpl): TodoRepository}---## 运行方式1. Android Studio打开 projects/03-todo 目录2. Gradle同步完成后Run运行> 系列导航: Android实战系列 1-6 篇。
Android实战项目③ Room+Clean Architecture开发待办事项App 完整源码详解
明天就是Friday2026-04-22 12:26
相关推荐
没有了遇见2 小时前
《彻底搞懂 ViewModel:作用、原理与源码分析》Fate_I_C2 小时前
Kotlin 协程:串行/并行请求、async/await、coroutineScope 管理并发、重试机制山河梧念2 小时前
【保姆级教程】VMware虚拟机安装全流程常利兵2 小时前
Kotlin类型魔法:Any、Unit、Nothing 深度探秘y小花2 小时前
安卓vold服务明天就是Friday2 小时前
Android实战项目⑤ Paging 3开发社交媒体信息流App 完整源码详解宋拾壹3 小时前
php网站小程序接入抖音团购核销莫逸风4 小时前
【java-core-collections】B+ 树深度解析我命由我123454 小时前
Android 开发问题:无法从存储库 “D:\keys\MyNotifications.jks“ 中读取密钥 MyNotifications.