
As an Android developer in the era of Jetpack Compose, you’re used to building UIs in a declarative, state-driven world. But what happens when you need to render something that updates 60 times per second, like a game, a complex animation, or a video stream? Drawing directly on a Compose Canvas is great for many things, but because it operates on the main thread, it can lead to UI stutter (jank) under heavy load.
For true high-performance graphics, you need to reach for a lower-level, battle-tested tool from the classic Android View system: the Surface.
A Surface is a raw buffer of memory that your application’s pixels are rendered into. Think of it as a dedicated digital canvas, completely separate from the main UI window. The Android system’s compositor, SurfaceFlinger, acts like a master artist, taking these canvases from all visible apps and layers them together to create the final image you see on the screen.
The game-changing feature is that you can update a Surface‘s pixel buffer from any thread. This allows you to offload all heavy rendering work from the main thread, keeping your Compose UI responsive and fluid.
Advanced Use Case: Cross-Process Rendering
One of the most powerful and unique features of a Surface is that it’s Parcelable. This is a core Android feature that allows an object to be serialized and sent across process boundaries through the Binder IPC (Inter-Process Communication) mechanism.
This isn’t just a theoretical capability; it’s used to build complex systems. For instance, when I was at Google working on the Car App Library, we used this exact feature to allow navigation apps (like Google Maps or Waze) to render their map content onto the car’s head unit screen.
Here’s how it worked:
- The Car App Library, running as part of the system UI process on the head unit, would display aÂ
SurfaceView. - It would obtain the underlyingÂ
Surface object from theÂSurfaceView‘sÂSurfaceHolder. - ThisÂ
Surface object was then passed over IPC to the third-party navigation app, which was running in its own separate background process. - The navigation app would receive thisÂ
Surface and could then direct its rendering engine (e.g., OpenGL) to draw the map, route, and other information directly onto it.
The result is incredibly efficient. The map app draws directly into a graphics buffer that the system’s SurfaceFlinger can use, even though the View that “owns” that buffer lives in a completely different process. No bitmaps are copied, and no heavy data is serialized—only the lightweight Surface handle is passed. This allows for secure, sandboxed, and high-performance integration between apps and the system.
A Quick Note: SurfaceView vs. TextureView
You might have also heard of TextureView. A TextureView also allows you to render content like video or OpenGL scenes, but it behaves more like a regular View. Its content is composited into the main UI window, which means you can apply transformations, animations, and alpha blending to it just like any other View.
SurfaceView: Highest performance. Renders on a separate layer managed byÂSurfaceFlinger. Cannot be easily transformed or animated within theÂView hierarchy. Best for video playback and games where performance is paramount.TextureView: More flexible. Renders into a hardware texture that behaves like a normalÂView. Has slightly more overhead (a few frames of latency). Best when you need to animate, transform, or blend the rendered content with other UI elements.
For this guide, we’ll focus on SurfaceView for maximum performance.
Why Use a Surface in a Compose World?
Even in a declarative UI toolkit, the underlying rendering principles of Android haven’t changed. A Surface remains the most direct and performant way to get pixels on the screen.
- 🚀 Performance and Concurrency: Drawing is done on a separate, dedicated thread. This completely frees the main thread to handle user input and manage Compose’s recomposition, layout, and drawing phases. Your UI remains smooth even when rendering a complex scene at 60 FPS.
- 🎮 Direct Rendering Pipeline: AÂ
Surface provides a direct conduit for graphics producers like media decoders (MediaCodec), the camera HAL, or low-level graphics APIs like OpenGL ES and Vulkan. It’s the most efficient way to render this content, bypassing the overhead of the Compose UI tree.
Handling the Surface in Compose
Since SurfaceView is a classic Android View, how do we use it in our declarative Compose UI? The answer is the AndroidView composable. AndroidView is a powerful interoperability API that allows you to host a traditional View inside your composable hierarchy.
Here’s the strategy:
- Use theÂ
AndroidView composable to create and manage aÂSurfaceView instance. - Use Compose’s lifecycle-aware side-effect handlers, specificallyÂ
LaunchedEffect, to safely start and stop our custom drawing thread. - Use Compose’s modern input system, theÂ
pointerInput modifier, to handle taps and gestures. - Manage the state (like the position of our drawn objects) carefully to avoid triggering unnecessary recompositions on every single frame.
Let’s build an app that visualizes touch input. When you tap the screen, it will display the coordinates and an animated concentric circle expanding from the touch point.
A Hands-On Example: Animated Touch Feedback with Jetpack Compose
This example will create a self-contained composable that manages a SurfaceView and a dedicated drawing thread to render dynamic touch feedback.
Step 1: The Drawing Thread and State
We’ll define a state class to hold information about an active tap (its position, current radius, and transparency). The DrawingThread will then animate this state.
| import android.graphics.Canvas | |
| import android.graphics.Color | |
| import android.graphics.Paint | |
| import android.view.SurfaceHolder | |
| // Represents an active ripple animation | |
| data class Ripple( | |
| val centerX: Float, | |
| val centerY: Float, | |
| var currentRadius: Float, | |
| var alpha: Int // 0-255 | |
| ) | |
| class DrawingThread( | |
| private val surfaceHolder: SurfaceHolder | |
| ) : Thread() { | |
| @Volatile | |
| private var isRunning = false | |
| private val paint = Paint().apply { isAntiAlias = true } | |
| private val textPaint = Paint().apply { | |
| color = Color.WHITE | |
| textSize = 48f | |
| isAntiAlias = true | |
| } | |
| // Use a mutable list to store active ripples | |
| private val activeRipples = mutableListOf<Ripple>() | |
| private var lastTouchX: Float = 0f | |
| private var lastTouchY: Float = 0f | |
| fun setRunning(running: Boolean) { | |
| isRunning = running | |
| } | |
| // Receive touch events from the Compose UI thread | |
| fun handleTouch(touchX: Float, touchY: Float) { | |
| synchronized(surfaceHolder) { | |
| lastTouchX = touchX | |
| lastTouchY = touchY | |
| // Add a new ripple at the touch location | |
| activeRipples.add(Ripple(touchX, touchY, 0f, 255)) | |
| } | |
| } | |
| override fun run() { | |
| while (isRunning) { | |
| var canvas: Canvas? = null | |
| try { | |
| canvas = surfaceHolder.lockCanvas() | |
| if (canvas != null) { | |
| synchronized(surfaceHolder) { | |
| updateState(canvas.width, canvas.height) | |
| doDraw(canvas) | |
| } | |
| } | |
| } finally { | |
| if (canvas != null) { | |
| surfaceHolder.unlockCanvasAndPost(canvas) | |
| } | |
| } | |
| try { | |
| sleep(16) // ~60 FPS | |
| } catch (e: InterruptedException) { | |
| // Ignore | |
| } | |
| } | |
| } | |
| private fun updateState(canvasWidth: Int, canvasHeight: Int) { | |
| val ripplesToRemove = mutableListOf<Ripple>() | |
| for (ripple in activeRipples) { | |
| ripple.currentRadius += 10f // Expand speed | |
| ripple.alpha -= 5 // Fade out speed | |
| if (ripple.alpha <= 0 || ripple.currentRadius > maxOf(canvasWidth, canvasHeight)) { | |
| ripplesToRemove.add(ripple) | |
| } | |
| } | |
| activeRipples.removeAll(ripplesToRemove) | |
| } | |
| private fun doDraw(canvas: Canvas) { | |
| // Clear the background | |
| canvas.drawColor(Color.BLACK) | |
| // Draw active ripples | |
| for (ripple in activeRipples) { | |
| paint.color = Color.WHITE | |
| paint.alpha = ripple.alpha // Apply fade | |
| paint.style = Paint.Style.STROKE // Draw outline | |
| paint.strokeWidth = 5f | |
| canvas.drawCircle(ripple.centerX, ripple.centerY, ripple.currentRadius, paint) | |
| } | |
| // Draw last touch coordinates | |
| val coordinatesText = "Tap: (${lastTouchX.toInt()}, ${lastTouchY.toInt()})" | |
| canvas.drawText(coordinatesText, 50f, 100f, textPaint) | |
| } | |
| } |
DrawingThread helps with threading and visualizing touch interactions
Step 2: The DrawingSurface Composable
This is where we bridge the gap between Compose and the SurfaceView. We’ll use AndroidView to host the SurfaceView and manage the DrawingThread‘s lifecycle. The pointerInput modifier will capture the taps.
| import android.view.SurfaceHolder | |
| import android.view.SurfaceView | |
| import androidx.compose.foundation.gestures.detectTapGestures | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.runtime.* | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.input.pointer.pointerInput | |
| import androidx.compose.ui.viewinterop.AndroidView | |
| @Composable | |
| fun DrawingSurface() { | |
| // This state holder for the thread is remembered across recompositions | |
| val drawingThreadHolder = remember { mutableStateOf<DrawingThread?>(null) } | |
| // Use SideEffect to react to surface holder creation/destruction | |
| val surfaceHolderCallback = remember { | |
| object : SurfaceHolder.Callback { | |
| override fun surfaceCreated(holder: SurfaceHolder) { | |
| // Surface is ready, create and start the thread | |
| val thread = DrawingThread(holder) | |
| drawingThreadHolder.value = thread | |
| thread.setRunning(true) | |
| thread.start() | |
| } | |
| override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {} | |
| override fun surfaceDestroyed(holder: SurfaceHolder) { | |
| // Surface is gone, stop the thread | |
| drawingThreadHolder.value?.let { thread -> | |
| var retry = true | |
| thread.setRunning(false) | |
| while (retry) { | |
| try { | |
| thread.join() | |
| retry = false | |
| } catch (e: InterruptedException) { | |
| // try again | |
| } | |
| } | |
| } | |
| drawingThreadHolder.value = null | |
| } | |
| } | |
| } | |
| AndroidView( | |
| factory = { context -> | |
| SurfaceView(context).apply { | |
| holder.addCallback(surfaceHolderCallback) | |
| } | |
| }, | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .pointerInput(Unit) { | |
| detectTapGestures( | |
| onTap = { offset -> | |
| // Pass the tap coordinates to the drawing thread | |
| drawingThreadHolder.value?.handleTouch(offset.x, offset.y) | |
| } | |
| ) | |
| } | |
| ) | |
| } |
DrawingSurface is our main compoosable
Step 3: Update MainActivity
Finally, your MainActivity is as simple as it gets in Compose.
| import android.os.Bundle | |
| import androidx.activity.ComponentActivity | |
| import androidx.activity.compose.setContent | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.material3.MaterialTheme | |
| import androidx.compose.material3.Surface | |
| import androidx.compose.ui.Modifier | |
| import com.example.composedemo.ui.theme.ComposeDemoTheme | |
| class MainActivity : ComponentActivity() { | |
| override fun onCreate(savedInstanceState: Bundle?) { | |
| super.onCreate(savedInstanceState) | |
| setContent { | |
| ComposeDemoTheme { | |
| Surface( | |
| modifier = Modifier.fillMaxSize(), | |
| color = MaterialTheme.colorScheme.background | |
| ) { | |
| DrawingSurface() | |
| } | |
| } | |
| } | |
| } | |
| } |
MainActivity
Now, run the app. Tap anywhere on the screen, and you’ll see an expanding white circle ripple outwards from your touch point, fading away as it grows. The coordinates of the last tap will also be displayed. This smooth, high-frame-rate animation is handled entirely by our dedicated DrawingThread, keeping your Compose UI performant and responsive.
Here is a demo of what this looks like when running on a phone:

