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 进行刷新。
- 在 Dao 中声明查询函数返回 Flow 类型,Room 在执行此类查询时会自动分发到线程池中执行。如果是只需单次执行的增删改查,则声明为 suspend 函数并直接返回普通结果类型。
@Dao
internal interface TodoDao {
@Query("SELECT * FROM todo")
fun getAll(): Flow<List<TodoEntity>>
@Insert
suspend fun add(item: TodoEntity): Long
}
- 在 Repository 中将 Flow 中的 Entity 转换为标准 Model 类型。对于增删改函数,将参数中的 Model 类型转换成 Entity 类型。
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())
}
- 在 UseCase 中根据需要整合业务逻辑(例如指派到 IO 线程执行),将查询结果 Flow 返回到上层(ViewModel)。
interface GetTodoListUseCase {
fun execute(): Flow<List<Todo>>
}
- 在 ViewModel 中将冷流 Flow 转换为热流 StateFlow。StateFlow 相当于 LiveData,可缓存最后数据,并避免每次订阅时都重新执行数据库查询。
对于 Flow 类型的查询,直接将结果保存在 val 变量;对于单次执行的增删改查,则通过 viewModelScope 启动协程来执行。
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)
}
}
}
- 在 UI 层的 Composable 函数中,订阅 ViewModel 的 StateFlow 并转换成 State 类型,提供给 Composable 进行绑定。
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:
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 中:
interface MainViewModelFunc {
fun addTodo(title: String, description: String)
fun updateTodo(todo: Todo)
}
在 UI 的 Composable 中分离对 ViewModel 与 MainViewModelFunc 的依赖:
// 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 的预览。

2025年我用 Compose 写了一个 Todo App的更多相关文章
- 写一个TODO App学习Flutter本地存储工具Moor
写一个TODO App学习Flutter本地存储工具Moor Flutter的数据库存储, 官方文档: https://flutter.dev/docs/cookbook/persistence/sq ...
- [Flutter] 写第一个 Flutter app,part1 要点
模拟器中调试元素的布局: Android Studio 右侧边栏 Flutter Inspector,选择 Toggle Debug Paint 打开. 格式化代码: 编辑器中右键 Reformat ...
- 用react + redux + router写一个todo
概述 最近学习redux,打算用redux + router写了一个todo.记录下来,供以后开发时参考,相信对其他人也有用. 注意: 我只实现了Footer组件的router,其它组件的实现方法是类 ...
- 用react+redux写一个todo
概述 最近学习redux,打算用redux写了一个todo.记录下来,供以后开发时参考,相信对其他人也有用. 代码 代码请见我的github 组织架构如下图:
- react写一个todo
概述 最近学习redux,打算先复习一下react,所以用react写了一个todo.记录下来,供以后开发时参考,相信对其他人也有用. 代码 代码请见我的github 组织架构如下图:
- 当我第一次通过Kotlin和Compose来实现一个Canvas时, 我收获了什么?
当我第一次通过Kotlin和Compose来实现一个Canvas时, 我收获了什么? 自从2019年Google推荐Kotlin为Android开发的首选语言以来已经经历了将近四年的时间, Compo ...
- 搞了我一下午竟然是web.config少写了一个点
Safari手机版居然有个这么愚蠢的bug,浪费了我整个下午,使尽浑身解数,国内国外网站搜索解决方案,每一行代码读了又想想了又读如此不知道多少遍,想破脑袋也想不通到底哪里出了问题,结果竟然是web.c ...
- 用C3中的animation和transform写的一个模仿加载的时动画效果
用用C3中的animation和transform写的一个模仿加载的时动画效果! 不多说直接上代码; html标签部分 <div class="wrap"> <h ...
- 写了一个常规性生成merge 的小脚本
现在使用数据库来写存储过程,动不动参数就会用到xml ,当然罗,优势也很明显,参数相对固定,而且灵活,如果要修改或者什么的,中间接口层也不需要做变化,只需要修改封装的存储过程以及程序传参就ok了. 随 ...
- c# .Net :Excel NPOI导入导出操作教程之List集合的数据写到一个Excel文件并导出
将List集合的数据写到一个Excel文件并导出示例: using NPOI.HSSF.UserModel;using NPOI.SS.UserModel;using System;using Sys ...
随机推荐
- Qt编写物联网管理平台44-告警邮件转发
一.前言 上一篇文章说的是告警短信发送,这种效率非常高,缺点也很明显,需要购买特定的短信硬件设备支持才行,而且每条短信都要收费,如果要求发送的短信数量特别多,这个费用常年累月下来也是不少的,客户就不愿 ...
- Qt控件SDK使用示例大全
文章 链接 01表盘控件-01汽车仪表盘-gaugecar https://qtchina.blog.csdn.net/article/details/120240257 01表盘控件-02圆弧仪表盘 ...
- 学习破解一个Android程序
首先编写一个android测试程序 功能:校验用户名和注册码,成功则弹出注册成功提示 以下仅给出关键部分的代码 res/layout/activity_main.xml <?xml versio ...
- [转]基于GMap.Net的地图解决方案
一 地图的加载与显示 关于GMap的介绍与使用可以看我以前的文章: GMap.Net开发之在WinForm和WPF中使用GMap.Net地图插件 GMap.Net是.Net下一个地图控件,可以基于Ht ...
- IDEA中基于SSM框架进行web开发部署项目到Tomcat时报错:Error:Cannot build artifact '******:war exploded' because it is included into a circular depency的解决办法
在Idea中使用Maven创建父子工程,第一个Model的那个项目可以很好的运行,在创建一个Model运行时报这个错.原因是tomcat部署了多个Web项目,可能最开始是两个项目的配置文件混用用,最后 ...
- 16. C++快速入门--模板和Concept
待修改 1 定义模板 1.1 模板形参 模板参数 模板可以有两种参数, 一种是类型参数, 一种是非类型参数 这两种参数可以同时存在, 非类型参数 的类型 可以是 模板类型形参 template < ...
- WSL设置中文、修改默认Root登陆、添加右键菜单
1.设置中文 首先安装 aptitude 管理工具 #apt-get install aptitude 然后安装语言环境并进入语言环境设置. #aptitude install locales #dp ...
- Docker 迁移数据目录
Centos7 环境,采用yum安装的,默认数据目录在/var/lib/docker中 1. 关闭docker服务 systemctl stop docker 2. 备份和迁移 # 迁移 cp -r ...
- Java方法引用、lambda如何序列化&方法引用与lambda底层原理
系列文章目录和关于我 0.引入 最近笔者使用flink实现一些实时数据清洗(从kafka清洗数据写入到clickhouse)的功能,在编写flink作业后进行上传,发现运行的时候抛出:java.io. ...
- Linux curl brew命令详解
命令:curl 在Linux中curl是一个利用URL规则在命令行下工作的文件传输工具,可以说是一款很强大的http命令行工具.它支持文件的上传和下载,是综合传输工具,但按传统,习惯称url为下载工具 ...