如果你正在使用 Jetpack Compose、ViewModel 和 StateFlow 开发现代化 Android 应用,那么你一定对状态管理和层与层之间的数据转换带来的繁琐深有体会。虽然数据类(data class)是大多数开发者的首选方案,但其实还有一种强大却少有人用的写法,可以大幅减少样板代码,降低架构复杂度。
在这篇文章里,我们会一起探索:如何利用接口 + Kotlin 委托特性,让数据映射更清爽、冗余代码彻底消失、ViewModel 更干净、更易维护。我们会对比传统写法与基于接口的写法,并告诉你为什么它很可能是你当前架构里缺失的那一块拼图。
一、先看基础:Android 里的数据类
Kotlin 提供的数据类非常方便,也是 Android 开发者最常用的工具。它帮我们自动生成了 equals/hashCode/toString/componentN 等方法,还能用 copy 轻松修改属性并生成新对象:
data class Book(
val id: Long,
val title: String,
val description: String,
)
val oldBook = getBookSomehow()
val updatedBook = oldBook.copy(title = "Updated Title")
这类数据类非常适合搭配 Compose、StateFlow、ViewModel 这套现代开发栈。但今天我们不满足于此 —— 我们往前走一步:
**如果把实体类 / 状态类换成接口,会怎么样?**对很多人来说,这听起来有点反直觉。
毕竟,MVVM / MVI 里的事实标准就是:实体(Entity)+ 页面状态(State)一律用数据类(偶尔搭配密封类),几乎不使用接口。
标准流程一句话总结:页面(Composable)订阅 ViewModel 里的 StateFlow,根据 State 里的数据渲染 UI。
典型代码如下(简化版,省略加载与错误):
interface GetBooksUseCase {
operator fun invoke(): Flow<List<Book>>
}
@HiltViewModel
class BooksViewModel @Inject constructor(
private val getBooks: GetBooksUseCase
) : ViewModel() {
data class State(
val books: ImmutableList<Book> = persistentListOf()
)
val stateFlow: StateFlow<State> = getBooks()
.map { list -> State(list.toImmutableList()) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 2000),
initialValue = State()
)
}
在页面里收集状态:
@Composable
fun BooksScreen(
viewModel: BooksViewModel = hiltViewModel()
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
BooksContent(state)
}
@Composable
private fun BooksContent(state: State) {
// 渲染书籍列表
}
这套写法的优点很明显:
- State 更新时,UI 自动刷新
- State 是不可变数据类,不会出现 “数据变了 UI 不刷新”
- 支持任意线程安全更新状态
二、真正的挑战:加入多选功能
上面的例子太简单了。现在我们加一个常见需求:书籍列表支持多选。每一项旁边有复选框,底部有删除选中书籍的按钮。
问题来了:数据层返回的 Book 只包含书籍本身信息,不包含任何选中状态。
data class Book(
val id: Long,
val title: String,
val description: String,
// 没有选中状态
)
从架构上讲,这本就应该如此:
- 数据层不关心 UI 交互
- 选中状态是页面级别的临时状态,生命周期只在当前页面
- 书籍信息是全局长期数据(数据库 / 接口)
所以我们必须做一层数据映射:Book → UiBook
data class UiBook(
val id: Long,
val title: String,
val description: String,
val isSelected: Boolean, // 新增
)
State 也要跟着改:
data class State(
val books: ImmutableList<UiBook> = persistentListOf()
)
UI 渲染倒是简单了,直接读 isSelected 就行。但怎么优雅地做映射、怎么更新选中状态,才是真正的麻烦。
下面我们看三种常见方案,从差到优。
三、方案 1:ViewModel 内部维护一套私有状态
这是项目里最常见的写法。
思路:
- 公开 State:给 UI 用,只包含
UiBook - 私有 State:ViewModel 内部用,保存选中 ID
- 用
combine把数据源 + 选中状态合并
private data class ViewModelState(
val selectedIds: Set<Long>,
) {
fun isBookSelected(book: Book) = selectedIds.contains(book.id)
}
private val viewModelStateFlow = MutableStateFlow(ViewModelState())
合并数据流:
val stateFlow: StateFlow<State> = combine(
getBooks(),
viewModelStateFlow
) { books, vmState ->
val mappedBooks = books.map { book ->
UiBook(
id = book.id,
title = book.title,
description = book.description,
isSelected = vmState.isBookSelected(book)
)
}
State(books = mappedBooks.toImmutableList())
}.stateIn(...)
切换选中:
fun toggle(bookId: Long) = viewModelStateFlow.update { old ->
val newIds = if (old.selectedIds.contains(bookId)) {
old.selectedIds - bookId
} else {
old.selectedIds + bookId
}
old.copy(selectedIds = newIds)
}
缺点
- ❌ 代码变复杂
- ❌ 要维护两套 State
- ❌ 多了一堆合并与映射逻辑
四、方案 2:单 State + 私有属性(全部塞一起)
有人会想:能不能只保留一个 State?可以,但体验很差。
data class State(
private val allBooks: List<Book> = emptyList(),
private val selectedIds: Set<Long> = emptySet(),
) {
val books: ImmutableList<UiBook> = allBooks.map { book ->
UiBook(
id = book.id,
title = book.title,
description = book.description,
isSelected = selectedIds.contains(book.id)
)
}.toImmutableList()
}
然后你会发现:
- 属性私有,外部改不了
- 必须自己加一堆
withXxx()方法 - 不能用
stateIn,必须手写MutableStateFlow + init - 更新状态的方法会暴露给 UI,架构不安全
即便用上 Reducer 模式或第三方库,访问权限问题依然解决不了。
五、方案 3:接口 + Kotlin 委托 —— 真正的优雅解
现在我们换一条路:不用直接用 data class,改用接口。
Step 1:给 Book 定义接口
interface Book {
val id: Long
val title: String
val description: String
data class Default(
override val id: Long,
override val title: String,
override val description: String
) : Book
}
Step 2:UiBook 实现接口 + 委托
@Immutable
data class UiBook(
val origin: Book,
val isSelected: Boolean,
) : Book by origin
这就是 Kotlin 委托的魔力:
- 只需要传
origin和isSelected - 但你依然可以直接使用:
uiBook.id、uiBook.title、uiBook.description
完全不用写一遍转发代码。
Step 3:给 State 也定义接口
@Immutable
interface State {
val books: ImmutableList<UiBook>
}
Step 4:内部实现私有化
private data class StateImpl(
val allBooks: List<Book> = emptyList(),
val selectedIds: Set<Long> = emptySet(),
) : State {
override val books = allBooks.map { origin ->
UiBook(origin, selectedIds.contains(origin.id))
}.toImmutableList()
}
重点:
StateImpl是 private,外部完全看不见- 对外只暴露
State接口 - 映射代码极度简洁
Step 5:暴露 StateFlow 无需任何转换
private val _stateFlow = MutableStateFlow(StateImpl())
val stateFlow: StateFlow<State> = _stateFlow
因为 StateFlow 是 ** 协变(out)** 的,所以可以直接赋值。
六、超级爽的额外好处:无需反向映射
比如删除选中书籍:
interface DeleteBooksUseCase {
suspend operator fun invoke(books: List<Book>)
}
你可以直接传 List<UiBook>:
fun deleteBooks() {
viewModelScope.launch {
val selected = _stateFlow.value.books.filter { it.isSelected }
deleteBooksUseCase(selected) // 直接传,不用映射
}
}
因为 UiBook implements Book,所以完全兼容。这一层反向映射代码直接消失。
七、唯一小缺点:Preview 要多写一个实现
因为 State 是接口,不能直接预览,需要加一个:
private data object PreviewState : State {
override val books = persistentListOf(
// 造点测试数据
)
}
但这并不算缺点,反而让预览数据更干净、更独立。
八、总结:三种方案对比
-
双 State 方案能用,但复杂度高、维护两套状态、映射代码多。
-
单 State + 私有属性看似集中,实则修改麻烦、权限不安全、不适合 Compose 架构。
-
接口 + Kotlin 委托(最强)
- ✅ 大幅减少样板代码
- ✅ 自动转发属性,不用手写重复代码
- ✅ 双向兼容,无需反向映射
- ✅ 内部状态完全封装,对外只暴露干净接口
- ✅ ViewModel 极度清爽
如果你正在用 Compose + ViewModel + StateFlow,这套写法真的能让你的架构上升一个档次。