Job Offers
Best Practices for Surface in Compose
- EmbraceÂ
AndroidView for Interoperability:ÂAndroidView is the correct and intended way to bring legacy Views into your Compose hierarchy. Don’t fight it. - Use Lifecycle-Aware Effects: Instead of relying solely on the oldÂ
SurfaceHolder.Callback, usingÂremember andÂDisposableEffect (or a remembered callback as shown) is the idiomatic Compose way to manage the lifecycle of non-composable resources like threads. This ensures your thread is properly started and, critically, stopped when the composable leaves the screen. - UseÂ
pointerInput for Gestures: TheÂpointerInput modifier is the modern, powerful, and coroutine-based way to handle all touch input in Compose. It’s more flexible and integrates better thanÂOnTouchListener. - Isolate High-Frequency State: Notice that theÂ
Ripple objects and their animated properties are managed entirely within theÂDrawingThread. We are not usingÂmutableStateOf for their positions or sizes. Doing so would trigger a recomposition 60 times per second, defeating the purpose. The state that changes every frame should live outside of Compose’s observation system. Only pass events (like taps) from Compose to your state manager.
When you need to render video, build a custom game engine, or display a live camera preview, reach for SurfaceView. It’s the performant, professional tool for getting pixels on the screen, fast.
✨ Stay in the Loop for More!
If you found this article helpful, you’re in for a treat. I’ll be writing more articles on using SurfaceView and TextureView in Android.
I plan to publish more stories from my time at Google developing for the automotive space and from my experience at Meta working on the Meta RayBan Display, Orion and the future of Augmented Reality.
To be the first to know when a new article drops, be sure to follow me on Medium and connect with me on LinkedIn.
Thank you for reading!
This article was previously published on proandroiddev.com


