Back to Articles
Android Views Performance Optimization Guide

Android Views Performance Optimization Guide

Comprehensive practices for building 60-240 FPS UI with Native Android Views


Table of Contents

  1. Frame Budget & Rendering Pipeline
  2. RecyclerView Mastery
  3. Layout Optimization
  4. View Hierarchy & Overdraw
  5. Hardware Acceleration & GPU Layers
  6. Adaptive Refresh Rate (Android 15+)
  7. Image Loading & Memory
  8. Animations & Transitions
  9. Threading & Async Operations
  10. Custom Views & Drawing
  11. Memory Management
  12. Profiling & Debugging
  13. Quick Reference

1. Frame Budget & Rendering Pipeline

Frame Timing

Target FPSFrame BudgetDisplay Type
60 FPS16.67msStandard 60Hz
90 FPS11.11msMid-range 90Hz
120 FPS8.33msHigh-end 120Hz
240 FPS4.17msGaming displays

Android Rendering Pipeline

┌─────────────┐   ┌─────────────┐   ┌─────────────┐   ┌─────────────┐   ┌─────────────┐
│   INPUT     │ → │   MEASURE   │ → │   LAYOUT    │ → │    DRAW     │ → │   DISPLAY   │
│  Handling   │   │  (onMeasure)│   │  (onLayout) │   │  (onDraw)   │   │  (VSync)    │
└─────────────┘   └─────────────┘   └─────────────┘   └─────────────┘   └─────────────┘
     ↓                  ↓                 ↓                  ↓                ↓
   < 1ms             < 2ms            < 2ms              < 3ms           VSync signal

Choreographer & VSync

// Choreographer synchronizes with display VSync
val choreographer = Choreographer.getInstance()

choreographer.postFrameCallback(object : Choreographer.FrameCallback {
    override fun doFrame(frameTimeNanos: Long) {
        val frameTimeMs = frameTimeNanos / 1_000_000

        // Your frame work here
        updateAnimations(frameTimeMs)

        // Schedule next frame
        choreographer.postFrameCallback(this)
    }
})

Frame Metrics Tracking

// API 24+
window.addOnFrameMetricsAvailableListener({ _, frameMetrics, _ ->
    val totalDuration = frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION)
    val layoutDuration = frameMetrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION)
    val drawDuration = frameMetrics.getMetric(FrameMetrics.DRAW_DURATION)

    if (totalDuration > 16_000_000) { // > 16ms in nanoseconds
        Log.w("Perf", "Dropped frame: ${totalDuration / 1_000_000}ms")
    }
}, Handler(Looper.getMainLooper()))

2. RecyclerView Mastery

Basic Setup with ListAdapter

class OptimizedAdapter : ListAdapter<Item, OptimizedAdapter.ViewHolder>(ItemDiffCallback()) {

    // ViewHolder: Cache ALL view references
    class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val title: TextView = view.findViewById(R.id.title)
        val subtitle: TextView = view.findViewById(R.id.subtitle)
        val image: ImageView = view.findViewById(R.id.image)
        val container: ViewGroup = view.findViewById(R.id.container)

        // Pre-create reusable objects
        val clickListener = View.OnClickListener { /* handle */ }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_layout, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val item = getItem(position)

        // ✅ Direct assignment - no findViewById
        holder.title.text = item.title
        holder.subtitle.text = item.subtitle

        // ✅ Reuse click listener
        holder.container.setOnClickListener(holder.clickListener)

        // ✅ Efficient image loading
        Glide.with(holder.image)
            .load(item.imageUrl)
            .override(200, 200)
            .into(holder.image)
    }
}

DiffUtil Implementation

class ItemDiffCallback : DiffUtil.ItemCallback<Item>() {

    // Are these the same item? (identity check)
    override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
        return oldItem.id == newItem.id
    }

    // Are the contents identical? (equality check)
    override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
        return oldItem == newItem
    }

    // Optional: Provide payload for partial updates
    override fun getChangePayload(oldItem: Item, newItem: Item): Any? {
        return when {
            oldItem.title != newItem.title -> PayloadType.TITLE
            oldItem.imageUrl != newItem.imageUrl -> PayloadType.IMAGE
            else -> null
        }
    }
}

