Here is the list of the blogs in this series:

When the XML file is too large or initializing the layout view takes a while Androidx AsyncLayoutInflater helps to load those layouts asynchronously

Loading XML layout into memory through IO operations and parsing views through reflection can be expensive. We know that when the main thread performs some time-consuming operations, it may cause the page to freeze, and even more, serious ANR may occur

too large and expensive item

When you do layout inflation in runtime your app should render frames in about 16ms to achieve 60 frames per second however average inflating time for RecyclerView complex Item and run some animation at the same time was around 30ms frame time and our FPS drops significantly.

How to find out drop FPS ?

we can use Android Studio Profiler. Open CPU Profiler -> select “CPU profiling mode” as “Trace System Calls” and press “record”.

Before async loading

As you can see that after inflating 4+ items we have a frame which takes 138 ms and almost all of it was taken by “RV OnLayout” . That’s our RecyclerView is trying to create/inflate its ViewHolders.

however after using AsyncLayoutInflator, it offloads from the main thread but runs sequentially in the background thread.

we’ve created a custom class AsyncCell. It extends FrameLayout which serves as “dummy item” before the actual view is loaded. When our target view is loaded we add it as a child to the initially loaded FrameLayout. AsyncCell has “createDataBindingView” method which should be overriden in order to use databinding solving problem that AsyncLayoutInflater doesn’t support databinding.

import android.content.Context
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import androidx.asynclayoutinflater.view.AsyncLayoutInflater

open class AsyncCell(context: Context) : FrameLayout(context, null, 0, 0) {
init {
layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT)
}

open val layoutId = -1 // override with your layout Id
private var isInflated = false
private val bindingFunctions: MutableList<AsyncCell.() -> Unit> = mutableListOf()

fun inflate() {
AsyncLayoutInflater(context).inflate(layoutId, this) { view, _, _ ->
isInflated = true
addView(createDataBindingView(view))
bindView()
}
}

private fun bindView() {
with(bindingFunctions) {
forEach { it() }
clear()
}
}

fun bindWhenInflated(bindFunc: AsyncCell.() -> Unit) {
if (isInflated) {
bindFunc()
} else {
bindingFunctions.add(bindFunc)
}
}

open fun createDataBindingView(view: View): View? = view // override for usage with dataBinding

}
private inner class LargeItemCell(context: Context) : AsyncCell(context) {
var binding: LargeItemCellBinding? = null
override val layoutId = R.layout.large_item_cell
override fun createDataBindingView(view: View): View? {
binding = LargeItemCellBinding.bind(view)
return view.rootView
}
}

private inner class SmallItemCell(context: Context) : AsyncCell(context) {
var binding: SmallItemCellBinding? = null
override val layoutId = R.layout.small_item_cell
override fun createDataBindingView(view: View): View? {
binding = SmallItemCellBinding.bind(view)
return view.rootView
}
}

After we used AsyncLayoutInflater and databinding we get better performance.

With async loading

You can see that the maximum frame time now is ~29ms (it was ~140 before) . So AsyncLayoutInflator reduce the work from the main thread.

Async view stubs

Suppose we want to use ViewStub that optimize inflation time of big layout.

This approach split layout inflation on several steps: first of all we will inflate part of layout that will be visible on that screen immediately and then we can inflate remain parts while user will investigate information on screen that he already received.

However inflation of view stubs happens in the main thread. So we will include usage of ViewStub and AsyncLayoutInflater. Here is a sample of how you can create an AsyncViewStub :

