Comprehensive practices for building 60-240 FPS UI with Native Android Views
Table of Contents
- Frame Budget & Rendering Pipeline
- RecyclerView Mastery
- Layout Optimization
- View Hierarchy & Overdraw
- Hardware Acceleration & GPU Layers
- Adaptive Refresh Rate (Android 15+)
- Image Loading & Memory
- Animations & Transitions
- Threading & Async Operations
- Custom Views & Drawing
- Memory Management
- Profiling & Debugging
- Quick Reference
1. Frame Budget & Rendering Pipeline
Frame Timing
| Target FPS | Frame Budget | Display Type |
|---|
| 60 FPS | 16.67ms | Standard 60Hz |
| 90 FPS | 11.11ms | Mid-range 90Hz |
| 120 FPS | 8.33ms | High-end 120Hz |
| 240 FPS | 4.17ms | Gaming 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 | Measure Passes | Best For |
|---|
| ConstraintLayout | 1 | Complex layouts |
| LinearLayout | 1 | Simple stacking |
| FrameLayout | 1 | Overlapping views |
| RelativeLayout | 2 | Avoid (legacy) |
| Nested LinearLayout | 2^n | Never 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 Type | Backed By | Use Case |
|---|
| LAYER_TYPE_NONE | N/A | Default, no caching |
| LAYER_TYPE_SOFTWARE | Bitmap | Complex drawing, filters |
| LAYER_TYPE_HARDWARE | GPU texture | Animations |
// 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
ViewPropertyAnimator (Recommended)
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
| Tool | Purpose |
|---|
| Layout Inspector | View hierarchy analysis |
| CPU Profiler | Method traces, frame timing |
| Memory Profiler | Allocation tracking, leak detection |
| GPU Profiler | Rendering performance |
| Systrace | System-wide analysis |
| LeakCanary | Automatic 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
240fps Additional Checks (Gaming Displays)
Common Pitfalls
| Pitfall | Symptom | Fix |
|---|
| notifyDataSetChanged() | Full list rebuild | Use DiffUtil |
| findViewById in bind | Slow scrolling | Cache in ViewHolder |
| Nested weights | Double measure | Use ConstraintLayout |
| Large images | OOM, jank | Size appropriately |
| Allocations in onDraw | GC pauses | Pre-allocate |
| Main thread I/O | ANR, jank | Use coroutines |