Epoxy: Random IllegalStateException when using WrappedEpoxyCheckedChangeListener

Created on 7 Aug 2019  路  15Comments  路  Source: airbnb/epoxy

I'm using epoxy 3.6.0 with databinding.

My layout looks like this:

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>

        <variable
            name="text"
            type="Integer" />

        <variable
            name="checked"
            type="Boolean" />

        <variable
            name="checkedChangeListener"
            type="android.widget.CompoundButton.OnCheckedChangeListener" />

    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:paddingStart="@dimen/keyline_5"
        android:paddingEnd="@dimen/keyline_5"
        android:paddingTop="@dimen/keyline_4"
        android:paddingBottom="@dimen/keyline_4"
        android:foreground="?selectableItemBackground">

        <TextView
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_gravity="center_vertical"
            android:layout_marginEnd="32dp"
            style="@style/TextAppearance.App.SimpleItemTitle"
            android:text="@{text}"
            android:layout_weight="1"/>

        <androidx.appcompat.widget.SwitchCompat
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_vertical"
            android:checked="@{checked}"
            app:onCheckedChangeListener="@{checkedChangeListener}"/>

    </LinearLayout>

</layout>

The relevant part of my AsyncEpoxyController(enableAsyncModelBuilding = false, enableAsyncDiffing = true)'s (but also happens with a regular EpoxyController) buildModels() function looks like this:

    switchNoIconItem {
            id("someSwitch")
            text(R.string.switch_text)
            checked(settings.isSomeSettingEnabled)
            checkedChangeListener { _, _, _, isChecked, _ ->
                // handle check change
            }
        }

Sometimes (seemingly purely randomly) I the app is crashing with the following exception:

java.lang.IllegalStateException: Could not find RecyclerView holder for clicked view
        at com.airbnb.epoxy.WrappedEpoxyModelCheckedChangeListener.onCheckedChanged(WrappedEpoxyModelCheckedChangeListener.java:31)
        at android.widget.CompoundButton.setChecked(CompoundButton.java:180)
        at androidx.appcompat.widget.SwitchCompat.setChecked(SwitchCompat.java:1064)
        at androidx.databinding.adapters.CompoundButtonBindingAdapter.setChecked(CompoundButtonBindingAdapter.java:44)
        at de.whisp.clear.databinding.ItemLayoutSwitchNoIconItemBindingImpl.executeBindings(ItemLayoutSwitchNoIconItemBindingImpl.java:249)
        at androidx.databinding.ViewDataBinding.executeBindingsInternal(ViewDataBinding.java:472)
        at androidx.databinding.ViewDataBinding.executePendingBindings(ViewDataBinding.java:444)
        at com.airbnb.epoxy.DataBindingEpoxyModel.bind(DataBindingEpoxyModel.java:56)
        at com.airbnb.epoxy.DataBindingEpoxyModel.bind(DataBindingEpoxyModel.java:36)
        at com.airbnb.epoxy.EpoxyViewHolder.bind(EpoxyViewHolder.java:58)
        at com.airbnb.epoxy.BaseEpoxyAdapter.onBindViewHolder(BaseEpoxyAdapter.java:98)
        at com.airbnb.epoxy.BaseEpoxyAdapter.onBindViewHolder(BaseEpoxyAdapter.java:15)
        at androidx.recyclerview.widget.RecyclerView$Adapter.bindViewHolder(RecyclerView.java:7075)
        at androidx.recyclerview.widget.RecyclerView$Recycler.tryBindViewHolderByDeadline(RecyclerView.java:5991)
        at androidx.recyclerview.widget.RecyclerView$Recycler.tryGetViewHolderForPositionByDeadline(RecyclerView.java:6258)
        at androidx.recyclerview.widget.RecyclerView$Recycler.getViewForPosition(RecyclerView.java:6097)
        at androidx.recyclerview.widget.RecyclerView$Recycler.getViewForPosition(RecyclerView.java:6093)
        at androidx.recyclerview.widget.LinearLayoutManager$LayoutState.next(LinearLayoutManager.java:2303)
        at androidx.recyclerview.widget.LinearLayoutManager.layoutChunk(LinearLayoutManager.java:1627)
        at androidx.recyclerview.widget.LinearLayoutManager.fill(LinearLayoutManager.java:1587)
        at androidx.recyclerview.widget.LinearLayoutManager.onLayoutChildren(LinearLayoutManager.java:665)
        at androidx.recyclerview.widget.RecyclerView.dispatchLayoutStep2(RecyclerView.java:4115)
        at androidx.recyclerview.widget.RecyclerView.dispatchLayout(RecyclerView.java:3832)
        at androidx.recyclerview.widget.RecyclerView.consumePendingUpdateOperations(RecyclerView.java:1900)
        at androidx.recyclerview.widget.RecyclerView$1.run(RecyclerView.java:416)
        at android.view.Choreographer$CallbackRecord.run(Choreographer.java:966)
        at android.view.Choreographer.doCallbacks(Choreographer.java:790)
        at android.view.Choreographer.doFrame(Choreographer.java:721)
        at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:951)
        at android.os.Handler.handleCallback(Handler.java:883)
        at android.os.Handler.dispatchMessage(Handler.java:100)
        at android.os.Looper.loop(Looper.java:214)
        at android.app.ActivityThread.main(ActivityThread.java:7343)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:933)

