Documentation Index
Fetch the complete documentation index at: https://mintlify.com/AmolPardeshi99/android-performance-skills/llms.txt
Use this file to discover all available pages before exploring further.
The View-based UI system has three primary sources of jank on the main thread: synchronous layout inflation for complex hierarchies, expensive work in onBindViewHolder during scroll, and object allocation inside onDraw causing GC pauses every frame.
Layout inflation cost
Layout inflation is synchronous on the main thread. Each XML element involves createViewFromTag, attribute resolution via nativeApplyStyle, and setContentView. Complex or deeply nested layouts inflate slowly, blocking the main thread for the entire duration.
ViewStub for deferred inflation
ViewStub is a zero-cost placeholder that defers the inflation of optional UI sections until they are actually needed. Views declared inside a ViewStub are never parsed or instantiated until stub.inflate() is called.
// ❌ BAD: inflating a large, always-present layout that contains optional sections
// All views in the layout are inflated even if they will never be shown
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.fragment_dashboard_with_everything, container, false)
}
Declare the stub in XML, then inflate only when the condition is met:
// In XML:
// <ViewStub
// android:id="@+id/stub_error_state"
// android:layout="@layout/layout_error_state"
// android:inflatedId="@+id/error_root"
// android:layout_width="match_parent"
// android:layout_height="wrap_content" />
// Inflate on demand — zero cost until this line executes:
if (errorVisible) {
val errorView = binding.stubErrorState.inflate()
}
Use ViewStub for any UI section that is not visible on first render: error states, empty states, expanded detail panels, or feature flags that are often off.
AsyncLayoutInflater for off-thread inflation
For large list item layouts or complex fragments that must be inflated at a predictable time, AsyncLayoutInflater moves the inflation work off the main thread entirely and delivers the result via a callback.
// ✅ CORRECT: AsyncLayoutInflater for off-thread inflation (pre-Compose)
AsyncLayoutInflater(requireContext()).inflate(R.layout.heavy_list_item, container) { view, _, _ ->
container?.addView(view)
}
AsyncLayoutInflater is most useful for the first load of a heavy RecyclerView item type or a bottom sheet that must appear without jank. The callback always runs on the main thread, so view manipulation is safe.
RecyclerView onBindViewHolder — heavy work
onBindViewHolder runs on the main thread for every visible item during scroll. Any filtering, sorting, parsing, or network call here causes frames to be dropped.
Bad: expensive work in bind
// ❌ BAD: expensive work in bind
class PostAdapter(private val rawPosts: List<RawPost>) :
RecyclerView.Adapter<PostViewHolder>() {
override fun onBindViewHolder(holder: PostViewHolder, position: Int) {
// Filtering on Main per bind — O(n) for every scroll frame
val activePosts = rawPosts.filter { it.isActive }
val post = activePosts[position]
// String formatting / Gson parsing per bind
val price = gson.fromJson(post.priceJson, Price::class.java)
holder.bind(post, price)
}
}
Correct: pre-processed domain models from ViewModel
Move all filtering, mapping, and parsing into the ViewModel before the list reaches the adapter. onBindViewHolder should only assign values to views.
// ✅ CORRECT: pre-process in ViewModel; bind only assigns values
class PostAdapter : ListAdapter<Post, PostViewHolder>(PostDiffCallback()) {
override fun onBindViewHolder(holder: PostViewHolder, position: Int) {
holder.bind(getItem(position)) // Post is already processed domain model
}
}
// ViewModel pre-processes before submitting to adapter:
viewModelScope.launch {
val posts = withContext(Dispatchers.Default) {
rawPosts.filter { it.isActive }.map { it.toDomainPost() }
}
adapter.submitList(posts)
}
DiffUtil must run off main
Calculating a diff on the main thread blocks the UI during large list changes. ListAdapter uses AsyncListDiffer internally and handles this automatically.
// ❌ BAD: calculateDiff on Main thread — blocks UI during large list changes
val diff = DiffUtil.calculateDiff(MyDiffCallback(old, new))
adapter.dispatchUpdatesFrom(diff)
// ✅ CORRECT: ListAdapter uses AsyncListDiffer internally — DiffUtil runs on a background thread
adapter.submitList(newList)
// ✅ CORRECT for manual DiffUtil:
viewModelScope.launch {
val diff = withContext(Dispatchers.Default) {
DiffUtil.calculateDiff(MyDiffCallback(oldList, newList))
}
adapter.applyDiff(diff)
}
onDraw — object allocations
onDraw is called for every frame. Object allocation inside onDraw causes GC pressure that interrupts frame rendering. The garbage collector pauses are unpredictable and happen exactly when the frame pipeline is under load.
Never allocate Paint, Path, RectF, or any other object inside onDraw. Even a single allocation per frame accumulates into GC pauses that cause missed frames and visible stutter. Android’s hardware-accelerated pipeline has no frame budget to spare for GC.
// ❌ BAD: Paint, Path, and RectF allocated on every frame
class GraphView(context: Context) : View(context) {
override fun onDraw(canvas: Canvas) {
val paint = Paint() // GC pressure every frame
val path = Path()
val rect = RectF(0f, 0f, width.toFloat(), height.toFloat())
paint.color = Color.RED
canvas.drawRect(rect, paint)
}
}
// ✅ CORRECT: allocate objects once in init or as class-level fields
class GraphView(context: Context) : View(context) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.RED }
private val path = Path()
private val rect = RectF()
override fun onDraw(canvas: Canvas) {
rect.set(0f, 0f, width.toFloat(), height.toFloat())
canvas.drawRect(rect, paint)
}
}
RenderThread blocking — nSyncDraw
The RenderThread performs GPU operations independently of the main thread. However, before each frame, the main thread calls nSyncDraw to hand off draw commands. This call blocks the main thread until the RenderThread’s previous frame completes. If the RenderThread is slow — due to heavy canvas operations, large bitmaps, or shader compilation — the main thread is directly delayed.
The signature in an ANR trace when the main thread is waiting for the RenderThread:
"main" prio=5 tid=1 Native
native: ... libhwui.so (RenderProxy::syncAndDrawFrame)
at android.view.ThreadedRenderer.nSyncAndDrawFrame(Native method)
at android.view.ThreadedRenderer.draw(ThreadedRenderer.java)
at android.view.ViewRootImpl.draw(ViewRootImpl.java)
Bitmap decoding
Decoding a large image on the main thread and passing it directly to the canvas forces the RenderThread to work with an oversized bitmap. Decode on a background dispatcher and scale to display dimensions first.
// ❌ BAD: large Bitmap decoded on Main then passed to canvas — blocks RenderThread
class HeavyView(context: Context) : View(context) {
private var bitmap: Bitmap? = null
fun setImage(path: String) {
// Decoding a 10 MB image takes 100-500ms — RenderThread is then busy with it
bitmap = BitmapFactory.decodeFile(path)
invalidate()
}
}
// ✅ CORRECT: decode on IO dispatcher; scale to display size before handing to RenderThread
class HeavyView(context: Context) : View(context) {
private var bitmap: Bitmap? = null
suspend fun setImage(path: String, targetWidth: Int, targetHeight: Int) {
bitmap = withContext(Dispatchers.IO) {
val opts = BitmapFactory.Options().apply {
inJustDecodeBounds = true
BitmapFactory.decodeFile(path, this)
inSampleSize = calculateInSampleSize(this, targetWidth, targetHeight)
inJustDecodeBounds = false
}
BitmapFactory.decodeFile(path, opts)
}
invalidate() // now back on Main; bitmap is correctly sized
}
}
During cold start, if the first frame takes too long — due to heavy onCreate, complex layouts, or synchronous I/O — the InputDispatcher 5-second clock starts as soon as the window is focused, even before the first frame is drawn. Touches during this window can trigger input ANRs.
Defer all non-critical startup work past the first frame using window.decorView.post {}. The posted block runs after the first layout and draw pass, so the window is already interactive when the heavy work begins.
// ✅ Defence: defer all non-critical startup work past the first frame
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// Post heavy work AFTER the first frame to avoid blocking input dispatch
window.decorView.post {
// This runs after the first layout and draw pass
lifecycleScope.launch(Dispatchers.IO) { performHeavyStartup() }
}
}
}