MVI - Model View Intent simplified
Ahmed Adel Ismail
Engineering Manager @ Yassir | x-SadaPay | x-Swvl | x-Talabat | x-TryCarriage | x-Vodafone | More than a decade of experience in Android development and teams leadership
I have been searching for a proper resource to explain the MVI pattern, but every time I get hit with Hannes Dorfmann Mosby examples, which is some how complex, and for some reason He is trying to re-use the already existing Mosby library, which is increasing the complexity of the pattern (from my point of View)
Why Model-View-Intent
Based on André Staltz, the man behind this pattern, the objective of the pattern is to do what MVC does, but in a reactive functional way, as elaborated in this link
And he describes the pattern to be as follows :
Instead of dividing our code into a View, Model, and Controller classes, we divide them into 3 functions, and using Reactive Extensions (Rx), we can make those three functions observe on each other and react to each other
For us (Android Developers), the main() function in the above image is the onCreate() of our Activity/Fragment, so we can translate the above image in Android as follows :
override fun onActivityCreated(savedInstanceState: Bundle?) {
...
val actions = BehaviorSubject.createDefault(Action())
intent(actions).subscribe { model -> view(actions, model) }
}
fun intent(actions: BehaviorSubject<Action>) : Observable<Model>{
// subscribe on the actions emitted,// and emits new Model objects based on every action
}
fun view(actions: BehaviorSubject<Action>, model: Model){
// update the views from the new Model received, // and emits user actions through actions.onNext(Action) when // an action occurres, like onClick for example
}
So The cycle goes like this :
The view() function does 2 things :
- Draw the Views based on the Model object received in it's parameter
- Set the click listeners, touch listeners, etc.. to call actions.onNext() with there related Actions
The intent() function acts as the Controller in the MVC but with slight difference:
- Subscribe on the Actions emitted by the BehaviorSubject, and based on the emitted action, it creates a new Model Object with the new UI expected state ... it may need to make a network call or a database call, or some calculations, and based on this operation, it creates a new Model instance to be drawn on the UI
And the cycle keeps going, but there are some limitations :
1- We have life-cycle events in Android, and this will require declaring variables in the Activity/Fragment class, which is not possible in such pattern, there is a library that helps handling Lifecycle events in a functional way, called Litecycle
2- This pattern does not support rotation, so it should be done in a Fragment that calls it's setRetainInstance(true) in it's creation ... in other words, it is not possible to use it in Activities for Android ... luckily enough we have the new Navigation in Jetpack which makes use of single Activity Architecture, where all the screens are Fragments
Sample Repository for this pattern can be found here, and sample code will be as follows :
class SplashFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_splash, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
LiteCycle.with(integration())
.forLifeCycle(this)
.onDestroyInvoke(Disposable::dispose)
.observe()
}
private fun integration() = intent(fragmentStartedObservable()).subscribe { view(it) }
private fun fragmentStartedObservable() = LiteCycle.with(false)
.forLifeCycle(this)
.onStartUpdate { true }
.onStopUpdate { false }
.observe(BehaviorSubject.create())
}
fun intent(fragmentStartedObservable: Observable<Boolean>) = fragmentStartedObservable
.switchMap { Observable.just(it).delay(2, TimeUnit.SECONDS) }!!
fun SplashFragment.view(finished: Boolean) = findNavController()
.takeIf { finished }
?.apply { navigate(R.id.action_splashFragment_to_loginFragment) }
In the above example, The Action is a boolean type, of weather the Fragment has started or not. and also the Model is a boolean type, of weather the splash screen shall navigate to the next screen or not
The pattern is applied in the integrate() function, which invokes :
intent(fragmentStartedObservable()).subscribe { view(it) }
A more advanced sample will be the Login screen :
class LoginFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_login, container, false)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
LiteCycle.with(integration())
.forLifeCycle(this)
.onDestroyInvoke(Disposable::dispose)
.observe()
}
private fun integration() = LoginViewState()
.let(::Initialize)
.let { BehaviorSubject.createDefault<LoginAction>(it) }
.let { IntentData(it) }
.let { intent(it).subscribe { state -> view(it.actions, state) } }
}
fun intent(data: IntentData): Observable<LoginViewState> = data.actions
.subscribeOn(data.backgroundScheduler)
.observeOn(data.backgroundScheduler)
.switchMap { handleIntent(it, data.loginRequest) }
.observeOn(data.mainScheduler)!!
private fun handleIntent(action: LoginAction, loginRequest: (String?, String?) -> Observable<User>) =
when (action) {
is Initialize -> Observable.just(LoginViewState(initialize = true))
is LoginRequest -> processLoginRequest(loginRequest, action)
}
private fun processLoginRequest(loginRequest: (String?, String?) -> Observable<User>, action: LoginRequest) =
loginRequest(action.state.userName, action.state.password)
.map { user -> LoginViewState(loginResponse = user) }
.onErrorReturn { throwable -> LoginViewState(errorMessage = throwable.message) }
fun LoginFragment.view(actions: BehaviorSubject<LoginAction>, viewState: LoginViewState) = with(viewState) {
login_progress.visibility = if (progressing) View.VISIBLE else View.INVISIBLE
when {
initialize -> login_button.setOnClickListener { handleOnClick(this, actions) }
errorMessage != null -> Toast.makeText(context, errorMessage, Toast.LENGTH_LONG).show()
loginResponse != null -> findNavController().navigate(R.id.action_loginFragment_to_homeFragment)
}
}
private fun LoginFragment.handleOnClick(state: LoginViewState, actions: BehaviorSubject<LoginAction>) = with(state) {
when {
progressing -> Toast.makeText(context, "please wait", Toast.LENGTH_SHORT).show()
loginResponse == null -> startLoginRequest(actions)
else -> Toast.makeText(context, "login success", Toast.LENGTH_SHORT).show()
}
}
private fun LoginFragment.startLoginRequest(actions: BehaviorSubject<LoginAction>) {
login_progress.visibility = View.VISIBLE
LoginViewState(userName = user_name_edit_text.asString(), password = password_edit_text.asString())
.let(::LoginRequest)
.also(actions::onNext)
}
data class IntentData(
val actions: BehaviorSubject<LoginAction>,
val loginRequest: ((String?, String?) -> Observable<User>) = ::login,
val backgroundScheduler: Scheduler = Schedulers.io(),
val mainScheduler: Scheduler = AndroidSchedulers.mainThread()
)
sealed class LoginAction
data class Initialize(val state: LoginViewState) : LoginAction()
data class LoginRequest(val state: LoginViewState) : LoginAction()
data class LoginViewState(
val initialize: Boolean = false,
val progressing: Boolean = false,
val userName: String? = null,
val password: String? = null,
val errorMessage: String? = null,
val loginResponse: User? = null
)
It may look strange at the beginning, but the concept is the same, to make all the events go through one direction, with the help of RxJava and LiteCycle, we can achieve this pattern in a purely functional way
When to use this pattern ?
When you want to make a UI pattern that is applying to Functional Programming concepts, it guarantees that events are executed in a loop - similar fashion, one after another, and in a very planned and expected way
Mobile Application Architect | Android, iOS, Flutter Expert | Agile & Open Source Advocate
6 年Superb Article. Thanks for writing.
11+ YoE || Fintech || Mobile Architect { Android, Multiplatform, KMP, React Native, Flutter } || Security
6 年Lovely article, must read
Software Engineer | Android
6 年This pattern is for sure a competitor to the other MV* patterns for the F/R programming fans. However, I have a concern about using "setRetainInstance(true)" to a fragment that will be added to the backstack, and with the single-activity approach in particular. https://stackoverflow.com/questions/11182180/understanding-fragments-setretaininstanceboolean Also, the pattern seems to use the all-in-one-place hell, all the binding+presentation logic is in the view, and indeed very similar to MVC at this specific point, and of course providing extra extensions to the View will not solve the issue, I've seen this done by iOS devs quite often when things start to get out of hand in their UIViewControllers too.