MVP Factory
ai startup development

Compose Multiplatform's Skia Rendering on iOS: Profiling Metal Shader Compilation Stalls, Texture Atlas Thrashing, and the Platform-Specific Patterns That Actually Hit 120fps on ProMotion

KW
Krystian Wiewiór · · 5 min read

TL;DR

Compose Multiplatform on iOS renders through Skiko (Skia for Kotlin), bypassing UIKit’s layout engine entirely and drawing straight to Metal. You get a unified rendering model, but you also inherit iOS-specific problems: first-frame Metal shader compilation stalls, texture atlas thrashing under memory pressure, and missed ProMotion deadlines from naive composition. I’ll walk through the profiler-driven workflow to diagnose each of these, and explain where dropping to native UIKitView is the right call instead of fighting the renderer.


How Skiko actually renders on iOS

Most developers treat Compose Multiplatform as a black box. If you want to hit 120fps, you can’t afford to.

On Android, Compose draws through the platform’s hardware-accelerated Canvas, backed by Skia internally within the OS. On iOS, Compose Multiplatform bundles its own Skia instance via Skiko, which targets Metal directly. There’s no UIKit layout pass. No Core Animation implicit transactions. Your @Composable tree produces Skia draw commands that compile to Metal shader programs and render into a CAMetalLayer.

Why does this matter? iOS has no equivalent of Android’s RenderThread pre-compiling shaders in the background. Every novel shader variant hits the Metal compiler on first use, on the main thread.

Metal shader compilation: the first-frame problem

The single most common source of Compose Multiplatform jank on iOS is shader compilation stalls. When Skia encounters a new draw configuration (a novel blend mode, clip shape, or gradient type) Metal must compile a pipeline state object (PSO). That can block for several milliseconds per shader variant.

Diagnosing with Instruments

Open Xcode Instruments and attach the GPU and Metal System Trace templates. You’re looking for:

  1. MTLCreatePipelineState calls exceeding 2ms in the Metal System Trace
  2. Frame gaps in the GPU track where command buffer submission is delayed
  3. Main thread hangs in the Time Profiler correlating with Skia’s GrMtlPipelineStateBuilder

The fix: shader warmup

Force Skia to compile its most common shader variants during your splash screen or loading phase. Render an offscreen canvas that exercises your typical draw operations: rounded rectangles, blurred shadows, gradient fills, text with different styles. This front-loads PSO compilation before users see interactive content.

Texture atlas thrashing

Skia manages a texture atlas for glyph caching and small image regions. On Android with larger GPU memory budgets, this rarely causes issues. On iOS, particularly older devices, memory pressure triggers atlas eviction and reupload cycles that show up as periodic frame drops.

FactorAndroid (Pixel 7+)iOS (iPhone 13+)
Skia backingOS-integrated SkiaBundled Skiko/Skia
Shader compilationBackground RenderThreadMain thread via Metal
Texture atlas memoryManaged by OS compositorApp-level Metal allocations
120fps target8.3ms frame budget8.3ms frame budget (ProMotion)
GPU profilingAndroid GPU InspectorXcode Metal System Trace

When you see periodic stutter every few seconds, check Metal Resource Allocations in Instruments. If GPU memory allocation/deallocation is spiking, your atlas is thrashing. Reduce the number of distinct font sizes and image scales in hot paths. Prefer integer-scaled images that map cleanly to atlas regions.

Hitting 120fps on ProMotion

ProMotion displays dynamically adjust between 1-120Hz. At 120fps, your frame budget is 8.3ms, roughly half of 60fps.

Minimize recomposition scope. On Android, a dropped frame at 60fps is noticeable but survivable. At 120fps, two dropped frames are visible as a hitch. Use derivedStateOf, stable keys, and @Immutable annotations aggressively to prevent unnecessary recomposition from propagating into draw calls.

Watch for software rendering fallbacks. Certain Skia operations on iOS, particularly complex path clipping combined with RenderEffect blur, can fall back to CPU rasterization. Profile with Metal System Trace to confirm GPU-side rendering for your critical animation paths.

Pre-allocate draw resources. Create Paint and Path objects outside of draw lambdas. Object allocation during the draw phase adds GC pressure that compounds at higher frame rates.

When to drop to native UIKit views

What most teams get wrong: they treat UIKitView interop as a failure state. It isn’t. It’s an architectural tool.

Use native UIKitView for:

  • Maps, because MapKit is GPU-optimized in ways you cannot replicate through Skia
  • Video playback, since AVPlayerLayer works with the OS compositor directly
  • Web content, where WKWebView works best as a native embed
  • Text input, because iOS keyboard interop through native UITextField avoids a category of IME edge cases

In my experience building production systems, teams that try to force complex platform-specific components through the Skia renderer spend weeks debugging edge cases that disappear with a single UIKitView wrapper. I’ve watched it happen more than once, and it’s always the same story: someone eventually writes the native wrapper anyway, just later and more frustrated.

What to do with all this

Warm Metal shaders at launch. Render an offscreen canvas exercising your common draw operations during the splash screen to front-load PSO compilation and eliminate first-interaction hitches.

Profile with Metal System Trace, not just frame counters. Frame time averages hide shader compilation spikes and atlas thrashing. Use Xcode Instruments to identify per-frame GPU stalls before you start optimizing Compose code. I’ve seen teams spend days rearranging recomposition boundaries when the real problem was a single unwarmed shader variant.

Use UIKitView for platform-optimized components without guilt. Maps, video, web content, and text input are better served by native views. Fight the renderer only where cross-platform consistency delivers measurable product value, not because it feels like the “right” approach.


Share: Twitter LinkedIn