How to Build and Structure a Modern Android App using MVVM and Kotlin (includes source code) Part 1

Introduction

This article shows how to build a clean Android app using MVVM, Kotlin, LiveData interfaced with RxJava and Retrofit, Data Repository, Room, Navigation, Data Binding, Notifications, Deep Links, SMS, Sharing, Preferences, and Permissions. It includes Kotlin Unit Tests built using MockK, Espresso, Mock Web Server and Robolectric.

Building a professional Android app can be challenging, even for experienced software engineers. For your app to provide a great user experience and run without crashing, the recommended approach is to use proven design patterns and tooling. This article discusses implementing the MVVM pattern using Google’s Architecture Components (JetPack) with Retrofit and RxJava libraries. Included is a fully working example, with unit tests, to help you get started. The sample is complete, maintainable, compact, efficient and easily adapted to your own ideas. It shows manual dependency inversion and MVVM best coding practices. Download the example app source code here: github.com/LeeHounshell/Dogs

MVVM is a layered architecture. Imagine your data as a ‘river’ flowing from the Network and Database into your Data Repository and from there into a ‘View Model’ (containing business logic), and finally onto the device screen’s Activity and Fragment display. From there interactions by a user cause data to flow back the way it came, from the Activity or Fragment to the ViewModel; from the ViewModel into the Data Repository and from there back to the Network and Database. The purpose of such a design is clean separation of code. By dividing code into sections that perform a single task well and communicate results, your app becomes both more stable and more maintainable. By using dependency inversion properly, your app becomes maintainable and testable.

Dependency Inversion simply means constructing the objects used by a class from outside of that class, and then passing (or injecting) those previously constructed objects into the class when using it. Decoupling this way is important because the technique allows for easy object reuse and extension. Your app becomes more maintainable. DI also allows for easier testing because object dependencies can be “mocked” so that specific functionality is targeted for testing. For example, if you have a unit test that uses a Network or Database, that test must be physically decoupled from the real Network and Database. Imagine a test that is not decoupled from the Network: One day it works fine, but the next day Internet is down and the test fails. That is unacceptable. Tests must be fashioned to validate functionality without being dependent on lower levels of abstraction. Using Dependency Inversion properly allows for that. However, it is my opinion that overuse of tools like Dagger2 and shiny-newer tools for automatic dependency injection can lead to violation of the Single Responsibility Principle. I believe it is better to structure your code well and use DI in a more targeted and limited manner. This demo shows how that can be accomplished, including creation of meaningful Unit Tests. Also seriously consider using the Service Locator Pattern. At some point you will likely want to extend your app’s functionality with new Services and Analytics. Using a Service Locator to obtain these resources and their dependencies is straight-forward and understandable for almost all software engineers. Ask yourself: Are the benefits of using dependency injection tools worth added complexity, risk and training expense? Does the Service Locator Pattern add equivalent functionality with reduced cost and risk?

Let’s Get Started

One of the first and most important tasks that you encounter when building an app is creating the build configuration. Getting your project and app ‘build.gradle‘ files correct from the beginning helps avoid problems. It is important to know what libraries are compatible with others. For example, you should never mix ‘androidx’ with the old ‘support’ libraries. Problems with incompatible build.gradle files are one main reason for app failure. Start with using a known stable build, like from this configuration, treating it as a template when building apps for Android API level 30:

The project build.gradle

// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
    def gradle_version = '4.0.0'

    //FIXME: these are duplicated in app build.gradle. global variables are broken in buildscript

    def kotlin_version = '1.3.72'
    def nav_version = '2.2.2'

    repositories {
        google()
        jcenter()
    }
    dependencies {
        classpath "com.android.tools.build:gradle:$gradle_version"
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
        classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
    }
}

