从 Android 组件化到 KMP 跨平台底座(六):扫码、地图、定位这些平台能力,在 KMP 里到底怎么设计?
前言
前面几篇已经建立了一个基本判断:
纯业务逻辑
放进 commonMain
平台差异能力
留在 androidMain、iosMain 或平台 App
但真正落到项目中,最容易设计混乱的,往往不是普通网络请求和数据模型,而是下面这些能力:
扫码
相机
地图
定位
权限
WebView
蓝牙
文件选择
系统服务
它们有几个共同特点:
-
强依赖平台 SDK
-
强依赖权限
-
强依赖页面生命周期
-
经常需要嵌入原生 View
-
Android 和 iOS 的 API 差异很大
-
Web 端实现方式又完全不同
例如扫码。
Android 可能使用:
CameraX + ML Kit
iOS 可能使用:
AVFoundation
Web 端可能使用:
浏览器 Camera API + JavaScript 扫码库
再例如地图。
Android 可能使用高德地图,iOS 可能使用 MapKit,Web 端可能使用高德地图 JavaScript API。
如果简单地认为:
KMP 就是给所有平台定义一个统一接口。
很容易写出这样的代码:
interface CommonMapView {
fun addMarker()
fun moveCamera()
fun setZoom()
fun startLocation()
}
看起来实现了跨平台,实际上却把完全不同的平台能力强行塞进了一个接口。
最终往往出现:
-
公共接口越来越大
-
平台生命周期无法统一
-
某些方法在部分平台无法实现
-
大量参数退化成
Any -
平台特色能力无法使用
-
UI 层和业务层严重耦合
所以,这一篇重点解决一个问题:
扫码、地图、定位这些平台能力,应该共享什么,又应该平台化什么?
核心结论可以先给出来:
平台层
负责 SDK、权限、生命周期和 UI
commonMain
负责模型、规则、解析和业务流程
一、先把“平台能力”拆成不同层次
很多设计混乱,都是因为把一个功能当成了一个不可拆分的整体。
例如“扫码功能”,看起来只是一个功能,实际上至少包含以下内容:
打开相机
显示预览画面
申请相机权限
识别二维码
播放提示音
触发震动
解析二维码内容
判断二维码是否合法
提取设备编号
跳转设备详情
这里面并不是所有代码都属于同一层。
可以进一步拆成四层。
第一层:平台 SDK 层
负责真正调用平台 API。
例如 Android:
CameraX
ML Kit
高德地图
FusedLocationProvider
Android BluetoothGatt
例如 iOS:
AVFoundation
Vision
MapKit
CoreLocation
CoreBluetooth
这一层天然存在平台差异。
第二层:平台交互层
负责:
-
权限
-
生命周期
-
页面打开和关闭
-
原生 View
-
用户取消操作
-
前后台切换
-
系统设置跳转
这一层通常也应该留在平台侧。
第三层:公共业务层
负责:
-
数据模型
-
解析规则
-
参数校验
-
业务状态
-
Repository
-
UseCase
-
错误模型
-
数据转换
这部分才是 KMP 最适合共享的内容。
第四层:UI 展示层
可以有多种选择:
Android Compose / XML
iOS SwiftUI / UIKit
Compose Multiplatform
Flutter
Web Vue / React
UI 是否共享,和业务逻辑是否共享,是两个不同的问题。
二、扫码功能到底应该怎么拆?
先从最典型的扫码功能开始。
假设设备二维码格式是:
https://example.com/device?deviceId=10001&type=elevator
业务真正关心的是:
deviceId
type
二维码是否合法
扫码后应该进入哪个业务流程
至于二维码是通过 CameraX、AVFoundation 还是浏览器相机识别出来的,公共业务层并不关心。
所以扫码应该拆成:
平台层
负责获得二维码原始字符串
commonMain
负责理解二维码内容
整体结构是:
CameraX / AVFoundation / Web Camera
│
▼
rawContent: String
│
▼
commonMain ScanParser
│
▼
ScanResult
│
▼
commonMain 业务处理
三、commonMain 中共享什么?
扫码公共层可以包含四部分:
ScanResult
ScanParser
ScanValidator
HandleScanResultUseCase
1. 扫码结果模型
data class ScanResult(
val rawContent: String,
val deviceId: String?,
val type: ScanType
)
enum class ScanType {
DEVICE,
INSPECTION,
UNKNOWN
}
这个模型不依赖任何平台 API。
Android、iOS、Flutter、CMP 和 Web 都可以使用。
2. 二维码解析器
class ScanParser {
fun parse(rawContent: String): ScanResult {
val params = rawContent
.substringAfter(
delimiter = "?",
missingDelimiterValue = ""
)
.split("&")
.mapNotNull { item ->
val separatorIndex = item.indexOf("=")
if (separatorIndex <= 0) {
null
} else {
val key = item.substring(
startIndex = 0,
endIndex = separatorIndex
)
val value = item.substring(
startIndex = separatorIndex + 1
)
key to value
}
}
.toMap()
val type = when (params["type"]) {
"device" -> ScanType.DEVICE
"inspection" -> ScanType.INSPECTION
else -> ScanType.UNKNOWN
}
return ScanResult(
rawContent = rawContent,
deviceId = params["deviceId"],
type = type
)
}
}
这里没有:
CameraX
Activity
Context
AVFoundation
UIViewController
因此可以直接放入 commonMain。
3. 二维码校验器
sealed interface ScanValidationResult {
data object Success : ScanValidationResult
data class Failure(
val message: String
) : ScanValidationResult
}
class ScanValidator {
fun validate(
result: ScanResult
): ScanValidationResult {
if (result.deviceId.isNullOrBlank()) {
return ScanValidationResult.Failure(
message = "二维码中缺少设备编号"
)
}
if (result.type == ScanType.UNKNOWN) {
return ScanValidationResult.Failure(
message = "暂不支持当前二维码类型"
)
}
return ScanValidationResult.Success
}
}
Android 和 iOS 不应该各自写一套二维码合法性判断。
否则一旦协议改变,就容易出现双端规则不一致。
4. 扫码业务 UseCase
sealed interface ScanAction {
data class OpenDeviceDetail(
val deviceId: String
) : ScanAction
data class StartInspection(
val deviceId: String
) : ScanAction
data class ShowError(
val message: String
) : ScanAction
}
class HandleScanResultUseCase(
private val parser: ScanParser,
private val validator: ScanValidator
) {
operator fun invoke(
rawContent: String
): ScanAction {
val result = parser.parse(rawContent)
return when (
val validation = validator.validate(result)
) {
ScanValidationResult.Success -> {
when (result.type) {
ScanType.DEVICE -> {
ScanAction.OpenDeviceDetail(
deviceId = requireNotNull(
result.deviceId
)
)
}
ScanType.INSPECTION -> {
ScanAction.StartInspection(
deviceId = requireNotNull(
result.deviceId
)
)
}
ScanType.UNKNOWN -> {
ScanAction.ShowError(
message = "二维码类型不支持"
)
}
}
}
is ScanValidationResult.Failure -> {
ScanAction.ShowError(
message = validation.message
)
}
}
}
}
这样,平台层只负责把 rawContent 传进来。
至于接下来:
-
打开设备详情
-
开始巡检
-
显示错误
由 UI 层根据 ScanAction 处理。
四、为什么不建议直接定义 suspend fun scan()?
很多人会在 commonMain 中定义:
interface QrScanner {
suspend fun scan(): ScanResult
}
表面上看很合理:
Android 实现 CameraX
iOS 实现 AVFoundation
但继续向下想,就会发现这个接口隐藏了大量问题:
-
scan()是否会打开一个页面? -
谁负责申请相机权限?
-
用户取消后返回什么?
-
页面退出时协程如何结束?
-
相机预览由谁显示?
-
扫码页面横竖屏如何处理?
-
App 进入后台后如何释放相机?
-
一个页面能否同时发起两个扫码请求?
如果把这些问题全部藏在:
suspend fun scan()
后面很容易出现生命周期混乱。
所以,对于带有完整 UI 和用户交互的扫码功能,通常更建议:
UI 层主动打开扫码页面
平台扫码页面返回 rawContent
commonMain 继续处理
而不是让业务层像调用普通网络接口一样打开相机页面。
五、什么时候可以抽象扫码接口?
不是说扫码永远不能抽接口。
关键要看抽象的是什么。
不建议直接抽象:
interface QrScanner {
suspend fun scan(): ScanResult
}
可以考虑抽象更底层、更稳定的能力:
interface ScanContentProvider {
suspend fun receiveContent():
ScanContentResult
}
sealed interface ScanContentResult {
data class Success(
val rawContent: String
) : ScanContentResult
data object Cancelled :
ScanContentResult
data object PermissionDenied :
ScanContentResult
data class Failure(
val message: String
) : ScanContentResult
}
但是,即使这样做,也要明确:
平台入口仍然负责创建和管理扫码页面。
这种接口更适合下面的场景:
-
平台已经有独立扫码组件
-
扫码组件生命周期边界清晰
-
上层只需要等待一次结果
-
Android 和 iOS 都能提供相似的调用语义
如果扫码功能深度绑定页面和导航,那么直接由 UI 层处理通常更简单。
六、Android 扫码层应该放在哪里?
Android 扫码实现可以放在:
android-qrcode
或者:
androidApp/qrcode
具体取决于是否需要在多个 Android App 中复用。
结构可以是:
android-qrcode/
├── CameraXScannerView.kt
├── MlKitQrAnalyzer.kt
├── QrPermissionController.kt
├── QrScannerScreen.kt
└── AndroidScanResult.kt
Android 负责:
-
CameraX 预览
-
ML Kit 识别
-
相机权限
-
生命周期
-
震动
-
提示音
-
扫码框 UI
最终只向公共层传递:
rawContent: String
例如:
fun onQrCodeDetected(
rawContent: String
) {
when (
val action = handleScanResultUseCase(
rawContent
)
) {
is ScanAction.OpenDeviceDetail -> {
navigator.openDeviceDetail(
action.deviceId
)
}
is ScanAction.StartInspection -> {
navigator.openInspection(
action.deviceId
)
}
is ScanAction.ShowError -> {
messageController.show(
action.message
)
}
}
}
七、iOS 扫码层应该怎么接?
iOS 可以使用:
AVFoundation
Vision
负责:
-
相机权限
-
视频采集
-
二维码识别
-
预览画面
-
ViewController 生命周期
识别完成后,把字符串传给 KMP:
func onQrCodeDetected(
rawContent: String
) {
let action = handleScanResultUseCase
.invoke(rawContent: rawContent)
switch action {
case let open as ScanActionOpenDeviceDetail:
openDeviceDetail(
deviceId: open.deviceId
)
case let inspection as ScanActionStartInspection:
openInspection(
deviceId: inspection.deviceId
)
case let error as ScanActionShowError:
showMessage(error.message)
default:
break
}
}
iOS 不需要重复实现二维码业务规则。
八、CMP 中扫码页面应该怎么处理?
如果项目使用 Compose Multiplatform,普通页面可以放在:
sharedUI/commonMain
但扫码页面涉及相机预览和权限,通常有两种处理方式。
方案一:扫码页面完全平台化
结构:
sharedUI
负责普通页面
androidApp
负责 Android 扫码页面
iosApp
负责 iOS 扫码页面
共享页面点击“扫码”时,触发平台事件:
interface ScanLauncher {
fun launchScanner()
}
CMP UI:
@Composable
fun DeviceScreen(
onScanClick: () -> Unit
) {
Button(
onClick = onScanClick
) {
Text("扫码识别设备")
}
}
平台入口负责真正打开扫码页面。
这种方式最稳定。
方案二:共享扫码页面外壳,嵌入平台相机 View
可以共享:
-
顶部标题
-
扫码框样式
-
提示文案
-
底部按钮
平台侧只提供相机预览组件。
结构类似:
CMP 扫码页面
├── 共享标题
├── 共享扫码框
├── 平台相机预览
└── 共享提示区域
这种方式共享率更高,但需要处理:
-
原生 View 嵌入
-
生命周期
-
权限
-
相机资源释放
-
iOS 和 Android 视图交互
对于第一阶段项目,更建议方案一。
九、Flutter 中扫码应该怎么接?
如果使用:
KMP + Flutter
Flutter 可以负责扫码 UI,也可以使用 Flutter 插件调用平台相机能力。
整体流程:
Flutter 扫码页面
│
▼
Flutter 扫码插件
│
├── Android CameraX / ML Kit
└── iOS AVFoundation
│
▼
rawContent
│
▼
Flutter Bridge
│
▼
KMP HandleScanResultUseCase
│
▼
ScanAction
│
▼
Flutter 路由和页面展示
Flutter 负责:
-
扫码框
-
动画
-
页面跳转
-
错误提示
KMP 负责:
-
二维码解析
-
二维码校验
-
业务类型判断
-
下一步业务动作
十、地图功能为什么更难统一?
地图和扫码不同。
扫码最终通常可以收敛成:
rawContent: String
但地图是一个长期存在于页面中的复杂 UI 组件。
地图包含:
-
地图 View
-
地图生命周期
-
相机位置
-
缩放级别
-
Marker
-
聚合
-
路线
-
覆盖物
-
定位
-
手势
-
InfoWindow
-
坐标系转换
-
离线地图
-
路线规划
不同地图 SDK 差异非常明显。
例如:
高德地图 Android SDK
MapKit
高德地图 iOS SDK
Google Maps
Web JavaScript 地图
它们不是简单的方法名不同,而是对象模型和使用方式都可能不同。
因此,不建议把整个地图能力抽象成一个巨大的统一接口。
十一、不推荐的 CommonMapView 设计
例如:
interface CommonMapView {
fun addMarker(
id: String,
latitude: Double,
longitude: Double
)
fun removeMarker(id: String)
fun moveCamera(
latitude: Double,
longitude: Double
)
fun setZoom(zoom: Float)
fun startLocation()
fun stopLocation()
fun drawPolyline(
points: List<GeoPoint>
)
}
这个接口看起来统一,但存在几个问题。
1. UI 对象被伪装成业务服务
MapView 本质上是 UI 组件,不是普通 Repository。
它需要:
-
创建
-
布局
-
生命周期
-
页面销毁
-
前后台切换
这些都很难通过一个普通接口完整表达。
2. 不同平台能力并不对等
例如某个平台支持:
-
Marker 聚合
-
3D 地图
-
室内地图
-
自定义 InfoWindow
另一个平台未必支持完全相同的功能。
最终公共接口只能保留所有平台的最小交集。
3. 平台特性被限制
为了满足统一接口,Android 高德地图中的高级功能可能无法使用。
iOS MapKit 的特性也可能无法体现。
4. 接口会不断膨胀
业务需求一增加,就会不断向 CommonMapView 增加方法。
最终变成一个巨型平台适配接口。
十二、地图真正应该共享什么?
地图功能中最适合共享的是:
地图业务模型
地图相关业务规则
地图数据来源
地图交互产生的业务事件
而不是地图 View 本身。
1. 共享经纬度模型
data class GeoPoint(
val latitude: Double,
val longitude: Double
)
可以增加基本校验:
fun GeoPoint.isValid(): Boolean {
return latitude in -90.0..90.0 &&
longitude in -180.0..180.0
}
2. 共享 Marker 业务模型
data class MapMarkerModel(
val id: String,
val title: String,
val description: String?,
val point: GeoPoint,
val status: DeviceStatus
)
注意这里不应该包含:
MarkerOptions
MKAnnotation
BitmapDescriptor
Drawable
UIImage
因为这些都是平台类型。
3. 共享地图业务状态
data class DeviceMapState(
val markers: List<MapMarkerModel>,
val selectedMarkerId: String?,
val isLoading: Boolean,
val errorMessage: String?
)
4. 共享 Marker 数据转换
class GetDeviceMarkersUseCase(
private val repository: DeviceRepository
) {
suspend operator fun invoke():
ApiResult<List<MapMarkerModel>> {
return repository
.getDeviceList()
.map { devices ->
devices.mapNotNull { device ->
val point = device.location
?: return@mapNotNull null
if (!point.isValid()) {
return@mapNotNull null
}
MapMarkerModel(
id = device.id,
title = device.name,
description = device.address,
point = point,
status = device.status
)
}
}
}
}
Android 和 iOS 都使用同一套设备到地图点位的转换逻辑。
十三、平台层如何渲染 MapMarkerModel?
Android 高德地图:
MapMarkerModel
│
▼
MarkerOptions
│
▼
AMap.addMarker()
概念代码:
fun MapMarkerModel.toMarkerOptions():
MarkerOptions {
return MarkerOptions()
.position(
LatLng(
point.latitude,
point.longitude
)
)
.title(title)
.snippet(description)
}
iOS MapKit:
MapMarkerModel
│
▼
MKAnnotation
│
▼
MKMapView
Flutter 地图插件:
MapMarkerModel
│
▼
Flutter Marker
Web Vue 页面:
MapMarkerModel
│
▼
JavaScript Map Marker
公共业务模型保持不变,平台层只负责转换成自己的地图对象。
十四、地图点击事件应该怎么返回业务层?
平台地图组件点击 Marker 后,不要把平台对象传入 commonMain。
错误设计:
fun onMarkerClick(
marker: com.amap.api.maps.model.Marker
)
公共层应该只接收业务 ID:
fun onMarkerSelected(
markerId: String
)
或者定义公共事件:
sealed interface MapAction {
data class SelectMarker(
val markerId: String
) : MapAction
data class OpenDeviceDetail(
val deviceId: String
) : MapAction
data object ClearSelection :
MapAction
}
平台层点击地图 Marker:
高德 Marker / MKAnnotation
│
▼
提取业务 ID
│
▼
commonMain onMarkerSelected(id)
这样公共层永远不需要知道高德 Marker 或 MapKit Annotation。
十五、定位和地图不是同一个能力
很多项目会把:
地图
定位
写在同一个模块中。
但从架构上看,它们不是一回事。
地图是:
长期存在的 UI 组件
定位是:
获取当前地理位置的系统能力
定位最终可以收敛为稳定的业务结果:
经度
纬度
精度
时间
错误
因此,定位比地图 View 更适合抽象接口。
十六、LocationProvider 应该怎么设计?
可以在 commonMain 定义:
interface LocationProvider {
suspend fun getCurrentLocation():
LocationResult
}
公共结果:
sealed interface LocationResult {
data class Success(
val point: GeoPoint,
val accuracyMeters: Double?,
val timestampMillis: Long?
) : LocationResult
data object PermissionDenied :
LocationResult
data object LocationDisabled :
LocationResult
data class Failure(
val message: String
) : LocationResult
}
公共业务可以使用:
class VerifyDeviceLocationUseCase(
private val locationProvider: LocationProvider,
private val distanceCalculator:
DistanceCalculator
) {
suspend operator fun invoke(
devicePoint: GeoPoint,
maxDistanceMeters: Double
): LocationVerifyResult {
return when (
val result =
locationProvider.getCurrentLocation()
) {
is LocationResult.Success -> {
val distance =
distanceCalculator.calculate(
from = result.point,
to = devicePoint
)
if (distance <= maxDistanceMeters) {
LocationVerifyResult.Success(
distanceMeters = distance
)
} else {
LocationVerifyResult.TooFar(
distanceMeters = distance
)
}
}
LocationResult.PermissionDenied -> {
LocationVerifyResult.PermissionDenied
}
LocationResult.LocationDisabled -> {
LocationVerifyResult.LocationDisabled
}
is LocationResult.Failure -> {
LocationVerifyResult.Failure(
message = result.message
)
}
}
}
}
这里:
平台层
负责获取经纬度
commonMain
负责距离计算和业务判断
十七、权限应该放在哪里?
权限一定是平台能力。
因为 Android 和 iOS 的权限机制并不完全相同。
Android 涉及:
-
Manifest 声明
-
运行时权限
-
不再询问
-
跳转系统设置
-
不同 Android 版本权限变化
iOS 涉及:
-
Info.plist
-
系统授权状态
-
首次请求
-
Denied
-
Restricted
-
跳转设置
因此,不建议让 commonMain 直接负责:
弹出权限框
调用 ActivityResult
打开系统设置页
但公共层可以定义业务需要的权限状态:
enum class PermissionState {
GRANTED,
DENIED,
PERMANENTLY_DENIED,
NOT_DETERMINED
}
或者定义公共结果:
sealed interface CameraAvailability {
data object Available :
CameraAvailability
data object PermissionRequired :
CameraAvailability
data object PermanentlyDenied :
CameraAvailability
data object Unsupported :
CameraAvailability
}
平台层负责把系统权限状态转换为公共状态。
十八、不要让 commonMain 主动弹权限
下面这种设计不太合理:
class StartScanUseCase(
private val permissionManager:
PermissionManager
) {
suspend operator fun invoke() {
permissionManager.requestCameraPermission()
}
}
因为“弹权限框”本质上属于 UI 和平台交互。
更合适的方式是:
commonMain
判断业务需要相机
UI 层
检查权限并请求权限
平台层
执行系统权限申请
例如:
sealed interface StartScanAction {
data object OpenScanner :
StartScanAction
data object RequestCameraPermission :
StartScanAction
data object ShowPermissionSettings :
StartScanAction
}
平台 UI 根据 Action 决定:
-
打开扫码页
-
请求权限
-
跳转设置
这样业务决策可以共享,系统交互继续平台化。
十九、生命周期为什么不能忽略?
扫码、地图和定位都与生命周期密切相关。
例如 Android CameraX 需要绑定:
LifecycleOwner
地图可能需要:
onCreate
onResume
onPause
onDestroy
iOS 则使用:
UIViewController
viewWillAppear
viewDidDisappear
AVCaptureSession
这些生命周期无法简单抽象成完全一致的公共 API。
因此,更稳妥的原则是:
生命周期由拥有 UI 和 SDK 实例的平台层负责。
公共层只接收生命周期产生的业务事件。
例如:
页面进入
→ 平台开始定位
获得位置
→ 传给 commonMain
页面退出
→ 平台停止定位
不要让 commonMain 持有:
Activity
LifecycleOwner
UIViewController
MapView
AVCaptureSession
二十、持续定位和单次定位要分开设计
定位能力还要区分:
获取一次当前位置
持续监听位置变化
单次定位可以使用:
suspend fun getCurrentLocation():
LocationResult
持续定位更适合使用 Flow:
interface LocationProvider {
fun observeLocation():
Flow<LocationResult>
}
例如:
class ObserveDeviceDistanceUseCase(
private val locationProvider: LocationProvider,
private val distanceCalculator:
DistanceCalculator
) {
fun execute(
devicePoint: GeoPoint
): Flow<Double> {
return locationProvider
.observeLocation()
.mapNotNull { result ->
when (result) {
is LocationResult.Success -> {
distanceCalculator.calculate(
from = result.point,
to = devicePoint
)
}
else -> null
}
}
}
}
但是谁负责开始和停止采集,仍然必须结合平台生命周期。
例如:
Android
页面 STARTED 时开始收集
页面 STOPPED 时停止定位
iOS 则根据页面和定位 Manager 生命周期控制。
二十一、距离计算应该放在哪里?
经纬度之间的距离计算本身不依赖地图 SDK。
因此可以进入 commonMain。
interface DistanceCalculator {
fun calculate(
from: GeoPoint,
to: GeoPoint
): Double
}
如果算法是纯数学公式,可以直接实现:
class HaversineDistanceCalculator :
DistanceCalculator {
override fun calculate(
from: GeoPoint,
to: GeoPoint
): Double {
val earthRadius = 6_371_000.0
val fromLatitude =
from.latitude.toRadians()
val toLatitude =
to.latitude.toRadians()
val latitudeDifference =
(to.latitude - from.latitude)
.toRadians()
val longitudeDifference =
(to.longitude - from.longitude)
.toRadians()
val value =
kotlin.math.sin(
latitudeDifference / 2
).let { it * it } +
kotlin.math.cos(fromLatitude) *
kotlin.math.cos(toLatitude) *
kotlin.math.sin(
longitudeDifference / 2
).let { it * it }
val angle =
2 * kotlin.math.atan2(
kotlin.math.sqrt(value),
kotlin.math.sqrt(1 - value)
)
return earthRadius * angle
}
}
private fun Double.toRadians(): Double {
return this / 180.0 * kotlin.math.PI
}
Android 和 iOS 不需要分别依赖地图 SDK 计算业务距离。
二十二、坐标系转换是否能共享?
这要看具体需求。
例如中国地图场景中可能涉及:
WGS84
GCJ-02
BD-09
如果坐标转换算法是纯数学实现,并且业务规则明确,可以放进 commonMain。
例如定义:
enum class CoordinateSystem {
WGS84,
GCJ02,
BD09
}
data class CoordinatePoint(
val point: GeoPoint,
val system: CoordinateSystem
)
然后由公共转换器处理:
interface CoordinateConverter {
fun convert(
source: CoordinatePoint,
targetSystem: CoordinateSystem
): CoordinatePoint
}
但是,如果转换依赖某个平台地图 SDK 的内部实现,就应该留在平台侧。
二十三、扫码和地图模块应该怎么拆?
可以把二维码模块拆成:
core-qrcode/
└── src/commonMain/
├── ScanResult.kt
├── ScanType.kt
├── ScanParser.kt
├── ScanValidator.kt
└── HandleScanResultUseCase.kt
android-qrcode/
├── CameraXScanner.kt
├── MlKitAnalyzer.kt
├── QrScannerScreen.kt
└── CameraPermissionController.kt
iosApp/qrcode/
├── IosQrScanner.swift
└── QrScannerView.swift
地图模块可以拆成:
core-map/
└── src/commonMain/
├── GeoPoint.kt
├── MapMarkerModel.kt
├── DistanceCalculator.kt
├── CoordinateConverter.kt
└── GetDeviceMarkersUseCase.kt
android-map/
├── AMapScreen.kt
├── AMapMarkerMapper.kt
└── AndroidLocationProvider.kt
iosApp/map/
├── MapKitScreen.swift
├── MapAnnotationMapper.swift
└── IosLocationProvider.swift
这样模块职责非常清晰。
二十四、平台 SDK 类型不能进入公共模型
这是强平台能力设计中最重要的规则之一。
例如下面的公共模型不合理:
data class DeviceMarker(
val marker: MarkerOptions
)
因为 MarkerOptions 属于高德 Android SDK。
下面也不合理:
data class ScanImage(
val bitmap: Bitmap
)
下面同样不合理:
data class IosScanImage(
val image: UIImage
)
公共模型应该使用平台无关类型:
data class BinaryFile(
val bytes: ByteArray,
val fileName: String,
val mimeType: String
)
或者:
data class LocalFileReference(
val path: String,
val fileName: String,
val mimeType: String
)
平台层负责转换:
Bitmap / UIImage / Flutter Image
│
▼
ByteArray / FileReference
│
▼
commonMain
二十五、Web 端怎么接入这些能力?
Web 同样需要区分业务层和平台层。
例如扫码:
浏览器 Camera API
+
JavaScript 扫码库
│
▼
rawContent
│
▼
KMP ScanParser
地图:
Vue / React 地图组件
│
▼
MapMarkerModel 数据
定位:
Browser Geolocation API
│
▼
LocationResult
所以 Web 并不需要复用 Android 或 iOS 的 SDK。
它只需要复用:
-
模型
-
解析
-
校验
-
UseCase
-
Repository
-
业务规则
这也再次说明:
KMP 共享的是业务核心,不是把某一个平台 SDK 强行移植到所有平台。
二十六、强平台能力的三种 UI 路线
同一套 KMP Core,可以接入不同 UI 方案。
路线一:原生 UI
Android
Compose + CameraX + 高德地图
iOS
SwiftUI + AVFoundation + MapKit
业务
KMP sharedLogic
优点:
-
平台能力最完整
-
生命周期处理自然
-
原生体验最好
缺点:
-
UI 需要分别实现
路线二:KMP + CMP
CMP
共享普通页面
平台组件
处理扫码、地图和权限
KMP
共享业务逻辑
优点:
-
普通页面共享率高
-
Kotlin 和 Compose 技术栈统一
缺点:
-
原生 View 嵌入需要额外处理
-
强平台页面仍可能需要平台化
路线三:KMP + Flutter
Flutter
负责跨平台 UI
Flutter Plugin
调用平台扫码和地图能力
KMP
负责业务底座
优点:
-
Flutter UI 共享率高
-
KMP Core 可以独立复用
缺点:
-
Flutter 与 KMP 之间需要桥接
-
调用链更长
-
模型和异步状态需要转换
二十七、推荐的职责边界
对于设备巡检 Demo,可以采用下面的边界。
commonMain
ScanResult
ScanType
ScanParser
ScanValidator
HandleScanResultUseCase
GeoPoint
MapMarkerModel
LocationResult
DistanceCalculator
CoordinateConverter
GetDeviceMarkersUseCase
Device
DeviceStatus
DeviceRepository
Android 平台层
CameraX
ML Kit
高德地图
高德定位
Android 权限
ActivityResult
LifecycleOwner
Android 震动和音效
iOS 平台层
AVFoundation
Vision
MapKit
CoreLocation
iOS 权限
UIViewController
iOS 震动和系统反馈
UI 层
负责:
打开扫码页
关闭扫码页
显示地图
请求权限
导航
错误提示
加载状态
二十八、常见错误总结
错误一:commonMain 直接调用相机
例如:
class ScanUseCase {
fun openCamera()
}
问题:
-
相机是 UI 和平台能力
-
业务层不应该控制页面和硬件生命周期
错误二:定义一个巨大的 CommonMapView
问题:
-
平台差异无法真正统一
-
接口越来越大
-
地图 SDK 能力被削弱
正确做法:
共享地图业务模型,地图 View 平台化。
错误三:把权限请求写进 UseCase
问题:
-
权限弹窗属于平台 UI
-
公共层无法管理 Activity 或 ViewController 生命周期
正确做法:
UseCase 返回业务动作,UI 层执行权限请求。
错误四:平台对象进入公共层
例如:
Bitmap
Uri
Marker
MapView
UIViewController
UIImage
正确做法:
平台层先转换为公共模型。
错误五:扫码解析规则写在每个平台
结果:
Android 一套规则
iOS 一套规则
Web 一套规则
容易导致协议不一致。
正确做法:
平台负责识别,commonMain 负责解析。
错误六:把地图和定位理解成一个能力
地图是 UI。
定位是系统服务。
两者可以使用不同设计:
地图 View
平台化
LocationProvider
可以抽象
错误七:为了共享率牺牲平台体验
例如为了使用统一地图接口,放弃 Android 高德地图中的核心能力。
正确做法:
共享业务规则,不强行统一平台 SDK。
二十九、四步设计法
以后面对新的平台能力,可以使用下面四步。
第一步:找出最终业务输入和输出
例如扫码:
输入:
二维码原始字符串
输出:
设备编号和扫码类型
例如定位:
输入:
系统定位能力
输出:
GeoPoint 和精度
第二步:剥离 SDK 和生命周期
把下面这些留在平台侧:
Activity
ViewController
MapView
Camera
权限
生命周期
第三步:把业务模型和规则放进 commonMain
例如:
ScanResult
GeoPoint
LocationResult
MapMarkerModel
业务校验
业务转换
第四步:让平台层只做适配
平台层负责:
平台 SDK 对象
│
▼
公共业务模型
公共层返回:
公共业务动作
│
▼
平台 UI 执行
三十、总结
扫码、地图、定位虽然都属于平台能力,但它们的设计方式并不完全相同。
扫码应该拆成:
平台负责相机和二维码识别
commonMain
负责二维码解析、校验和业务动作
地图应该拆成:
平台负责 MapView、Marker 和生命周期
commonMain
负责 GeoPoint、MapMarkerModel 和地图业务规则
定位则可以抽象成:
LocationProvider
因为定位最终可以收敛为稳定的平台无关结果:
经纬度
精度
时间
错误状态
权限和生命周期仍然应该由平台 UI 层负责。
所以,强平台能力的核心设计原则不是:
给所有平台强行定义完全相同的 SDK 接口
而是:
找到平台能力与公共业务之间最稳定的数据边界
可以用三句话概括:
平台层负责怎么做。
commonMain 负责做完以后怎么处理。
UI 层负责什么时候做,以及如何展示。
对于设备巡检项目,最终结构应该是:
CameraX / AVFoundation
负责识别二维码
│
▼
commonMain ScanParser
负责解析业务内容
高德地图 / MapKit
负责地图展示
│
▼
commonMain MapMarkerModel
负责地图业务数据
高德定位 / CoreLocation
负责获取位置
│
▼
commonMain LocationResult
负责距离和业务判断
这才是 KMP 处理强平台能力时更稳定、更真实的落地方式。
下一篇预告
下一篇将开始搭建 KMP 业务底座内部结构:
《KMP sharedLogic 如何做分层:model、result、network、repository、usecase》
我们会重点讲清楚:
-
sharedLogic为什么不能变成新的大杂烩模块 -
core、domain、feature应该怎么划分 -
DTO、Entity、Domain Model 为什么要区分
-
Repository 接口和实现应该分别放哪里
-
UseCase 是否一定需要
-
Network、Storage 和业务 Feature 如何控制依赖方向
-
ApiResult、AppError、Mapper 应该放在哪一层 -
登录、设备、巡检三个 Feature 如何组织
-
如何为后续 KMP 多模块组件化做好准备
下一篇会从单个能力设计,进入完整 sharedLogic 底座的分层架构。
更多推荐



所有评论(0)