标题党嫌疑犯实锤

序言

从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 函数并直接返回普通结果类型。
@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 类型。
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)。
interface GetTodoListUseCase {
fun execute(): Flow<List<Todo>>
}
  1. 在 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)
}
}
}
  1. 在 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的更多相关文章

  1. 写一个TODO App学习Flutter本地存储工具Moor

    写一个TODO App学习Flutter本地存储工具Moor Flutter的数据库存储, 官方文档: https://flutter.dev/docs/cookbook/persistence/sq ...

  2. [Flutter] 写第一个 Flutter app,part1 要点

    模拟器中调试元素的布局: Android Studio 右侧边栏 Flutter Inspector,选择 Toggle Debug Paint 打开. 格式化代码: 编辑器中右键 Reformat ...

  3. 用react + redux + router写一个todo

    概述 最近学习redux,打算用redux + router写了一个todo.记录下来,供以后开发时参考,相信对其他人也有用. 注意: 我只实现了Footer组件的router,其它组件的实现方法是类 ...

  4. 用react+redux写一个todo

    概述 最近学习redux,打算用redux写了一个todo.记录下来,供以后开发时参考,相信对其他人也有用. 代码 代码请见我的github 组织架构如下图:

  5. react写一个todo

    概述 最近学习redux,打算先复习一下react,所以用react写了一个todo.记录下来,供以后开发时参考,相信对其他人也有用. 代码 代码请见我的github 组织架构如下图:

  6. 当我第一次通过Kotlin和Compose来实现一个Canvas时, 我收获了什么?

    当我第一次通过Kotlin和Compose来实现一个Canvas时, 我收获了什么? 自从2019年Google推荐Kotlin为Android开发的首选语言以来已经经历了将近四年的时间, Compo ...

  7. 搞了我一下午竟然是web.config少写了一个点

    Safari手机版居然有个这么愚蠢的bug,浪费了我整个下午,使尽浑身解数,国内国外网站搜索解决方案,每一行代码读了又想想了又读如此不知道多少遍,想破脑袋也想不通到底哪里出了问题,结果竟然是web.c ...

  8. 用C3中的animation和transform写的一个模仿加载的时动画效果

    用用C3中的animation和transform写的一个模仿加载的时动画效果! 不多说直接上代码; html标签部分 <div class="wrap"> <h ...

  9. 写了一个常规性生成merge 的小脚本

    现在使用数据库来写存储过程,动不动参数就会用到xml ,当然罗,优势也很明显,参数相对固定,而且灵活,如果要修改或者什么的,中间接口层也不需要做变化,只需要修改封装的存储过程以及程序传参就ok了. 随 ...

  10. c# .Net :Excel NPOI导入导出操作教程之List集合的数据写到一个Excel文件并导出

    将List集合的数据写到一个Excel文件并导出示例: using NPOI.HSSF.UserModel;using NPOI.SS.UserModel;using System;using Sys ...

随机推荐

  1. Qt编写视频监控系统74-悬浮工具栏(半透明/上下左右位置/自定义按钮)

    一.前言 在监控系统中一般在视频实时预览的时候,希望提供一个悬浮工具条,可以显示一些提示信息比如分辨率.码率.帧率,提供一堆快捷操作按钮,可以录像.抓拍.云台控制.关闭等操作,参考了国内很多监控厂商客 ...

  2. g2o编译出现的问题及解决办法 By not providing "FindG2O.cmake" in CMAKE_MODULE_PATH this project has

    在安装完该g2o之后 运行一些程序 如高翔的ch6 代码会出现如下错误: CMake Warning at CMakeLists.txt:10 (FIND_PACKAGE): By not provi ...

  3. KMS for Windows 11

    I. 镜像下载 Windows 镜像下载地址:站点1,站点2 II. 手动激活 参考文档:Easy ways to activate Windows 11 for FREE without a pro ...

  4. CH32V203F6P6-TSSOP20测试之02---点灯成功

    一.问题思考 直接用官方提供的例程,为何下载程序后没有什么响应,难道自己设计的电路有什么不妥? 于是,对于电路进行具体分析,结果发现: 第一.官方的BOOT0采用杜邦线连接,在芯片手册好像找不到关于B ...

  5. Java虚拟机调优-垃圾回收算法-工具

    背景: 垃圾回收的瓶颈 传统分代垃圾回收方式,已经在一定程度上把垃圾回收给应用带来的负担降到了最小,把应用的吞吐量推到了一个极限.但是他无法解决的一个问题,就是Full GC所带来的应用暂停.在一些对 ...

  6. ctfshow--web6 sql注入空格绕过

    这道题目空格被过滤了,那么我们可以用/**/来替换空格 'union/**/select/**/1,(select/**/database()),3# //查看数据库名名字为web2 查看web2下的 ...

  7. Codeforces Round 958 (Div. 2)

    题目链接:Codeforces Round 958 (Div. 2) 总结:C因为常数没转\(long long\) \(wa\)两发,难绷. A. Split the Multiset fag:模拟 ...

  8. mysl 修改数据存储位置后服务启动后停止

    在 Windows 系统中安装完 mysql 后,如果是生产用的机器,通常会修改数据存储位置.基本步骤: 1. 停止 mysql 服务: 2. 修改 my.ini 文件中的 datadir=" ...

  9. Linux部署Python项目

    项目部署 项目部署对于操作通常是由运维人员来进行统一管理装配的,但是对于一个开发人员来讲,基础的项目部署还是要会的. 这里我主要讲解python的项目是如何部署的,至于项目哪里来的,这就要看观众如何抉 ...

  10. Ansible之一module

    常用自动化运维工具 Ansible:python,Agentless,中小型应用环境 saltstack:python,一般需部署agent,执行效率更高 puppet:ruby,功能强大,配置复杂, ...