allprojects {
    repositories {
        google()
        jcenter()
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

The app build.gradle

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: 'androidx.navigation.safeargs'

android {
    compileSdkVersion 30
    buildToolsVersion "30.0.0"

    defaultConfig {
        applicationId "com.harlie.dogs"
        minSdkVersion 19
        targetSdkVersion 30
        versionCode 1
        versionName "1.0"
        multiDexEnabled true
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        // The following argument makes the Android Test Orchestrator run its
        // "pm clear" command after each test invocation. This command ensures
        // that the app's state is completely cleared between tests.
        testInstrumentationRunnerArguments clearPackageData: 'true'
        javaCompileOptions {
            annotationProcessorOptions {
                arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
            }
        }
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }

    buildFeatures{
        dataBinding = true
        // for view binding:
        // viewBinding = true
    }

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }

    kotlinOptions {
        jvmTarget = JavaVersion.VERSION_1_8.toString()
    }

    testOptions {
        execution 'ANDROIDX_TEST_ORCHESTRATOR'
        unitTests {
            includeAndroidResources = true
            unitTests.returnDefaultValues = true
        }
    }

    packagingOptions {
        exclude 'META-INF/INDEX.LIST'
        exclude 'META-INF/DEPENDENCIES'
        exclude 'META-INF/LICENSE'
        exclude 'META-INF/LICENSE.txt'
        exclude 'META-INF/license.txt'
        exclude 'META-INF/NOTICE'
        exclude 'META-INF/NOTICE.txt'
        exclude 'META-INF/notice.txt'
        exclude 'META-INF/AL2.0'
        exclude 'META-INF/LGPL2.1'
        exclude("META-INF/*.kotlin_module")
    }
}

def kotlin_version = '1.3.72'
def multidex_version = '2.0.1'
def appCompat_version = '1.1.0'
def ktx_version = '1.3.0'
def constraint_version = '1.1.3'
def lifecycle_version = '2.2.0'
def room_version = '2.2.5'
def legacySupport_version = '1.0.0'
def coroutines_version = '1.3.7'
def nav_version = '2.2.2'
def material_version = '1.1.0'
def retrofit_version = '2.9.0'
def rxJava_version = '2.2.19'
def rxAndroid_version = '2.1.1'
def rxRetrofit_version = '2.9.0'
def glide_version = '4.11.0'
def palette_version = '1.0.0'
def preferences_version = '1.1.1'
def timberkt_version = '1.5.1'
def greenrobot_version = '3.2.0'
def antlr_version = '4.7.1'
def junit_version = '4.13'
def junitTest_version = '1.2.0'
def espresso_version = '3.2.0'
def mockk_version = '1.9.3'
def livedataTest_version = '1.1.2'
def okhttp_version = '4.7.2'
def robolectric_version = '4.2.1'

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
    implementation "androidx.appcompat:appcompat:$appCompat_version"
    implementation "androidx.core:core-ktx:$ktx_version"
    implementation "androidx.constraintlayout:constraintlayout:$constraint_version"

    // Multidex
    implementation "androidx.multidex:multidex:$multidex_version"

    // Lifecycle
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
    implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version"
    implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"

    // Room
    implementation "androidx.room:room-runtime:$room_version"
    implementation "androidx.legacy:legacy-support-v4:$legacySupport_version"
    implementation "androidx.room:room-ktx:$room_version"
    kapt "androidx.room:room-compiler:$room_version"

    // Navigation
    implementation "android.arch.navigation:navigation-common-ktx:$nav_version"
    implementation "android.arch.navigation:navigation-fragment-ktx:$nav_version"
    implementation "android.arch.navigation:navigation-runtime-ktx:$nav_version"
    implementation "android.arch.navigation:navigation-ui-ktx:$nav_version"

    // Retrofit
    implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
    implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
    implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofit_version"

    // RxJava
    implementation "io.reactivex.rxjava2:rxjava:$rxJava_version"
    implementation "io.reactivex.rxjava2:rxandroid:$rxAndroid_version"
    implementation "com.squareup.retrofit2:adapter-rxjava2:$rxRetrofit_version"
    implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycle_version"

    // Glide
    implementation "com.github.bumptech.glide:glide:$glide_version"
    implementation "androidx.palette:palette:$palette_version"

    // Misc
    implementation "androidx.legacy:legacy-support-v4:$legacySupport_version"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
    implementation "com.google.android.material:material:$material_version"
    implementation "androidx.preference:preference:$preferences_version"
    implementation "com.github.ajalt:timberkt:$timberkt_version"
    implementation "org.greenrobot:eventbus:$greenrobot_version"

    // Unit Tests - MockK
    testImplementation "junit:junit:$junit_version"
    testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version"
    testImplementation "io.mockk:mockk:$mockk_version"
    testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
    testImplementation "com.jraska.livedata:testing-ktx:$livedataTest_version"

    // Unit Tests - Robolectric
    testImplementation "org.robolectric:robolectric:$robolectric_version"
    testImplementation "androidx.test:core:$junitTest_version"

    // Instrumented Tests - espresso
    androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
    androidTestImplementation "androidx.test.espresso:espresso-contrib:$espresso_version"
    androidTestImplementation "androidx.test:rules:$junitTest_version"

    // Instrumented Tests - MockK
    androidTestImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version"
    androidTestImplementation "io.mockk:mockk-android:$mockk_version"
    androidTestImplementation "androidx.test:runner:$junitTest_version"
    androidTestUtil "androidx.test:orchestrator:$junitTest_version"
    androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
    androidTestImplementation "com.jraska.livedata:testing-ktx:$livedataTest_version"

    // Mock Test Web Server
    testImplementation "com.squareup.okhttp3:okhttp:$okhttp_version"
    testImplementation "com.squareup.okhttp3:mockwebserver:$okhttp_version"

}

