Search code examples
androidperformanceandroid-alertdialogmaterial-components-androidandroid-viewbinding

Why is MaterialAlertDialog so slow when measuring a custom view/layout?


I'm working on a hobby project where I'm building a custom Bluetooth remote that can be configured using a companion native Android app. Everything is working just fine but I'm running into this weird performance issue with the MaterialAlertDialog using a custom layout and View Binding.
To be specific, when first showing the dialog, inflating, measuring, and then drawing the dialog takes quite a long time. According to some systraces I've recorded, the UI hangs for about 1 to 2 seconds before rendering the next frame after starting the MaterialAlertDialogBuilder logic.

For context, this screenshot from Android Studio shows the UI I've built and this is the custom view for the AlertDialog.
Android Studio Layout Screenshot

These are the related libraries I'm using and their respective versions

Kotlin 1.4.30
AndroidX AppCompat 1.3.0-beta01
AndroidX ConstraintLayout 2.0.4
AndroidX Fragment KTX 1.3.0
Material Components 1.3.0

And here's the XML for this layout

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:paddingHorizontal="24dp"
    android:paddingTop="24dp">

    <com.google.android.material.textview.MaterialTextView
        android:id="@+id/dialogEditPresetHotkeyButtonPosition"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:maxLines="1"
        android:textAppearance="@style/TextAppearance.ctrl.Overline"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.textview.MaterialTextView
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:maxLines="1"
        android:text="@string/edit_hotkey"
        android:textAppearance="@style/TextAppearance.ctrl.Headline6.Emphasis"
        app:layout_constraintEnd_toEndOf="@id/dialogEditPresetHotkeyButtonPosition"
        app:layout_constraintStart_toStartOf="@id/dialogEditPresetHotkeyButtonPosition"
        app:layout_constraintTop_toBottomOf="@id/dialogEditPresetHotkeyButtonPosition" />

    <androidx.appcompat.widget.AppCompatSpinner
        android:id="@+id/dialogEditPresetHotkeyGroup"
        android:layout_width="0dp"
        android:layout_height="48dp"
        android:layout_marginTop="40dp"
        android:layout_marginEnd="1dp"
        android:background="@drawable/bg_spinner_key_groups"
        app:layout_constraintEnd_toStartOf="@id/dialogEditPresetHotkeyKeyContainer"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/dialogEditPresetHotkeyButtonPosition" />

    <FrameLayout android:id="@+id/dialogEditPresetHotkeyKeyContainer"
        android:layout_width="0dp"
        android:layout_height="48dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toEndOf="@+id/dialogEditPresetHotkeyGroup"
        app:layout_constraintTop_toTopOf="@+id/dialogEditPresetHotkeyGroup">

        <androidx.appcompat.widget.AppCompatEditText
            android:id="@+id/dialogEditPresetHotkeyKeyAlphanumeric"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:paddingHorizontal="16dp"
            android:background="@drawable/bg_key"
            android:digits="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
            android:inputType="textCapCharacters"
            android:maxLength="1" />

        <androidx.appcompat.widget.AppCompatSpinner
            android:id="@+id/dialogEditPresetHotkeyKey"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@drawable/bg_spinner_keys"
            android:visibility="gone" />

    </FrameLayout>

    <com.google.android.material.button.MaterialButton
        android:id="@+id/dialogEditPresetHotkeyModifierShift"
        style="@style/Widget.ctrl.Button.Outline.Narrow"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:checkable="true"
        android:text="@string/shift"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/dialogEditPresetHotkeyGroup" />

    <com.google.android.material.button.MaterialButtonToggleGroup
        android:id="@+id/dialogEditPresetHotkeyModifiers"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/dialogEditPresetHotkeyModifierShift">

        <com.google.android.material.button.MaterialButton
            android:id="@+id/dialogEditPresetHotkeyModifierCtrl"
            style="@style/Widget.ctrl.Button.Outline.Narrow"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/ctrl" />

        <com.google.android.material.button.MaterialButton
            android:id="@+id/dialogEditPresetHotkeyModifierMeta"
            style="@style/Widget.ctrl.Button.Outline.Narrow"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/meta" />

        <com.google.android.material.button.MaterialButton
            android:id="@+id/dialogEditPresetHotkeyModifierAlt"
            style="@style/Widget.ctrl.Button.Outline.Narrow"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/alt" />

    </com.google.android.material.button.MaterialButtonToggleGroup>

    <com.google.android.material.button.MaterialButton
        android:id="@+id/dialogEditPresetHotkeyModifierRShift"
        style="@style/Widget.ctrl.Button.Outline.Narrow"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:checkable="true"
        android:text="@string/rshift"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="@id/dialogEditPresetHotkeyModifierShift" />

    <com.google.android.material.button.MaterialButtonToggleGroup
        android:id="@+id/dialogEditPresetHotkeyRModifiers"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@id/dialogEditPresetHotkeyModifiers">

        <com.google.android.material.button.MaterialButton
            android:id="@+id/dialogEditPresetHotkeyModifierRAlt"
            style="@style/Widget.ctrl.Button.Outline.Narrow"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/ralt" />

        <com.google.android.material.button.MaterialButton
            android:id="@+id/dialogEditPresetHotkeyModifierRMeta"
            style="@style/Widget.ctrl.Button.Outline.Narrow"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/rmeta" />

        <com.google.android.material.button.MaterialButton
            android:id="@+id/dialogEditPresetHotkeyModifierRCtrl"
            style="@style/Widget.ctrl.Button.Outline.Narrow"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@string/rctrl" />

    </com.google.android.material.button.MaterialButtonToggleGroup>

