CMP iOS rendering: Skia, Metal & memory pressure on 3GB
Meta description: Deep dive into Compose Multiplatform’s Skia-to-Metal rendering on iOS, GPU context switches at UIKitView boundaries, and compositing decisions that spike memory.
Tags: kmp multiplatform ios jetpackcompose mobile
If your Compose Multiplatform app passes QA on a simulator but ships with dropped frames on iPhone XR or older, the culprit is almost never your business logic. It’s your compositing graph. Knowing where Skia ends and Metal begins is what separates a smooth 60fps experience from a frame-rate regression that only surfaces in production, on devices your team never tested.
TL;DR
Compose Multiplatform on iOS runs a Skia rendering pipeline that delegates to Apple’s Metal GPU via a CAMetalLayer. The boundary between Skia-managed compositing and UIKitView-hosted native components forces GPU context switches that are expensive under memory pressure. Offscreen buffers, non-opaque layers, and complex clip paths make this worse. Profile with Xcode’s Frame Debugger and Instruments’ Metal System Trace, and audit your compositing graph before shipping to devices with 3GB RAM.
How CMP gets pixels onto an iOS screen
Compose Multiplatform does not use UIKit’s native rendering stack. It embeds a Skia canvas backed by a CAMetalLayer, so every composable you write gets rasterized by Skia and submitted to Metal as a command buffer. The host UIViewController hands ownership of a MetalView to the CMP runtime, which drives its own render loop independent of UIKit’s display link.
The fundamental constraint: one Skia context, one Metal device, one command queue, for the entire CMP surface.
// CMP iOS entry point (simplified)
ComposeUIViewController {
App() // Your composables render entirely within Skia's canvas
}
That model is clean and fast for pure-Compose screens. The trouble starts the moment you introduce native UIKit components.
The UIKitView boundary: where GPU context switches happen
When you embed a native component using UIKitView, CMP must composite two worlds: Skia’s Metal-backed texture and UIKit’s CALayer tree. iOS handles this via layer compositing — the system compositor (WindowServer) blends the CAMetalLayer with the native CALayer hierarchy.
Here’s what most teams miss: each UIKitView insertion creates a compositor boundary. The GPU has to flush Skia’s current render pass, submit it, then let the system compositor blend the result with the native layer. That’s an extra roundtrip through the display pipeline on every frame.
On A14 and newer silicon this cost is nearly imperceptible at 60fps. On A12-class chips with 3GB RAM, under concurrent memory pressure from background processes, these flushes can stall the Metal command queue long enough to drop frames.
Compositing decisions that spike memory
Not all compositing is equal. Four specific patterns reliably trigger allocation spikes:
| Trigger | Why it allocates | Mitigation |
|---|---|---|
| Non-opaque root layer | Forces an offscreen MSAA buffer | Set background(Color.Black) or fully opaque fill |
clip { } with non-rectangular shape | Skia allocates a stencil buffer | Prefer clip(RoundedCornerShape) — often batched |
graphicsLayer { alpha = ... } on large surfaces | Promotes layer to offscreen texture | Scope alpha to leaf nodes, not containers |
Nested UIKitView in a LazyColumn | Repeated compositor handoffs per row | Virtualize or replace with Skia-native equivalent |
In a production app with a mixed Skia/UIKit screen, auditing these four patterns cut peak IOSurface allocations by ~25–35% on 3GB devices. The biggest gains came from eliminating offscreen MSAA buffers — memory the OS doesn’t aggressively reclaim under pressure.
Profiling with Xcode Frame Debugger and Instruments
The data is there if you know where to look.
Frame Debugger
Launch via Product → Profile → GPU Frame Capture and expand the render pass list. You’re looking for unexpected Offscreen Pass entries. Each one is a compositing cost you probably didn’t intend. Check the Attachments column: MSAA resolve targets indicate non-opaque layer promotions.
Metal System Trace
Use the GPU instrument track alongside VM Tracker. Correlate IOKit memory allocations with your compositing operations. A spike in IOSurface allocations during list scroll is the signature of repeated offscreen buffer creation.
// Force a GPU capture from code during testing
MTLCaptureManager.shared().startCapture(with: captureDescriptor)
Pair this with the Allocations instrument filtered to IOSurface and MTLTexture to isolate Skia-owned GPU memory from UIKit’s.
Before you ship
-
Audit your
graphicsLayerusage before shipping to low-RAM devices. Everyalpha,shadowElevation, orrenderEffecton a container composable is a potential offscreen buffer. Scope these modifiers to leaf-level composables where possible. -
Minimize
UIKitViewboundaries on high-frequency screens. Each boundary is a compositor handoff. Where native components are unavoidable, batch them at screen edges rather than scattering them through Skia-rendered content. -
Add GPU Frame Capture to your pre-release checklist. Run a capture on a 3GB device (iPhone XR, A12 Bionic, is a reliable test target) and count
Offscreen Passentries. Anything above single digits on a standard screen warrants investigation.
CMP’s rendering pipeline is fast and mostly gets out of your way — until it doesn’t. The problems only appear on older hardware, under real-world memory pressure, well after you’ve shipped. The compositing graph is not where most engineers spend their attention, and that’s exactly why it bites them.