前言

前面几篇已经讲清楚了:

  • 哪些代码可以进入 commonMain

  • 哪些代码必须留在平台层

  • 扫码、地图、定位等强平台能力应该怎么拆

  • Android 组件化项目如何逐步迁移到 KMP

到了这一步,我们已经可以把不少代码放进 sharedLogic

Device
DeviceStatus
ApiResult
AppError
ScanParser
LoginValidator
DeviceRepository
LoginUseCase
TokenStorage
LocationProvider

但新的问题也会很快出现:

这些代码到底应该怎么组织?

如果没有明确分层,sharedLogic 很容易逐渐变成一个新的大杂烩模块:

sharedLogic/
├── Device.kt
├── LoginRequest.kt
├── ApiResult.kt
├── HttpClient.kt
├── DeviceApi.kt
├── TokenManager.kt
├── ScanParser.kt
├── LoginUseCase.kt
├── DeviceRepository.kt
├── DeviceRepositoryImpl.kt
├── Utils.kt
├── Mapper.kt
└── CommonManager.kt

虽然代码已经跨平台了,但项目依然会出现:

  • 网络代码和业务代码混在一起

  • DTO 被 UI 直接使用

  • Repository 接口和实现放在一起

  • Feature 之间互相引用

  • 所有公共能力都堆进 core

  • commonMain 变成新的万能模块

  • 后续拆多模块时无从下手

所以,KMP 项目并不是把代码放进 commonMain 就结束了。

真正稳定的跨平台底座还需要解决:

代码职责怎么分?

依赖方向怎么控制?

业务 Feature 怎么组织?

哪些是公共基础能力?

哪些属于具体业务?

这一篇就围绕设备巡检 Demo,完整讲清楚 sharedLogic 内部应该如何分层。


一、sharedLogic 的职责是什么?

先明确 sharedLogic 的定位。

它不是:

所有平台都能调用的工具箱

也不是:

Android 代码迁移后的临时存放区

更合理的定位是:

sharedLogic 是 Android、iOS、Web 等客户端共同依赖的业务核心。

它主要负责:

  • 公共业务模型

  • 统一结果和错误

  • 网络请求

  • 数据存储抽象

  • Repository

  • UseCase

  • 数据转换

  • 表单校验

  • 业务状态

  • 协议解析

  • 平台能力抽象

而不负责:

  • Android Activity

  • SwiftUI 页面

  • Compose 页面

  • Flutter Widget

  • 高德 MapView

  • CameraX

  • AVFoundation

  • 权限弹窗

  • 页面导航

可以把它理解为:

                 Android UI
                     │
                 iOS UI
                     │
                Flutter UI
                     │
                 CMP UI
                     │
                     ▼
                sharedLogic
                     │
        ┌────────────┼────────────┐
        │            │            │
      Network      Storage      Platform

无论上层最终使用哪一种 UI,底层业务核心都保持稳定。


二、sharedLogic 最基本的分层思路

一个比较容易理解的分层方式是:

core
domain
data
feature

它们分别解决不同问题。


1. core:跨业务公共基础能力

core 放置多个 Feature 都可能使用的基础能力。

例如:

core/
├── result/
├── error/
├── network/
├── storage/
├── platform/
├── coroutine/
└── util/

典型内容包括:

  • ApiResult

  • AppError

  • HttpClient

  • 网络请求配置

  • Token 存储抽象

  • Logger 抽象

  • 平台信息

  • 时间、UUID 等公共能力

core 不应该包含具体业务。

例如下面这些不属于 core

Device
LoginUseCase
InspectionForm
ScanDeviceUseCase

因为它们属于具体业务领域。


2. domain:稳定的业务定义

domain 负责表达项目真正的业务。

包括:

  • Domain Model

  • Repository 接口

  • UseCase

  • 业务规则

  • 业务错误

  • 业务状态

例如设备领域:

domain/
├── model/
│   ├── Device.kt
│   └── DeviceStatus.kt
│
├── repository/
│   └── DeviceRepository.kt
│
└── usecase/
    ├── GetDeviceListUseCase.kt
    ├── GetDeviceDetailUseCase.kt
    └── FilterDevicesUseCase.kt

Domain 层不应该关心:

  • Ktor 的请求写法

  • JSON 字段名称

  • Room 表结构

  • DataStore Key

  • Android Context

  • iOS Keychain

  • Retrofit 或 OkHttp

它表达的是:

系统具有什么业务能力

而不是:

底层使用什么技术实现

3. data:数据获取和持久化实现

data 负责实现 Domain 层定义的 Repository。

包括:

  • Remote DataSource

  • Local DataSource

  • DTO

  • Entity

  • Mapper

  • Repository 实现

  • 缓存策略

例如:

data/
├── remote/
│   ├── DeviceApi.kt
│   └── dto/
│       └── DeviceDto.kt
│
├── local/
│   ├── DeviceLocalDataSource.kt
│   └── entity/
│       └── DeviceEntity.kt
│
├── mapper/
│   └── DeviceMapper.kt
│
└── repository/
    └── DeviceRepositoryImpl.kt

Data 层可以知道:

  • Ktor

  • kotlinx.serialization

  • Room

  • DataStore

  • 缓存

  • 服务端返回结构

但 Domain 层不应该反向知道 Data 层的具体实现。


4. feature:按业务组织代码

