Jakub Kinst5 min

Android App Architecture Made Simple — Part I: ViewModelBinding

EngineeringAndroidApr 6, 2018

EngineeringAndroid

/

Apr 6, 2018

Jakub KinstAndroid Engineer

Share this article

In this series, we would like to present you with a set of tools we designed to simplify the development of Android apps in 2018 using Android Architecture Components. All of the tools leverage great Kotlin language features to make the code even cleaner and more efficient. Real-world examples of the usage of every single tool discussed in this series can be found within the dundee open-source showcase app on Github. The app does quite a few useful things — it displays the current value of various cryptocurrencies fetched from different sources, stores your personal portfolio in the cloud within the Firebase Firestore database and provides you with detailed info about profit/loss for each of the coins, including handy charts.

dundee - Android App Architecture Showcase

ViewModelBinding

With the release of the new Android Architecture Components library, there is no doubt which way Google would like us to head when it comes to Android app architecture. In the addition to the official app architecture guide, there are a lot of great articles about how to use those tools, but not so many of them focus on combining ViewModels and LiveData with Google’s own Android Data Binding framework.

All of the official docs tell us we should have our state data stored in the LiveData form within our ViewModels and in the view (Activity or Fragment) we should observe changes and modify our view accordingly:

class MainActivity : AppCompatActivity(){
    val viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
  
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val titleView = findViewById<TextView>(R.id.title)
        viewModel.myliveData.observe(this, Observer {
            titleView.setText(it.title)
        })
    }
}

Officially recommended way to handle LiveData in View

But we already use Data Binding. We forgot about findViewById() a long time ago. We want to bind our data directly from the ViewModel to the layout, so we came up with a concept we named ViewModelBinding which automatically links the View with ViewModel via Data Binding. This is a single Kotlin extension function that can do everything for you. All you have to do is initialize it in your View via a Kotlin delegate:

interface MainView {
    fun showSnackbar(message: String)
    fun openSomeScreen()
}
class MainActivity : AppCompatActivity(), MainView {
    val vmb by vmb<MainViewModel, ActivityMainBinding>(R.layout.activity_main)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // access viewModel
        vmb.viewModel.doSomething()
        // access layout via binding class
        setupToolbar(vmb.binding.toolbar)
    }
	
    override fun showSnackbar(message: String) {...}
    override fun openSomeScreen() {...}
}

Complete Activity setup with connection to ViewModel and initialized Data Binding

The delegate automatically creates the Data Binding class, sets binding variables (view and viewModel), so you can directly access both within your layout file, and obtains a ViewModel instance from a proper ViewModelProvider.

<layout>
    <data>
        <variable
            name="viewModel"
            type="com.strv.dundee.ui.main.MainViewModel" />

        <variable
            name="view"
            type="com.strv.dundee.ui.main.MainView" />
    </data>
  
    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textAppearance="@style/TextAppearance.AppCompat.Title"
        android:text="@{viewModel.data.title}" />
  
</layout>

Two variables that are automatically set by vmb. Both are optional.

A big advantage of this approach is that the view class (Activity/Fragment) does not have to extend any special superclass or implement any interface. If the view is Fragment, you still need to override the onCreateView() method. In this method, all you have to do is to return vmb.rootView, which will already be inflated. ViewModel, on the other hand, needs to extend ViewModel or AndroidViewModel to be able to leverage all of the Android Architecture goodness.

interface SignInView {
   //...
}

class SignInFragment : Fragment(), SignInView {
    val vmb by vmb<SignInViewModel, FragmentSignInBinding>(R.layout.fragment_sign_in)

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return vmb.rootView
    }
}

ViewModelBinding setup within a Fragment

If you want to provide the ViewModel instance yourself, you can specify a lambda function that provides the instance. The function will be called just in time, when you have access to Intent and other data within the Activity/Fragment, allowing you to pass any parameters to your ViewModel constructor:

class DetailActivity : AppCompatActivity() {
	val vmb by vmb<DetailViewModel, ActivityDetailBinding>(R.layout.activity_detail) { 
		DetailViewModel(intent.getStringExtra("itemId")) 
	}
}

Pass Activity Extras directly to ViewModel’s constructor

ViewModelBinding has an absolutely minimal footprint. To start using it, just copy this single file from our repo to your own project. The big advantage of this is that you can always tweak the functionality according to your current needs if necessary.

LiveData and Data Binding

Since the launch of the Android Gradle Plugin 3.1.0-alpha06, ViewModels can hold all data within LiveData properties. This version allowed us to hook a LifecycleOwner with the Data Binding class and directly bind LiveData into the layout. (More about this here.)

ObservableFields

Together with Data Binding, Google introduced BaseObservable and ObservableFields, which were supposed to be used to change the View state through Data Binding. They both worked flawlessly, but we want to be consistent, and use LiveData whenever possible. All ObservableFields can be simply replaced by MutableLiveData, which can be directly consumed by the layout (view). Once you use a LiveData instance within a layout file, the DataBinding mechanism will observe the data with a proper LifecycleOwner — Activity or Fragment.

class MainViewModel : ViewModel(){
    // val email = ObservableField<String>("@")
    val email = mutableLiveDataOf("@")
}

Replacing ObservableField with MutableLiveData

Note: MutableLiveData does not come with a constructor that accepts a default value, so you can either use MutableLiveData().apply{ value = "initial value"}, or you can define a global function mutableLiveDataOf("initial value") like we did in the dundee project.

@Bindable

But Sometimes ObservableFields aren’t enough. Consider the following scenario: based on email and password values, we need to tell if a sign-in form is valid. This use-case was is solved by extending BaseObservable, the marking computed property’s getter with @Bindable and calling notifyPropertyChanged() when its value should change.

This scenario can be solved by understanding the concept of MediatorLiveData. It is a LiveData implementation that accepts multiple source LiveData instances, each with an onChanged callback via the addSource(liveData, onChangedCallback) function. The idea is simple: when you subscribe to the MediatorLiveData, all of its sources are subscribed as well, and when any of the sources change their value, a proper onChanged callback is called. (See documentation for an example.)

With this behavior we can easily get the form validation working:

val email = mutableLiveDataOf(defaultEmail)
val password = mutableLiveDataOf(defaultPassword)
val formValid = MediatorLiveData<Boolean>().apply { 
    addSource(email, {value = validateForm(email.value, password.value)})
    addSource(password, {value = validateForm(email.value, password.value)})
}

private fun validateForm(email: String, password: String): Boolean 
    = validateEmail(email) && validatePassword(password, config.MIN_PASSWORD_LENGTH)

Whenever the email or password changes, recalculate proper value of formValid

With this, we can simply use formValid within the layout file to distinguish the different states, and since Data Binding observes the formValid property, email and password are observed as well.

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">
    <EditText
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="@string/global_email"
        android:inputType="textEmailAddress"
        android:text="@={viewModel.email}" />
    <EditText
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="@string/global_email"
        android:inputType="textPassword"
        android:text="@={viewModel.password}" />
    <Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@string/onboarding_sign_up"
        android:enabled="@{viewModel.formValid}"
        android:onClick="@{() -> viewModel.createAccount()}"
        style="@style/Widget.AppCompat.Button.Colored" />
</LinearLayout>

Note: You can simplify the code above by using our own extension function addValueSource(), which automatically assigns the returned value as a new value for the MediatorLiveData itself:

val formValid = MediatorLiveData<Boolean>()
    .addValueSource(email, { validateForm(email, password) })
    .addValueSource(password, { validateForm(email, password) })

With these couple of tricks, your code will be significantly cleaner and easier to read. If you want to see all of them in action, you can head over to the dundee Github repository.

Next time we’ll take a closer look at the repository pattern and how we tackled it within our clean LiveData-only environment.

We're hiring

Share this article



Sign up to our newsletter

Monthly updates, real stuff, our views. No BS.