标题党嫌疑犯实锤

序言

从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. Could not install packages due to an OSError: ("Connection broken: ConnectionResetError(10054, '远程主机强迫关闭 了一个现有的连接。', None, 10054, None)",

    今天安装微软AI量化投资平台Qlib遇到报错 ERROR: Could not install packages due to an OSError: ("Connection broken ...

  2. Qt/C++音视频开发78-获取本地摄像头支持的分辨率/帧率/格式等信息/mjpeg/yuyv/h264

    一.前言 上一篇文章讲到用ffmpeg命令方式执行打印到日志输出,可以拿到本地摄像头设备信息,顺藤摸瓜,发现可以通过执行 ffmpeg -f dshow -list_options true -i v ...

  3. Qt编写安防视频监控系统61-子模块5设备控制

    一.前言 设备控制模块,和云台控制模块都是用的onvif协议通信,可以对选中的通道的摄像机(ONVIF协议),进行获取参数.设置参数.手动校时.重启设备.抓拍图片.模拟报警等操作,可以对摄像机的明亮度 ...

  4. IM通讯协议专题学习(一):Protobuf从入门到精通,一篇就够!

    本文由IBM开发者社区分享,有较多修订和改动. 1.引言 在当今移动网络时代,手机流量和电量是宝贵的资源,对于移动端最常见的即时通讯IM应用,由于实时通信基于Socket长连接,它对于流量和电量的需求 ...

  5. 使用 NodeLocalDNS 提升集群 DNS 性能和可靠性

    本文主要分享如何使用 NodeLocal DNSCache 来提升集群中的 DNS 性能以及可靠性,包括部署.使用配置以及原理分析,最终通过压测表明使用后带来了高达 50% 的性能提升. 1.背景 什 ...

  6. 树莓派cm4更新bootloader(eeprom)

    cm4不能在系统里通过 rpi-eeprom-update 指令进行升级,也不能通过 update 进行更新,只能通过recovery模式进行更新. 以下为Windows的升级方式. Setp 1:下 ...

  7. Event Store-其它存储

    背景 ENode是一个CQRS+Event Sourcing架构的开发框架,Event Sourcing需要持久化事件,事件可以持久化在DB,但是DB由于面向的是CRUD场景,是针对数据会不断修改或删 ...

  8. Golang-容器3

    http://c.biancheng.net/golang/container/ Go语言数组详解 数组是一个由固定长度的特定类型元素组成的序列,一个数组可以由零个或多个元素组成.因为数组的长度是固定 ...

  9. 自定义快捷命令程序(VC++加批处理)

    一 概述 在看<从小工到专家-程序员修炼之道>时,看到建议使用Shell,很有感触.在很多时候,通过键盘操作,比鼠标的确会块很多,如果能用好shell命令(或批处理命令)   ,的确能节省 ...

  10. System类、Math类、BigInteger与BigDecimal的使用

     System类代表系统,系统级的很多属性和控制方法都放置在该类的内部.该类位于java.lang包. 由于该类的构造器是private的,所以无法创建该类的对象,也就是无法实例化该类.其内部的成 ...