随着项目扩大,仅仅使用全局的:

domain/
data/

可能会出现另一个问题:

所有业务的 Model 放在一起
所有 Repository 放在一起
所有 UseCase 放在一起

例如:

domain/model/
├── User.kt
├── Device.kt
├── InspectionForm.kt
├── InspectionItem.kt
├── LoginResult.kt
├── MapMarkerModel.kt
└── ScanResult.kt

业务越来越多以后,目录会迅速膨胀。

所以更适合企业项目的方式是:

core
+
feature

每个 Feature 内部再分 domain 和 data

例如:

sharedLogic/src/commonMain/kotlin/com/sqx/liftkmpcore/
├── core/
│   ├── result/
│   ├── error/
│   ├── network/
│   ├── storage/
│   └── platform/
│
├── feature/
│   ├── auth/
│   │   ├── domain/
│   │   └── data/
│   │
│   ├── device/
│   │   ├── domain/
│   │   └── data/
│   │
│   └── inspection/
│       ├── domain/
│       └── data/
│
├── qrcode/
└── map/

这种方式同时保留了:

  • 横向基础能力

  • 纵向业务边界


三、推荐使用“core + feature”的混合结构

对于设备巡检 Demo,我更推荐下面这种结构:

sharedLogic/
└── src/
    └── commonMain/
        └── kotlin/com/sqx/liftkmpcore/
            ├── core/
            │   ├── result/
            │   ├── error/
            │   ├── network/
            │   ├── storage/
            │   ├── platform/
            │   └── logging/
            │
            ├── feature/
            │   ├── auth/
            │   │   ├── domain/
            │   │   │   ├── model/
            │   │   │   ├── repository/
            │   │   │   └── usecase/
            │   │   │
            │   │   └── data/
            │   │       ├── remote/
            │   │       ├── local/
            │   │       ├── mapper/
            │   │       └── repository/
            │   │
            │   ├── device/
            │   │   ├── domain/
            │   │   └── data/
            │   │
            │   └── inspection/
            │       ├── domain/
            │       └── data/
            │
            ├── qrcode/
            └── map/

这里的核心思想是:

core
放跨业务公共能力

feature
放具体业务闭环

例如:

ApiResult
多个 Feature 都会用
所以放 core

Device
只属于设备业务
所以放 feature/device

InspectionForm
只属于巡检业务
所以放 feature/inspection

四、model 不是只有一种

在很多 Android 项目中,服务端返回什么字段,我们就直接定义一个 data class,然后从网络层一直用到 UI 层。

例如:

@Serializable
data class Device(
    val device_id: String,
    val device_name: String,
    val status: Int,
    val lng: String?,
    val lat: String?
)

这个模型同时承担了:

  • JSON 反序列化

  • Repository 返回值

  • 业务判断

  • UI 展示

  • 数据库存储

项目小的时候看起来很方便,但后续会产生很多问题。

因此,需要区分几种不同模型。


1. DTO:网络传输模型

DTO 是 Data Transfer Object。

它用于匹配服务端接口结构。

例如:

@Serializable
data class DeviceDto(
    @SerialName("device_id")
    val deviceId: String,

    @SerialName("device_name")
    val deviceName: String,

    @SerialName("status")
    val status: Int,

    @SerialName("lng")
    val longitude: String?,

    @SerialName("lat")
    val latitude: String?
)

DTO 可以包含:

  • 服务端字段命名

  • 可空字段

  • 状态码

  • 接口兼容字段

  • 序列化注解

它属于:

data/remote/dto

DTO 不应该直接进入 Domain 层。


2. Entity:本地数据库模型

Entity 负责匹配数据库表结构。

例如:

@Entity(tableName = "device")
data class DeviceEntity(
    @PrimaryKey
    val id: String,

    val name: String,

    val status: String,

    val latitude: Double?,

    val longitude: Double?,

    val updatedAt: Long
)

它属于:

data/local/entity

Entity 可以包含:

  • 表名

  • 主键

  • 索引

  • 数据库字段

  • 本地同步状态

它也不应该被 UI 直接使用。


3. Domain Model:业务模型

Domain Model 用于表达业务真正需要的数据。

data class Device(
    val id: String,
    val name: String,
    val status: DeviceStatus,
    val location: GeoPoint?
)

enum class DeviceStatus {
    ONLINE,
    OFFLINE,
    WARNING
}

Domain Model 不需要知道:

  • 服务端字段叫 device_id

  • 数据库存储状态是字符串还是整数

  • JSON 使用什么注解

  • Room 表名是什么

它只表达业务含义。

它属于:

feature/device/domain/model

4. UI Model:展示模型

如果 UI 需要额外的展示数据,可以再定义 UI Model。

例如:

data class DeviceUiModel(
    val id: String,
    val title: String,
    val statusText: String,
    val statusStyle: DeviceStatusStyle,
    val locationText: String
)

这个模型通常应该放在:

sharedUI

或者平台 Presentation 层。

不建议放入纯业务 sharedLogic,除非项目明确共享 ViewModel 和展示状态。


五、DTO 为什么不能直接当 Domain Model?

假设服务端返回:

{
  "device_id": "10001",
  "device_name": null,
  "status": 1,
  "lng": "120.6334",
  "lat": "31.2799"
}

DTO 为了兼容服务端,可能需要定义:

