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 systrace
s 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
.
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 Fragment
s.
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.
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
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.
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...