如何简化状态和实体映射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,这套写法真的能让你的架构上升一个档次

相关推荐
飘逸飘逸3 小时前
Autojs进阶前言
android·javascript
spencer_tseng3 小时前
Branding Printing System (Android Pad)
android·pad
FrameNotWork4 小时前
多设备 Android Logcat 自动采集方案:基于 Docker + Shell 实现日志按天切割与自动清理
android·docker·容器
bqliang5 小时前
Compose 实验性 Styles API
android·android jetpack
大尚来也5 小时前
PHP 入门指南:从零基础到掌握核心语法
android
summerkissyou19875 小时前
android -wifi/蓝牙-常见面试题
android·wifi·bluetooth
XiaoLeisj5 小时前
Android Activity 页面导航基础:Manifest 声明、Intent 显式/隐式跳转与数据传递
android·java
littlegnal6 小时前
Flutter Android如何延迟加载代码
android·flutter