Epoxy updating the epoxy attributes in the models once they have been built results in crash

3.8k Views Asked by At

App has crashed, executing CustomActivityOnCrash's UncaughtExceptionHandler

com.airbnb.epoxy.ImmutableModelException: The model was changed between being added to the controller and being bound

Controller class

class SortFilterController @Inject constructor(
    private val schedulersFacade: SchedulersFacade,
    private val generateMapOfCategoryFilters: GenerateMapOfCategoryFilters
) : EpoxyController() {

    private val tapCountryRelay: PublishRelay<TopsProductFilter> = PublishRelay.create()

    var sortFilterViewState: SortFilterViewState = SortFilterViewState()
        set(value) {
            field = value
            requestModelBuild()
        }

    var sortFilterType: SortFilterType = SortFilterType.ALL
        set(value) {
            field = value
            requestModelBuild()
        }

    override fun buildModels() {
        sortFilterViewState.let { sortFilterViewState ->
            sortFilterViewState.filterTypes?.forEach { topsProductFilter ->
                when (SortFilterType.getId(topsProductFilter.attributeCode)) {
                    SortFilterType.COUNTRY -> {
                        CountryItemModel_()
                            .id(UUID.randomUUID().toString())
                            .tapCountryChipRelay(tapCountryRelay)
                            .countryFilter(topsProductFilter)
                            .listOfPreSelectedCountryFilters(sortFilterViewState.listOfCurrentlySelectedCountryItems ?: emptyList())
                            .addTo(this)
                    }
                }
            }
        }
    }

    val bindTapCountryRelay: Observable<TopsProductFilter> = tapCountryRelay.hide()
}

// model class

@EpoxyModelClass(layout = R.layout.list_item_country_item)
abstract class CountryItemModel : EpoxyBaseModel() {

    @EpoxyAttribute
    lateinit var tapCountryChipRelay: PublishRelay<TopsProductFilter>

    @EpoxyAttribute
    lateinit var countryFilter: TopsProductFilter

    @EpoxyAttribute
    lateinit var listOfPreSelectedCountryFilters: MutableList<TopsProductFilterItem>

    override fun bind(holder: EpoxyBaseViewHolder) {
        with(holder.itemView) {
        // snippet here 
      }
   }
}

In the DialogFragment oncreate I setup the epoxyRecyclerView.

 epoxyRecyclerView.setController(sortFilterController)
    epoxyRecyclerView.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.VERTICAL, false)

And call the setters on the controller and request the model build

sortFilterController.sortFilterViewState = sortFilterViewState
sortFilterController.sortFilterType = SortFilterType.ALL

However, the problem is that I want to change the data that is displayed in the models with some new data. So when the user taps on a country I want to set the setter again.

  private fun onTapClearAll() {
       // sortFilterViewState has some new data so I want to set it again for display.
       
       // This calling these resulted in a crash as the models epoxy attributes have changed with this new data
       sortFilterController.sortFilterViewState = sortFilterViewState
       sortFilterController.sortFilterType = SortFilterType.ALL    
    }
    
// Then I tried to do the same with a interceptor but again the app will crash.
  private fun onTapClearAll() {
            sortFilterController.addInterceptor(object : EpoxyController.Interceptor {
                override fun intercept(models: MutableList<EpoxyModel<*>>) {
                    val countryModel = models[0] as CountryItemModel_

                    countryModel.listOfPreSelectedCountryFilters(sortFilterViewState.listOfCurrentlySelectedCountryItems)
                }
            })
            sortFilterController.requestModelBuild()
        }
3

There are 3 best solutions below

0
On

Problem

I'm not the Epoxy expert, but Epoxy models are required to be not modified after they were added to the controller. And that means not only modifying their immediate properties, but also deep contents of these properties.

In your buildModels() you always create entirely new CountryItemModel_ objects, which is good, but you fill them with objects that are stored elsewhere, mostly in sortFilterViewState and which are mutable. This is not visible in the source code provided by you, but I guess you then modify the contents of sortFilterViewState and as a result you actually modify already created models as well.

For example, if you modify listOfCurrentlySelectedCountryItems after you created the model, then listOfPreSelectedCountryFilters in existing model will be affected as well, because both of them actually reference exactly the same list. And modifying listOfPreSelectedCountryFilters is forbidden by Epoxy, because it is a part of the model.

Technical details

Epoxy uses hashCode() and equals() to detect changes in the model. Most classes implement these methods by delegating to hashCode()/equals() of their properties, therefore changes are detected deeply. For example, if we have a list of some objects and we modify just a single property in one of items, hashCode() of this item will change and hashCode() of the whole list will also change. The same if we add new item, remove some or reorder items - hashCode() of the list will change. If such list is a part of the Epoxy model, this will be detected as a change in the model and will result in a runtime error.

This is described in details in Epoxy wiki here and here.

Solution