configurations.all {
    resolutionStrategy {
        force("org.antlr:antlr4-runtime:$antlr_version")
        force("org.antlr:antlr4-tool:$antlr_version")
    }
}

App Overview

This demo app has one Activity and two Fragments. One Fragment displays a master list and the other shows detail information. Master/Detail is a common pattern that occurs in many apps; implementing it properly sometimes is challenging.

In this app, we initially load a local database with JSON default data. Then, if network is available, we update that data using a Retrofit REST API and RxJava. Network data overlays default database information with the latest. Note that Retrofit and RxJava are proven and powerful tools for accessing REST APIs; but because Google’s Jetpack libraries use ‘LiveData‘ for the data binding display, we need to convert RxJava ‘Flowables‘ into ‘LiveData’. Our Data Repository will handle that job using the converter MyLiveDataReactiveStreams. This app loads the network data from this dogs.json

The app screens look like this:

Initial Navigation and Display

Android uses the ‘AndroidManifest.xml‘ to determine the starting Activity, marked as the LAUNCHER Activity. For this demo, there is only a single activity: ‘MainActivity‘, to load. When that runs, a call to ‘setContentView‘ causes the main activity layout to inflate. Note that inside this layout is a fragment (or more properly a FragmentContainerView) element identified with android:name = “androidx.navigation.fragment.NavHostFragment”. That is what triggers Navigation to initialize our ListFragment. It works like this:

JetPack’s Navigation library uses ‘res/navigation/navigation.xml‘ to determine the starting transition, and all future screen transitions and transition arguments. Since ‘listFragment‘ is the id marked as the starting navigation fragment destination (see ‘app:startDestination = “@id/listFragment” ‘), the ‘listFragment’ navigation fragment loads, which in turn loads and runs the actual ListFragment Kotlin code. Finally, the ListFragment’s generated display content is displayed in the ‘NavHostFragment’ section of our ‘activity_main.xml‘. The next section shows how ListFragment loads the display content:

Create a ViewModel with DataRepository

In MVVM, the ViewModel’s purpose is to hold business logic and data for attached Activities and Fragments. It is decoupled from Activities and Fragments and has no dependencies on UI components. Any data stored in a ViewModel will survive ‘Configuration Changes‘, reducing (but not always eliminating) the need to override ‘onSaveInstanceState‘ and ‘onRestoreInstanceState‘. It is the ViewModel’s responsibility to communicate with a DataRepository when requesting or updating data to/from the Network or Database. That data is also presented to Activities and Fragments by having them observe changes to the ViewModel’s LiveData. The ViewModel should implement ‘LifeCycleOwner‘ to make it lifecycle aware so that it is not necessary to release observers when a UI component gets destroyed.

