4 万行代码,81% 双端共用:共享电单车运维 App 的跨平台实战

在这里插入图片描述

早上七点,运维员站在路边,面前是一排歪歪扭扭的共享电单车。

他要做的事很具体:打开 App 看哪台车没电、扫码开锁、换上满电电池、拍张照完单,再去下一个点。中间可能还要挪几台违停的车、处理一条用户举报、给一台坏车报修。一天下来,App 要被打开几十次,每次都在户外、单手、可能还戴着手套。

这就是共享电单车运维端的日常。它不性感,但它是这门生意的地面部队。

而站在技术这一侧,问题只有一个:这套东西要不要在 Android 和 iOS 上各写一遍?

我们的答案是不用。整个工程 4 万行 Kotlin,其中 81% 放在双端共用的共享层,剩下 19% 才是每多一个平台需要重写的部分。这篇文章把这个 81% 是怎么来的,完整讲一遍。

源码已公开https://github.com/wanghengwen/ebike-go,工程在 ebike-OpsApp/ 目录。文中所有模块名、文件名都能在仓库里对上。许可采用 Elastic License 2.0,属于源码开放(可自建自用、可修改),不等于 OSI 定义的开源,唯一限制是不能拿去做托管 / 代运营服务对外售卖。


一、先想清楚:这个 App 到底难在哪

很多人一听"跨平台",第一反应是选框架。但框架是最后一步,第一步是把需求称一称重

我们把运维端要做的四十来屏摊在桌上数了一遍,结论意外地清爽:

第一类,纯业务界面。 登录、选服务区、任务列表、完单表单、仓库出入库记录、报修类型选择、权限工作台、我的上报……这些屏的本质是「表单 + 列表 + 状态流转」,交互模式高度稳定,跟平台特性没什么关系。这一类占了八成以上

第二类,必须碰原生 SDK。 数下来只有五个点:

  • 地图 —— 车点渲染、聚合、围栏
  • 蓝牙 —— 近场控车、蓝牙雷达找车
  • 相机扫码 —— 扫车号、扫中控 IMEI
  • 定位 —— 到点判定、轨迹上报
  • 文件上传 —— 完单照片、报修图

就这五个。别的都不是。

这个"八二结构"直接决定了后面的所有选择。因为如果不先做这道题,很容易掉进两个坑:

坑一:全都用原生写两遍。 那四十屏表单,Android 写一遍、iOS 再写一遍,工作量翻倍、bug 翻倍,最要命的是业务规则会分叉——同一个"完单要不要拍照"的判断,两边各写一次,迟早不一样。运维员在 Android 上能完单、在 iOS 上完不了,这种问题排查起来极其耗人。

坑二:全都用跨端框架写。 表单确实省了,但地图和私有蓝牙 SDK 会把你按在 interop 的地上摩擦。尤其是私有 BLE SDK,闭源、只给 aar 和 framework,跨端框架的桥接层能让你写到怀疑人生。

所以我们没有二选一,而是按"这块东西的难点在哪"来分配技术


二、选型:四条路,我们为什么选了第四条

选型时只问自己两个问题:

  1. 业务界面能不能只写一份?
  2. 强平台能力能不能插拔,而不是被 UI 框架绑死?

带着这两问过一遍候选方案:

方案好处代价结论
双端各写原生 UI平台能力接得最直接四十屏表单重复劳动,业务规则会分叉
Flutter / RN 全量跨端UI 一套搞定地图与私有 BLE 桥接成本高,已有 Kotlin 资产断层
只共享网络层,UI 全原生上手最简单省的是最不值钱的那部分,界面还是双份❌ 不够
KMP 业务内核 + CMP 共享界面 + H5 大屏 + 平台能力接口化业务和界面各写一份;SDK 可换;大屏独立迭代要划清「共享界面 / 宿主 / H5」的边界

最后的分工是这样的:

这部分交给谁为什么
业务界面(登录、任务、仓库、报修、工作台…)Compose Multiplatform交互模式稳定,Material3 组件够用,收益最大风险最小
运营 / 营收大屏H5(Vue3)迭代节奏和现场作业完全不同,要能独立发版
地图 · 蓝牙 · 扫码 · 定位 · 上传接口 + expect/actual闭源 SDK 多、各端权限合规不同,只定契约不定实现
登录、权限、任务状态机、签名请求KMP 共享内核这是最不能分叉的部分,也最好写单测

