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.