@Serializable
data class DeviceDto(
    @SerialName("device_id")
    val deviceId: String?,

    @SerialName("device_name")
    val deviceName: String?,

    val status: Int?,

    val lng: String?,

    val lat: String?
)

但业务层希望使用的是:

data class Device(
    val id: String,
    val name: String,
    val status: DeviceStatus,
    val location: GeoPoint?
)

两者之间应该通过 Mapper 转换:

fun DeviceDto.toDomain(): Device {
    return Device(
        id = requireNotNull(deviceId) {
            "设备编号不能为空"
        },
        name = deviceName.orEmpty(),
        status = status.toDeviceStatus(),
        location = createGeoPoint(
            latitude = lat,
            longitude = lng
        )
    )
}

状态转换:

private fun Int?.toDeviceStatus(): DeviceStatus {
    return when (this) {
        1 -> DeviceStatus.ONLINE
        2 -> DeviceStatus.OFFLINE
        3 -> DeviceStatus.WARNING
        else -> DeviceStatus.OFFLINE
    }
}

经纬度转换:

private fun createGeoPoint(
    latitude: String?,
    longitude: String?
): GeoPoint? {
    val latitudeValue =
        latitude?.toDoubleOrNull()

    val longitudeValue =
        longitude?.toDoubleOrNull()

    if (
        latitudeValue == null ||
        longitudeValue == null
    ) {
        return null
    }

    return GeoPoint(
        latitude = latitudeValue,
        longitude = longitudeValue
    )
}

这样服务端字段发生变化时,影响主要集中在:

DTO
+
Mapper

Domain 和 UI 不需要全部修改。


六、result 层应该怎么设计?

原 Android 项目中可能已经有:

data class ResultDo<T>(
    val code: Int,
    val msg: String,
    val data: T?
)

这种模型通常更接近接口响应结构。

进入 KMP 后,建议区分:

服务器响应
和
客户端业务结果

1. 服务器响应模型

@Serializable
data class BaseResponse<T>(
    val code: Int,
    val msg: String,
    val data: T?
)

它属于网络层。

服务端返回什么格式,它就匹配什么格式。


2. 公共业务结果

sealed interface AppResult<out T> {

    data class Success<T>(
        val data: T
    ) : AppResult<T>

    data class Failure(
        val error: AppError
    ) : AppResult<Nothing>
}

错误定义:

sealed interface AppError {

    data object Unauthorized : AppError

    data object NetworkUnavailable : AppError

    data object Timeout : AppError

    data class Server(
        val code: String,
        val message: String
    ) : AppError

    data class Validation(
        val message: String
    ) : AppError

    data class Unknown(
        val message: String
    ) : AppError
}

这样上层只处理:

成功
失败

不需要直接理解:

  • HTTP 401

  • SocketTimeoutException

  • JSON 解析异常

  • 服务端 code

  • iOS 网络错误类型


3. 为什么不建议全项目都叫 ApiResult?

如果 Result 不仅用于网络请求,还用于:

  • 本地数据库

  • 表单校验

  • 文件读取

  • Token 获取

  • 缓存读取

那么叫:

ApiResult

可能会让语义过于局限。

可以根据项目选择:

AppResult
DataResult
Result

如果项目当前主要围绕接口请求,继续使用 ApiResult 也没有问题。

关键不是名称,而是不要让服务端响应模型直接渗透到整个业务层。


七、error 层应该解决什么问题?

错误处理不能只做一件事:

捕获 Exception,然后 message 输出

一个完整错误体系通常要解决:

  • 平台异常转换

  • 网络异常转换

  • HTTP 错误转换

  • 服务端业务错误转换

  • 业务校验错误

  • UI 可展示错误

  • 日志记录

  • 是否允许重试

例如网络层可能捕获:

超时
断网
JSON 解析失败
服务端 500
Token 失效

然后统一转换成:

AppError.Timeout
AppError.NetworkUnavailable
AppError.Server
AppError.Unauthorized
AppError.Unknown

UseCase 和 UI 不需要知道底层具体异常类型。

例如:

class ErrorMapper {

    fun map(
        throwable: Throwable
    ): AppError {
        return when (throwable) {
            is NetworkUnavailableException -> {
                AppError.NetworkUnavailable
            }

            is RequestTimeoutException -> {
                AppError.Timeout
            }

            else -> {
                AppError.Unknown(
                    message = throwable.message
                        ?: "未知错误"
                )
            }
        }
    }
}

不过不要把所有错误转换都塞进一个几千行的 ErrorMapper

更合理的是按来源拆分:

core/error/
├── AppError.kt
├── NetworkErrorMapper.kt
├── ServerErrorMapper.kt
└── ErrorMessageResolver.kt

八、network 层应该放什么?

network 是 sharedLogic 中很容易膨胀的部分。

建议把它分成公共网络基础和具体业务 API。


1. core/network:公共网络能力

可以包含:

core/network/
├── HttpClientFactory.kt
├── NetworkConfig.kt
├── AuthHeaderProvider.kt
├── NetworkErrorMapper.kt
├── ResponseParser.kt
└── RetryPolicy.kt

它负责多个 Feature 共同使用的网络能力。

例如:

data class NetworkConfig(
    val baseUrl: String,
    val connectTimeoutMillis: Long,
    val requestTimeoutMillis: Long
)

2. HttpClientFactory

可以在 commonMain 创建公共配置:

class HttpClientFactory(
    private val config: NetworkConfig,
    private val tokenStorage: TokenStorage,
    private val logger: AppLogger
) {

    fun create(): HttpClient {
        return HttpClient {
            install(ContentNegotiation) {
                json(
                    Json {
                        ignoreUnknownKeys = true
                        isLenient = true
                    }
                )
            }

            install(HttpTimeout) {
                connectTimeoutMillis =
                    config.connectTimeoutMillis

                requestTimeoutMillis =
                    config.requestTimeoutMillis
            }

            defaultRequest {
                url(config.baseUrl)
            }
        }
    }
}

具体 Engine 可以由平台配置或依赖决定。

但要注意:

HttpClientFactory 只负责网络基础配置,不应该知道登录、设备、巡检等具体业务接口。


3. 具体 Feature 的 API 放在 Feature 内

例如设备 API:

feature/device/data/remote/
├── DeviceRemoteDataSource.kt
└── dto/
    └── DeviceDto.kt
class DeviceRemoteDataSource(
    private val client: HttpClient
) {

    suspend fun getDeviceList():
        BaseResponse<List<DeviceDto>> {
        return client
            .get("/device/list")
            .body()
    }
}

不要把所有接口都堆进:

core/network/api/

否则 core 会逐渐知道所有业务。


九、DataSource 是不是必须的?

常见结构是:

Repository
        │
        ▼
RemoteDataSource / LocalDataSource
        │
        ▼
Network / Database

例如:

interface DeviceRemoteDataSource {

    suspend fun getDeviceList():
        List<DeviceDto>
}

实现:

class KtorDeviceRemoteDataSource(
    private val client: HttpClient
) : DeviceRemoteDataSource {

    override suspend fun getDeviceList():
        List<DeviceDto> {
        return client
            .get("/device/list")
            .body<BaseResponse<List<DeviceDto>>>()
            .data
            .orEmpty()
    }
}

DataSource 的价值在于:

  • 隔离 Repository 和具体框架

  • 区分远程与本地数据源

  • 便于 Fake

  • 便于缓存组合

  • 便于替换数据来源

但并不是每个简单接口都必须先抽一个 DataSource 接口。

如果一个 Feature 只有一个简单接口,也没有本地缓存需求,可以先写:

RepositoryImpl
直接调用 DeviceApi

等复杂度出现后再拆。

不要为了符合某种架构图,强制每层都增加接口。


十、Repository 到底负责什么?

Repository 是 Domain 与 Data 之间最重要的边界。

Domain 定义接口:

interface DeviceRepository {

    suspend fun getDeviceList():
        AppResult<List<Device>>

    suspend fun getDeviceDetail(
        deviceId: String
    ): AppResult<Device>
}

Data 层提供实现:

class DeviceRepositoryImpl(
    private val remoteDataSource:
        DeviceRemoteDataSource,

    private val localDataSource:
        DeviceLocalDataSource,

    private val errorMapper:
        NetworkErrorMapper
) : DeviceRepository {

    override suspend fun getDeviceList():
        AppResult<List<Device>> {
        return try {
            val remoteDevices =
                remoteDataSource.getDeviceList()

            val devices =
                remoteDevices.map {
                    it.toDomain()
                }

            localDataSource.saveDevices(
                devices.map {
                    it.toEntity()
                }
            )

            AppResult.Success(devices)
        } catch (throwable: Throwable) {
            val cachedDevices =
                localDataSource.getDevices()

            if (cachedDevices.isNotEmpty()) {
                AppResult.Success(
                    cachedDevices.map {
                        it.toDomain()
                    }
                )
            } else {
                AppResult.Failure(
                    errorMapper.map(throwable)
                )
            }
        }
    }

    override suspend fun getDeviceDetail(
        deviceId: String
    ): AppResult<Device> {
        TODO()
    }
}

Repository 负责:

  • 选择远程还是本地数据源

  • 缓存策略

  • DTO 和 Entity 转 Domain

  • 异常转换

  • 对外提供稳定业务数据

Repository 不应该负责:

  • 页面加载动画

  • Toast

  • 页面导航

  • Compose State

  • Android Context


十一、Repository 接口为什么放 Domain?

因为 Domain 需要表达:

业务需要什么数据能力

例如:

interface DeviceRepository {
    suspend fun getDeviceList():
        AppResult<List<Device>>
}

这里不关心数据来自:

  • Ktor

  • Retrofit

  • Room

  • 内存

  • Mock

  • 本地 JSON

所以接口属于 Domain。

实现属于 Data:

domain/repository/DeviceRepository.kt

data/repository/DeviceRepositoryImpl.kt

依赖方向是:

Domain
定义抽象

Data
实现抽象

而不是 Domain 依赖 Data。


十二、UseCase 到底有没有必要?

UseCase 是最容易被滥用的层。

有些项目会把每一个 Repository 方法都机械包装一层:

class GetDeviceListUseCase(
    private val repository: DeviceRepository
) {
    suspend operator fun invoke() =
        repository.getDeviceList()
}

如果 UseCase 只是原样转发,而且整个项目非常简单,那么它的价值确实有限。

但 UseCase 在下面这些场景很有价值。


1. 组合多个 Repository

例如提交巡检表单:

class SubmitInspectionUseCase(
    private val inspectionRepository:
        InspectionRepository,

    private val deviceRepository:
        DeviceRepository,

    private val locationProvider:
        LocationProvider,

    private val validator:
        InspectionValidator
) {

    suspend operator fun invoke(
        form: InspectionForm
    ): AppResult<InspectionResult> {
        val validation =
            validator.validate(form)

        if (
            validation is
                ValidationResult.Failure
        ) {
            return AppResult.Failure(
                AppError.Validation(
                    validation.message
                )
            )
        }

        val locationResult =
            locationProvider
                .getCurrentLocation()

        val completedForm =
            form.copy(
                location =
                    locationResult
                        .successPointOrNull()
            )

        return inspectionRepository
            .submitInspection(
                completedForm
            )
    }
}

2. 包含明确业务规则

例如设备过滤:

class FilterDevicesUseCase {

    operator fun invoke(
        devices: List<Device>,
        keyword: String,
        status: DeviceStatus?
    ): List<Device> {
        return devices.filter { device ->
            val matchesKeyword =
                keyword.isBlank() ||
                    device.name.contains(
                        keyword,
                        ignoreCase = true
                    )

            val matchesStatus =
                status == null ||
                    device.status == status

            matchesKeyword &&
                matchesStatus
        }
    }
}

3. 同一业务流程被多个 UI 使用

例如登录逻辑可能同时被:

  • Android Compose

  • iOS SwiftUI

  • CMP

  • Flutter

调用。

UseCase 可以成为稳定入口。


4. 便于独立测试

UseCase 只依赖抽象,可以使用 Fake Repository 进行测试。


十三、什么时候不需要 UseCase?

下面这种代码:

class GetDeviceNameUseCase {
    operator fun invoke(
        device: Device
    ): String {
        return device.name
    }
}

通常没有必要。

再例如:

class ClearTokenUseCase(
    private val tokenStorage:
        TokenStorage
) {
    suspend operator fun invoke() {
        tokenStorage.clear()
    }
}

如果清除 Token 没有附加业务规则,也只有一个调用点,可以直接由登录状态管理器调用 TokenStorage

所以 UseCase 的判断标准不是:

每个 Repository 方法都必须对应一个 UseCase

而是:

这是不是一个明确、可复用、可测试的业务动作?


十四、storage 层应该怎么组织?

存储可以分成两部分:

公共存储抽象
+
具体 Feature 数据存储

1. core/storage:跨业务存储抽象

例如:

interface TokenStorage {

    suspend fun save(
        accessToken: String,
        refreshToken: String?
    )

    suspend fun getAccessToken():
        String?

    suspend fun getRefreshToken():
        String?

    suspend fun clear()
}

还可以包含:

KeyValueStorage
SecureStorage
FileStorage

但不要设计一个无所不能的:

interface StorageManager {
    fun save(key: String, value: Any)
    fun get(key: String): Any?
}

这种接口缺乏类型安全,也容易把业务存储全部变成字符串 Key。


2. Feature 本地数据源

设备缓存属于设备 Feature:

feature/device/data/local/
├── DeviceEntity.kt
├── DeviceDao.kt
└── DeviceLocalDataSource.kt

巡检草稿属于巡检 Feature:

feature/inspection/data/local/
├── InspectionDraftEntity.kt
└── InspectionDraftDataSource.kt

不要把所有本地表都塞进:

core/storage

core/storage 提供基础能力,Feature 决定具体存什么。


十五、platform 层应该放什么?

core/platform 用于定义公共业务需要的平台能力。

例如:

core/platform/
├── AppLogger.kt
├── PlatformInfo.kt
├── LocationProvider.kt
├── NetworkStatusProvider.kt
├── FileStorage.kt
└── UuidProvider.kt

这些是抽象,而不是 Android 或 iOS 实现。

例如:

interface AppLogger {

    fun debug(
        tag: String,
        message: String
    )

    fun error(
        tag: String,
        message: String,
        throwable: Throwable? = null
    )
}

Android 实现放在:

androidMain/platform/AndroidLogger.kt

iOS 实现放在:

iosMain/platform/IosLogger.kt

commonMain 只知道抽象。


十六、qrcode 和 map 应该放 core 还是 feature?

这取决于它们的业务属性。


1. 如果是多个业务都会使用的通用能力

例如二维码解析协议在多个 Feature 使用,可以单独放:

qrcode/
├── ScanResult.kt
├── ScanParser.kt
└── ScanValidator.kt

地图点位模型可以放:

map/
├── GeoPoint.kt
├── MapMarkerModel.kt
└── DistanceCalculator.kt

2. 如果强绑定某个 Feature

例如二维码只用于设备识别,可以放:

feature/device/qrcode/

如果地图只用于设备点位,也可以放:

feature/device/map/

不要为了看起来“通用”,过早把业务代码提升到 core

判断标准是:

它现在是否真的被多个 Feature 共同依赖?

如果没有,就先留在对应 Feature。


十七、Feature 之间应该怎么依赖?

假设项目有:

feature-auth
feature-device
feature-inspection

不建议让它们随意互相调用。

例如:

feature-inspection
直接依赖
feature-device/data/repository/DeviceRepositoryImpl

这是不合理的。

如果巡检需要设备信息,它应该依赖稳定的业务抽象:

interface DeviceRepository {

    suspend fun getDeviceDetail(
        deviceId: String
    ): AppResult<Device>
}