class AsyncViewStub @JvmOverloads constructor(
context: Context, set: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, set, defStyleAttr) {
private val inflater: AsyncLayoutInflater by lazy { AsyncLayoutInflater(context) }
private var inflatedId = NO_ID
get
private var layoutRes = 0
init {
val attrs = intArrayOf(android.R.attr.layout, android.R.attr.inflatedId)
context.obtainStyledAttributes(set, attrs, defStyleAttr, 0).use {
layoutRes = getResourceId(0, 0)
inflatedId = getResourceId(1, NO_ID)
}
visibility = View.GONE
setWillNotDraw(true)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (isInEditMode) {
val view = inflate(context, layoutRes, null)
(parent as? ViewGroup)?.let {
it.addViewSmart(view, it.indexOfChild(this), layoutParams)
it.removeViewInLayout(this)
}
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
setMeasuredDimension(0, 0)
}
override fun draw(canvas: Canvas) {}
override fun dispatchDraw(canvas: Canvas) {}
fun inflate(listener: AsyncLayoutInflater.OnInflateFinishedListener? = null) {
val viewParent = parent
if (viewParent != null && viewParent is ViewGroup) {
if (layoutRes != 0) {
inflater.inflate(layoutRes, viewParent) { view, resId, parent ->
if (inflatedId != NO_ID) {
view.id = inflatedId
}
val stub = this
val index = parent.removeViewInLayoutIndexed(stub)
parent.addViewSmart(view, index, layoutParams)
listener?.onInflateFinished(view, resId, parent)
}
} else {
throw IllegalArgumentException("AsyncViewStub must have a valid layoutResource")
}
} else {
throw IllegalStateException("AsyncViewStub must have a non-null ViewGroup viewParent")
}
}
private fun ViewGroup.removeViewInLayoutIndexed(view: View): Int {
val index = indexOfChild(view)
removeViewInLayout(view)
return index
}
private fun ViewGroup.addViewSmart(child: View, index: Int, params: LayoutParams? = null) {
if (params == null) addView(child, index)
else addView(child, index, params)
}
}

You can checkout this article to find out how use it.

However, there are some limitations about AsyncLayoutInflator :

  • Single thread to do all the inflate work

As we know AsyncLayoutInflator offloads from the main thread and runs sequentially in the background thread but ne of our items took a long time to inflate so it does not help us save time.

Our solution for that is using coroutine with default dispatch, which has the maximum parallelism equal to the number of CPU cores. As a result, the recycler view will load and scroll faster.

The inflate work cannot be controlled once it has been scheduled with androidx inflate. Inflating callbacks can result in crashes if the user closes the page before the work is complete. Coroutines allow us to control the inflation of work jobs and cancel the context of lifecycle events.

Hook you can supply that is called when inflating from a LayoutInflater. You can use this to customize the tag names available in your XML layout files. Note that it is good practice to prefix these custom names with your package (i.e., com.coolcompany.apps) to avoid conflicts with system names.

This inflater does not support setting a LayoutInflater.Factory nor LayoutInflater.Factory2.

  • The default size limit of the cache queue is 10. If it exceeds 10, it will cause the main thread to wait

here as you can see, in AsyncLayoutInflator if the job exceeds 10, the main thread will wait. It has been replaced by a coroutine.

So let’s improve AsyncLayoutInflater with a coroutine.

/**
*
* Helper class for inflating layouts asynchronously. To use, construct
* an instance of [AsyncLayoutInflater] on the UI thread and call
* [.inflate]. The
* [OnInflateFinishedListener] will be invoked on the UI thread
* when the inflate request has completed.
*
*
* This is intended for parts of the UI that are created lazily or in
* response to user interactions. This allows the UI thread to continue
* to be responsive & animate while the relatively heavy inflate
* is being performed.
*
*
* For a layout to be inflated asynchronously it needs to have a parent
* whose [ViewGroup.generateLayoutParams] is thread-safe
* and all the Views being constructed as part of inflation must not create
* any [Handler]s or otherwise call [Looper.myLooper]. If the
* layout that is trying to be inflated cannot be constructed
* asynchronously for whatever reason, [AsyncLayoutInflater] will
* automatically fall back to inflating on the UI thread.
*
*
* NOTE that the inflated View hierarchy is NOT added to the parent. It is
* equivalent to calling [LayoutInflater.inflate]
* with attachToRoot set to false. Callers will likely want to call
* [ViewGroup.addView] in the [OnInflateFinishedListener]
* callback at a minimum.
*
*
* This inflater does not support setting a [LayoutInflater.Factory]
* nor [LayoutInflater.Factory2]. Similarly it does not support inflating
* layouts that contain fragments.
*/
class AsyncCoroutineLayoutInflater : LifecycleEventObserver {
lateinit var mInflater: LayoutInflater

private val coroutineContext = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.Default + coroutineContext)

private lateinit var context: Context
private var fragment: Fragment? = null

constructor(context: Context) {
doStart(context)
}

/**
* @param fragment For view inflation & adding Observer to the [Fragment.getViewLifecycleOwner].
*/
constructor(fragment: Fragment) {
this.fragment = fragment
doStart(fragment.requireContext())
}

/**
* @param view For child view inflation in Custom Views.
* Inflation is cancelled if the view provided is detached from the window.
*/
constructor(view: View) {
this.context = view.context
view.onViewDetachedFromWindow { cancelChildJobs() }
}

private fun doStart(context: Context) {
this.context = context
mInflater = BasicInflater(context)

val componentLifecycle = when {
fragment != null -> fragment?.viewLifecycleOwner?.lifecycle
context is LifecycleOwner -> context.lifecycle
else -> null
}

if (componentLifecycle != null) {
componentLifecycle.addObserver(this)
} else {
Log.d(
TAG,
"Current context does not seem to have a Lifecycle, make sure to call `cancel()` " +
"in your onDestroy or other appropriate lifecycle callback."
)
}
}

private fun cancel() {
coroutineContext.cancel()
coroutineContext.cancelChildren()
}

private fun cancelChildJobs() {
coroutineContext.cancelChildren()
}

override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_DESTROY) {
cancel()
}
}

