'ViewModel updates on screen rotation

I've created a simple project to study Kotlin and Android architecture

https://github.com/AOreshin/shtatus

The screen consists of RecyclerView and three EditTexts.

Corresponding ViewModel is exposing 7 LiveData's:

  • Three LiveData corresponding to filters
  • Event to notify the user that no entries are found
  • Event to notify the user that no entries are present
  • Status of SwipeRefreshLayout
  • List of connections to show based on filter input

When user types text in filter ViewModel's LiveData gets notified about the changes and updates the data. I 've read that it's a bad practice to expose MutableLiveData to Activities/Fragments but they have to notify ViewModel about the changes somehow. When no entries are found based on the user's input Toast is shown.

The problem

When the user enters filter values that have no matches, Toast is shown. If the user then rotates the device Toast is shown again and again.

I've read these articles:

https://medium.com/androiddevelopers/livedata-with-snackbar-navigation-and-other-events-the-singleliveevent-case-ac2622673150

https://proandroiddev.com/livedata-with-single-events-2395dea972a8

But I don't understand how I can apply these to my use case. I think the problem in how I perform the updates

private val connections = connectionRepository.allConnections()
private val mediatorConnection = MediatorLiveData<List<Connection>>().also {
    it.value = connections.value
}

private val refreshLiveData = MutableLiveData(RefreshStatus.READY)
private val noMatchesEvent = SingleLiveEvent<Void>()
private val emptyTableEvent = SingleLiveEvent<Void>()

val nameLiveData = MutableLiveData<String>()
val urlLiveData = MutableLiveData<String>()
val actualStatusLiveData = MutableLiveData<String>()

init {
    with(mediatorConnection) {
        addSource(connections) { update() }
        addSource(nameLiveData) { update() }
        addSource(urlLiveData) { update() }
        addSource(actualStatusLiveData) { update() }
    }
}

fun getRefreshLiveData(): LiveData<RefreshStatus> = refreshLiveData
fun getNoMatchesEvent(): LiveData<Void> = noMatchesEvent
fun getEmptyTableEvent(): LiveData<Void> = emptyTableEvent
fun getConnections(): LiveData<List<Connection>> = mediatorConnection

private fun update() {
    if (connections.value.isNullOrEmpty()) {
        emptyTableEvent.call()
    } else {
        mediatorConnection.value = connections.value?.filter { connection -> getPredicate().test(connection) }

        if (mediatorConnection.value.isNullOrEmpty()) {
            noMatchesEvent.call()
        }
    }
}

update() gets triggered on screen rotation because of new subscription to mediatorConnection and MediatorLiveData.onActive() is called. And it's intented behavior

Android live data - observe always fires after config change

Code for showing toast

package com.github.aoreshin.shtatus.fragments

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.Toast
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.github.aoreshin.shtatus.R
import com.github.aoreshin.shtatus.ShatusApplication
import com.github.aoreshin.shtatus.viewmodels.ConnectionListViewModel
import javax.inject.Inject

class ConnectionListFragment : Fragment() {
    @Inject
    lateinit var viewModelFactory: ViewModelProvider.Factory

    private lateinit var refreshLayout: SwipeRefreshLayout

    private lateinit var nameEt: EditText
    private lateinit var urlEt: EditText
    private lateinit var statusCodeEt: EditText

    private lateinit var viewModel: ConnectionListViewModel

    private lateinit var recyclerView: RecyclerView
    private lateinit var viewAdapter: ConnectionListAdapter

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        val view = inflater.inflate(R.layout.fragment_connection_list, container, false)

        val application = (requireActivity().application as ShatusApplication)
        application.appComponent.inject(this)

        val viewModelProvider = ViewModelProvider(this, viewModelFactory)
        viewModel = viewModelProvider.get(ConnectionListViewModel::class.java)

