如何简化状态和实体映射Kotlin接口,委托和协变泛型

如果你正在使用 Jetpack Compose、ViewModel 和 StateFlow 开发现代化 Android 应用,那么你一定对状态管理层与层之间的数据转换 带来的繁琐深有体会。虽然数据类(data class)是大多数开发者的首选方案,但其实还有一种强大却少有人用的写法,可以大幅减少样板代码,降低架构复杂度。

在这篇文章里,我们会一起探索:如何利用接口 + Kotlin 委托特性 ,让数据映射更清爽、冗余代码彻底消失、ViewModel 更干净、更易维护。我们会对比传统写法与基于接口的写法,并告诉你为什么它很可能是你当前架构里缺失的那一块拼图


一、先看基础:Android 里的数据类

Kotlin 提供的数据类非常方便,也是 Android 开发者最常用的工具。它帮我们自动生成了 equals/hashCode/toString/componentN 等方法,还能用 copy 轻松修改属性并生成新对象:

kotlin 复制代码
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。

典型代码如下(简化版,省略加载与错误):

kotlin 复制代码
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()
        )
}

在页面里收集状态:

kotlin 复制代码
@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 只包含书籍本身信息,不包含任何选中状态

kotlin 复制代码
data class Book(
    val id: Long,
    val title: String,
    val description: String,
    // 没有选中状态
)

从架构上讲,这本就应该如此:

  • 数据层不关心 UI 交互
  • 选中状态是页面级别的临时状态,生命周期只在当前页面
  • 书籍信息是全局长期数据(数据库 / 接口)

所以我们必须做一层数据映射:Book → UiBook

kotlin 复制代码
data class UiBook(
    val id: Long,
    val title: String,
    val description: String,
    val isSelected: Boolean, // 新增
)

State 也要跟着改:

kotlin 复制代码
data class State(
    val books: ImmutableList<UiBook> = persistentListOf()
)

UI 渲染倒是简单了,直接读 isSelected 就行。但怎么优雅地做映射、怎么更新选中状态,才是真正的麻烦。

下面我们看三种常见方案,从差到优。


三、方案 1:ViewModel 内部维护一套私有状态

这是项目里最常见的写法。

思路:

  • 公开 State:给 UI 用,只包含 UiBook
  • 私有 State:ViewModel 内部用,保存选中 ID
  • combine 把数据源 + 选中状态合并
kotlin 复制代码
private data class ViewModelState(
    val selectedIds: Set<Long>,
) {
    fun isBookSelected(book: Book) = selectedIds.contains(book.id)
}

private val viewModelStateFlow = MutableStateFlow(ViewModelState())

合并数据流:

kotlin 复制代码
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(...)

切换选中:

kotlin 复制代码
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?可以,但体验很差。

kotlin 复制代码
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 定义接口

kotlin 复制代码
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 实现接口 + 委托

kotlin 复制代码
@Immutable
data class UiBook(
    val origin: Book,
    val isSelected: Boolean,
) : Book by origin

这就是 Kotlin 委托的魔力:

  • 只需要传 originisSelected
  • 但你依然可以直接使用:uiBook.iduiBook.titleuiBook.description

完全不用写一遍转发代码。

Step 3:给 State 也定义接口

kotlin 复制代码
@Immutable
interface State {
    val books: ImmutableList<UiBook>
}

Step 4:内部实现私有化

kotlin 复制代码
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()
}

重点:

  • StateImplprivate,外部完全看不见
  • 对外只暴露 State 接口
  • 映射代码极度简洁

Step 5:暴露 StateFlow 无需任何转换

kotlin 复制代码
private val _stateFlow = MutableStateFlow(StateImpl())
val stateFlow: StateFlow<State> = _stateFlow

因为 StateFlow 是 ** 协变(out)** 的,所以可以直接赋值。


六、超级爽的额外好处:无需反向映射

比如删除选中书籍:

kotlin 复制代码
interface DeleteBooksUseCase {
    suspend operator fun invoke(books: List<Book>)
}

你可以直接传 List<UiBook>

kotlin 复制代码
fun deleteBooks() {
    viewModelScope.launch {
        val selected = _stateFlow.value.books.filter { it.isSelected }
        deleteBooksUseCase(selected) // 直接传,不用映射
    }
}

因为 UiBook implements Book,所以完全兼容。这一层反向映射代码直接消失。


七、唯一小缺点:Preview 要多写一个实现

因为 State 是接口,不能直接预览,需要加一个:

kotlin 复制代码
private data object PreviewState : State {
    override val books = persistentListOf(
        // 造点测试数据
    )
}

但这并不算缺点,反而让预览数据更干净、更独立。


八、总结:三种方案对比

  1. 双 State 方案能用,但复杂度高、维护两套状态、映射代码多。

  2. 单 State + 私有属性看似集中,实则修改麻烦、权限不安全、不适合 Compose 架构。

  3. 接口 + Kotlin 委托(最强)

    • ✅ 大幅减少样板代码
    • ✅ 自动转发属性,不用手写重复代码
    • ✅ 双向兼容,无需反向映射
    • ✅ 内部状态完全封装,对外只暴露干净接口
    • ✅ ViewModel 极度清爽

如果你正在用 Compose + ViewModel + StateFlow,这套写法真的能让你的架构上升一个档次

相关推荐
用户693717500138425 分钟前
Android 开发,别只钻技术一亩三分地,也该学点“广度”了
android·前端·后端
唔6633 分钟前
原生 Android(Kotlin)仅串口「继承架构」完整案例二
android·开发语言·kotlin
一直都在5721 小时前
MySQL索引优化
android·数据库·mysql
代码s贝多芬的音符2 小时前
android mlkit 实现仰卧起坐和俯卧撑识别
android
jwn9993 小时前
Laravel9.x核心特性全解析
android
今天又在写代码4 小时前
数据智能分析平台部署服务器
android·服务器·adb
梦里花开知多少4 小时前
深入谈谈Launcher的启动流程
android·架构
jwn9994 小时前
Laravel11.x新特性全解析
android·开发语言·php·laravel
我就是马云飞5 小时前
停更5年后,我为什么重新开始写技术内容了
android·前端·程序员
stevenzqzq5 小时前
Kotlin 协程:withContext 与 async 核心区别与使用场景
android·开发语言·kotlin