'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://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 |