enum class PayloadType { TITLE, IMAGE }

// Handle partial updates in adapter
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: List<Any>) {
    if (payloads.isEmpty()) {
        onBindViewHolder(holder, position)
        return
    }

    // Partial update - only changed fields
    payloads.forEach { payload ->
        when (payload) {
            PayloadType.TITLE -> holder.title.text = getItem(position).title
            PayloadType.IMAGE -> loadImage(holder.image, getItem(position).imageUrl)
        }
    }
}

RecyclerView Configuration

recyclerView.apply {
    // Fixed size optimization
    setHasFixedSize(true)

    // Increase view cache for smoother scrolling
    setItemViewCacheSize(20)

    // Disable nested scrolling if not needed
    isNestedScrollingEnabled = false

    // Pre-fetch for smoother scrolling
    (layoutManager as? LinearLayoutManager)?.apply {
        initialPrefetchItemCount = 4
    }

    // Shared RecycledViewPool for multiple RecyclerViews
    setRecycledViewPool(sharedPool)

    // Disable item animations if not needed
    itemAnimator = null
    // Or disable change animations only
    (itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false
}

ViewHolder Pool Sharing

// Share ViewHolder pool across multiple RecyclerViews
class SharedPoolManager {
    val sharedPool = RecyclerView.RecycledViewPool().apply {
        setMaxRecycledViews(VIEW_TYPE_ITEM, 20)
        setMaxRecycledViews(VIEW_TYPE_HEADER, 5)
    }

    companion object {
        const val VIEW_TYPE_ITEM = 0
        const val VIEW_TYPE_HEADER = 1
    }
}

// Usage
recyclerView1.setRecycledViewPool(sharedPoolManager.sharedPool)
recyclerView2.setRecycledViewPool(sharedPoolManager.sharedPool)

Paging 3 Integration

// PagingSource
class ItemPagingSource(private val api: ApiService) : PagingSource<Int, Item>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Item> {
        val page = params.key ?: 1
        return try {
            val response = api.getItems(page, params.loadSize)
            LoadResult.Page(
                data = response.items,
                prevKey = if (page == 1) null else page - 1,
                nextKey = if (response.items.isEmpty()) null else page + 1
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Item>): Int? {
        return state.anchorPosition?.let { position ->
            state.closestPageToPosition(position)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(position)?.nextKey?.minus(1)
        }
    }
}

// PagingDataAdapter
class PagingItemAdapter : PagingDataAdapter<Item, ViewHolder>(ItemDiffCallback()) {
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        getItem(position)?.let { holder.bind(it) }
    }
}

// ViewModel
class ItemViewModel : ViewModel() {
    val items: Flow<PagingData<Item>> = Pager(
        config = PagingConfig(pageSize = 20, prefetchDistance = 5, enablePlaceholders = false),
        pagingSourceFactory = { ItemPagingSource(api) }
    ).flow.cachedIn(viewModelScope)
}

// Fragment
lifecycleScope.launch {
    viewModel.items.collectLatest { pagingData ->
        adapter.submitData(pagingData)
    }
}

3. Layout Optimization

Layout Performance Comparison

LayoutMeasure PassesBest For
ConstraintLayout1Complex layouts
LinearLayout1Simple stacking
FrameLayout1Overlapping views
RelativeLayout2Avoid (legacy)
Nested LinearLayout2^nNever use

ConstraintLayout Best Practices

<!-- ✅ Flat hierarchy with ConstraintLayout -->
<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <ImageView
        android:id="@+id/avatar"
        android:layout_width="48dp"
        android:layout_height="48dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:layout_margin="16dp" />

    <TextView
        android:id="@+id/title"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toEndOf="@id/avatar"
        app:layout_constraintEnd_toStartOf="@id/action"
        app:layout_constraintTop_toTopOf="@id/avatar"
        android:layout_marginStart="12dp" />

    <TextView
        android:id="@+id/subtitle"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="@id/title"
        app:layout_constraintEnd_toEndOf="@id/title"
        app:layout_constraintTop_toBottomOf="@id/title" />

    <ImageButton
        android:id="@+id/action"
        android:layout_width="48dp"
        android:layout_height="48dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

ViewStub for Deferred Inflation

<!-- ViewStub doesn't inflate until needed -->
<ViewStub
    android:id="@+id/stub_error"
    android:layout="@layout/layout_error_state"
    android:inflatedId="@+id/error_container"
    android:layout_width="match_parent"
    android:layout_height="wrap_content" />
// Inflate only when error occurs
fun showError(message: String) {
    val stub = findViewById<ViewStub>(R.id.stub_error)
    val inflated = stub?.inflate() ?: findViewById(R.id.error_container)
    inflated.findViewById<TextView>(R.id.error_message).text = message
    inflated.visibility = View.VISIBLE
}

Merge Tag

<!-- included_layout.xml -->
<!-- ✅ Use merge to avoid extra ViewGroup -->
<merge xmlns:android="http://schemas.android.com/apk/res/android">
    <TextView android:id="@+id/text1" ... />
    <TextView android:id="@+id/text2" ... />
</merge>

<!-- parent_layout.xml -->
<LinearLayout ...>
    <include layout="@layout/included_layout" />
</LinearLayout>

4. View Hierarchy & Overdraw

Reducing Overdraw

// Remove redundant window background
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    window.setBackgroundDrawable(null)
}
<!-- Or in theme -->
<style name="AppTheme" parent="Theme.MaterialComponents.Light">
    <item name="android:windowBackground">@null</item>