或者定义更小的能力:

interface DeviceQueryService {

    suspend fun getDevice(
        deviceId: String
    ): AppResult<Device>
}

Feature 之间应该通过:

  • Domain 接口

  • 公共模型

  • UseCase

  • 事件

进行协作,而不是直接访问对方 Data 层。


十八、完整的登录链路应该怎么分层?

以登录功能为例。

目录:

feature/auth/
├── domain/
│   ├── model/
│   │   ├── User.kt
│   │   └── LoginResult.kt
│   │
│   ├── repository/
│   │   └── AuthRepository.kt
│   │
│   └── usecase/
│       └── LoginUseCase.kt
│
└── data/
    ├── remote/
    │   ├── AuthRemoteDataSource.kt
    │   └── dto/
    │       ├── LoginRequestDto.kt
    │       └── LoginResponseDto.kt
    │
    ├── mapper/
    │   └── AuthMapper.kt
    │
    └── repository/
        └── AuthRepositoryImpl.kt

1. Domain Model

data class User(
    val id: String,
    val name: String
)

data class LoginResult(
    val user: User,
    val accessToken: String,
    val refreshToken: String?
)

2. Repository 接口

interface AuthRepository {

    suspend fun login(
        account: String,
        password: String
    ): AppResult<LoginResult>
}

3. DTO

@Serializable
data class LoginRequestDto(
    val account: String,
    val password: String
)

@Serializable
data class LoginResponseDto(
    val accessToken: String,
    val refreshToken: String?,
    val user: UserDto
)

4. RemoteDataSource

class AuthRemoteDataSource(
    private val client: HttpClient
) {

    suspend fun login(
        request: LoginRequestDto
    ): LoginResponseDto {
        return client
            .post("/auth/login") {
                setBody(request)
            }
            .body<BaseResponse<LoginResponseDto>>()
            .requireData()
    }
}

5. Repository 实现

class AuthRepositoryImpl(
    private val remoteDataSource:
        AuthRemoteDataSource,

    private val errorMapper:
        NetworkErrorMapper
) : AuthRepository {

    override suspend fun login(
        account: String,
        password: String
    ): AppResult<LoginResult> {
        return try {
            val response =
                remoteDataSource.login(
                    LoginRequestDto(
                        account = account,
                        password = password
                    )
                )

            AppResult.Success(
                response.toDomain()
            )
        } catch (throwable: Throwable) {
            AppResult.Failure(
                errorMapper.map(throwable)
            )
        }
    }
}

6. UseCase

class LoginUseCase(
    private val repository:
        AuthRepository,

    private val tokenStorage:
        TokenStorage,

    private val validator:
        LoginValidator
) {

    suspend operator fun invoke(
        account: String,
        password: String
    ): AppResult<User> {
        val validation =
            validator.validate(
                account = account,
                password = password
            )

        if (
            validation is
                ValidationResult.Failure
        ) {
            return AppResult.Failure(
                AppError.Validation(
                    validation.message
                )
            )
        }

        return when (
            val result = repository.login(
                account = account,
                password = password
            )
        ) {
            is AppResult.Success -> {
                tokenStorage.save(
                    accessToken =
                        result.data.accessToken,

                    refreshToken =
                        result.data.refreshToken
                )

                AppResult.Success(
                    result.data.user
                )
            }

            is AppResult.Failure -> result
        }
    }
}

完整调用链:

UI
 │
 ▼
LoginUseCase
 │
 ├── LoginValidator
 │
 ├── AuthRepository
 │       │
 │       ▼
 │ AuthRepositoryImpl
 │       │
 │       ▼
 │ AuthRemoteDataSource
 │       │
 │       ▼
 │     Ktor
 │
 └── TokenStorage

十九、依赖方向应该怎么控制?

推荐依赖方向:

UI / Presentation
        │
        ▼
      UseCase
        │
        ▼
Repository Interface
        ▲
        │
Repository Implementation
        │
        ▼
Remote / Local DataSource
        │
        ▼
Network / Database / Storage

换成层级关系:

Presentation
依赖 Domain

Data
依赖 Domain

Domain
不依赖 Presentation

Domain
也不依赖具体 Data 实现

虽然在一个 sharedLogic Module 中,Gradle 暂时无法阻止包之间随意引用,但在代码组织上仍然应该保持这个方向。

下一篇拆成 KMP 多模块后,就可以通过 Gradle 依赖真正限制边界。


二十、依赖注入应该放在哪里?

所有对象最终都需要被创建。

例如:

HttpClient
AuthRemoteDataSource
AuthRepositoryImpl
LoginValidator
LoginUseCase

对象组装应该集中在 Composition Root,而不是散落在业务类中。

不推荐:

class LoginUseCase {

    private val repository =
        AuthRepositoryImpl(
            AuthRemoteDataSource(
                HttpClient()
            )
        )
}

因为这样:

  • 无法替换实现

  • 难以测试

  • 平台配置无法注入

  • 对象创建分散

更合理的是:

class SharedContainer(
    val loginUseCase: LoginUseCase,
    val getDeviceListUseCase:
        GetDeviceListUseCase
)

工厂:

fun createSharedContainer(
    platformDependencies:
        PlatformDependencies
): SharedContainer {
    val client =
        HttpClientFactory(
            config =
                platformDependencies
                    .networkConfig,

            tokenStorage =
                platformDependencies
                    .tokenStorage,

            logger =
                platformDependencies
                    .logger
        ).create()

    val authRemoteDataSource =
        AuthRemoteDataSource(client)

    val authRepository =
        AuthRepositoryImpl(
            remoteDataSource =
                authRemoteDataSource,

            errorMapper =
                NetworkErrorMapper()
        )

    val loginUseCase =
        LoginUseCase(
            repository =
                authRepository,

            tokenStorage =
                platformDependencies
                    .tokenStorage,

            validator =
                LoginValidator()
        )

    return SharedContainer(
        loginUseCase = loginUseCase,
        getDeviceListUseCase =
            createDeviceUseCase(client)
    )
}

项目扩大后,也可以使用支持 KMP 的依赖注入框架。


二十一、测试应该放在哪一层?

不同层有不同测试重点。


1. Domain 测试

测试:

  • Validator

  • UseCase

  • 状态转换

  • 业务规则

  • Mapper 的业务逻辑

例如:

class InspectionValidatorTest {

    private val validator =
        InspectionValidator()

    @Test
    fun emptyDeviceId_shouldFail() {
        val form =
            InspectionForm(
                deviceId = "",
                items = emptyList()
            )

        val result =
            validator.validate(form)

        assertTrue(
            result is
                ValidationResult.Failure
        )
    }
}

2. Repository 测试

使用 Fake DataSource 测试:

  • 缓存策略

  • 错误转换

  • DTO 转 Domain

  • 远程失败后读取本地


3. Network 测试

测试:

  • 请求路径

  • JSON 解析

  • Header

  • Token

  • 服务端错误转换


4. 平台实现测试

分别测试:

  • Android DataStore

  • iOS Keychain

  • Android LocationProvider

  • iOS LocationProvider

公共业务测试不应该依赖 Android 模拟器或 iOS 模拟器。


二十二、常见的错误分层方式

错误一:所有代码都放 core

例如:

core/
├── Device.kt
├── LoginUseCase.kt
├── InspectionForm.kt
├── DeviceApi.kt
└── MapMarkerModel.kt

最后 core 变成新的大杂烩。

正确做法:

core 只放真正跨业务的基础能力。


错误二:DTO 直接传给 UI

结果:

  • UI 依赖接口字段

  • 服务端字段变化影响所有层

  • 可空字段大量扩散

  • 状态码到处判断

正确做法:

DTO
→ Mapper
→ Domain Model

错误三:Repository 接口和实现没有区分

例如:

class DeviceRepository(
    private val client: HttpClient
)

这样 Domain 直接绑定网络实现。

正确做法:

DeviceRepository
是业务抽象

DeviceRepositoryImpl
是数据实现

错误四:UseCase 只有一行也必须创建

结果:

  • 文件数量急剧增加

  • 调用链变长

  • 没有实际业务价值

正确做法:

只有明确业务动作、组合逻辑或复用价值时才创建 UseCase。


错误五:所有 Mapper 都放一个文件

例如:

Mapper.kt

里面包含几十个业务模型转换。

正确做法:

AuthMapper
DeviceMapper
InspectionMapper

或者将简单转换放在对应 DTO 附近。


错误六:Feature 直接依赖另一个 Feature 的 Data 层

例如:

inspection
直接调用
DeviceRemoteDataSource

正确做法:

依赖 Domain 接口或者稳定业务服务。


错误七:业务层知道 Ktor、Room 和 DataStore

例如 UseCase 中直接调用:

HttpClient
DeviceDao
Preferences

正确做法:

UseCase 依赖 Repository 和业务抽象。


二十三、sharedLogic 的推荐完整结构

设备巡检 Demo 可以先采用下面的结构:

sharedLogic/src/commonMain/kotlin/com/sqx/liftkmpcore/
├── core/
│   ├── result/
│   │   └── AppResult.kt
│   │
│   ├── error/
│   │   ├── AppError.kt
│   │   └── NetworkErrorMapper.kt
│   │
│   ├── network/
│   │   ├── NetworkConfig.kt
│   │   ├── HttpClientFactory.kt
│   │   ├── BaseResponse.kt
│   │   └── RetryPolicy.kt
│   │
│   ├── storage/
│   │   ├── TokenStorage.kt
│   │   └── FileStorage.kt
│   │
│   ├── platform/
│   │   ├── LocationProvider.kt
│   │   ├── NetworkStatusProvider.kt
│   │   └── PlatformInfo.kt
│   │
│   └── logging/
│       └── AppLogger.kt
│
├── feature/
│   ├── auth/
│   │   ├── domain/
│   │   │   ├── model/
│   │   │   │   ├── User.kt
│   │   │   │   └── LoginResult.kt
│   │   │   │
│   │   │   ├── repository/
│   │   │   │   └── AuthRepository.kt
│   │   │   │
│   │   │   └── usecase/
│   │   │       └── LoginUseCase.kt
│   │   │
│   │   └── data/
│   │       ├── remote/
│   │       │   ├── AuthRemoteDataSource.kt
│   │       │   └── dto/
│   │       │       ├── LoginRequestDto.kt
│   │       │       └── LoginResponseDto.kt
│   │       │
│   │       ├── mapper/
│   │       │   └── AuthMapper.kt
│   │       │
│   │       └── repository/
│   │           └── AuthRepositoryImpl.kt
│   │
│   ├── device/
│   │   ├── domain/
│   │   │   ├── model/
│   │   │   ├── repository/
│   │   │   └── usecase/
│   │   │
│   │   └── data/
│   │       ├── remote/
│   │       ├── local/
│   │       ├── mapper/
│   │       └── repository/
│   │
│   └── inspection/
│       ├── domain/
│       └── data/
│
├── qrcode/
│   ├── ScanResult.kt
│   ├── ScanParser.kt
│   ├── ScanValidator.kt
│   └── HandleScanResultUseCase.kt
│
└── map/
    ├── GeoPoint.kt
    ├── MapMarkerModel.kt
    ├── DistanceCalculator.kt
    └── GetDeviceMarkersUseCase.kt

