11、数据层到Domain层的映射:Mapper模式与数据转换

做Android开发久了,你会发现一个有意思的现象:数据层拿到的模型,跟业务层想用的模型,往往不是一回事

举个例子。后端返回的用户信息里,有个字段叫 user_status,值是 0、1、2。但在你的Domain层里,你希望它是一个枚举 UserStatus.ACTIVEUserStatus.BANNED。怎么办?直接改后端接口?不现实。在数据层里到处写 if-else?那代码就烂了。

这时候就需要一个专门的转换层——Mapper。说白了,它就是数据模型和领域模型之间的翻译官。

为什么需要Mapper?

我在项目里见过不少团队,直接把数据层的 Entity 往 ViewModel 里传。一开始觉得挺方便,但迭代三个月后,问题就来了:

  • 后端改了字段名,整个App到处都要改
  • 数据层里塞满了业务逻辑
  • 单元测试变得极其困难

Clean Architecture 的核心原则之一就是依赖倒置。Domain层不应该知道数据层的存在。Mapper 就是这层隔离的守护者。

核心原则:数据层的模型只属于数据层。Domain层的模型只属于Domain层。两者之间的转换,交给Mapper。

Mapper的三种常见形式

嗯,这里我根据实际项目经验,把Mapper分成了三类。你可以根据场景灵活选择。

类型 适用场景 复杂度
接口式Mapper 简单的一对一映射
扩展函数式Mapper Kotlin项目,追求简洁
抽象基类Mapper 复杂映射,需要复用逻辑

接口式Mapper:最正统的方式

先定义一个接口,明确输入输出:

interface Mapper<in From, out To> {
    fun map(from: From): To
}

然后实现它:

class UserEntityToDomainMapper : Mapper<UserEntity, User> {
    override fun map(from: UserEntity): User {
        return User(
            id = from.id,
            name = from.name,
            email = from.email,
            status = mapStatus(from.status)
        )
    }

    private fun mapStatus(status: Int): UserStatus {
        return when (status) {
            1 -> UserStatus.ACTIVE
            2 -> UserStatus.INACTIVE
            else -> UserStatus.BANNED
        }
    }
}

这种方式的好处是类型安全,而且每个Mapper的职责非常清晰。我在大型项目中比较推荐这种写法,因为后期维护时,你一眼就能看出每个字段是怎么转换的。

扩展函数式Mapper:Kotlin的优雅写法

如果你觉得写一个类太啰嗦,Kotlin的扩展函数可以帮你简化:

fun UserEntity.toDomain(): User {
    return User(
        id = id,
        name = name,
        email = email,
        status = when (status) {
            1 -> UserStatus.ACTIVE
            2 -> UserStatus.INACTIVE
            else -> UserStatus.BANNED
        }
    )
}

调用时就更简洁了:

val domainUser = entity.toDomain()
我的建议:如果映射逻辑简单,且只在少数地方使用,扩展函数完全够用。但如果映射逻辑复杂,或者需要依赖注入,还是用接口式Mapper更合适。

抽象基类Mapper:处理复杂场景

有时候,映射不是简单的字段赋值。比如你需要把列表里的每个元素都映射一遍,或者需要处理空值、默认值。这时候抽象基类就派上用场了:

abstract class BaseMapper<in From, out To> {
    abstract fun map(from: From): To

    fun mapList(fromList: List<From>): List<To> {
        return fromList.map { map(it) }
    }

    fun mapNullable(from: From?): To? {
        return from?.let { map(it) }
    }
}

子类只需要实现 map 方法,列表和空值处理自动就有了:

class UserMapper : BaseMapper<UserEntity, User>() {
    override fun map(from: UserEntity): User {
        // 具体映射逻辑
    }
}

// 使用
val users: List<User> = userMapper.mapList(entityList)

我曾经在一个电商项目里,用这种方式处理了二十多种实体映射。每个Mapper只需要关注核心转换逻辑,公共的列表处理、空值处理都由基类搞定。代码量减少了将近40%。

避坑指南:我曾经踩过的坑

讲几个我实际遇到过的坑,你写Mapper的时候可以留意一下。

坑1:在Mapper里写业务逻辑
Mapper只做数据转换,不要在里面做校验、计算、过滤。比如「如果用户年龄大于18岁,返回VIP状态」——这种逻辑应该放在Domain层的UseCase里,而不是Mapper里。
坑2:双向Mapper不一致
如果你同时需要 Entity → Domain 和 Domain → Entity 的映射,一定要保证它们互逆。我见过一个项目,Entity转Domain时把时间戳转成了日期字符串,但Domain转Entity时忘了转回来,结果数据保存后全乱了。
坑3:忽略性能问题
如果列表很大(比如上千条),每次映射都创建新对象,GC压力会很大。考虑使用对象池,或者用 Sequence 做懒加载映射。

数据转换的完整流程

下面这张图展示了数据从网络层到Domain层的完整流转过程:

数据转换完整流程 数据层 UserEntity (来自API/数据库) Mapper Domain层 User (纯业务模型) UseCase UI层 ViewModel (展示数据) 关键说明: 1. 数据层只关心数据来源(网络/本地),不关心业务含义 2. Mapper 负责将 Entity 转换为 Domain 模型,完成「数据 → 业务」的语义转换 3. Domain 层只使用纯业务模型,不依赖任何数据层框架(如 Retrofit、Room) 4. UseCase 组合多个 Mapper 和 Repository,完成完整的业务逻辑 5. UI 层通过 ViewModel 获取 Domain 模型,再转换为 UI 所需格式 注意:箭头方向代表依赖关系,Domain 层不依赖数据层

实际项目中的Mapper组织方式

我个人习惯在数据层里创建一个 mapper 包,把所有Mapper放在一起。结构大概是这样的:

data/
├── mapper/
│   ├── UserEntityMapper.kt
│   ├── OrderEntityMapper.kt
│   └── ProductEntityMapper.kt
├── remote/
│   ├── api/
│   └── dto/
├── local/
│   ├── dao/
│   └── entity/
└── repository/
    └── UserRepositoryImpl.kt

每个Mapper只负责一种实体的转换。如果某个Mapper变得很复杂,我会考虑把它拆成多个小Mapper。比如 UserProfileMapperUserSettingsMapper,而不是一个巨大的 UserMapper

小技巧:写单元测试时,Mapper是最容易测试的组件之一。你只需要构造一个Entity,调用map方法,然后断言Domain对象的每个字段是否正确。我建议每个Mapper都配上单元测试,这能帮你发现很多隐蔽的转换错误。

总结

Mapper模式不是什么高深的技术,但它体现了Clean Architecture的核心思想——关注点分离。数据层的模型怎么变,Domain层都不受影响。你只需要修改对应的Mapper就行了。

说白了,Mapper就是一道防火墙。它把数据层的「脏乱差」挡在外面,让Domain层始终保持干净、纯粹。你在项目里用好了它,后期维护会轻松很多。


公众号:蓝海资料掘金营,微信deep3321