</style>

<!-- ❌ BAD: Multiple overlapping backgrounds -->
<FrameLayout android:background="@color/white">
    <LinearLayout android:background="@color/white">
        <TextView android:background="@color/white" />
    </LinearLayout>
</FrameLayout>

<!-- ✅ GOOD: Single background at root -->
<FrameLayout android:background="@color/white">
    <LinearLayout>
        <TextView />
    </LinearLayout>
</FrameLayout>

Developer Options for Debugging

  • Debug GPU Overdraw: Shows overdraw with colors (blue=1x, green=2x, pink=3x, red=4x+)
  • Profile GPU Rendering: Shows frame timing bars
  • Show Layout Bounds: Displays view boundaries

5. Hardware Acceleration & GPU Layers

Enabling Hardware Acceleration

<!-- Application level (default true for API 14+) -->
<application android:hardwareAccelerated="true">

<!-- Per-View layer for animations -->
<View android:layerType="hardware" />

View Layer Types

Layer TypeBacked ByUse Case
LAYER_TYPE_NONEN/ADefault, no caching
LAYER_TYPE_SOFTWAREBitmapComplex drawing, filters
LAYER_TYPE_HARDWAREGPU textureAnimations
// Enable hardware layer during animation
view.setLayerType(View.LAYER_TYPE_HARDWARE, null)

ObjectAnimator.ofFloat(view, "alpha", 0f, 1f).apply {
    duration = 300
    addListener(object : AnimatorListenerAdapter() {
        override fun onAnimationEnd(animation: Animator) {
            // Clear layer after animation
            view.setLayerType(View.LAYER_TYPE_NONE, null)
        }
    })
    start()
}

GPU-Optimized Properties

// ✅ GPU-accelerated (fast)
ObjectAnimator.ofFloat(view, View.TRANSLATION_X, 0f, 100f)
ObjectAnimator.ofFloat(view, View.TRANSLATION_Y, 0f, 100f)
ObjectAnimator.ofFloat(view, View.SCALE_X, 1f, 1.5f)
ObjectAnimator.ofFloat(view, View.SCALE_Y, 1f, 1.5f)
ObjectAnimator.ofFloat(view, View.ROTATION, 0f, 360f)
ObjectAnimator.ofFloat(view, View.ALPHA, 0f, 1f)

// ❌ Triggers layout (slow)
ObjectAnimator.ofInt(view, "width", 100, 200)
ObjectAnimator.ofInt(view, "height", 100, 200)

6. Adaptive Refresh Rate (Android 15+)

Checking ARR Support

