This will be a continuation of the article The Evolution of MV* Patterns in Android: Part 2
MVI
MVI (Model-View-Intent) is an architectural pattern that is part of the Unidirectional Data Flow pattern family - an approach to system design in which everything appears as a unidirectional flow of actions and control states. Unlike MVVM, MVI assumes only one data source (single source of truth or SSOT). MVI consists of three components: logic, data, and state (Model); UI layer displaying the state (View) and intention(Intent).
For example, if the user clicks on the “Respond to request” button, the click is converted into an event (Intent) necessary for the Model. In this formula, a request will be made to the server, the resulting result will update the screen state. The UI layer, in accordance with the new state, creates a button and displays text stating that the application has been sent.
Let's move on to the example and the code. The example will show a simple method. Let's take an example from previous articles and the first thing we will write is states and events.
sealed interface Event {
object FetchBooks: Event
}
sealed interface State {
object Loading: State
object Empty: State
data class Success(val books: List<Book> = emptyList()) : State
data class Error(val errorMessage: String)
}
Let's move on to the ViewModel. Here we will also use LiveData
, but keep in mind that you can use Kotlin Flow
.
This is what the MainViewModel looks like now:
class MainViewModel : ViewModel() {
private val booksRepository: BooksRepository = BooksRepositoryImpl()
private val _state = MutableLiveData<State>(State.Empty)
val state: LiveData<State>
get() = _state
fun event(event: Event) {
_state.value = State.Loading
when(event) {
Event.FetchBooks -> {
booksRepository.fetchBooks {
if (it.isNotEmpty())
_state.value = State.Success(books = it)
else
_state.value = State.Empty
}
}
}
}
}
Changed MainFragment:
class MainFragment : Fragment(R.layout.fragment_main) {
private lateinit var booksRV: RecyclerView
private lateinit var progressBar: ProgressBar
private val adapter = BooksListAdapter()
private val mainViewModel: MainViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
booksRV = view.findViewById(R.id.books_rv)
progressBar = view.findViewById(R.id.progress_bar)
mainViewModel.event(Event.FetchBooks)
observeLiveData()
}
private fun observeLiveData() {
mainViewModel.state.observe(viewLifecycleOwner) { state ->
when(state) {
is State.Empty -> {
progressBar.visibility = View.GONE
}
is State.Loading -> {
progressBar.visibility = View.VISIBLE
}
is State.Success -> {
progressBar.visibility = View.GONE
adapter.submitList(state.books)
}
is State.Error -> {
Toast.makeText(context, state.errorMessage, Toast.LENGTH_SHORT).show()
}
}
}
}
companion object {
@JvmStatic
fun newInstance() = MainFragment()
}
}
As a result, we have one input where events are processed and an output with a state.
Pros
- State objects are immutable so it is thread-safe.
- All actions like state and event are in the same file so it is easy to understand what happens on the screen at one look.
- Maintaining state is easy.
- Since data flow is unidirectional, tracking is easy.
Cons
- It causes a lot of boilerplate.
- High memory management because we have to create lots of object.
- Sometimes, we have many views and complicated logics, in this kind of situation
State
become huge and we might want split thisState
into smaller ones with extraStateFlows
instead of just using one.