DEV Community

Cover image for ~40k LOC, Shared 90%: Building a Cross-Platform Shared E-bike Ops App
Peter wang
Peter wang

Posted on

~40k LOC, Shared 90%: Building a Cross-Platform Shared E-bike Ops App

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:

  1. Can business UI be written once?
  2. 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
Enter fullscreen mode Exit fullscreen mode

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) and ReturnCarScatterMapView (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, no ui.res.
  • They lived in the host for one reason: they called those two Composables directly.
  • More ironic: SimulatorMapView (155 lines) is pure Compose Canvas, using projection and clustering from shared, 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 }
Enter fullscreen mode Exit fullscreen mode

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
R.drawable (torch, manual entry) 4 Direct Android resource IDs
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 }
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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) }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. OpsI18n.setLanguage(...) updates in-memory language
  2. Write to SecureStore (persist choice)
  3. Update LocaleContext.acceptLanguage (next request carries new language)
  4. Strings.install(this) — process-wide resolver points at new catalog
  5. 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())
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

4.4 Adding a New Language

  1. Add enum entry and acceptLanguage on OpsLanguage
  2. Add full catalog in StringCatalogs
  3. Add option on settings language list
  4. (Optional) Append lang to 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
Enter fullscreen mode Exit fullscreen mode

Quick start:

  • Open ebike-OpsApp in 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, copy androidApp/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)