When the ListFragment loads, it needs to create (or attach to) a DogsListViewModel. Also the DogsListViewModel needs to use a DogsListDataRepository to manage the data flows. And the DogsListDataRepository requires both a DogsApiService and a SharedPreferencesHelper to do that data management. We need to use Dependency Inversion to properly setup those relationships. To accomplish that we construct DogsListDataRepository passing in the network URL, the DogsApiService and a SharedPreferencesHelper. Then we create the DogsListViewModel with the new DogsListDataRepository as a constructor parameter. In Android, to create any ViewModel with constructor parameters we need to use a ‘ViewModelFactory‘. The class MyViewModelFactory does just that. Note that the factory constructor takes a ‘DataRepository’ as a parameter and that this is our repository base class type. This lets us use different DataRepository classes mixed with different ViewModel implementations. Here is how to set it all up from inside the ListFragment’s ‘onViewCreated‘ method using Kotlin:

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        Timber.tag(_tag).d("onViewCreated")
        super.onViewCreated(view, savedInstanceState)
        val repositoryURL: String = DogsApiService.BASE_URL
        val apiService = DogsApiService()
        val prefHelper = SharedPreferencesHelper()
        val viewModelFactory = MyViewModelFactory(DogsListDataRepository(repositoryURL, apiService, prefHelper))
        dogListViewModel = ViewModelProvider(this, viewModelFactory).get(DogsListViewModel::class.java)
        ...
    }

ViewModel Initialization

When the DogsListViewModel is created, it runs a Kotlin init block to check if the database is created, and to initialize that if it does not exist yet. The MutableLiveDatadogsMutableLoading‘ flag is observed by the ListFragment. It is initially set false so that a loading spinner displays. During first time init, the ViewModel’s ‘initializeData‘ method background loads the starting database from a local JSON asset file. Then the database is marked as ‘created’ using a SharedPrefrencesHelper. Now the ListFragment UI Thread is concurrently inside ‘onViewCreated‘ and it calls ‘dogListViewModel.checkRefresh‘ to load the dog list data, and start the ‘refresh‘ loop.

    init {
        Timber.tag(_tag).d("init")
        dogsRepository = repository
        viewModelScope.launch {
            dogsMutableLoading.postValue(false)
            initializeData(MyApplication.applicationContext())
        }
    }

The ‘refresh’ Loop

The idea here is to always have something valid to display. Because network may not be available, the initial database comes from local JSON. But if we do have access to a network, then we want to get the most up-to-date information from a remote server. If we load that, then we need to overlay our initial database with the new DogBreed values. Note that when the DogsListViewModel calls ‘dogsRepository.storeDogsLocally(dogsList)‘ the list is stored asynchronously and so won’t exist in the database immediately. That means we don’t initially have each DogBreed’s ‘Uuid‘ field set with an index value. The index is needed because when one of the list items is clicked we load that item from our database. Without an index, we don’t know if the clicked DogBreed info is actually written to the local database yet, meaning a database read of the clicked item may fail. Only after a bulk insert completes, do we learn the assigned ‘Uuid‘ of each dog. A fetch and display refresh loop runs from the ListFragment to ensure the database writes are flushed to disk. Also, when network data replaces existing database content, we need to loop to keep running until everything stabilizes and we have proper ‘Uuid‘ values for each replaced DogBreed list item.