        bindViews(view)
        setupObservers()
        setupListeners()
        addFilterValues()
        setupRecyclerView()
        return view
    }

    private fun setupObservers() {
        with(viewModel) {
            getConnections().observe(viewLifecycleOwner, Observer { viewAdapter.submitList(it) })

            getRefreshLiveData().observe(viewLifecycleOwner, Observer { status ->
                when (status) {
                    ConnectionListViewModel.RefreshStatus.LOADING -> refreshLayout.isRefreshing = true
                    ConnectionListViewModel.RefreshStatus.READY -> refreshLayout.isRefreshing = false
                    else -> throwException(status.toString())
                }
            })

            getNoMatchesEvent().observe(viewLifecycleOwner, Observer { showToast(R.string.status_no_matches) })
            getEmptyTableEvent().observe(viewLifecycleOwner, Observer { showToast(R.string.status_no_connections) })
        }
    }

    private fun setupRecyclerView() {
        viewAdapter = ConnectionListAdapter(parentFragmentManager, ConnectionItemCallback())
        recyclerView.apply {
            layoutManager = LinearLayoutManager(context)
            adapter = viewAdapter
        }
    }

    private fun addFilterValues() {
        with(viewModel) {
            nameEt.setText(nameLiveData.value)
            urlEt.setText(urlLiveData.value)
            statusCodeEt.setText(actualStatusLiveData.value)
        }
    }

    private fun bindViews(view: View) {
        with(view) {
            recyclerView = findViewById(R.id.recycler_view)
            refreshLayout = findViewById(R.id.refresher)
            nameEt = findViewById(R.id.nameEt)
            urlEt = findViewById(R.id.urlEt)
            statusCodeEt = findViewById(R.id.statusCodeEt)
        }
    }

    private fun setupListeners() {
        with(viewModel) {
            refreshLayout.setOnRefreshListener { send() }
            nameEt.addTextChangedListener { nameLiveData.value = it.toString() }
            urlEt.addTextChangedListener { urlLiveData.value = it.toString() }
            statusCodeEt.addTextChangedListener { actualStatusLiveData.value = it.toString() }
        }
    }

    private fun throwException(status: String) {
        throw IllegalStateException(getString(R.string.error_no_such_status) + status)
    }

    private fun showToast(resourceId: Int) {
        Toast.makeText(context, getString(resourceId), Toast.LENGTH_SHORT).show()
    }

    override fun onDestroyView() {
        super.onDestroyView()
        with(viewModel) {
            getNoMatchesEvent().removeObservers(viewLifecycleOwner)
            getRefreshLiveData().removeObservers(viewLifecycleOwner)
            getEmptyTableEvent().removeObservers(viewLifecycleOwner)
            getConnections().removeObservers(viewLifecycleOwner)
        }
    }
}

How I should address this issue?



Solution 1:[1]

After some head scratching I've decided to go with internal ViewModel statuses, this way logic in Activity/Fragment is kept to a minimum.

So now my ViewModel looks like this:

package com.github.aoreshin.shtatus.viewmodels