fun inflate(
@LayoutRes resId: Int,
parent: ViewGroup?,
callback: suspend (view: View) -> Unit,
) {
scope.launch {
val view = inflateView(resId, parent)
withContext(Dispatchers.Main) { callback.invoke(view) }
}
}

private suspend fun inflateView(
@LayoutRes resId: Int,
parent: ViewGroup?,
): View = try {
mInflater.inflate(resId, parent, false)
} catch (ex: RuntimeException) {
Log.e(TAG, "The background thread failed to inflate. Inflation falls back to the main thread. Error message=${ex.message}")

// Some views need to be inflation-only in the main thread,
// fall back to inflation in the main thread if there is an exception
withContext(Dispatchers.Main) { mInflater.inflate(resId, parent, false) }
}

private class BasicInflater constructor(context: Context?) :
LayoutInflater(context) {
override fun cloneInContext(newContext: Context): LayoutInflater {
return BasicInflater(newContext)
}

@Throws(ClassNotFoundException::class)
override fun onCreateView(name: String, attrs: AttributeSet): View {
for (prefix in sClassPrefixList) {
try {
val view = createView(name, prefix, attrs)
if (view != null) return view
} catch (e: ClassNotFoundException) {
// In this case we want to let the base class take a crack
// at it.
}
}
return super.onCreateView(name, attrs)
}

init {
if (context is AppCompatActivity) {
val appCompatDelegate = context.delegate
if (appCompatDelegate is Factory2) {
LayoutInflaterCompat.setFactory2(this, appCompatDelegate)
}
}
}

companion object {
private val sClassPrefixList = arrayOf(
"android.widget.",
"android.webkit.",
"android.app."
)
}
}

companion object {
private const val TAG = "AsyncLayoutInflater"
}


}

And let’s change AsyncLayoutInflater with AsyncCoroutineLayoutInflater in AsyncCell class :

import android.content.Context
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import androidx.asynclayoutinflater.view.AsyncLayoutInflater

open class AsyncCell(context: Context) : FrameLayout(context, null, 0, 0) {
init {
layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT)
}

open val layoutId = -1 // override with your layout Id
private var isInflated = false
private val bindingFunctions: MutableList<AsyncCell.() -> Unit> = mutableListOf()

fun inflate() {
AsyncCoroutineLayoutInflater(context).inflate(layoutId, this) { view, _, _ ->
isInflated = true
addView(createDataBindingView(view))
bindView()
}
}

private fun bindView() {
with(bindingFunctions) {
forEach { it() }
clear()
}
}

fun bindWhenInflated(bindFunc: AsyncCell.() -> Unit) {
if (isInflated) {
bindFunc()
} else {
bindingFunctions.add(bindFunc)
}
}

open fun createDataBindingView(view: View): View? = view // override for usage with dataBinding

}

After executed this code we get performed smooth RecyclerView

after using AsyncCoroutineLayoutInflater

--

--