So when the DogsListViewModel posts new list data, that is observed by the ListFragment, which in turn triggers a call to ‘showCurrentDogs‘. That completes the refresh loop by checking for valid ‘Uuid‘ indexes. If those are not set yet, then the loop runs again, until we have proper ‘Uuid‘ indexes for each list item. An additional check happens in ListFragment’s ‘observeViewModel‘ method when ‘dogListViewModel.checkIfLoadingIsComplete‘ checks to see if we have loaded from the network. If not and network is available, then ‘refresh‘ loops again, this time with Retrofit and RxJava loading the latest JSON.

    fun showCurrentDogs() {
        haveUuids = (currentDogs.isNotEmpty() && currentDogs[0].uuid != 0)
        Timber.tag(_tag).d("--------- showCurrentDogs: haveUuids=${haveUuids}")
        if (! haveUuids) {
            refresh()
        }
        else if (currentDogs.isNotEmpty()) {
            Timber.tag(_tag).d("showCurrentDogs: update the RecyclerView")
            // this forces the RecyclerView to redraw images (to fix an Android rotation bug)
            val myAdapter = dogsList.adapter
            dogsList.adapter = myAdapter
            // set adapter data as current dogs list
            dogListAdapter.updateDogList(currentDogs)
            dogsList.visibility = View.VISIBLE
            dogsList.layoutManager?.scrollToPosition(dogListViewModel.lastClickedDogListIndex)
        }
    }

    private fun refresh() {
        Timber.tag(_tag).d("refresh")
        Timer("refresh", false).schedule(500) {
            uiScope.launch(Dispatchers.IO) {
                if (haveUuids && currentDogs.isNotEmpty() && ! isNetworkAvailable()) {
                    Timber.tag(_tag).d("refresh: NO NETWORK, SHOW EXISTING DATA")
                }
                else {
                    Timber.tag(_tag).d("refresh: do the refresh")
                    dogListViewModel.refresh()
                }
            }
        }
    }

Room Database

Room‘ is a wrapper implementation around SQLite by Google. It allows easy implementation of object persistence by defining data models and abstract data access interfaces, and linking those using an abstract Database, like in this example. The real power here is that you don’t have to write low level data access code. Defining a ‘Data Access Object‘ interface and referencing that in the abstract database is enough to allow operations on the underlying SQLite database. Room works really well with LiveData and RxJava. Annotations in the DAO interface instruct Room how to interact with SQLite. Here is an example DAO for our demo app showing CRUD database operations for ‘DogBreed‘ models:

package com.harlie.dogs.room

import androidx.room.*
import com.harlie.dogs.model.DogBreed

@Dao
interface DogDao {
    @Insert
    suspend fun insertAll(vararg dogs: DogBreed): List<Long>

    @Query("SELECT * FROM dogbreed")
    suspend fun getAllDogs(): List<DogBreed>

    @Query("SELECT * FROM dogbreed WHERE breed_id = :dogId")
    suspend fun getDog(dogId: String): DogBreed

    @Query("DELETE FROM dogbreed")
    suspend fun deleteAllDogs()

    @Update
    suspend fun updateDog(dog: DogBreed)

    @Delete
    suspend fun deleteDog(dog: DogBreed)
}

And here is an example of using that Dao from our DogsListDataRepository to replace the DogBreed items with a new list:

        databaseScope.launch {
            val context: Context = MyApplication.applicationContext()
            val dao = DogDatabase.getInstance(context)?.dogDao()
            dao?.deleteAllDogs() // since we are replacing the cache, delete old data first
            val result = dao?.insertAll(*dogsList.toTypedArray())
            result.let {
                var i = 0
                while (i < dogsList.size) {
                    dogsList[i].uuid = it?.get(i)?.toInt() ?: 0
                    ++i
                }
                prefHelper.markDatabaseCreated()
            }
        }

Retrofit and RxJava

