It's 7 a.m. An ops worker stands by the roadside, facing a row of shared e-bikes parked at odd angles.
The job is concrete: open the app to see which bikes are low on battery, scan to unlock, swap in a fully charged battery, snap a photo to close the task, then move to the next stop. Along the way they might relocate a few illegally parked bikes, handle a user report, or file a repair ticket for a broken unit. Over a full day the app gets opened dozens of times — outdoors, one-handed, sometimes with gloves on.
That's everyday life on the operations side of a shared e-bike business. It isn't glamorous, but it's the ground force that keeps the operation running.
On the engineering side, the question is simpler: do we build all of this twice — once for Android and once for iOS?
Our answer is no. The whole project is roughly 40,000 lines of Kotlin. About 90% lives in a shared layer used by both platforms (measured at 95.8% in source; see §3.5). The rest — roughly 5% per host shell — is what you rewrite when you add another platform. This article walks through how we got there: starting at 81%, pushing through two migration rounds to ~96% shared code, and how each round dismantled the excuses for "this can't move."
Source available: https://github.com/wanghengwen/ebike-go, project under
ebike-OpsApp/. Every module and file name in this article maps to the repo. Licensed under Elastic License 2.0 — source-available (self-host, modify, use internally), not OSI open source. The sole restriction: you cannot use it to offer hosted / managed operations services for sale to third parties.
1. First, Understand What Makes This App Hard
When people hear "cross-platform," their first instinct is to pick a framework. But the framework comes last. Step one is weighing the requirements.
We laid out the ~40 screens the ops app needs and counted. The picture turned out cleaner than expected:
Category 1: pure business UI. Login, service-area selection, task lists, task-completion forms, warehouse in/out records, repair-type pickers, permission-driven workbench, my reports… These screens are essentially "forms + lists + state transitions." Interaction patterns are stable and mostly platform-agnostic. This category accounts for more than 80%.
Category 2: must touch native SDKs. Only five areas:
- Maps — vehicle pin rendering, clustering, geofences
- Bluetooth — near-field vehicle control, BLE radar for finding bikes
- Camera / scanning — scan vehicle IDs, scan controller IMEIs
- Location — arrival detection, track upload
- File upload — completion photos, repair images
That's it. Nothing else.
This "80/20 split" drove every decision afterward. Skip this exercise and you fall into two traps:
Trap 1: build everything natively, twice. Forty form screens on Android, forty again on iOS — double the work, double the bugs, and worst of all business rules diverge. The same "does completion require a photo?" check written twice will eventually disagree. Ops staff can finish a task on Android but not on iOS; debugging that is brutal.
Trap 2: build everything with a cross-platform UI framework. Forms are cheaper, but maps and proprietary BLE SDKs grind you down in interop hell. Proprietary BLE SDKs especially — closed-source, shipped as aar/framework only — can make bridge layers feel endless.
We didn't pick one or the other. We assigned technology by where the hard part actually lives.
2. Choosing a Stack: Four Paths, Why We Took the Fourth
We asked only two questions:
- Can business UI be written once?
- Can strong platform capabilities be plugged in, instead of being locked to a UI framework?
Running the candidates through those questions:
| Approach | Upside | Cost | Verdict |
|---|---|---|---|
| Native UI on each platform | Most direct platform integration | ~40 form screens duplicated; business rules fork | ❌ |
| Full cross-platform UI (Flutter / RN) | One UI stack | High bridge cost for maps & proprietary BLE; breaks existing Kotlin assets | ❌ |
| Shared network layer only, native UI | Easiest to start | Saves the least valuable part; UI still duplicated | ❌ Not enough |
| KMP business core + CMP shared UI + H5 dashboards + platform capabilities as interfaces | Business & UI written once; SDKs swappable; dashboards ship independently | Must draw clear boundaries: shared UI / host / H5 | ✅ |
The final split:
| What | Owner | Why |
|---|---|---|
| Business UI (login, tasks, warehouse, repair, workbench…) | Compose Multiplatform | Stable interaction patterns; Material3 is enough; best ROI, lowest risk |
| Operations / revenue dashboards | H5 (Vue3) | Different release cadence from field work; needs independent shipping |
| Maps · Bluetooth · scan · location · upload | Interfaces + expect/actual | Many closed SDKs; permission/compliance differs per platform — define contracts, not implementations |
| Login, permissions, task state machines, signed requests | KMP shared core | Must not fork; easiest to unit-test |
Three sentences:
Share business UI. Interface-ize platform capabilities. Web-ize data dashboards.
The second point deserves expansion. For maps and Bluetooth, the right move isn't "find a cross-platform framework with a built-in map widget." It's define contracts in the shared layer and let hosts fill implementations. An unexpected benefit: without a map key, without a real device, without a proprietary BLE SDK, drop in a simulator — every flow except real vehicle control still runs. Development and demos aren't blocked by hardware. Critical for the open-source drop, since proprietary SDKs can't live in the repo.
3. Final Architecture
3.1 Four Layers, Bottom to Top
Bottom: backend gateway and H5 sites. Network calls hit the business gateway; two dashboard sites are independent Vue3 static deployments.
Third layer: shared, business core, Kotlin Multiplatform. Four areas:
-
Feature— per-domain state flows and intent functions; exposes state to UI, not repositories -
Domain— permission codes, task state machines, vehicle-control policy, validation rules -
Data— API definitions, DTOs, mappers, repositories -
i18n— string keys and multilingual catalogs
Second layer: sharedUi, main UI, Compose Multiplatform. Login, workbench, tasks, warehouse, repair, report entry screens — all in commonMain, one codebase for both platforms.
Top: two host shells. This layer is easy to misunderstand; worth its own section.
3.2 What the Top "Shell" Actually Is
It is not another UI layer. It's two thin shells — one Android, one iOS. Each does exactly three things:
| Job | What it is |
|---|---|
| Bootstrap | Android MainActivity / OpsApplication, iOS OpsAppViewController — mount shared UI |
| Implement platform capabilities | Tencent Map view, CameraX preview, foreground location service, permission flows |
| Inject | Push those implementations into shared-layer contracts via CompositionLocal
|
No business rules and no business UI in the shell. Completion conditions, permission checks, state transitions — not a single line belongs here. That's our hardest line. Android host MainActivity is 62 lines total: the three jobs above, nothing else. Analogy: shared layer is engine and transmission; the shell is bodywork and the ignition key.
3.3 Dependency Direction
Note the cyan dashed line: the shell isn't "calling into" the core. It's filling holes — shared layer defines interfaces; the shell plugs in real SDKs. Reverse that direction and the architecture stops being reusable.
3.4 Module Layout
ebike-OpsApp
├── shared/ # KMP: Feature · Domain · Data · platform contracts · i18n
├── sharedUi/ # Compose Multiplatform: business UI (nav shell, login, 4 tabs, scan screen)
├── androidApp/ # Android host: bootstrap · permissions · SDK adapters (1,639 LOC, no business UI)
├── iosApp/ # iOS host: links SharedUi.framework, 27 lines Swift
├── webH5/ # Vue3: operations dashboard, revenue dashboard
├── config/ # Multi-tenant config (demo only in repo; secrets supplied locally)
└── docs/ # Architecture, migration, i18n, BLE, open-source boundaries
3.5 How We Calculated "Shared ≈ 90%"
No hand-waving — we counted lines (.kt / .swift, excluding tests and blank lines):
| Layer | Files | Lines | Shared both platforms |
|---|---|---|---|
shared (business core) |
196 | 22,989 | ✅ |
sharedUi (CMP UI) |
62 | 14,833 | ✅ |
androidApp (Android host) |
16 | 1,639 | ❌ |
iosApp (iOS host, Swift) |
2 | 27 | ❌ |
| Total | 276 | 39,488 | 95.8% shared |
Also: commonTest 2,571 lines (149 test cases on the shared layer) and webH5 5,070 lines.
Of sharedUi's 14,833 lines, only 246 sit in androidMain / iosMain (WebView body, icon resource mapping, Chinese sorting); the rest is commonMain.
Figures and titles in this article use Shared ≈ 90% / Android ≈ 5% / iOS ≈ 5% as a round illustration: measured shared code is ~95.8%, Android host ~4%. After iOS platform adapters (maps / camera / WebView / HUD) catch up, both host shells should land at similar scale — closer to "90% shared, ~5% thin shell per platform."
3.6 From 81% to 96%: Two Rounds of "Can't Move" Excuses
This section matters on its own — it's the architecture's strongest proof point. The real win isn't two percentage points. It's this: every time we said "this can't move," the blocker wasn't technical. We were missing a contract.
Round 1: 81% → 88%, Map Screens
When we started this article, the Android host had 7,404 lines — far too heavy for a "thin shell." My explanation then: "Any screen embedding a native map view must stay in the host." Sounds reasonable.
We counted file by file. That explanation didn't hold:
- Only two files actually embed
AndroidView:TencentMapView(321 lines) andReturnCarScatterMapView(164 lines). - The five "map screens" — task map, relocation map, battery-swap map, vehicle condition distribution, return distribution — had zero Android-specific code: no
android.*imports, no Material2, noui.res. - They lived in the host for one reason: they called those two Composables directly.
- More ironic:
SimulatorMapView(155 lines) is pure ComposeCanvas, using projection and clustering fromshared, zero platform deps — it belonged in the shared layer but stayed on Android because it sat in the same folder as Tencent Map.
So the issue wasn't "technically impossible to move." We lacked a cross-platform map container to call.
Filling that gap took a bit over a hundred lines. Same pattern as the repo's proven H5Screen: contract in shared layer, implementation injected by host.
// sharedUi/commonMain — params are all shared-layer types, so the contract lives in commonMain
data class OpsMapSpec(
val pins: List<MapPin> = emptyList(),
val selectedCarId: String? = null,
val fencePolygons: List<FencePolygon> = emptyList(),
val trackPoints: List<TrackPoint> = emptyList(),
// Viewport control via incrementing nonce — don't leak SDK camera objects into shared layer
val fitNonce: Int = 0,
// …
)
interface OpsMapRenderer {
@Composable fun Pins(spec: OpsMapSpec, modifier: Modifier)
// Vendors without scatter support fall back to normal pin map
@Composable fun Scatter(spec: OpsScatterMapSpec, modifier: Modifier) { /* default impl */ }
}
// Default is pure Compose canvas — runs both sides; host injects real SDK when key is available
val LocalOpsMapRenderer = staticCompositionLocalOf<OpsMapRenderer> { SimulatorMapRenderer }
The host side shrank to 49 lines of glue mapping spec → Tencent Map; we also added LocalOpsToast so UI says "show this message" and the host picks the widget.
After the move:
| Before | After | |
|---|---|---|
sharedUi |
9,779 | 12,668 |
androidApp |
7,404 | 4,587 |
| Share rate | 81.5% | 88.5% |
Eight screens moved to shared; host slimmed 38%. We also wrote less code: each map screen had duplicated if (isTencent) TencentMapView(…) else SimulatorMapView(…) — six copies gone.
Two details worth noting:
One: after git mv, most files needed no edits. sharedUi and androidApp share the same package prefix. com.luopingtech.ebike.ops.ui.task.ChangeBatteryMapScreen kept its FQN after the move — host imports unchanged. Moving UI across modules that lightly is a dividend from early package planning.
Two: verification is mechanical, not honor-based. After migration, :sharedUi:compileCommonMainKotlinMetadata passing means those eight screens don't sneak platform APIs into commonMain. More reliable than human review saying "looks fine."
Round 2: 88% → 96%, Moving the Nav Shell Too
After round 1 the host still had 4,587 lines; MainActivity alone was 3,108. The excuse: "It's the nav shell — login, four tabs, scan overlay. It needs Activity context; can't move."
Same method, even flimsier excuse. Of 3,108 lines, only these touch the platform:
| Platform coupling | Lines | Why in host |
|---|---|---|
Activity class itself |
27 | Truly host-only |
| Location / notification permission requests | 73 |
rememberLauncherForActivityResult is Android |
| 3 camera previews | 21 | Direct CameraX Composable calls |
2× R.drawable (torch, manual entry) |
4 | Direct Android resource IDs |
6× Toast
|
12 | Direct android.widget.Toast
|
137 lines total. The rest is pure Compose business UI.
We also found 1,063 lines of dead code: 432 lines in MainActivity were old task UI replaced by redesign plus two unused Composables; sharedUi had two old map screens from round 1 that nothing referenced (377 + 254 lines). They compiled but never rendered. Deleted. So the sharedUi delta below is "moved in minus deleted."
Same recipe for new contracts — two this time. Camera preview mirrors OpsMapRenderer:
// sharedUi/commonMain: shared layer only needs "a surface that emits codes"
interface OpsScanPreview {
@Composable fun Preview(modifier: Modifier, torchOn: Boolean, enabled: Boolean, onCode: (String) -> Unit)
}
// When host has no camera, don't show a black box — say scanning isn't available on this device
val LocalOpsScanPreview = staticCompositionLocalOf<OpsScanPreview> { UnavailableScanPreview }
Permissions were more interesting — they forced a split between business rules and platform flows. Android needs two dialogs (foreground location + notification first, then nudge for "always allow" — combined prompts get rejected); iOS has whenInUse / always tiers — that flow can't be shared. But "once granted, start uploading tracks" is a business rule and must live in shared code. The contract is one question:
// sharedUi/commonMain: null = OK to start upload; String = user-facing denial message
fun interface OpsTrackPermissionGate {
fun request(onResult: (String?) -> Unit)
}
// Usage in MainShell: who asks for permission doesn't matter; start upload on grant stays in shared layer
LaunchedEffect(homeState.session?.userId) {
if (homeState.session == null || app.trackUploadFeature.state.value.enabled) return@LaunchedEffect
trackPermissionGate.request { denied ->
trackPermissionHint = denied
if (denied == null) app.trackUploadFeature.setEnabled(true)
}
}
Then we split MainActivity into seven files under sharedUi/ui/shell/, 2,689 lines total: OpsAppRoot (login / set password / pick service area), MainShell (switching among ~40 full screens), MapTab, TasksTab, AnalysisTab, WorkbenchTab, ScanOverlay.
After the move:
| After round 1 | After round 2 | |
|---|---|---|
sharedUi |
12,668 | 14,833 |
androidApp |
4,587 | 1,639 |
MainActivity |
3,108 | 62 |
| Share rate | 88.5% | 95.8% |
The remaining 1,639 host lines are clean: 1,442 lines are platform capability implementations (Tencent Map 534 · camera scan 358 · location & foreground service 262 · permission flow 82 · photo capture 107 · upload 67 · reverse geocode 32), 197 lines bootstrap & injection (OpsApplication 135 + MainActivity 62).
MainActivity now — that's all of it:
setContent {
OpsTheme(branding = app.config.branding) {
CompositionLocalProvider(
LocalOpsMapRenderer provides opsMapRendererFor(app),
LocalOpsScanPreview provides AndroidScanPreview,
LocalOpsTrackPermissionGate provides rememberTrackPermissionGate(app),
LocalOpsToast provides { msg: String -> Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() },
) {
Surface(modifier = Modifier.fillMaxSize()) { OpsAppRoot(app) } // all UI in shared layer
}
}
}
Interestingly, after round 1 we estimated "truly non-movable platform code ~1,400 lines." Actual count: 1,442. That match suggests "what must stay in the host" can be estimated upfront — you don't need gut feel.
What This Round Means for iOS
This is the point. After the move, "what does the iOS host do?" became a ** countable checklist**: one entry function plus four injection points.
// sharedUi/iosMain — same job as Android setContent
fun OpsAppViewController(app: OpsApp): UIViewController = ComposeUIViewController {
OpsTheme(branding = app.config.branding) {
Surface(modifier = Modifier.fillMaxSize()) { OpsAppRoot(app) }
}
}
Swift side: 27 lines, one UIViewControllerRepresentable, done. Not a single screen rewritten in SwiftUI. SharedUi.framework export(project(":shared")), so import SharedUi brings OpsApp along — link one framework.
More important: unfilled holes don't block bootstrapping. No camera → "this host has no camera"; no map → shared Canvas renderer; H5 screen → placeholder. iOS todo went from "rewrite ~40 Android screens" to four concrete items: AVFoundation preview, map renderer, WKWebView, HUD. Those four should land near Android's 1,442-line host scale.
3.7 Interface-ized Capability Inventory
Shared core knows capabilities, not vendors:
| Contract | Role | Implemented by |
|---|---|---|
MapCapability |
Whether map is ready, which vendor | Tenant config + key |
OpsMapRenderer |
Pin / scatter maps | Tencent Map / shared Canvas |
BleTransport |
Near-field control, BLE radar | Proprietary BLE SDK / simulator |
CodeScanner |
Standalone scan page, one code | CameraX + ML Kit |
OpsScanPreview |
In-page camera preview | CameraX / iOS AVFoundation TBD |
LocationTracker |
Arrival check, track upload, vehicle re-location | System location |
OpsTrackPermissionGate |
Ask "can we start uploading now?" | Per-platform permission UI |
MediaUploader |
Completion photos, repair images | Multipart upload / demo stub |
PlatformWebView |
WebView body for H5 screens | Android WebView / iOS WKWebView TBD |
LocalOpsToast |
One-shot lightweight toast | Android Toast / iOS HUD |
Business layer uses these for "scan once, upload a photo, beep" — UI only binds state: loading, error copy, button enabled.
Pattern: contract parameters must not include vendor types. OpsMapRenderer lives in commonMain because it takes MapPin, FencePolygon, etc. Even "zoom in one level" is an incrementing zoomInNonce, not an SDK camera object. One LatLng in the signature breaks the layer.
Side note on vehicle control: VehicleControlPolicy falls back to network commands when BLE is unavailable. Rule lives in shared — both platforms behave the same. Write it twice and "when to fall back" almost certainly diverges.
3.8 Embedding H5 in CMP
sharedUi has cross-platform H5Screen: common layer handles title, load-failure retry, back stack; PlatformWebView in androidMain / iosMain (back-key semantics differ — implement separately). Dashboard URLs built from tenant config + login session — field app and data dashboards ship independently.
4. i18n: Change Once, Both Platforms Update
Ops apps often serve domestic and overseas tenants — i18n isn't optional.
We didn't scatter strings across strings.xml and Localizable.strings — one new sentence, two files, no compile-time guard for misses. Instead: type-safe keys + multilingual catalogs in the shared layer; UI and network share one resolver.
4.1 End-to-End Chain
Str (enum keys — missing keys caught at compile / in tests)
│
▼
StringCatalogs (ZH_CN / EN maps)
│
▼
OpsI18n.t(key, args…)
│
├── Strings global delegate → Feature / Repository get copy without Compose
└── LocaleContext.acceptLanguage → HTTP Accept-Language header
Business layer uses the same Strings.t(). Toasts, API error mapping, demo fake data stay language-consistent with UI — no "English UI, Chinese errors." A test guards it: every Str key must exist in both ZH and EN catalogs.
4.2 The Moment Language Switches
User taps English in settings:
-
OpsI18n.setLanguage(...)updates in-memory language - Write to
SecureStore(persist choice) - Update
LocaleContext.acceptLanguage(next request carries new language) -
Strings.install(this)— process-wide resolver points at new catalog - UI
collectAsState(languageFlow)— copy refreshes immediately
No app restart. No re-navigation.
4.3 Default Language: Follow System
First launch follows device language; after manual choice, user wins forever. Two priorities only:
val lang = when {
!stored.isNullOrBlank() -> OpsLanguage.fromTag(stored) // user chose — user wins
else -> OpsLanguage.fromSystemLanguage(systemLanguage ?: platformLanguageTag())
}
Reading device language is itself expect/actual — another "interface-ize platform capability" exercise:
// 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 Adding a New Language
- Add enum entry and
acceptLanguageonOpsLanguage - Add full catalog in
StringCatalogs - Add option on settings language list
- (Optional) Append
langto H5 dashboard URLs
No Android / iOS resource files to touch — UI already consumes shared t(). That's "change once, both platforms update."
Easily overlooked: login country codes. Going overseas means international phone numbers — built-in calling-codes catalog and picker; submit normalized as +{code}-{number}. Independent from UI language, same "going global" bundle.
5. What's Shipped So Far
Architecture done — here's what actually runs on it.
Account & field prep: password / SMS login, multi-tenant branch selection, service-area picker, permission-code-driven workbench entries, EN/ZH switch, international dialing codes.
Find & control vehicles: list and map, ops-state and alarm filters, scan parsing, lock/unlock, ring, battery compartment open/close (network or BLE per policy), battery SN binding, vehicle re-location.
Tasks & work orders: battery swap / relocation / inspection / repair — claim, execute, photo completion, review results; self-service relocation and batch manual relocation; legacy inspection/repair ledgers (list, claim, complete).
Repair & reports: configurable repair types, out-of-service option, photo submit, my report history; user reports with last-order validation, multi-select types, optional photos, pending revoke.
Warehouse & production: coded / codeless in-out scanning and records, vehicle inspection, controller bind/unbind, shelf up/down, unlocked-vehicle checks, BLE radar find, ops track upload.
Data dashboards: one tap from workbench to operations / revenue H5.
Extra win: behavior alignment. Legacy rules hid in details: which status code defines "low battery," whether "all" includes sold-out bikes, offline alarms via alarmState vs connection state, 200 m gate on relocation completion, max repair plate length… We aligned each rule in shared code with tests. Rules exist once — no "Android correct, iOS wrong."
6. Want to Run It?
Repo: https://github.com/wanghengwen/ebike-go, directory ebike-OpsApp/.
cd ebike-OpsApp
# JDK 17 or 21 (25 breaks — current Gradle Kotlin DSL can't parse it)
# Windows / Linux: shared-layer check + tests + Android APK
gradlew.bat :sharedUi:compileCommonMainKotlinMetadata :shared:testAndroidHostTest :androidApp:assembleDebug
# macOS can also link iOS framework (unverified by us — see §6)
# ./gradlew :sharedUi:linkDebugFrameworkIosSimulatorArm64 && cd iosApp && xcodegen generate
Quick start:
- Open
ebike-OpsAppin Android Studio, run androidApp Debug. -
No backend required: empty
api.baseUrl→ Demo mode — login, tasks, warehouse, repair on local fake data, including simulated BLE ring. For real gateway, copyandroidApp/src/main/assets/tenant.json.example, fill URL and secrets (file is gitignored). - Map key in local
local.properties, not in VCS. - Multi-tenant config in
config/{tenant}_{mode}.json; only demo checked in.



Top comments (0)