2025年我用 Compose 写了一个 Todo App

标题党嫌疑犯实锤

序言

从2月12日到3月4日这整整三周时间里,我从零开始又学习了一次 Compose。

为什么说又,是因为这已经是我第二次学习这套课程了。

故事从 4 年前说起,2021 年在意外获悉扔物线朱凯老师准备发布一套名为 Compose 的新课程,意识到这是 Android 未来的方向,花重金从扔物线朱凯老师手里购买了这一套新鲜出炉的课程并为此沾沾自喜。

当时正值 Compose 正式版公布前后的时间点,朱老师作为推广 Compose 的亲善大使,在技术仍未稳定之时录制了第一期 Compose 课程,当时跟着朱老师系统地学习了一次。曾经也许计划过等待朱老师更新正式版课程后,再与朱老师重走长征路,但不出所料被我如期爽约。

之后 4 年里,完全没有再接触过 Compose 技术,所学知识一滴不漏地全部漏光。

在迎来 35 岁又数个月的今天,中年危机意识汹涌袭来,因而再次挑灯夜读,孤独地在每个月上柳梢头的饭后良宵,以及春意盎然睡意绵绵的周末,驻守在电脑面前,默默打开了朱老师的这个课程网站:https://edu.rengwuxian.com/p/t_pc/goods_pc_detail/goods_detail/course_2Dpw6101YdL7bHFs5LFpYyzSUS6

3422 分钟,70 节课程,似懂非懂一知半解不求甚解地再次游历一遍 Compose 的海洋,终于又再次站在了公司里 Compose 技术的顶点,登高望极,一览山下低头耕耘那片屎山的同事们。(同事里只有另一个人专职做安卓开发,而他估计还没学过我们朱老师的这套课程,所以此时山下只有一人)

2025年的第一个 Compose App

趁热打铁,赶在知识仍未漏光之前,着手编写了这个极简 Todo 应用。以功能极简,设计极简之名,将其命名为 Simple Todo。

原型设计

※ 出于嫌麻烦等不可控原因,实装中省略了时间部分的实现。

效果图:

项目源码:https://github.com/wavky/SimpleTodo-Compose-

技术框架:

  • Jetpack Compose
  • MVVM
  • Room
  • Koin

项目结构

项目整体采用 MVVM 结构:

  • UI 层使用 Compose
  • ViewModel 层提供数据与 UI 的耦合
  • UseCase 层提供数据增删改查等单点功能的业务逻辑封装
  • Domain 层定义底层数据仓库接口与数据源(文件、DB、API 等)
  • 最后通过 Koin 进行依赖注入,串连 Domain -> UseCase -> ViewModel -> UI 的数据单向流转

从 Room 到 ViewModel

Room 作为 DB 访问库,主要工作在 Domain 层。

在 domain \ infra \ db 目录中,放置所有 Room 定义的数据库文件。

Domain 层结构

  • infra 目录下的文件不对上层公开(UI 等上层类无法直接访问),只提供底层数据服务到 Repository。

  • model 目录定义对上层公开的数据层标准数据模型(data class)。

  • repository 目录向上层公开并提供数据服务,在 Repository 中将 Entity 类型转换成对等 Model 类型后传递到上层(UseCase 层)。

    domain
    ├── infra
    │ └── db
    │ ├── AppDatabase.kt
    │ ├── dao
    │ │ └── TodoDao.kt
    │ └── entity
    │ └── TodoEntity.kt
    ├── model
    │ └── Todo.kt
    └── repository
    ├── TodoRepository.kt
    └── TodoRepositoryImpl.kt

与 Compose 集成

Compose 中的第一方数据类型是 State 和 StateFlow,因此在 UI 需要订阅响应数据变化的时候,直接让 Room 返回 Flow 类型的查询结果,通过 ViewModel 传递到 UI 进行订阅绑定,这样在之后数据表发生数据变化时,Room 会自动重新执行一次查询并将结果通知到 UI 进行刷新。

  1. 在 Dao 中声明查询函数返回 Flow 类型,Room 在执行此类查询时会自动分发到线程池中执行。如果是只需单次执行的增删改查,则声明为 suspend 函数并直接返回普通结果类型。
kotlin 复制代码
@Dao
internal interface TodoDao {
  @Query("SELECT * FROM todo")
  fun getAll(): Flow<List<TodoEntity>>

  @Insert
  suspend fun add(item: TodoEntity): Long
}
  1. 在 Repository 中将 Flow 中的 Entity 转换为标准 Model 类型。对于增删改函数,将参数中的 Model 类型转换成 Entity 类型。
kotlin 复制代码
internal class TodoRepositoryImpl(private val dao: TodoDao) : TodoRepository {

  override fun getAll(): Flow<List<Todo>> = dao.getAll().map { list ->
    list.map { it.convertToModel() }
  }

  override suspend fun add(todo: Todo): Long =
    dao.add(todo.convertToEntity())
}
  1. 在 UseCase 中根据需要整合业务逻辑(例如指派到 IO 线程执行),将查询结果 Flow 返回到上层(ViewModel)。
