0

I am at stage when I would like to expose my StateFlow<Model>() into the common BaseViewModel() class to implement common operations on it without repeating them in others implementations of ViewModels. I ended up with one idea, but faced with several limitations when has built PoC. My idea and its limitations are below, your solutions for origin question are welcomed.

I split my model into interface and one concrete implementation.

sealed interface Model {
    var isLoading: Boolean
    var errors: List<String>
    /**
     * @param obj Should has the same concrete type as concrete type of object which copy() it invokes
     * */
    fun copy(obj: Model): Model
}

data class DeputiesModel(
    override var isLoading: Boolean = false,
    override var errors: List<String> = emptyList<String>(),
    var deputies: List<Deputy> = emptyList()
) : Model {
    override fun copy(obj: Model): DeputiesModel {
        if (obj !is DeputiesModel)
            throw IllegalArgumentException("Passed object implements ${Model::javaClass.name}" +
                    " interface, but should be concrete ${DeputiesModel::javaClass::name} implementation.")
        return this.copy(deputies = obj.deputies, isLoading = obj.isLoading, errors = obj.errors)
    }
}

I need a copy() method in interface to data class copy() is closed for overloading and overriding.

My StateFlow implementation has been moved in BaseViewModel()

abstract class BaseViewModel<T : Model> : ViewModel() {

    protected lateinit var state: MutableStateFlow<T>
    lateinit var uiState: StateFlow<T>

}

I added generics here to avoid casting Model type to one of its concrete implementation, e.g. DeputiesModel, in classes which inherits BaseViewModel, otherwise this extra code would made a whole intent to expose common methods redundant.

And here is a first common method:

fun removeShownError() {
    state.update { state ->
        state.errors = state.errors.filter { str -> !str.equals(state.errors.first()) }
        state.copy(state) as T
    }
}

The limitation of this design&implementation is state.copy(state) doesn't trigger uiState.collectLatest{} call when origin parameterized state.copy(isLoading = false) does it. I haven't find yet the root cause of it.

    val viewModel: DeputiesViewModel by viewModels { viewModelFactory }
    lifecycleScope.launch {
        viewModel.uiState.collectLatest { it ->
            if (it.errors.isNotEmpty()) {
                showError(
                    view = requireActivity().findViewById(R.id.nav_view),
                    text = it.errors.first(),
                    onDismiss = { viewModel.removeShownError() }
                )
            }
            (binding.list.adapter as DeputiesAdapter).update(it.deputies)
        }
    }

That's all. Your ideas are appreciated.

Joe Dow
  • 583
  • 1
  • 3
  • 12
  • What kinds of common operations are you planning? When I see the name "BaseViewModel", I think code smell. There are possibly better ways to avoid code repetition, such as extension functions and the composition pattern. – Tenfour04 Jul 06 '22 at 14:24
  • Oh, and mutable state classes do not mix well with StateFlow at all. Mutating the state will not trigger another emission. Also, the flow tries to compare old and new values on each value change. This would be very prone to errors and unexpected results. – Tenfour04 Jul 06 '22 at 14:38
  • @Tenfour04, thanks for point out on this. Kotlin extensions are new concept for me which is still an area of improvement and professional growth. Before its introduction exposing common logic in parent classes was a common way. Regarding common methods, at this stage I have an urge to expose supplying error string based on type of error and few operations on `DataModel` like `removeShownError()` and `addAnError()` – Joe Dow Jul 07 '22 at 08:55
  • @Tenfour04, [quote]mutable state classes do not mix well with StateFlow at all.[/quote]. `StateFlow` usage instead of `LiveData` was encouraged by a year old article from an engineer probably from Google team. I tried this concept and had found it awesome. Yes, I faced with some issues, but was able to overcome the most of them except one which I mentioned in this post. Who knows may eventually will throw it away, but now I would like to continue with it. – Joe Dow Jul 07 '22 at 09:06
  • @Tenfour04, [quote]Mutating the state will not trigger another emission[/quote]. No, it does - you just need to do it in this way `state.update { state -> state.copy(name = 'A new name' )}`. Try it to confirm it works. May be did you mean another case? – Joe Dow Jul 07 '22 at 09:13
  • If you use `copy()`, that's not mutating the class, that's creating a new instance. I was specifically talking about how that data class has some `var` properties. If you modify those properties, there's no way to get the StateFlow to emit the change. At least with LiveData, you can call `setValue()` with the existing value instance, and it will re-notify observers. Regarding my other comments, really "prefer composition to inheritance" is a design principle for all OOP languages, not just Kotlin. But extension functions are a nice language feature for another way to resolve the issue. – Tenfour04 Jul 07 '22 at 19:40
  • Inheritance is a fantastic feature for solving some kinds of problems, but in many cases, composition is better for maintainability and testing, and so inheritance should be avoided when there are other ways to solve a problem and you have more than a few classes that have shared functionality. You can look up "composition over inheritance" to read about the reasons for this. Kotlin does provide the delegates feature which eliminates much of the oft-stated drawback of composition (having to write pass-through functions). Or you can just use an interface with default implementation. – Tenfour04 Jul 07 '22 at 19:42
  • Actually, in this case, you have both mutated and then duplicated the mutated object. So StateFlow will not emit the change because it's looking at the now mutated original instance and comparing it to a new instance that will pass the equality check. – Tenfour04 Jul 07 '22 at 19:57