import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.github.aoreshin.shtatus.events.SingleLiveEvent
import com.github.aoreshin.shtatus.room.Connection
import io.reactivex.FlowableSubscriber
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import io.reactivex.subscribers.DisposableSubscriber
import okhttp3.ResponseBody
import retrofit2.Response
import java.util.function.Predicate
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class ConnectionListViewModel @Inject constructor(
    private val connectionRepository: ConnectionRepository
) : ViewModel() {
    private var tableStatus = TableStatus.OK

    private val connections = connectionRepository.allConnections()
    private val mediatorConnection = MediatorLiveData<List<Connection>>()

    private val stopRefreshingEvent = SingleLiveEvent<Void>()
    private val noMatchesEvent = SingleLiveEvent<Void>()
    private val emptyTableEvent = SingleLiveEvent<Void>()

    private val nameLiveData = MutableLiveData<String>()
    private val urlLiveData = MutableLiveData<String>()
    private val statusLiveData = MutableLiveData<String>()

    init {
        with(mediatorConnection) {
            addSource(connections) { update() }
            addSource(nameLiveData) { update() }
            addSource(urlLiveData) { update() }
            addSource(statusLiveData) { update() }
        }
    }

    fun getStopRefreshingEvent(): LiveData<Void> = stopRefreshingEvent
    fun getNoMatchesEvent(): LiveData<Void> = noMatchesEvent
    fun getEmptyTableEvent(): LiveData<Void> = emptyTableEvent
    fun getConnections(): LiveData<List<Connection>> = mediatorConnection
    fun getName(): String? = nameLiveData.value
    fun getUrl(): String? = urlLiveData.value
    fun getStatus(): String? = statusLiveData.value
    fun setName(name: String) { nameLiveData.value = name }
    fun setUrl(url: String) { urlLiveData.value = url }
    fun setStatus(status: String) { statusLiveData.value = status }

    private fun update() {
        if (connections.value != null) {
            if (connections.value.isNullOrEmpty()) {
                if (tableStatus != TableStatus.EMPTY) {
                    emptyTableEvent.call()
                    tableStatus = TableStatus.EMPTY
                }
            } else {
                mediatorConnection.value = connections.value?.filter { connection -> getPredicate().test(connection) }

                if (mediatorConnection.value.isNullOrEmpty()) {
                    if (tableStatus != TableStatus.NO_MATCHES) {
                        noMatchesEvent.call()
                        tableStatus = TableStatus.NO_MATCHES
                    }
                } else {
                    tableStatus = TableStatus.OK
                }
            }
        }
    }

    fun send() {
        if (!connections.value.isNullOrEmpty()) {
            val singles = connections.value?.map { connection ->
                val id = connection.id
                val description = connection.description
                val url = connection.url
                var message = ""

                connectionRepository.sendRequest(url)
                    .doOnSuccess { message = it.code().toString() }
                    .doOnError { message = it.message!! }
                    .doFinally {
                        val result = Connection(id, description, url, message)
                        connectionRepository.insert(result)
                    }
            }

            Single.mergeDelayError(singles)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .doFinally { stopRefreshingEvent.call() }
                .subscribe(getSubscriber())
        } else {
            stopRefreshingEvent.call()
        }
    }

    private fun getSubscriber() : FlowableSubscriber<Response<ResponseBody>> {
        return object: DisposableSubscriber<Response<ResponseBody>>() {
            override fun onComplete() { Log.d(TAG, "All requests sent") }
            override fun onNext(t: Response<ResponseBody>?) { Log.d(TAG, "Request is done") }
            override fun onError(t: Throwable?) { Log.d(TAG, t!!.message!!) }
        }
    }

    private fun getPredicate(): Predicate<Connection> {
        return Predicate { connection ->
            connection.description.contains(nameLiveData.value.toString(), ignoreCase = true)
                    && connection.url.contains(urlLiveData.value.toString(), ignoreCase = true)
                    && connection.actualStatusCode.contains(
                statusLiveData.value.toString(),
                ignoreCase = true
            )
        }
    }

    private enum class TableStatus {
        NO_MATCHES,
        EMPTY,
        OK
    }

    companion object {
        private const val TAG = "ConnectionListViewModel"
    }
}

And corresponding Fragment looks like this:

package com.github.aoreshin.shtatus.fragments

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.Toast
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.github.aoreshin.shtatus.R
import com.github.aoreshin.shtatus.ShatusApplication
import com.github.aoreshin.shtatus.viewmodels.ConnectionListViewModel
import javax.inject.Inject

class ConnectionListFragment : Fragment() {
    @Inject
    lateinit var viewModelFactory: ViewModelProvider.Factory

    private lateinit var refreshLayout: SwipeRefreshLayout

    private lateinit var nameEt: EditText
    private lateinit var urlEt: EditText
    private lateinit var statusCodeEt: EditText

    private lateinit var viewModel: ConnectionListViewModel

    private lateinit var recyclerView: RecyclerView
    private lateinit var viewAdapter: ConnectionListAdapter

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        val view = inflater.inflate(R.layout.fragment_connection_list, container, false)

        val application = (requireActivity().application as ShatusApplication)
        application.appComponent.inject(this)