平台实现:

sharedLogic/src/androidMain/kotlin/
├── platform/
│   ├── AndroidLogger.kt
│   ├── AndroidTokenStorage.kt
│   ├── AndroidLocationProvider.kt
│   └── AndroidPlatformInfo.kt
│
└── network/
    └── AndroidHttpEngineFactory.kt
sharedLogic/src/iosMain/kotlin/
├── platform/
│   ├── IosLogger.kt
│   ├── IosTokenStorage.kt
│   ├── IosLocationProvider.kt
│   └── IosPlatformInfo.kt
│
└── network/
    └── IosHttpEngineFactory.kt

二十四、什么时候应该从单模块拆成多模块?

当前结构全部放在一个 sharedLogic Module 中,适合:

  • 项目初期

  • 业务数量不多

  • 团队规模较小

  • 先验证 KMP 链路

  • 还在迁移阶段

当出现以下情况时,可以考虑拆多模块:

  • sharedLogic 编译越来越慢

  • Feature 数量明显增加

  • 多个项目只需要其中一部分能力

  • 团队需要独立维护业务模块

  • 希望通过 Gradle 限制依赖方向

  • 某些 Module 需要单独发布

  • 平台依赖差异明显

例如后续拆成:

core-model
core-result
core-network
core-storage
core-platform

feature-auth
feature-device
feature-inspection

core-qrcode
core-map

但不要在 Demo 刚开始时就拆几十个 Module。

更稳妥的路径是:

先在 sharedLogic 内建立清晰包结构

业务稳定以后

再把包迁移成独立 KMP Module

二十五、从单模块分层到多模块组件化

这一篇讲的是:

一个 sharedLogic Module 内部
如何控制职责

下一篇要解决的是:

如何把这些职责真正拆成多个 KMP Module

两者关系是:

第一阶段
通过 package 建立逻辑边界

第二阶段
通过 Gradle Module 建立物理边界

例如当前:

sharedLogic/
├── core/network
├── core/result
└── feature/device

以后可以变成:

core-network/
core-result/
feature-device/

代码职责本身不需要推倒重来,只是从包边界升级成 Module 边界。


二十六、总结

sharedLogic 不是简单的公共代码目录,而是整个跨平台项目的业务底座。

一个稳定的 sharedLogic 需要同时解决:

公共能力
业务边界
数据来源
依赖方向
平台差异

推荐使用:

core
+
feature

的混合结构。

其中:

core
负责多个 Feature 共同使用的基础能力

feature
负责登录、设备、巡检等具体业务闭环

模型应该区分:

DTO
服务网络传输

Entity
服务本地数据库

Domain Model
表达稳定业务

UI Model
服务页面展示

Result 应该区分:

BaseResponse
服务端响应结构

AppResult
客户端统一业务结果

Repository 的职责是:

屏蔽数据来源
转换数据模型
处理缓存策略
向 Domain 提供稳定数据

UseCase 的职责是:

表达明确业务动作
组合 Repository
封装业务规则
提供可测试入口

但不要为了形式,为每个简单方法都创建 UseCase。

依赖方向应该始终保持:

UI
  │
  ▼
UseCase
  │
  ▼
Repository 接口
  ▲
  │
Repository 实现
  │
  ▼
Remote / Local DataSource

最终可以用一句话概括:

core 负责横向公共能力,feature 负责纵向业务闭环;Domain 定义业务,Data 负责实现,平台层提供差异能力,UI 只消费稳定的业务结果。

只有先把单个 sharedLogic Module 内部的边界设计清楚,后续拆成 KMP 多模块组件化时,才不会只是把一个大泥球拆成多个小泥球。


下一篇预告

下一篇将正式进入 KMP 多模块组件化:

《KMP 多模块组件化:core-model、core-network、feature-device 的依赖怎么拆?》

我们会重点讲清楚:

  • KMP Module 和普通 Android Library 有什么区别

  • 为什么每个 KMP Module 都可以拥有自己的 commonMainandroidMain 和 iosMain

  • core-modelcore-resultcore-network 应该怎么拆

  • feature-authfeature-devicefeature-inspection 如何依赖公共模块

  • commonMain 如何依赖跨平台 Module

  • androidMain 如何依赖 Android 专属 Module

  • 如何避免 Feature 之间循环依赖

  • api 和 implementation 在 KMP 多模块中的区别

  • 哪些模块值得独立发布和复用

  • 如何从单体 sharedLogic 平滑迁移到多模块 KMP 底座

下一篇会把这一篇的“包分层”,正式升级为 Gradle 层面的“模块边界”。

Logo

一站式 AI 云服务平台

更多推荐