kotlin 复制代码
interface GetTodoListUseCase {
  fun execute(): Flow<List<Todo>>
}
  1. 在 ViewModel 中将冷流 Flow 转换为热流 StateFlow。StateFlow 相当于 LiveData,可缓存最后数据,并避免每次订阅时都重新执行数据库查询。
    对于 Flow 类型的查询,直接将结果保存在 val 变量;对于单次执行的增删改查,则通过 viewModelScope 启动协程来执行。
kotlin 复制代码
class MainViewModel(
  getTodoListUseCase: GetTodoListUseCase,
  private val addTodoUseCase: AddTodoUseCase,
) : ViewModel() {

  val todoList: StateFlow<List<Todo>> = getTodoListUseCase.execute().stateIn(
    viewModelScope,
    started = SharingStarted.WhileSubscribed(),
    initialValue = emptyList()
  )

  fun addTodo(title: String, description: String) {
    viewModelScope.launch {
      addTodoUseCase.execute(title, description)
    }
  }
}
  1. 在 UI 层的 Composable 函数中,订阅 ViewModel 的 StateFlow 并转换成 State 类型,提供给 Composable 进行绑定。
kotlin 复制代码
val todoList by viewModel.todoList.collectAsState()

LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
  items(todoList, key = { item -> item.id }) { item ->
    TodoItem(item.title, item.isDone) { isChecked ->
      viewModel.updateTodo(item.copy(isDone = isChecked))
    }
  }
}

至此,完成了数据从 DB -> Repository -> UseCase -> ViewModel -> UI 的单向传输。在增删改动作发生时,数据表中数据发生变化都会触发 Flow 类型查询的执行,并将最新结果通知到 UI 进行渲染输出,实现数据订阅刷新。


集成 Koin

Koin 用于提供依赖注入,在项目中连接 Repository -> UseCase -> ViewModel -> UI(Composable)。

这一节内容用于引流,有兴趣的同学请移步至小站:https://wavky.top/SimpleTodo/

(我看看是谁在白嫖)


解决 ViewModel 与预览功能的冲突

在 Android Studio 中,如果 Composable 函数依赖 ViewModel,IDE 的预览功能会无法正常工作。

因为 ViewModel 是属于 Android 环境中的对象,并且我们是通过依赖注入方式动态获取的,IDE 的预览功能无法对此类对象进行有效的实例化工作。

破局方法是,将 ViewModel 的功能接口化,将 ViewModel 中所有公开的函数抽离到上层接口中,将 Composable 中对 ViewModel 的依赖转变成对一个普通的回调接口类型的依赖。

以 MainViewModel 为例

在 MainViewModel 的实现中,添加声明实现一个同名接口 MainViewModelFunc:

kotlin 复制代码
class MainViewModel(
  getTodoListUseCase: GetTodoListUseCase,
  private val addTodoUseCase: AddTodoUseCase,
  ...
  // 添加实现接口声明 MainViewModelFunc
) : ViewModel(), MainViewModelFunc {

  val todoList: StateFlow<List<Todo>> = getTodoListUseCase.execute().stateIn(emptyList())

  override fun addTodo(title: String, description: String) {
    ...
  }

  override fun updateTodo(todo: Todo) {
    ...
  }
}

将 MainViewModel 中的公开函数抽离到接口 MainViewModelFunc 中:

kotlin 复制代码
interface MainViewModelFunc {
  fun addTodo(title: String, description: String)
  fun updateTodo(todo: Todo)
}

在 UI 的 Composable 中分离对 ViewModel 与 MainViewModelFunc 的依赖:

kotlin 复制代码
// MainScreen 依赖 ViewModel,但不能被直接预览,实际预览的是内容函数 MainScreenContent
@Composable
fun MainScreen(
  modifier: Modifier = Modifier, 
  viewModel: MainViewModel = koinViewModel(),
) {

  // 提取出需要直接访问的 ViewModel 中的变量对象(StateFlow)
  val todoList by viewModel.todoList.collectAsState()

  // 将 Composable 的所有 UI 内容封装成子 Composable
  MainScreenContent(modifier, todoList, viewModel)
}



// MainScreenContent 中依赖 ViewModel 的功能接口
@Composable
fun MainScreenContent(
  modifier: Modifier = Modifier,
  todoList: List<Todo>,

  // 对 ViewModel 的依赖改为对接口的依赖
  viewModel: MainViewModelFunc
) {
  Scaffold(...) {...}
}



// 实现 Composable 内容的预览
@Preview(showBackground = true)
@Composable
private fun PreviewMainScreen() {
  MainScreenContent(
    todoList = (1..10).map { Todo(...) },

    // 实现一个 ViewModel 功能接口的空实现对象
    viewModel = object : MainViewModelFunc {
      override fun addTodo(title: String, description: String) {}
      override fun updateTodo(todo: Todo) {}
      override fun deleteTodo(todo: Todo) {}
    }
  )
}

至此完成对 MainScreen 的预览。