I never saw this happening with the old WrappedEpoxyModelClickListener and I cannot reproduce it reliably - it just happens sometimes when clicking on the switch. Any idea what might cause this?

All 15 comments

I seem to have found a workaround, although I don't think it is an actual fix for the underlying issue: I am now always checking whether the model has already has the same state like the state the switch listener just received. In code this looks like this:

switchNoIconItem {
            id("someSwitch")
            text(R.string.switch_text)
            checked(settings.isSomeSettingEnabled)
            checkedChangeListener { model, _, _, isChecked, _ ->
                if (model.checked() != isChecked) {
                    // handle check change
                }
            }
        }

The fact that this seems to work might indicate that the underlying issue is actually only triggered when the same state is fed rapidly into the WrappedEpoxyCheckedChangeListener...

Unfortunately I was wrong - this did not fix it. :( Back to the drawing board.

FYI: For the time being I'll go with the good old unwrapped CheckedChangeListener in my controller as I wasn't able to prevent the crash from happening.

Thanks for reporting, this is indeed an issue.

It looks like the problem is that when the view is bound, if the check listener is set before the checked value is set, the initial binding of the checked value triggers a call to the listener.

The listener tries to look up which recyclerview item it is in, so it can do the nifty wrapping helper callback, and throws this error when it realized it isn't fully bound yet.

I'm going to assume that the behavior we want is for the initial bind to _not_ trigger the checked change callback (although your vanilla listener is likely doing that now) - it is wasteful and unnecessary because your data should be in the same state that was just bound.

So then the question is, how can we prevent this listener from calling back in the case where it is not yet bound.

The easy solution is to remove the error throwing and just ignore it

 public void onCheckedChanged(CompoundButton button, boolean isChecked) {
    EpoxyViewHolder epoxyHolder = ListenersUtils.getEpoxyHolderForChildView(button);
    if (epoxyHolder == null) {
      throw new IllegalStateException("Could not find RecyclerView holder for clicked view");
    }

I _think_ this would be fine - we could potentially miss other errors caused by this bad state, but that doesn't seem likely.

The other solution is to change the generated code to disable the checked change listener and reenable it after binding completes, but that complexity in the generated code concerns me.

@elihart Neat, thanks a lot! Made my day! 馃槏

@elihart any idea when this will be released? ;)

@elihart May I know when this will release?

I'll push a release this week when I have time

@elihart anything we can help you with in preparation of the release? :)

No, I've just been waiting for two other issues to get fixed before releasing, but I'll stop waiting and make a release right now, sorry for the long delay :(

@elihart Thanks a lot, we really appreciate the efforts you are putting into this project! 鉂わ笍

There is a 3.8.0 now available on maven http://central.maven.org/maven2/com/airbnb/android/epoxy/3.8.0/ with this change

I don't think this can be closed - I'm having the same issue even when using 3.8.0:

06-09 18:08:16.165 E/AndroidRuntime( 4980): FATAL EXCEPTION: main
06-09 18:08:16.165 E/AndroidRuntime( 4980): Process: com.peloton.activity, PID: 4980
06-09 18:08:16.165 E/AndroidRuntime( 4980): java.lang.IllegalStateException: Could not find RecyclerView holder for clicked view
06-09 18:08:16.165 E/AndroidRuntime( 4980):     at com.airbnb.epoxy.WrappedEpoxyModelClickListener.b(SourceFile:14)
06-09 18:08:16.165 E/AndroidRuntime( 4980):     at com.airbnb.epoxy.WrappedEpoxyModelClickListener.onClick(SourceFile:1)
06-09 18:08:16.165 E/AndroidRuntime( 4980):     at android.view.View.performClick(View.java:5609)
06-09 18:08:16.165 E/AndroidRuntime( 4980):     at android.view.View$PerformClick.run(View.java:22262)
06-09 18:08:16.165 E/AndroidRuntime( 4980):     at android.os.Handler.handleCallback(Handler.java:751)
06-09 18:08:16.165 E/AndroidRuntime( 4980):     at android.os.Handler.dispatchMessage(Handler.java:95)
06-09 18:08:16.165 E/AndroidRuntime( 4980):     at android.os.Looper.loop(Looper.java:154)
06-09 18:08:16.165 E/AndroidRuntime( 4980):     at android.app.ActivityThread.main(ActivityThread.java:6077)
06-09 18:08:16.165 E/AndroidRuntime( 4980):     at java.lang.reflect.Method.invoke(Native Method)
06-09 18:08:16.165 E/AndroidRuntime( 4980):     at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:865)
06-09 18:08:16.165 E/AndroidRuntime( 4980):     at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:755)

I can't post the internals of my code, but I essentially have a TypedEpoxyController building models using Kotlin's generation extension functions. When I build that model, I'm using the clickListener builder method that wraps an OnModelClickListener.

@mformetal does this happen consistently for you or is it sporadic?

This issue was specifically about the checked changed listener, which was fixed - it looks like you are using a normal click listener. If you would like this to be investigated can you open a new issue with as many details as possible?

Was this page helpful?
0 / 5 - 0 ratings