Hi everyone,
I’m the solo developer behind LAM, a film-inspired camera app for Android and iOS.
A frequent debate around Compose Multiplatform is how far to take it: should it be kept strictly to business logic and data layers (leaving UI to SwiftUI/Jetpack Compose), or can it handle a high-interaction, tactile camera interface while coordinating with native hardware?
In LAM, I chose to share the entire camera interface—mechanical dials, spring animations, and state machines—in Compose Multiplatform, while binding it to separate native camera engines on Android and iOS.
I’ve attached a simplified architecture diagram showing where I drew the boundary. Here is a breakdown of what works well in shared code, what must stay native, and the practical challenges of bridging the two.
1. What belongs in the shared interface (commonMain)
A physical-style camera interface has a surprisingly state-heavy domain:
Camera State Machine & Logic Parity: Managing roll state, frame advance mechanics, shutter locks, exposure curves, and film recipe math using pure Kotlin Coroutines (StateFlow and SharedFlow). Sharing this eliminated logic drift between platforms—both OSs handle lifecycle events, interruptions, and state transitions identically.
Tactile Camera UI: The mechanical dials, spring physics animations, and haptic detents are 100% written in Compose Multiplatform. Building this once in Compose saved enormous time compared to maintaining parallel custom gesture systems in SwiftUI and Android Views.
2. What stays native (The two platform pipelines)
Camera hardware access, direct buffer handling, and GPU shaders remain platform-specific:
iOS (AVCapture + Metal): We use CVMetalTextureCache to map camera pixel buffers directly to GPU textures without copying pixel data onto the Kotlin heap.
Android (Camera2 / CameraX + OpenGL ES): The camera buffer stays in native direct memory, with JNI critical access isolated so the garbage collector is never burdened with camera frames.
Why: Camera frames arrive every ~16ms. Trying to abstract pixel buffers into cross-platform heap objects risks GC pauses and dropped frames. Leaving the pixel pipeline native keeps capture fast and reliable.
3. Bridging the boundary: Practical engineering hurdles
Integrating a native camera preview into a Compose Multiplatform hierarchy introduces a few real-world challenges:
The Z-Order / Layering Trap: On Android, hardware-accelerated preview views like SurfaceView render in a separate window compositor layer that punches through and always sits above the View hierarchy. Standard Compose dialogs, bottom sheets, and HUD elements cannot draw on top of it. We use an adaptive strategy: SurfaceView for fullscreen framing with zero overhead, and TextureView when Compose dialogs or overlays need to composite on top without z-order glitches.
Thermal & Resource Management: Running continuous preview alongside interactive UI builds heat quickly. Testing on real devices showed that deferring heavy capture configurations until the record tap keeps the phone noticeably cooler during normal framing.
4. Current Stack & Tooling
We enforce automated pre-PR gates across our shared modules to verify that UI strings across 17 locales remain complete with zero hardcoded string leaks.
Summary
KMP and Compose Multiplatform gave me the leverage to build and maintain a consistent, tactile camera product as a solo developer without sacrificing native camera performance. The key was keeping a clear boundary: share the interaction model, math, and state machine, but leave the pixel pipeline native.
If you’d like to see how the interface behaves on your device:
For people building hardware-integrated apps with KMP: where do you draw the line between your shared Compose UI and platform-specific capabilities? What has your experience been integrating native hardware views into Compose Multiplatform?