三句话概括:

业务界面共享,平台能力接口化,数据大屏 Web 化。

其中第二条最值得展开说。地图和蓝牙这类能力,正确的做法不是"找一个自带地图组件的跨端框架",而是在共享层定义契约,让宿主去填实现。这样带来一个意外的好处:没有地图 Key、没有真机、没有私有 BLE SDK 的时候,塞一个模拟实现进去,除了真实控车之外的所有流程都能跑通——开发和演示不会被硬件卡住。这一点对开源版本尤其关键,因为私有 SDK 本来就不能进仓库。


三、最终架构

3.1 四层,从下往上看

最底层是后端网关和 H5 站点。 网络请求打到业务网关,两块大屏是独立的 Vue3 静态站点。

第三层 shared,业务内核,Kotlin Multiplatform。 四块内容:

  • Feature —— 每个业务域的状态流和意图函数,向界面暴露状态,不暴露 Repository
  • Domain —— 权限码、任务状态机、车控策略、校验规则
  • Data —— API 定义、DTO、Mapper、Repository
  • i18n —— 文案键与多语言目录

第二层 sharedUi,界面主体,Compose Multiplatform。 登录、工作台、任务、仓库、报修、报表入口这些屏都在这儿,写在 commonMain,双端共用同一份代码。

最上层是两个宿主壳。 这一层最容易被误解,单独讲。

3.2 最上面那层"壳"到底是什么

不是又一层 UI,而是两个薄壳——Android 一个、iOS 一个。壳里原则上只干三件事(唯一的例外见 3.5):

干什么具体是什么
启动Android 的 MainActivity / OpsApplication、iOS 的 App 入口,把共享界面挂上去
要权限定位、蓝牙、相机的系统权限弹窗,以及各端不同的合规文案
注入实现把腾讯地图、CameraX 扫码、系统定位的真实实现,塞进共享层定义好的接口

壳里不写任何业务规则。 完单条件、权限判断、状态流转,一行都不许出现在这层——这是我们守得最严的一条线。打个比方:共享层是发动机和变速箱,壳只是车壳加点火钥匙。

3.3 依赖方向

在这里插入图片描述

注意图里那条青色虚线:壳不是在"调用"内核的功能,而是在填坑——共享层挖好接口,壳把真实 SDK 填进去。这个方向搞反了,整套架构就复用不起来了。

3.4 模块结构

ebike-OpsApp
├── shared/          # KMP:Feature · Domain · Data · 平台契约 · i18n
├── sharedUi/        # Compose Multiplatform:业务界面
├── androidApp/      # Android 宿主:启动 · 权限 · SDK 适配 · 原生地图屏
├── iosApp/          # iOS 宿主:嵌入 Shared / SharedUi framework
├── webH5/           # Vue3:运营大屏、营收大屏
├── config/          # 多租户配置(仅 demo 入库,密钥本地提供)
└── docs/            # 架构、迁移、i18n、BLE、开源边界

3.5 那个 81% 是怎么算出来的

不玩虚的,直接数行数(.kt 文件,不含测试):

文件数代码行双端共用
shared(业务内核)19622,989
sharedUi(CMP 界面)439,779
androidApp(Android 宿主)227,404
iosApp(iOS 宿主)245
合计26340,21781.5% 共享

另外还有 commonTest 2,571 行(149 个测试用例,跑在共享层)和 webH5 5,038 行。

这里必须坦白一件事:Android 宿主有 7,404 行,比"薄壳"该有的体量大不少。原因很实在——凡是画面里嵌着原生地图 View 的屏,目前都还留在宿主里:任务地图、挪车地图、换电地图、车况分布地图、还有腾讯地图的封装本身。地图是原生 View,Compose Multiplatform 只能给它留个容器,屏内的手势与图层交互跟着 View 一起留在了平台侧。注意留下的是交互逻辑,不是业务规则——完单条件、任务状态流转、车控策略依然只在 shared 里有一份。

这是个真实的取舍,不是设计缺陷,但也确实是下一步要继续收敛的地方:把地图屏的状态和逻辑抽到 shared,只把 View 留在宿主。

