Handling edge-cases of MVVM
Hi!
google.com/+CosminStefan
cosmin@greenerpastures.ro
Goal for today?
We've all heard
of MVVM
Quick recap!
MVVM
Model-View-ViewModel
MVVM - Basic structure
Activities
Fragments
Views
Memory
Remote APIs
Database
Many options for the View - ViewModel binding
View
ViewModel
Via Data binding
<data>
<variable
name="viewModel"
type="io.greenerpastures.example.MyViewModel" />
</data>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{viewModel.name}"
android:visibility="@{viewModel.isNameVisible}"
tools:text="Min bonus" />
observes
View
ViewModel
Via LiveData
fun onCreate(savedInstanceState: Bundle?) {
...
viewModel.viewState
.observe(this, Observer<ViewState> { data ->
findViewById<TextView>(R.id.name).text = data.name
findViewById<ImageView>(R.id.image).drawable = data.image
})
}
observes
View
ViewModel
Via RxJava
fun onStart() {
...
viewModel.viewState
.subscribe { data ->
findViewById<TextView>(R.id.name).text = data.name
findViewById<ImageView>(R.id.image).drawable = data.image
}
}
observes
fun onStart() {
...
viewModel.viewState
.subscribe { data ->
findViewById<TextView>(R.id.name).text = data.name
findViewById<ImageView>(R.id.image).drawable = data.image
}
.disposeOnStop()
}
disposeOnStop() ?
class BaseActivity() : Activity() {
private var compositeDisposable: CompositeDisposable = CompositeDisposable()
protected fun Disposable.disposeOnStop() {
compositeDisposable.add(this)
}
override fun onStop() {
super.onStop()
compositeDisposable.dispose()
}
...
}
How about the more complex situations?
Animations
RecyclerView
Navigation
Animations
ViewModel doesn't have access to Views
Issue:
class MainViewModel : ViewModel() {
var isVisible: ObservableBoolean = ObservableBoolean(true)
...
}
Solutions?
Animate all DataBinding changes
Option 1:
override fun onStart() {
super.onStart()
binding.addOnRebindCallback(object : OnRebindCallback<ViewDataBinding>() {
override fun onPreBind(binding: ViewDataBinding): Boolean {
TransitionManager.beginDelayedTransition(binding.root as ViewGroup)
return super.onPreBind(binding)
}
})
}
override fun onStart() {
super.onStart()
binding.addOnRebindCallback(object : OnRebindCallback<ViewDataBinding>() {
override fun onPreBind(binding: ViewDataBinding): Boolean {
return super.onPreBind(binding)
}
})
}
No filtering
Very quick and easy
Basic animations only
Custom binding adapter
Option 2:
@BindingAdapter("animatedVisibility")
fun setAnimatedVisibility(target: View, isVisible: Boolean) {
TransitionManager.beginDelayedTransition(target.rootView as ViewGroup)
target.visibility = if (isVisible) View.VISIBLE else View.GONE
// Or run any other animation using any other mechanism
}
Quick and easy
Same animation
Observe property changes
Option 3:
viewModel.isVisible.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
TransitionManager.beginDelayedTransition(binding.root as ViewGroup)
binding.view.visibility = if (viewModel.isVisible.get()) View.VISIBLE else View.GONE
// Or run any other animation using any other mechanism
}
// NOTE: Requires cleaning up the OnPropertyChangeCallback via
// viewModel.isVisible.removeOnPropertyChangedCallback()
viewModel.isVisible.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() {
override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
TransitionManager.beginDelayedTransition(binding.root as ViewGroup)
binding.view.visibility = if (viewModel.isVisible.get()) View.VISIBLE else View.GONE
// Or run any other animation using any other mechanism
}
Easily customizable
Requires cleanup
Specific to view & prop
RecyclerView
Create DataBinding layout for each item
- Rule 1 -
Adapter only deals with item ViewModels
- Rule 2 -
Item ViewModels get bound to ViewHolder
- Rule 3 -
Building the base tooling
Item View Model & Holder
/**
* The base ViewModel class for an item to be shown in a list.
*/
interface ItemViewModel {
}
/**
* A [RecyclerView.ViewHolder] implementation that handles binding of an [ItemViewModel] with a
* [ViewDataBinding].
*/
open class SimpleViewModelItemViewHolder<in VM : ItemViewModel, out DB : ViewDataBinding>
constructor(protected val binding: DB,
private val viewModelBindingVarId: Int) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: VM) {
this.binding.setVariable(viewModelBindingVarId, item)
this.binding.executePendingBindings()
}
}
/**
* The base ViewModel class for an item to be shown in a list.
*/
interface ItemViewModel {
}
/**
* The base ViewModel class for an item to be shown in a list.
*/
interface ItemViewModel {
}
/**
* A [RecyclerView.ViewHolder] implementation that handles binding of an [ItemViewModel] with a
* [ViewDataBinding].
*/
open class SimpleViewModelItemViewHolder<in VM : ItemViewModel, out DB : ViewDataBinding>
constructor(protected val binding: DB,
private val viewModelBindingVarId: Int) : RecyclerView.ViewHolder(binding.root) {
}
Base Adapter
abstract class BaseViewModelItemAdapter<IVM : ItemViewModel, out B : ViewDataBinding>
: RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
SimpleViewModelItemViewHolder<IVM, B>(
inflateView(LayoutInflater.from(parent.context), parent, viewType),
itemViewModelBindingVarId
)
abstract fun inflateView(inflater: LayoutInflater, parent: ViewGroup, viewType: Int): B
abstract val itemViewModelBindingVarId: Int
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) =
(holder as SimpleViewModelItemViewHolder<IVM, B>).bind(getItem(position))
abstract fun getItem(position: Int): IVM
}
abstract class BaseViewModelItemAdapter<IVM : ItemViewModel, out B : ViewDataBinding>
: RecyclerView.Adapter<RecyclerView.ViewHolder>() {
}
abstract class BaseViewModelItemAdapter<IVM : ItemViewModel, out B : ViewDataBinding>
: RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
SimpleViewModelItemViewHolder<IVM, B>(
inflateView(LayoutInflater.from(parent.context), parent, viewType),
itemViewModelBindingVarId
)
abstract fun inflateView(inflater: LayoutInflater, parent: ViewGroup, viewType: Int): B
abstract val itemViewModelBindingVarId: Int
}
Practical example
Item layout
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="ro.greenerpastures.devfest2018.ProductItemViewModel" />
</data>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{viewModel.name}"
android:textSize="18sp"
tools:text="Main Text" />
</layout>
ViewModel & Adapter
class ProductItemViewModel(private val product: Product) : ItemViewModel {
val name: String
get() = product.name
}
class ProductAdapter : BaseViewModelItemAdapter<ProductItemViewModel, ProductItemBinding>() {
var items: List<ProductItemViewModel> = listOf()
set(value) {
field = value
notifyDataSetChanged() // DiffUtil should be used for a better experience
}
override fun getItem(position: Int): ProductItemViewModel = items[position]
override fun getItemCount(): Int = items.size
override val itemViewModelBindingVarId: Int
get() = BR.viewModel
override fun inflateView(inflater: LayoutInflater, parent: ViewGroup, viewType: Int) =
ProductItemBinding.inflate(inflater, parent, false)
}
class ProductItemViewModel(private val product: Product) : ItemViewModel {
val name: String
get() = product.name
}
class ProductAdapter : BaseViewModelItemAdapter<ProductItemViewModel, ProductItemBinding>() {
var items: List<ProductItemViewModel> = listOf()
set(value) {
field = value
notifyDataSetChanged() // DiffUtil should be used for a better experience
}
override fun getItem(position: Int): ProductItemViewModel = items[position]
override fun getItemCount(): Int = items.size
}
class ProductItemViewModel(private val product: Product) : ItemViewModel {
val name: String
get() = product.name
}
class ProductAdapter : BaseViewModelItemAdapter<ProductItemViewModel, ProductItemBinding>() {
}
class ProductItemViewModel(private val product: Product) : ItemViewModel {
val name: String
get() = product.name
}
Hook everything together
class ProductListViewModel : ViewModel() {
// Expose the list of items as a list of item view models
val items = MutableLiveData<List<ProductItemViewModel>>()
...
}
class ProductListActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Setup ViewModel & RecyclerView
val viewModel: ProductListViewModel = ...
val recyclerView: RecyclerView = ...
// Setup adapter
val adapter = ProductAdapter()
recyclerView.adapter = adapter
// Make sure we update the data in the adapter when data in the ViewModel gets updated
viewModel.items.observe(this, Observer { updatedItems: List<ProductItemViewModel> ->
adapter.items = updatedItems
})
}
}
class ProductListViewModel : ViewModel() {
// Expose the list of items as a list of item view models
val items = MutableLiveData<List<ProductItemViewModel>>()
...
}
class ProductListActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Setup ViewModel & RecyclerView
val viewModel: ProductListViewModel = ...
val recyclerView: RecyclerView = ...
// Setup adapter
val adapter = ProductAdapter()
recyclerView.adapter = adapter
}
}
class ProductListViewModel : ViewModel() {
// Expose the list of items as a list of item view models
val items = MutableLiveData<List<ProductItemViewModel>>()
...
}
class ProductListActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Setup ViewModel & RecyclerView
val viewModel: ProductListViewModel = ...
val recyclerView: RecyclerView = ...
}
}
class ProductListViewModel : ViewModel() {
// Expose the list of items as a list of item view models
val items = MutableLiveData<List<ProductItemViewModel>>()
...
}
Multi-type lists
Category 1
Product 1.1
Product 1.2
Category 2
Product 2.1
Product 2.2
Product 2.3
List ViewModel exposes mixed list of ItemViewModels
Solution:
Multi-type adapter
class ProductAdapter : BaseViewModelItemAdapter<ItemViewModel, ViewDataBinding>() {
...
override fun getItemViewType(position: Int): Int =
when (getItem(position)) {
is CategoryItemViewModel -> VIEW_TYPE_CATEGORY
is ProductItemViewModel -> VIEW_TYPE_PRODUCT
else -> throw UnsupportedOperationException("Invalid view model type")
}
override fun inflateView(inflater: LayoutInflater, parent: ViewGroup, viewType: Int) =
when (viewType) {
VIEW_TYPE_PRODUCT -> ProductItemBinding.inflate(inflater, parent, false)
VIEW_TYPE_CATEGORY -> CategoryItemBinding.inflate(inflater, parent, false)
else -> throw UnsupportedOperationException("Invalid view type")
}
}
class ProductAdapter : BaseViewModelItemAdapter<ItemViewModel, ViewDataBinding>() {
...
override fun getItemViewType(position: Int): Int =
when (getItem(position)) {
is CategoryItemViewModel -> VIEW_TYPE_CATEGORY
is ProductItemViewModel -> VIEW_TYPE_PRODUCT
else -> throw UnsupportedOperationException("Invalid view model type")
}
}
class ProductAdapter : BaseViewModelItemAdapter<ItemViewModel, ViewDataBinding>() {
}
class ProductListViewModel : ViewModel() {
// Expose the list of items as a mixt list of item view models,
// either CategoryItemViewModel or ProductItemViewModel
val items = MutableLiveData<List<ItemViewModel>>()
...
}
Navigation
Why it's a hard problem?
Navigation must be driven by View Models
Context is required for Navigation
View Models mustn't reference Context
Solutions?
Event
Option 1:
View
ViewModel
observes
View
Navigate back
event
observes
event
once!
class MainViewModel : ViewModel() {
val events: PublishSubject<Event> = PublishSubject.create()
enum class Event {
NAV_TO_DETAILS
}
}
class MainActivity : Activity() {
override fun onStart() {
super.onStart()
viewModel.events
.subscribe {
when (it) {
MainViewModel.Event.NAV_TO_DETAILS -> startActivity(..)
}
}
.disposeOnStop()
}
}
Using RxJava
class MainViewModel : ViewModel() {
private val navigateToDetails = SingleLiveEvent<Boolean>()
}
class MainActivity : Activity() {
override fun onStart() {
super.onStart()
viewModel.navigateToDetails.observe(this, Observer {
startActivity(DetailsActivity...)
})
}
}
Using LiveData
Doesn't support multiple observers
Issue:
View
ViewModel
observes
View
event
observes
event
Wrapped Event
Option 2:
open class Event<out T>(private val content: T) {
var hasBeenHandled = false
private set
}
Event with handled status
open class Event<out T>(private val content: T) {
var hasBeenHandled = false
private set
/** Fetches the content and prevents its use again. */
fun consumeContentIfNotHandled(): T? =
if (hasBeenHandled) {
null
} else {
hasBeenHandled = true
content
}
/** Fetches the content, even if it's already been handled. */
fun peekContent(): T = content
}
class MainViewModel : ViewModel() {
val events: PublishSubject<Event> = PublishSubject.create()
enum class Event {
NAV_TO_DETAILS
}
}
class MainActivity : Activity() {
override fun onStart() {
super.onStart()
viewModel.events
.subscribe {
when (it.consumeContentIfNotHandled()) {
MainViewModel2.Event.NAV_TO_DETAILS -> startActivity(..)
}
}
.disposeOnStop()
}
}
Using RxJava
Using a command handler
Option 3:
View
ViewModel
Command Handler
Command Handler
class MainViewModel() {
var commandHandler: Commands? = null
interface Commands {
fun navigateToDetails()
fun showConfirmationDialog(): Single<Boolean>
}
fun onButtonClick() {
commandHandler?.showConfirmationDialog()
?.subscribe { isConfirmed ->
if(isConfirmed)
commandHandler?.navigateToDetails()
}
}
}
class MainViewModel() {
var commandHandler: Commands? = null
interface Commands {
fun navigateToDetails()
fun showConfirmationDialog(): Single<Boolean>
}
}
Command Handler
abstract class BaseActivity() {
abstract lateinit var viewModel: ViewModel
abstract val commandHandler: Any
override fun onStart() {
super.onStart()
viewModel.commandHandler = commandHandler
}
override fun onStop() {
super.onStart()
viewModel.commandHandler = null
}
}
abstract class BaseActivity() {
abstract lateinit var viewModel: ViewModel
abstract val commandHandler: Any
override fun onStart() {
super.onStart()
viewModel.commandHandler = commandHandler
}
}
abstract class BaseActivity() {
abstract lateinit var viewModel: ViewModel
abstract val commandHandler: Any
}
Requires careful cleanup
More intuitive usage
Not always available (e.g. while in background)
What to keep in mind?
Architecture must
be central
Adapt, but be consistent
Use base building blocks
Thank you!
Handling edge cases in an MVVM architecture
By Cosmin Stefan
Handling edge cases in an MVVM architecture
Handling edge cases in an MVVM architecture MVVM has become the go-to recommended architecture pattern in Android development. And all for good reason: it’s scalable, well suited and quick to grasp. As such, you can find a multitude of resources and samples to get started and deal with the most common scenarios. However, there are a series of edge cases that are not trivial to handle in an MVVM architecture and, even worse, can easily get handled in a wrong way, leading to memory leaks and bad architecture.
- 710