        val viewModelProvider = ViewModelProvider(this, viewModelFactory)
        viewModel = viewModelProvider.get(ConnectionListViewModel::class.java)

        bindViews(view)
        setupObservers()
        setupListeners()
        addFilterValues()
        setupRecyclerView()
        return view
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        if (savedInstanceState != null) {
            refreshLayout.isRefreshing = savedInstanceState.getBoolean(REFRESHING, false)
        }
    }

    private fun setupObservers() {
        with(viewModel) {
            getConnections().observe(viewLifecycleOwner, Observer { viewAdapter.submitList(it) })
            getStopRefreshingEvent().observe(viewLifecycleOwner, Observer { refreshLayout.isRefreshing = false })
            getNoMatchesEvent().observe(viewLifecycleOwner, Observer { showToast(R.string.status_no_matches) })
            getEmptyTableEvent().observe(viewLifecycleOwner, Observer { showToast(R.string.status_no_connections) })
        }
    }

    private fun setupRecyclerView() {
        viewAdapter = ConnectionListAdapter(parentFragmentManager, ConnectionItemCallback())
        recyclerView.apply {
            layoutManager = LinearLayoutManager(context)
            adapter = viewAdapter
        }
    }

    private fun addFilterValues() {
        with(viewModel) {
            nameEt.setText(getName())
            urlEt.setText(getUrl())
            statusCodeEt.setText(getStatus())
        }
    }

    private fun bindViews(view: View) {
        with(view) {
            recyclerView = findViewById(R.id.recycler_view)
            refreshLayout = findViewById(R.id.refresher)
            nameEt = findViewById(R.id.nameEt)
            urlEt = findViewById(R.id.urlEt)
            statusCodeEt = findViewById(R.id.statusCodeEt)
        }
    }

    private fun setupListeners() {
        with(viewModel) {
            refreshLayout.setOnRefreshListener { send() }
            nameEt.addTextChangedListener { setName(it.toString()) }
            urlEt.addTextChangedListener { setUrl(it.toString()) }
            statusCodeEt.addTextChangedListener { setStatus(it.toString()) }
        }
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putBoolean(REFRESHING, refreshLayout.isRefreshing)
    }

    private fun showToast(resourceId: Int) {
        Toast.makeText(context, getString(resourceId), Toast.LENGTH_SHORT).show()
    }

    override fun onDestroyView() {
        super.onDestroyView()
        with(viewModel) {
            getNoMatchesEvent().removeObservers(viewLifecycleOwner)
            getEmptyTableEvent().removeObservers(viewLifecycleOwner)
            getStopRefreshingEvent().removeObservers(viewLifecycleOwner)
            getConnections().removeObservers(viewLifecycleOwner)
        }
    }

    companion object {
        private const val REFRESHING = "isRefreshing"
    }
}

Pros

  • No additional dependencies
  • Usage of widespread SingleLiveEvent
  • Pretty straightforward to implement

Cons

Conditional logic is quickly getting out of hand even in this simple case, surely needs refactoring. Not sure if this approach will work in real-life complex scenarios.

If there are cleaner and more concise approaches to solve this problem I will be happy to hear about them!

Solution 2:[2]

In your solution; you introduced TableStatus which is acting like flag and not needed.

If you really looking for a good Android Architecture; instead you could just do

if (viewLifecycleOwner.lifecycle.currentState == Lifecycle.State.RESUMED) {
            showToast(R.string.status_no_connections)
        

and

if (viewLifecycleOwner.lifecycle.currentState == Lifecycle.State.RESUMED) {
            showToast(R.string.status_no_matches)

NOTE:
viewLifecycleOwner.lifecycle.currentState == Lifecycle.State.RESUMED is not a patch Google implemented this fix in support library as well.

And remove @Singleton from (why would you need it to be singleton)

@Singleton
class ConnectionListViewModel @Inject constructor(

PS:
From the top of my head looks like; you may also don't need SingleLiveEvent for you case.
(would love to talk more on this if you want I also just have started Kotlin + Clear & Scalable Android Architecture)

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Alexander Oreshin
Solution 2 Maverick