3.6 接口化的五个能力

共享内核只认能力,不认厂商:

契约干什么谁来实现
MapCapability首页 / 任务地图腾讯地图 / 模拟画布 / 不启用
BleTransport近场控车、蓝牙雷达私有 BLE SDK / 模拟实现
CodeScanner扫车号 / 中控 IMEICameraX + ML Kit
LocationTracker到点判定、轨迹上报、车辆重定位系统定位
MediaUploader完单照片、报修图multipart 上传 / Demo 假实现

业务层通过这些接口完成"扫一下、传张图、响一声",界面只管绑状态:加载中、失败文案、按钮能不能点。

顺带说一句车控策略:VehicleControlPolicy 在 BLE 不可用时会自动回落到网络下发。这条规则写在 shared 里,双端行为天然一致——如果两边各写一遍,这种"回落时机"几乎必然会不一样。

3.7 H5 怎么嵌进 CMP

sharedUi 里有个跨端的 H5Screen:公共层管标题、加载失败重试、返回栈,PlatformWebView 在 androidMain / iosMain 各自实现(连返回键语义都不一样,索性各写各的)。大屏 URL 由租户配置加登录态拼出来,现场 App 和数据大屏彻底解耦发布。


四、国际化:一处改,双端生效

运维端经常要同时服务国内和海外租户,i18n 绕不开。

我们没有把文案散在两端的 strings.xmlLocalizable.strings 里各维护一份——那意味着加一句话要改两个地方,而且漏了不会报错。做法是:在共享层用类型安全的键 + 多语言目录,界面和网络共用同一个解析器。

4.1 整条链路

Str(枚举键,缺键在编译期 / 单测里就能发现)
   │
   ▼
StringCatalogs(ZH_CN / EN 各一套 Map)
   │
   ▼
OpsI18n.t(key, args…)
   │
   ├── Strings 全局委托 → Feature / Repository 不依赖 Compose 也能取文案
   └── LocaleContext.acceptLanguage → HTTP 请求头 Accept-Language

关键是业务层也走同一套 Strings.t()。这样 Toast、接口错误映射、Demo 假数据的语言和界面永远一致,不会出现"界面英文、报错中文"的尴尬。有个单测专门守着这件事:Str 里的每个键,中英两套目录都必须有值,缺一个就红。

4.2 切换语言的那一瞬间

用户在设置里点了 English:

  1. OpsI18n.setLanguage(...) 更新内存里的语言
  2. 写入 SecureStore(记住选择)
  3. 更新 LocaleContext.acceptLanguage(下一个请求就带新语言)
  4. Strings.install(this),全进程解析器指向新目录
  5. 界面侧 collectAsState(languageFlow),文案立刻刷新

不用重启 App,不用重进页面。

4.3 默认语言:跟随系统

首次启动跟随设备语言,用户手动选过就永远听用户的。 这个优先级只有两条:

val lang = when {
    !stored.isNullOrBlank() -> OpsLanguage.fromTag(stored)          // 用户选过,用户说了算
    else -> OpsLanguage.fromSystemLanguage(systemLanguage ?: platformLanguageTag())
}

设备语言的读取本身就是一个 expect/actual ——正好又是一次"平台能力接口化"的实践:

// commonMain
expect fun platformLanguageTag(): String?

// androidMain
actual fun platformLanguageTag(): String? = Locale.getDefault().toLanguageTag()

// iosMain
actual fun platformLanguageTag(): String? = NSLocale.preferredLanguages.firstOrNull() as? String

4.4 加一门新语言要做什么

  1. OpsLanguage 加枚举项和 acceptLanguage
  2. StringCatalogs 加一套完整目录
  3. 设置页语言列表加选项
  4. (可选)H5 大屏 URL 带上 lang 参数

不用再动 Android 和 iOS 的资源文件——因为界面已经在共享层消费同一个 t() 了。这就是"一处改双端生效"的具体含义。

另外还有一个容易被忽略的细节:登录区号。出海就要处理国际手机号,我们内置了 Calling Codes 目录和区号选择器,提交时按 +{区号}-{号码} 规范化。它和界面语言相互独立,但同属"出海"这一包能力。


五、到今天做出了哪些能力