fun checkArrSupport(display: Display): Boolean {
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
        display.hasArrSupport()
    } else false
}

fun getSuggestedFrameRate(display: Display): Float {
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
        display.getSuggestedFrameRate(Surface.FRAME_RATE_CATEGORY_HIGH)
    } else 60f
}

Per-View Frame Rate

// Request specific frame rate for a view
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
    animatedView.preferredFrameRate = 120f
}

// Frame rate categories
view.setFrameRateCategory(Surface.FRAME_RATE_CATEGORY_HIGH)      // 120Hz
view.setFrameRateCategory(Surface.FRAME_RATE_CATEGORY_NORMAL)    // 60-90Hz
view.setFrameRateCategory(Surface.FRAME_RATE_CATEGORY_LOW)       // 30-60Hz

240Hz Gaming Display Support

// Request 240Hz for gaming/high-performance scenarios
fun request240Hz(surface: Surface) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
        surface.setFrameRate(
            240f,
            Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
            Surface.CHANGE_FRAME_RATE_ALWAYS
        )
    }
}

// Mixed refresh rate scenario: static UI at 60Hz, animated at 240Hz
fun setupMixedRefresh(staticView: View, animatedView: View) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
        staticView.setFrameRateCategory(Surface.FRAME_RATE_CATEGORY_LOW)
        animatedView.preferredFrameRate = 240f
    }
}

// Per-Surface frame rate hints for SurfaceView
class GameSurfaceView(context: Context) : SurfaceView(context) {
    override fun surfaceCreated(holder: SurfaceHolder) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            holder.surface.setFrameRate(
                240f,
                Surface.FRAME_RATE_COMPATIBILITY_DEFAULT
            )
        }
    }
}

240fps Frame Budget Breakdown

Total: 4.17ms
├── Input:    < 0.5ms  ← Touch/event handling
├── Measure:  < 1.0ms  ← View.onMeasure()
├── Layout:   < 1.0ms  ← View.onLayout()
├── Draw:     < 1.5ms  ← View.onDraw() + GPU
└── Buffer:   < 0.17ms ← Margin for VSync

7. Image Loading & Memory

Glide Configuration

@GlideModule
class AppGlideModule : AppGlideModule() {
    override fun applyOptions(context: Context, builder: GlideBuilder) {
        // Memory cache: 20% of available memory
        val memorySize = Runtime.getRuntime().maxMemory() / 5
        builder.setMemoryCache(LruResourceCache(memorySize))

        // Disk cache: 250MB
        builder.setDiskCache(InternalCacheDiskCacheFactory(context, 250 * 1024 * 1024))

        // Default options
        builder.setDefaultRequestOptions(
            RequestOptions()
                .format(DecodeFormat.PREFER_RGB_565)
                .diskCacheStrategy(DiskCacheStrategy.ALL)
        )
    }
}

Efficient Image Loading

// ✅ Always specify size
Glide.with(context)
    .load(url)
    .override(200, 200)  // Don't decode full resolution
    .centerCrop()
    .into(imageView)

// ✅ Thumbnail for faster loading
Glide.with(context)
    .load(url)
    .thumbnail(0.1f)  // Load 10% size first
    .into(imageView)

// ✅ Preload for smoother scrolling
fun preloadImages(urls: List<String>) {
    urls.forEach { url ->
        Glide.with(context).load(url).preload(200, 200)
    }
}

Coil (Kotlin-first Alternative)

imageView.load(url) {
    crossfade(true)
    placeholder(R.drawable.placeholder)
    error(R.drawable.error)
    size(200, 200)
    transformations(CircleCropTransformation())
}

8. Animations & Transitions

view.animate()
    .alpha(1f)
    .translationY(0f)
    .scaleX(1f)
    .scaleY(1f)
    .setDuration(300)
    .setInterpolator(FastOutSlowInInterpolator())
    .withStartAction { /* before */ }
    .withEndAction { /* after */ }
    .start()

Spring Animations (Physics-based)

SpringAnimation(view, DynamicAnimation.TRANSLATION_X, 0f).apply {
    spring.stiffness = SpringForce.STIFFNESS_MEDIUM
    spring.dampingRatio = SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY
    start()
}