</androidx.constraintlayout.widget.ConstraintLayout>

To help keep my Fragment's code clean, I've written a few Kotlin Extension functions to set up the MaterialAlertDialog, with ViewBinding, like I'm also using in my Fragments.

class ViewBindingMaterialAlertDialogBuilder<VB : ViewBinding>(
    context: Context,
    viewBindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> VB
) : MaterialAlertDialogBuilder(context) {
    val viewBinding: VB by lazy {
        viewBindingInflater(LayoutInflater.from(context), null, false)
    }
}
inline fun <VB: ViewBinding> Context.customViewAlertDialogBuilder(
    noinline viewBindingInflater: (LayoutInflater, ViewGroup?, Boolean) -> VB,
    cancelable: Boolean = false,
    showManually: Boolean = false,
    init: ViewBindingMaterialAlertDialogBuilder<VB>.() -> Unit
) = ViewBindingMaterialAlertDialogBuilder(this, viewBindingInflater).apply {
    setCancelable(cancelable)
    init()
    if (!showManually) show()
}
inline fun <VB : ViewBinding> ViewBindingMaterialAlertDialogBuilder<VB>.initCustomView(
    init: VB.() -> Unit
) = setView(viewBinding.apply(init).root)
inline fun MaterialAlertDialogBuilder.positiveButton(
    @StringRes text: Int,
    automaticallyDismiss: Boolean = true,
    crossinline action: () -> Unit
) = setPositiveButton(text) { dialog, _ -> if (automaticallyDismiss) dialog.dismiss(); action() }
inline fun MaterialAlertDialogBuilder.negativeButton(
    @StringRes text: Int,
    automaticallyDismiss: Boolean = true,
    crossinline action: () -> Unit
) = setNegativeButton(text) { dialog, _ -> if (automaticallyDismiss) dialog.dismiss(); action() }
inline fun MaterialAlertDialogBuilder.neutralButton(
    @StringRes text: Int,
    automaticallyDismiss: Boolean = true,
    crossinline action: () -> Unit
) = setNeutralButton(text) { dialog, _ -> if (automaticallyDismiss) dialog.dismiss(); action() }
inline fun MaterialAlertDialogBuilder.show(
    crossinline onShow: AlertDialog.() -> Unit
) = create().apply {
    setOnShowListener { onShow(it as AlertDialog) }
}.show()

And then I use these as follows in my Fragment.
NOTE: The actual code described in the comments below had been commented out at the time of recording the systrace further down. This was done to make sure the initialization code wasn't impacting performance or skewing data.

context.customViewAlertDialogBuilder(DialogEditPresetHotkeyBinding::inflate, showManually = true) {
    initCustomView {
        //Populate TextView and set two adapters for the Spinners
    }
    positiveButton(R.string.save) {
        //Persist entered value to device
    }
    negativeButton(R.string.discard) {} //Simply dismisses
    neutralButton(R.string.clear) {
        //Persists default value to device
    }
    show {
        /*
        Set OnItemSelectedListeners for Spinners
        Add TextWatcher for EditText
        Check if entered values are valid (else disable Save button)
         */
    }
}

After then cleaning, building, and running my app on a physical device (LG G6 H870), I get the following systrace.
NOTE: I restarted my phone before running this trace to make sure the least amount of apps possible were running in the background to not skew results. AlertDialog Systrace

For good measure (pun intended) and as a performance comparison, I used the same layout in a separate Fragment

class TestFragment : Fragment() {
    private var viewBinding: DialogEditPresetHotkeyBinding? = null

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

    override fun onDestroyView() {
        super.onDestroyView()
        viewBinding = null
    }
}

And the systrace when navigating to this Fragment.
NOTE: I force-stopped the app from Settings, then ran it after again cleaning and building Fragment Systrace


So, after all that, my question boils down to:

Why is inflating and measuring a custom layout in an AlertDialog so much slower than when using a Fragment?

I know there probably are some optimizations possible within my XML, but it still takes over a second to first show the AlertDialog, compared to half that when using a Fragment.
This is a best-case scenario as I've seen it go as high as 1.7 seconds with some apps running in the background. This is way higher than the 700ms Android 'jank' threshold, so I'm also getting Davey! log messages, skipped frames, halted ripple animations, etc.

P.S. I know this is a very long question, thank you for reading :)


EDIT 1
Adding my styles.xml and themes.xml, nothing but colors and some dimensions, I like to keep it simple, plus the fact that I'm not a designer :)

styles.xml