1 Answers1

0

Here's how your example of the common function removeShownError() could be solved with an extension function so you don't need a BaseViewModel.

Model classes without mutability:

sealed interface Model {
    val isLoading: Boolean
    val errors: List<String>
}

data class DeputiesModel(
    override val isLoading: Boolean = false,
    override val errors: List<String> = emptyList<String>(),
    val deputies: List<Deputy> = emptyList()
) : Model 

Extension functions for shared functionality, defined at the top level somewhere:

// Since this is a sealed interface, you can create a single extension function to
// implement clearing errors for each of the implementations. This will be cleaner
// than having to deal with wrong input type problem in your current copy function
// implementation.
@Suppress("UNCHECKED_CAST")
fun <T: Model> T.withNewErrors(newErrors: List<String>): T = when (this) {
    is DeputiesModel -> copy(errors = newErrors)
    //... other types
} as T

fun <T: Model> MutableStateFlow<T>.removeShownError() {
    update { state ->
        state.withNewErrors(
            state.errors.filter { str -> !str.equals(state.errors.first()) }
        )
    }
}

Now there's no need for a BaseViewModel. You can just add to any ViewModel that has a relevant flow:

fun removeShownError() = state.removeShownError()

By the way, it doesn't make much sense to use lateinit in a ViewModel. That's a hack needed for classes like Activity where most of what you need to do to initialize variables requires Context, but the context isn't ready at class instantiation time and create() is the earliest point at which any of your own properties will be used for anything. In a ViewModel, everything you could possibly need to use is already available at class initialization via the constructor, so there's no reason to postpone initialization of a property.

Even if you did have to use lateinit for it, it wouldn't be necessary to use it for it's partner non-mutable public property, because that property could just use a custom getter, so it has no backing variable to initialize in the first place:

val uiState: StateFlow<T> get() = state
Tenfour04
  • 83,111
  • 11
  • 94
  • 154
  • Thank you for an interesting discussion, but I think we lost common ground and in our thoughts we went in slightly different directions. My main intent was\is to increase code quality by avoiding boilerplate code and repeating the same code. I agree on "prefer composition over inheritance" principle and your design decision is better than mine, but it is still introduce boilerplate and repeating code which is better to group in BaseClass and сonsequently leaves issues above as open. – Joe Dow Jul 08 '22 at 16:06
  • Here is codebase to have overview of what is going on https://github.com/Gelassen/government-rus.git (hash code of the current commit ea8c36204d5237f0ae362b0a40aa4f718c4ad639). In case you would like to spend more efforts to clarify your thoughts on the issue, PR is might be better. – Joe Dow Jul 08 '22 at 16:06
  • Here's an article that touches on the problem of Base classes.https://levelup.gitconnected.com/android-nightmares-base-classes-ccf55dbd0604 Basically, imagine some of your ViewModels need two or three different States and some don't. Then you have to add some boolean properties that are controlled from subclass constructors and your Base class starts looking like spaghetti. Not worth it to avoid a tiny amount of boiler plate. In my solution above, it reduces boiler plate to one pass-through per common function. You could take it a step further with delegates, – Tenfour04 Jul 08 '22 at 16:13
  • but I don't have more time to devote to this issue to explain it, sorry. Here's another article that explains the problem in more detail: https://proandroiddev.com/baseactivity-and-basefragment-are-monsters-5cda31639938 – Tenfour04 Jul 08 '22 at 16:13
  • I believe sophistication of the project should change with sophistication of requirements to it. It is good to think about possible future cases, but we may fall into another trap described as "building the factory to just solve a fibonacci numbers task". Two years ago I developed 4 projects and concepts, only two of them have enough interest from me and users to continue work on them and only now I faced with issue behind this topic. Just thinking. Thank you for articles, I'll take a look on them. – Joe Dow Jul 08 '22 at 16:35