Blog Infos
Author
Published
Topics
, , , , ,
Published

 

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:

  1. The Car App Library, running as part of the system UI process on the head unit, would display a SurfaceView.
  2. It would obtain the underlying Surface object from the SurfaceView‘s SurfaceHolder.
  3. This Surface object was then passed over IPC to the third-party navigation app, which was running in its own separate background process.
  4. 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:

  1. Use the AndroidView composable to create and manage a SurfaceView instance.
  2. Use Compose’s lifecycle-aware side-effect handlers, specifically LaunchedEffect, to safely start and stop our custom drawing thread.
  3. Use Compose’s modern input system, the pointerInput modifier, to handle taps and gestures.
  4. 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()
}
}
}
}
}
view raw MainActivity.kt hosted with ❤ by GitHub

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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

Best Practices for Surface in Compose
  1. Embrace AndroidView for Interoperability: AndroidView is the correct and intended way to bring legacy Views into your Compose hierarchy. Don’t fight it.
  2. 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.
  3. 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.
  4. 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

Menu