<resources>
    <style name="Widget.ctrl.AlertDialog" parent="MaterialAlertDialog.MaterialComponents">
        <item name="backgroundInsetTop">@dimen/mtrl_alert_dialog_background_inset_start</item>
    </style>

    <style name="Widget.ctrl.Button.Outline.Narrow" parent="Widget.MaterialComponents.Button.OutlinedButton">
        <item name="android:minWidth">64dp</item>
    </style>
    <style name="Widget.ctrl.Button.Dialog" parent="Widget.MaterialComponents.Button.TextButton.Dialog">
    </style>
    <style name="Widget.ctrl.Button.Dialog.Positive">
        <item name="android:textColor">@color/dialog_button_positive</item>
        <item name="rippleColor">@color/colorPrimary</item>
    </style>
    <style name="Widget.ctrl.Button.Dialog.Negative">
        <item name="android:textColor">@color/colorError</item>
        <item name="rippleColor">@color/colorError</item>
    </style>
    <style name="Widget.ctrl.Button.Dialog.Neutral">
        <item name="android:textColor">@color/colorOnSurface</item>
        <item name="rippleColor">@color/colorOnSurface</item>
    </style>

    <style name="TextAppearance.ctrl.Headline6" parent="TextAppearance.MaterialComponents.Headline6">
        <item name="android:textColor">@color/colorTextNormal</item>
    </style>
    <style name="TextAppearance.ctrl.Headline6.Emphasis">
        <item name="android:textColor">@color/colorTextEmphasis</item>
    </style>
    <style name="TextAppearance.ctrl.Subtitle1" parent="TextAppearance.MaterialComponents.Subtitle1">
        <item name="android:textColor">@color/colorTextNormal</item>
    </style>
    <style name="TextAppearance.ctrl.Subtitle1.Emphasis">
        <item name="android:textColor">@color/colorTextEmphasis</item>
    </style>
    <style name="TextAppearance.ctrl.Body1" parent="TextAppearance.MaterialComponents.Body1">
        <item name="android:textColor">@color/colorTextNormal</item>
    </style>
    <style name="TextAppearance.ctrl.Body1.Emphasis">
        <item name="android:textColor">@color/colorTextEmphasis</item>
    </style>
    <style name="TextAppearance.ctrl.Body2" parent="TextAppearance.MaterialComponents.Body2">
        <item name="android:textColor">@color/colorTextNormal</item>
    </style>
    <style name="TextAppearance.ctrl.Overline" parent="TextAppearance.MaterialComponents.Overline">
        <item name="android:textColor">@color/colorTextNormal</item>
    </style>

    <style name="ShapeAppearance.ctrl.40dpCircle" parent="ShapeAppearance.MaterialComponents">
        <item name="cornerSize">20dp</item>
    </style>
</resources>

themes.xml

<resources>
    <style name="Theme.ctrl" parent="Theme.MaterialComponents.NoActionBar">
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryVariant">@color/colorPrimaryVariant</item>
        <item name="colorOnPrimary">@color/colorOnPrimary</item>
        <item name="colorSecondary">@color/colorSecondary</item>
        <item name="colorSecondaryVariant">@color/colorSecondaryVariant</item>
        <item name="colorOnSecondary">@color/colorOnSecondary</item>
        <item name="android:colorBackground">@color/colorBackground</item>
        <item name="colorOnBackground">@color/colorOnBackground</item>
        <item name="colorSurface">@color/colorSurface</item>
        <item name="colorOnSurface">@color/colorOnSurface</item>
        <item name="colorError">@color/colorError</item>
        <item name="colorOnError">@color/colorOnError</item>
        <item name="android:statusBarColor">@color/colorBackground</item>

        <item name="materialAlertDialogTheme">@style/ThemeOverlay.ctrl.MaterialAlertDialog</item>
    </style>

    <style name="ThemeOverlay.ctrl.MaterialAlertDialog" parent="ThemeOverlay.MaterialComponents.MaterialAlertDialog">
        <item name="elevationOverlayEnabled">false</item>
        <item name="alertDialogStyle">@style/Widget.ctrl.AlertDialog</item>
        <item name="buttonBarPositiveButtonStyle">@style/Widget.ctrl.Button.Dialog.Positive</item>
        <item name="buttonBarNegativeButtonStyle">@style/Widget.ctrl.Button.Dialog.Negative</item>
        <item name="buttonBarNeutralButtonStyle">@style/Widget.ctrl.Button.Dialog.Neutral</item>
    </style>
</resources>

EDIT 2:
I tried using the AndroidX AsyncLayoutInflater to see if it makes a difference. It does...
...but it feels even worse. What ends up happening is that the ripple animation no longer hangs halfway through, instead, it completes and a second later, a dialog finally pops up. I feel this is confusing to a user as one part of the interface feels responsive but there's a lot of lag on the dialog showing due to the long measuring. So I don't see this as a fix.


Solution

  • In the end, it turned out my device I was testing on was just very slow... I switched from an LG G6 to a OnePlus 8T and the dialog now pops up almost instantaneously, oh well...