Proper solution to this problem is to not modify the model after it was added to the controller. It could be helpful to make the model deeply immutable, for example make sure that all properties of TopsProductFilter and TopsProductFilterItem are val, not var and if some of these properties hold objects then make these objects immutable as well. If some object can't be immutable then create a copy when providing to the model. For example, instead of providing listOfCurrentlySelectedCountryItems directly to the model, provide listOfCurrentlySelectedCountryItems.toMutableList() which creates a copy. Just note this is a shallow copy, so if TopsProductFilterItem objects are mutable then you need to create copies of all items as well.

This PublishRelay is especially suspicious. Epoxy model is for holding relatively simple data and relay is much more than that. It is hard to even specify what does it mean that it has changed or not. I guess it may cause chaos when diffing by Epoxy. Why do you need this in the model?

There is also much simpler "solution". You can just disable model validation by setting validateEpoxyModelUsage to false. This is described in Configuration. I guess I don't have to add this is highly discouraged.

0
On

Please check the document of Epoxy: https://github.com/airbnb/epoxy/wiki/Epoxy-Controller

The epoxy require model is immutable, that's mean you can not change your model still epoxy building, that will be crash.

Simple solution is, copy your:

var sortFilterViewState: SortFilterViewState = SortFilterViewState()
        set(value) {
            field = value
            requestModelBuild()
        }

    var sortFilterType: SortFilterType = SortFilterType.ALL
        set(value) {
            field = value
            requestModelBuild()
        }

to an DataClass. And use copy function for new filter you want to apply, then requestModelBuild with TypedEpoxyController

Suggestion:

data class Sort(
  val filterViewState: SortFilterViewState = SortFilterViewState(),
  val filterType: SortFilterType = SortFilterType.ALL
)
class YourEpoxyController constructor(...): TypedEpoxyController<Sort>() {
....

  override fun buildModels(data: Sort) {
    // Build model with sort here
  }
}

And in Your Acitivity or Fragment, where has sort data change, let do:

 fun applySort(filterViewState: SortFilterViewState, filterType: SortFilterType) {
    val newSort = sort.copy(...) // sort maybe in your view_model class
    epoxyController.setData(newSort)
}
0
On
        sortFilterViewState = SortFilterViewState(
            sortTypes = instantSearchFilterViewState.sortTypes,
            filterTypes = instantSearchFilterViewState.filterTypes,
            listOfCurrentlySelectedBrandItems = mainViewModel.listOfCurrentlySelectedBrandItems.value,
            listOfCurrentlySelectedCategoryItems = mainViewModel.listOfCurrentlySelectedCategoryItems.value,
            listOfCurrentlySelectedCountryItems = mainViewModel.listOfCurrentlySelectedCountryItems.value,
            listOfCurrentlySelectedPromotionItems = mainViewModel.listOfCurrentlySelectedPromotionItems.value,
            listOfCurrentlySelectedLifestyleBenefitItems = mainViewModel.listOfCurrentlySelectedLifestyleBenefitItems.value,
            categoryLevel = mainViewModel.categoryLevel.value
        )

        sortFilterController.sortFilterViewState = sortFilterViewState

If one of the following properties change in that class and you try and reload the epoxy throw a exception. As SortFilterViewState will have a different hashcode that will be compared to when the epoxy first built the models.

The solution is to take all the properties out and pass them separately to the epoxy controller

        sortFilterController.sortTypes = instantSearchFilterViewState.sortTypes ?: emptyList()
        sortFilterController.filterTypes = instantSearchFilterViewState.filterTypes ?: emptyList()
        sortFilterController.listOfCurrentlySelectedCountryItems = mainViewModel.listOfCurrentlySelectedCountryItems.value?.toList() ?: emptyList()
        sortFilterController.listOfCurrentlySelectedBrandItems = mainViewModel.listOfCurrentlySelectedBrandItems.value?.toList() ?: emptyList()
        sortFilterController.listOfCurrentlySelectedLifestyleBenefitItems = mainViewModel.listOfCurrentlySelectedLifestyleBenefitItems.value?.toList() ?: emptyList()
        sortFilterController.listOfCurrentlySelectedPromotionItems = mainViewModel.listOfCurrentlySelectedPromotionItems.value?.toList() ?: emptyList()
        sortFilterController.listOfCurrentlySelectedCategoryItems = mainViewModel.listOfCurrentlySelectedCategoryItems.value?.toList() ?: emptyList()
        

And in the controller you will have something like this

var listOfCurrentlySelectedCountryItems: List<TopsProductFilterItem> = emptyList()
    set(value) {
        field = value
        requestModelBuild()
    }

var listOfCurrentlySelectedBrandItems: List<TopsProductFilterItem> = emptyList()
    set(value) {
        field = value
        requestModelBuild()
    }

var listOfCurrentlySelectedPromotionItems: List<TopsProductFilterItem> = emptyList()
    set(value) {
        field = value
        requestModelBuild()
    }

etc.