MotionLayout for Complex Animations

<androidx.constraintlayout.motion.widget.MotionLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layoutDescription="@xml/scene">

    <ImageView android:id="@+id/image" ... />

</androidx.constraintlayout.motion.widget.MotionLayout>

9. Threading & Async Operations

Coroutines for Async Work

class MyViewModel : ViewModel() {
    fun loadData() {
        viewModelScope.launch {
            val data = withContext(Dispatchers.IO) {
                repository.fetchData()
            }
            _uiState.value = UiState.Success(data)
        }
    }

    // Parallel loading
    fun loadMultiple() {
        viewModelScope.launch {
            val (users, posts) = coroutineScope {
                val usersDeferred = async(Dispatchers.IO) { api.getUsers() }
                val postsDeferred = async(Dispatchers.IO) { api.getPosts() }
                usersDeferred.await() to postsDeferred.await()
            }
            _uiState.value = UiState.Success(users, posts)
        }
    }
}

StrictMode for Detecting Issues

if (BuildConfig.DEBUG) {
    StrictMode.setThreadPolicy(
        StrictMode.ThreadPolicy.Builder()
            .detectDiskReads()
            .detectDiskWrites()
            .detectNetwork()
            .penaltyLog()
            .build()
    )
}

10. Custom Views & Drawing

Optimized onDraw()

class OptimizedCustomView(context: Context) : View(context) {

    // ✅ Pre-allocate in constructor, not in onDraw
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.BLUE
        style = Paint.Style.FILL
    }
    private val rect = RectF()
    private val path = Path()

    private var cachedRadius = 0f
    private var needsRecalculation = true

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        needsRecalculation = true
    }

    override fun onDraw(canvas: Canvas) {
        if (needsRecalculation) {
            recalculateDrawingParams()
            needsRecalculation = false
        }

        canvas.drawCircle(width / 2f, height / 2f, cachedRadius, paint)

        // ❌ NEVER allocate in onDraw
        // val newPaint = Paint()  // BAD!
    }

    private fun recalculateDrawingParams() {
        cachedRadius = minOf(width, height) / 2f * 0.8f
    }
}

11. Memory Management

Avoiding Memory Leaks

class MyFragment : Fragment() {
    // ✅ Nullable view references
    private var _binding: FragmentBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        _binding = FragmentBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null  // ✅ Clear binding reference
    }
}

12. Profiling & Debugging

Tools Reference

ToolPurpose
Layout InspectorView hierarchy analysis
CPU ProfilerMethod traces, frame timing
Memory ProfilerAllocation tracking, leak detection
GPU ProfilerRendering performance
SystraceSystem-wide analysis
LeakCanaryAutomatic leak detection

ADB Commands

# Dump view hierarchy
adb shell dumpsys activity top

# Dump rendering info
adb shell dumpsys gfxinfo <package_name>

# Reset stats
adb shell dumpsys gfxinfo <package_name> reset

13. Quick Reference

Performance Checklist

  • RecyclerView uses DiffUtil/ListAdapter
  • ViewHolder caches all view references
  • Layout hierarchy < 10 levels deep
  • Using ConstraintLayout for complex layouts
  • No overdraw (check with GPU Overdraw)
  • Hardware acceleration enabled
  • Images sized appropriately
  • No allocations in onDraw()
  • No work on main thread
  • Profiled in release mode

240fps Additional Checks (Gaming Displays)

  • Surface.setFrameRate(240f) for high-refresh surfaces
  • Per-view preferredFrameRate set appropriately
  • Mixed refresh rates for static vs animated content
  • Hardware layers for animated views
  • VSYNC-aligned frame callbacks via Choreographer
  • Zero object allocations in draw path

Common Pitfalls

PitfallSymptomFix
notifyDataSetChanged()Full list rebuildUse DiffUtil
findViewById in bindSlow scrollingCache in ViewHolder
Nested weightsDouble measureUse ConstraintLayout
Large imagesOOM, jankSize appropriately
Allocations in onDrawGC pausesPre-allocate
Main thread I/OANR, jankUse coroutines