从 Android 组件化到 KMP 跨平台底座(七):KMP sharedLogic 如何分层——model、result、network、repository、usecase
前言
前面几篇已经讲清楚了:
-
哪些代码可以进入
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 都可以拥有自己的
commonMain、androidMain和iosMain -
core-model、core-result、core-network应该怎么拆 -
feature-auth、feature-device、feature-inspection如何依赖公共模块 -
commonMain如何依赖跨平台 Module -
androidMain如何依赖 Android 专属 Module -
如何避免 Feature 之间循环依赖
-
api和implementation在 KMP 多模块中的区别 -
哪些模块值得独立发布和复用
-
如何从单体
sharedLogic平滑迁移到多模块 KMP 底座
下一篇会把这一篇的“包分层”,正式升级为 Gradle 层面的“模块边界”。
更多推荐

所有评论(0)