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()
}
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 newCountryItemModel_
objects, which is good, but you fill them with objects that are stored elsewhere, mostly insortFilterViewState
and which are mutable. This is not visible in the source code provided by you, but I guess you then modify the contents ofsortFilterViewState
and as a result you actually modify already created models as well.For example, if you modify
listOfCurrentlySelectedCountryItems
after you created the model, thenlistOfPreSelectedCountryFilters
in existing model will be affected as well, because both of them actually reference exactly the same list. And modifyinglistOfPreSelectedCountryFilters
is forbidden by Epoxy, because it is a part of the model.Technical details
Epoxy uses
hashCode()
andequals()
to detect changes in the model. Most classes implement these methods by delegating tohashCode()
/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 andhashCode()
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
andTopsProductFilterItem
areval
, notvar
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 providinglistOfCurrentlySelectedCountryItems
directly to the model, providelistOfCurrentlySelectedCountryItems.toMutableList()
which creates a copy. Just note this is a shallow copy, so ifTopsProductFilterItem
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
tofalse
. This is described in Configuration. I guess I don't have to add this is highly discouraged.