从 Android 组件化到 KMP 跨平台底座(六):扫码、地图、定位这些平台能力,在 KMP 里到底怎么设计?

发布时间:2026/7/15 9:43:05
从 Android 组件化到 KMP 跨平台底座(六):扫码、地图、定位这些平台能力,在 KMP 里到底怎么设计? 前言前面几篇已经建立了一个基本判断纯业务逻辑 放进 commonMain 平台差异能力 留在 androidMain、iosMain 或平台 App但真正落到项目中最容易设计混乱的往往不是普通网络请求和数据模型而是下面这些能力扫码 相机 地图 定位 权限 WebView 蓝牙 文件选择 系统服务它们有几个共同特点强依赖平台 SDK强依赖权限强依赖页面生命周期经常需要嵌入原生 ViewAndroid 和 iOS 的 API 差异很大Web 端实现方式又完全不同例如扫码。Android 可能使用CameraX ML KitiOS 可能使用AVFoundationWeb 端可能使用浏览器 Camera API JavaScript 扫码库再例如地图。Android 可能使用高德地图iOS 可能使用 MapKitWeb 端可能使用高德地图 JavaScript API。如果简单地认为KMP 就是给所有平台定义一个统一接口。很容易写出这样的代码interface CommonMapView { fun addMarker() fun moveCamera() fun setZoom() fun startLocation() }看起来实现了跨平台实际上却把完全不同的平台能力强行塞进了一个接口。最终往往出现公共接口越来越大平台生命周期无法统一某些方法在部分平台无法实现大量参数退化成Any平台特色能力无法使用UI 层和业务层严重耦合所以这一篇重点解决一个问题扫码、地图、定位这些平台能力应该共享什么又应该平台化什么核心结论可以先给出来平台层 负责 SDK、权限、生命周期和 UI commonMain 负责模型、规则、解析和业务流程一、先把“平台能力”拆成不同层次很多设计混乱都是因为把一个功能当成了一个不可拆分的整体。例如“扫码功能”看起来只是一个功能实际上至少包含以下内容打开相机 显示预览画面 申请相机权限 识别二维码 播放提示音 触发震动 解析二维码内容 判断二维码是否合法 提取设备编号 跳转设备详情这里面并不是所有代码都属于同一层。可以进一步拆成四层。第一层平台 SDK 层负责真正调用平台 API。例如 AndroidCameraX ML Kit 高德地图 FusedLocationProvider Android BluetoothGatt例如 iOSAVFoundation Vision MapKit CoreLocation CoreBluetooth这一层天然存在平台差异。第二层平台交互层负责权限生命周期页面打开和关闭原生 View用户取消操作前后台切换系统设置跳转这一层通常也应该留在平台侧。第三层公共业务层负责数据模型解析规则参数校验业务状态RepositoryUseCase错误模型数据转换这部分才是 KMP 最适合共享的内容。第四层UI 展示层可以有多种选择Android Compose / XML iOS SwiftUI / UIKit Compose Multiplatform Flutter Web Vue / ReactUI 是否共享和业务逻辑是否共享是两个不同的问题。二、扫码功能到底应该怎么拆先从最典型的扫码功能开始。假设设备二维码格式是https://example.com/device?deviceId10001typeelevator业务真正关心的是deviceId type 二维码是否合法 扫码后应该进入哪个业务流程至于二维码是通过 CameraX、AVFoundation 还是浏览器相机识别出来的公共业务层并不关心。所以扫码应该拆成平台层 负责获得二维码原始字符串 commonMain 负责理解二维码内容整体结构是CameraX / AVFoundation / Web Camera │ ▼ rawContent: String │ ▼ commonMain ScanParser │ ▼ ScanResult │ ▼ commonMain 业务处理三、commonMain 中共享什么扫码公共层可以包含四部分ScanResult ScanParser ScanValidator HandleScanResultUseCase1. 扫码结果模型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. 扫码业务 UseCasesealed 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.ktAndroid 负责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 生命周期识别完成后把字符串传给 KMPfunc 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 UIComposable fun DeviceScreen( onScanClick: () - Unit ) { Button( onClick onScanClick ) { Text(扫码识别设备) } }平台入口负责真正打开扫码页面。这种方式最稳定。方案二共享扫码页面外壳嵌入平台相机 View可以共享顶部标题扫码框样式提示文案底部按钮平台侧只提供相机预览组件。结构类似CMP 扫码页面 ├── 共享标题 ├── 共享扫码框 ├── 平台相机预览 └── 共享提示区域这种方式共享率更高但需要处理原生 View 嵌入生命周期权限相机资源释放iOS 和 Android 视图交互对于第一阶段项目更建议方案一。九、Flutter 中扫码应该怎么接如果使用KMP FlutterFlutter 可以负责扫码 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: ListGeoPoint ) }这个接口看起来统一但存在几个问题。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: ListMapMarkerModel, val selectedMarkerId: String?, val isLoading: Boolean, val errorMessage: String? )4. 共享 Marker 数据转换class GetDeviceMarkersUseCase( private val repository: DeviceRepository ) { suspend operator fun invoke(): ApiResultListMapMarkerModel { return repository .getDeviceList() .map { devices - devices.mapNotNull { device - val point device.location ?: returnmapNotNull null if (!point.isValid()) { returnmapNotNull null } MapMarkerModel( id device.id, title device.name, description device.address, point point, status device.status ) } } } }Android 和 iOS 都使用同一套设备到地图点位的转换逻辑。十三、平台层如何渲染 MapMarkerModelAndroid 高德地图MapMarkerModel │ ▼ MarkerOptions │ ▼ AMap.addMarker()概念代码fun MapMarkerModel.toMarkerOptions(): MarkerOptions { return MarkerOptions() .position( LatLng( point.latitude, point.longitude ) ) .title(title) .snippet(description) }iOS MapKitMapMarkerModel │ ▼ MKAnnotation │ ▼ MKMapViewFlutter 地图插件MapMarkerModel │ ▼ Flutter MarkerWeb Vue 页面MapMarkerModel │ ▼ JavaScript Map Marker公共业务模型保持不变平台层只负责转换成自己的地图对象。十四、地图点击事件应该怎么返回业务层平台地图组件点击 Marker 后不要把平台对象传入commonMain。错误设计fun onMarkerClick( marker: com.amap.api.maps.model.Marker )公共层应该只接收业务 IDfun 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系统授权状态首次请求DeniedRestricted跳转设置因此不建议让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 onDestroyiOS 则使用UIViewController viewWillAppear viewDidDisappear AVCaptureSession这些生命周期无法简单抽象成完全一致的公共 API。因此更稳妥的原则是生命周期由拥有 UI 和 SDK 实例的平台层负责。公共层只接收生命周期产生的业务事件。例如页面进入 → 平台开始定位 获得位置 → 传给 commonMain 页面退出 → 平台停止定位不要让commonMain持有Activity LifecycleOwner UIViewController MapView AVCaptureSession二十、持续定位和单次定位要分开设计定位能力还要区分获取一次当前位置 持续监听位置变化单次定位可以使用suspend fun getCurrentLocation(): LocationResult持续定位更适合使用 Flowinterface LocationProvider { fun observeLocation(): FlowLocationResult }例如class ObserveDeviceDistanceUseCase( private val locationProvider: LocationProvider, private val distanceCalculator: DistanceCalculator ) { fun execute( devicePoint: GeoPoint ): FlowDouble { 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。它只需要复用模型解析校验UseCaseRepository业务规则这也再次说明KMP 共享的是业务核心不是把某一个平台 SDK 强行移植到所有平台。二十六、强平台能力的三种 UI 路线同一套 KMP Core可以接入不同 UI 方案。路线一原生 UIAndroid Compose CameraX 高德地图 iOS SwiftUI AVFoundation MapKit 业务 KMP sharedLogic优点平台能力最完整生命周期处理自然原生体验最好缺点UI 需要分别实现路线二KMP CMPCMP 共享普通页面 平台组件 处理扫码、地图和权限 KMP 共享业务逻辑优点普通页面共享率高Kotlin 和 Compose 技术栈统一缺点原生 View 嵌入需要额外处理强平台页面仍可能需要平台化路线三KMP FlutterFlutter 负责跨平台 UI Flutter Plugin 调用平台扫码和地图能力 KMP 负责业务底座优点Flutter UI 共享率高KMP Core 可以独立复用缺点Flutter 与 KMP 之间需要桥接调用链更长模型和异步状态需要转换二十七、推荐的职责边界对于设备巡检 Demo可以采用下面的边界。commonMainScanResult ScanType ScanParser ScanValidator HandleScanResultUseCase GeoPoint MapMarkerModel LocationResult DistanceCalculator CoordinateConverter GetDeviceMarkersUseCase Device DeviceStatus DeviceRepositoryAndroid 平台层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底座的分层架构。