RxJava is an amazing library for reactive programming in Android. It allows you to do chained operations on data streamed from the Network or a Database in an intuitive manner that is extensible, maintainable and Thread safe. In RxJava, Observables and Flowables emit data items that are later iterated over by Observers. Flowables are different than Observables in that they can handle back-pressure for large data sets. In Flowables, back-pressure can be handled using a BackpressureStrategy like: MISSING, ERROR, BUFFER, DROP, or LATEST. In this example, RxJava and Retrofit work together with a Flowable to load and process JSON data from the Network. No BackPressureStrategy is defined, so it falls back to default which buffers up to 128 items in the queue. Because our ViewModels use LiveData for data-binding to the UI, we must convert the emitted data into LiveData objects. To make RxJava work together with LiveData we use LiveDataReactiveStreams. Here is an example of using RxJava together with Retrofit to load and convert REST objects from remote JSON data:

    fun fetchFromRemote(): LiveData<List<DogBreed>> {
        Timber.tag(_tag).d("fetchFromRemote")
        return MyLiveDataReactiveStreams.fromPublisher(
            apiService.getRequestApi()
                .getFlowableDogs()
                .subscribeOn(Schedulers.io())
                .observeOn(Schedulers.computation())
        )
    }

Notice that we are using MyLiveDataReactiveStreams to do the conversion from Flowable to LiveData. This is a modified version of Google’s LiveDataReactiveStreams. It is modified because Google’s code does not handle error states very well. In this demo, the original code is changed to trap errors and send them via GreenRobot’s EventBus for handling. Then any errors that happen inside MyLiveDataReactiveStreams are easily handled with this code:

    @Subscribe(threadMode = ThreadMode.MAIN)
    fun onRxErrorEvent(rxError_event: RxErrorEvent) {
        Timber.tag(_tag).e("onRxErrorEvent: $rxError_event")
        Toast.makeText(this, rxError_event.description, Toast.LENGTH_LONG).show()
        recover()
    }

Data Binding with LiveData

Data Binding is a library for binding XML UI components to data sources in the app. The app’s data sources get updated via observed changes in a ViewModel’s LiveData. In this example we use data-binding for the DogBreed RecyclerView when showing the ListFragment, and also in the DetailFragment when displaying a single DogBreed list item. Additionally data-binding helps with a popup dialog when sending a SMS.

The ListFragment contains a RecyclerView and a display adapter. All of the RecyclerView XML binding happens in the adapter DogsListAdapter. Note the adapter’s ‘onCreateViewHolder‘ method attaches to item_dogs.xml using DataBindingUtil like this:

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DogViewHolder {
        //Timber.tag(_tag).d("onCreateViewHolder")
        val inflater = LayoutInflater.from(parent.context)
        val view = DataBindingUtil.inflate<ItemDogsBinding>(inflater, R.layout.item_dogs, parent, false)
        return DogViewHolder(view)
    }

    // Also in the DogsListAdapter we define a ViewHolder with a data binding parameter
    class DogViewHolder(var view: ItemDogsBinding): RecyclerView.ViewHolder(view.root)

The class ‘ItemDogsBinding‘ is generated from the XML ‘data‘ section inside ‘item_dogs.xml‘. The binding class’s generated name comes from the XML file name. So ‘item_dogs.xml’ generates an ‘ItemDogsBinding’ class. In ‘item_dogs.xml’ the ‘data‘ section holds XML variable mappings. Here we map the XML variable ‘dog‘ to a DogBreed and the variable ‘listener’ to a ‘DogClickListener‘. For our RecyclerView items, those mappings look like this:

    <data>
        <variable
            name="dog"
            type="com.harlie.dogs.model.DogBreed">
        </variable>
        <variable
            name="listener"
            type="com.harlie.dogs.view.DogClickListener">
        </variable>
    </data>

Later in the ‘item_dogs.xml’ we use those variables. The list item click listeners are set using ‘android:onClick=”@{listener::onDogClicked}”‘ in the LinearLayout container. And text values are set for the breed name and lifespan inside their respective TextViews with code like this: ‘android:text=”@{dog.breedName}”‘ and ‘android:text=”@{dog.breedLifespan}”‘. A third invisible TextView holds ‘android:text=”@{dog.breedId}”‘. The invisible widget exists solely as a data holder so we can easily pass the dog’s breedId to a DetailFragment when a list item is clicked. Lastly, the ‘CenterBottomImageView‘ is set with XML code ‘android:imageUrl=”@{dog.breedImageUrl}”‘. But to make the image display we also need to add an image BindingAdapter to our UtilityFunctions. The BindingAdapter binds to the name ‘android:imageUrl‘ and looks like this:

@BindingAdapter("android:imageUrl")
fun loadImage(view: ImageView, url: String?) {
    //Timber.tag(utag).d("loadImage binding")
    GlideWrapper().loadImage(view, url, getProgressDrawable(view.context))
}

We have another (slightly different) BindingAdapter for the DetailFragment to load from our Glide image cache:

@BindingAdapter("bind:image_url")
fun loadCachedImage(view: ImageView, url: String?) {
    //Timber.tag(utag).d("loadCachedImage binding")
    GlideWrapper().loadCachedImage(view, url)
}

The DetailFragment uses the alternate image BindingAdapter for the CenterBottomImageView defined in ‘fragment_detail.xml‘. Note the ‘data’ section needed for defining the ‘dog‘ and color ‘palette‘ XML variables. The detail image is set from the image cache with this code: ‘app:image_url=”@{dog.breedImageUrl}”‘. Be aware that fragment_detail.xml binds to the alternate image BindingAdapter with the name ‘bind:image_url‘. This alternate BindingAdapter definition makes use of the Glide image cache.

From our DetailFragment, we attach to the fragment_detail.xml inside ‘onCreateView’ like this:

    private lateinit var dataBinding: FragmentDetailBinding

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        Timber.tag(_tag).d("onCreateView")
        setHasOptionsMenu(true)
        dataBinding = DataBindingUtil.inflate(inflater, R.layout.fragment_detail, container, false)
        return dataBinding.root
    }

Here the class ‘FragmentDetailBinding’ is generated from the ‘data‘ section of fragment_detail.xml and the XML’s file name, just like before. So the name ‘fragment_detail.xml’ generates a ‘FragmentDetailBinding’ class.

The ListFragment and DetailFragment both receive UI updates from their respective ViewModels. When changes happen, these are propagated to the UI XML display using the ‘binding adapters’ discussed earlier. These generated binding adapters are the mechanism used to change the UI’s display.

This is how the ListFragment observes and propagates LiveData changes from its attached DogsListViewModel into the ItemDogsBinding (in DogsListAdapter):

    private fun observeViewModel() {
        Timber.tag(_tag).d("observeViewModel")
        dogListViewModel.dogsList.observe(viewLifecycleOwner, Observer { dogs ->
            Timber.tag(_tag).d("observeViewModel: observe dogsLiveList size=${dogs?.size}")
            dogs?.let {
                currentDogs = dogs
                showCurrentDogs()
                if (dogs.isNotEmpty()) {
                    dogListViewModel.checkIfLoadingIsComplete()
                }
            }
        })
        dogListViewModel.dogsLoading.observe(viewLifecycleOwner, Observer { isLoading ->
            Timber.tag(_tag).d("observe dogsLoading=${isLoading}")
            isLoading?.let {
                dogsLoadingProgress.visibility = if (it) View.VISIBLE else View.INVISIBLE
                if (it) {
                    dogsList.visibility = View.INVISIBLE
                }
            }
        })
    }

And here is the code that observes and propagates detail data into the FragmentDetailBinding from the DetailFragment using LiveData from an attached DogDetailViewModel:

    private fun observeViewModel() {
        Timber.tag(_tag).d("observeViewModel")
        dogDetailViewModel.dog.observe(viewLifecycleOwner, Observer { dog ->
            if (dog != null) {
                currentDog = dog
                Timber.tag(_tag).d("observeViewModel: observe dog_icon dog_icon=${dog}")
                dataBinding.dog = dog
                dog.breedImageUrl?.let {
                    GlideWrapper().setBackgroundColor(this, dataBinding, it)
                }
            }
            else {
                Timber.tag(_tag).d("observeViewModel: need to refresh()")
                refresh()
            }
        })
    }



This is the end of part 1. The second half of this article will include:

  • Notifications and Deep Links
  • SMS, Sharing and Permissions
  • User Preferences
  • Building Unit Tests
  • Leave a Reply