架构说完了,看实际交付的东西。下面这些全部跑在上面那套架构上。
在这里插入图片描述

账号与作业准备:密码 / 短信登录、多租户选分部、服务区选择、权限码驱动工作台入口显隐、中英文切换、国际区号。

找车与控车:车辆列表与地图、运维态与告警筛选、扫码解析、开关锁、响铃、开关电池仓(按策略走网络或 BLE)、电池 SN 绑定、车辆重新定位。

任务与工单:换电 / 挪车 / 巡检 / 维修任务的领取、执行、拍照完单、审核结果回看;自主挪车与批量人工挪车;巡检 / 维修旧工单台账(列表接单、完单)。

报修与举报:报修支持类型配置、停运选项、拍照提交、我的上报记录;用户举报支持末单校验、类型多选、可选照片、待审撤销。

仓库与生产:有码 / 无码出入库扫描与记录、车辆检测、中控绑定解绑、上下架、未关锁车辆排查、蓝牙雷达找车、运维员轨迹上报。

数据大屏:工作台一键打开运营 / 营收 H5。

一个额外收获是行为对齐。老系统的很多规则藏在细节里:低电量筛选到底看哪个状态码、"全部"筛选要不要包含已售罄的车、离线告警是看 alarmState 还是看连接状态、挪车完单要不要卡 200 米距离、报修车号最多几位。这些我们逐条对齐并写成了共享层的规则和单测。规则只存在一份,所以不存在"Android 对了 iOS 错了"这种问题。


六、想自己跑一下?

仓库:https://github.com/wanghengwen/ebike-go,工程目录 ebike-OpsApp/

cd ebike-OpsApp
# JDK 17 或 21(25 不行,当前 Gradle Kotlin DSL 解析不了)
# Windows
gradlew.bat :shared:compileCommonMainKotlinMetadata :shared:testAndroidHostTest :androidApp:assembleDebug

几个上手要点:

  • 直接用 Android Studio 打开 ebike-OpsApp,跑 androidApp 的 Debug 配置就行。
  • 不配任何后端也能跑api.baseUrl 留空即进入 Demo 模式,登录、任务、仓库、报修全流程走本地假数据,包括模拟的蓝牙响铃。想联调真实网关,复制 androidApp/src/main/assets/tenant.json.example 填上地址和密钥(该文件已 gitignore)。
  • 地图 Key 写进本机 local.properties,不进版本库。
  • 多租户配置在 config/{tenant}_{mode}.json,只有 demo 配置入库。

许可再说一次:Elastic License 2.0,源码开放,可自建自用、可改、可商用于自己的业务,唯一限制是不能把它作为托管 / 代运营服务对外提供。


七、给准备做同类 App 的几条建议

1. 先数屏,再选框架。 数清楚有多少屏是纯业务界面、多少屏必须碰硬件。这道题做完,框架的选择基本就是推论了。

2. 平台能力第一天就做成接口。 千万别在业务界面里直接 new 一个 SDK。第一天做接口的成本是半小时,第三个月再补是重构。

3. 最该共享的不是界面,是规则。 界面共享省的是工时,规则共享省的是"双端行为不一致"这类最难查的 bug。如果只能共享一层,选规则。

4. 大屏和报表优先 H5。 别把可视化的迭代速度绑进应用商店的审核队列。

5. i18n 用共享键值目录,让业务层和界面说同一种语言,网络头带上 Accept-Language,默认语言跟随系统。

6. 留一个机器能执行的边界检查。 我们靠 compileCommonMainKotlinMetadata 守住"共享层不许用平台 API"。任何靠自觉维持的架构约束,迟早会被赶工期的自己打破。


最后

做跨平台的运维 App,关键从来不是"选一个最火的跨端框架",而是:

把大量业务界面放进 Compose Multiplatform,把地图 / 蓝牙 / 扫码留在可替换的接口背后,把数据大屏交给 H5,再用共享的业务内核和 i18n 把双端行为锁齐。

当换电完单、仓库扫码、报修提交、语言切换都走同一套 shared + sharedUi 的时候,"跨平台"才从一句口号,变成一个真正能交给一线的现场工具。

源码在这儿,欢迎来拆:https://github.com/wanghengwen/ebike-go

Logo

一站式 AI 云服务平台

更多推荐