So, I would like to use StateFlow instead of LiveData, but I can not figure out what's the problem with my logic.
I have a flow, which has a default null value. When I open a dialog which contains a some datas, after that I select one data, I emit the new value to the flow.
In the first time, after the dialog closed, collectLatest called, and I get the null value (init), after the emit, I get the new value, it is good. But If I open the dialog again, and select value, and close the dialog, the collectLatest fun called 3-times, and I again open the dialog... and collectLatest called 4 times and so on.
So this is very bad behavior, and I'm sure , I did something wrong, but I don't see the bug.
In the liveData the expected behavior is after the dialog close, that the observer fun is called just once. I would like to achive this.
I also checked, that I emit the new value only once, so there is no reason why collectLatest fire multiple times.
ViewModel:
private val _previousManufacture = MutableStateFlow<PreviousManufactureView?>(null)
val previousManufacture = _previousManufacture.asStateFlow()
private suspend fun setPreviousManufactureByMachineId(machineId: String) {
val result = stateReportRepository.getPreviousManufactureByMachineId(machineId)
if (result is Result.Success) {
_previousManufacture.emit(result.data)
} else {
_previousManufacture.emit(null)
}
}
Fragment:
lifecycleScope.launchWhenCreated {
viewModel.previousManufacture.collectLatest {
var d = it
}
}
[Update]
Fragment:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.vm = viewModel
initFlows()
}
private fun initFlows() {
lifecycleScope.launchWhenCreated {
viewModel.openStateOfWorkflowBrowser.collectLatest {
openStateOfWorkflowSelectionDialog()
}
}
...
}
Sorry, I missed this before in my comment, but I think the problem is that you are calling launchWhenCreated in the lifecycleScope of the Fragment, not in its viewLifecycle.lifecycleScope. So if the Fragment is reused (like after a dialog fragment has a appeared), the old collector is not cancelled and a new one is added, because the lifecycle of the Fragment has not ended, only the lifecycle of its previous view. You should almost always use viewLifecycle.lifecycleScope when you are using coroutines in a Fragment.
Related
There is ParentFragment that shows DialogFragment. I collect a dialog result through SharedFlow. When result received, dialog dismissed. Should I stop collect by additional code? What happens when dialog closed, but fragment still resumed?
// ParentFragment
private fun save() {
val dialog = ContinueDialogFragment(R.string.dialog_is_save_task)
dialog.show(parentFragmentManager, "is_save_dialog")
lifecycleScope.launch {
dialog.resultSharedFlow.collect {
when (it) {
ContinueDialogFragment.RESULT_YES -> {
viewModel.saveTask()
closeFragment()
}
ContinueDialogFragment.RESULT_NO -> {
closeFragment()
}
ContinueDialogFragment.RESULT_CONTINUE -> {
// dont close fragment
}
}
}
}
}
class ContinueDialogFragment(
#StringRes private val titleStringId: Int,
#StringRes private val messageStringId: Int? = null
) : DialogFragment() {
private val _resultSharedFlow = MutableSharedFlow<Int>(1)
val resultSharedFlow = _resultSharedFlow.asSharedFlow()
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return activity?.let { context ->
AlertDialog.Builder(context)
.setTitle(getString(titleStringId))
.setMessage(messageStringId?.let { getString(it) })
.setPositiveButton(getString(R.string.dialog_yes)) { _, _ ->
_resultSharedFlow.tryEmit(RESULT_YES)
}
.setNegativeButton(getString(R.string.dialog_no)) { _, _ ->
_resultSharedFlow.tryEmit(RESULT_NO)
}
.setNeutralButton(getString(R.string.dialog_continue)) { _, _ ->
_resultSharedFlow.tryEmit(RESULT_CONTINUE)
}
.create()
} ?: throw IllegalStateException("Activity cannot be null")
}
companion object {
const val RESULT_YES = 1
const val RESULT_NO = 0
const val RESULT_CONTINUE = 2
}
}
When a Flow completes depends on its original source. A Flow built with flowOf or asFlow() ends once it reaches the last item in its list. A Flow built with the flow builder could be finite or infinite, depending on whether it has an infinite loop in it.
A flow created with MutableSharedFlow is always infinite. It stays open until the coroutine collecting it is cancelled. Therefore, you are leaking the dialog fragment with your current code because you are hanging onto its MutableSharedFlow reference, which is capturing the dialog fragment reference. You need to manually cancel your coroutine or collection.
Or more simply, you could use first() instead of collect { }.
Side note, this is a highly unusual uses of a Flow, which is why you're running into this fragile condition in the first place. A Flow is for a series of emitted objects, not for a single object.
It is also very fragile that you're collecting this flow is a function called save(), but you don't appear to be doing anything in save() to store the instance state such that if the activity/fragment is recreated you'll start collecting from the flow again. So, if the screen rotates, the dialog will reappear, the user could click the positive button, and nothing will be saved. It will silently fail.
DialogFragments are pretty clumsy to work with in my opinion. Anyway, I would take the easiest route and directly put your behaviors in the DialogFragment code instead of trying to react to the result back in your parent fragment. But if you don't want to do that, you need to go through the pain of calling back through to the parent fragment. Alternatively, you could use a shared ViewModel between these two fragments that will handle the dialog results.
I believe you will have a memory leak of DialogFragment: ParentFragment will be referencing the field dialog.resultSharedFlow until the corresponding coroutine finishes execution. The latter may never happen while ParentFragment is open because dialog.resultSharedFlow is an infinite Flow. You can call cancel() to finish the coroutine execution and make dialog eligible for garbage collection:
lifecycleScope.launch {
dialog.resultSharedFlow.collect {
when (it) {
ContinueDialogFragment.RESULT_YES -> {
viewModel.saveTask()
closeFragment()
cancel()
}
ContinueDialogFragment.RESULT_NO -> {
closeFragment()
cancel()
}
ContinueDialogFragment.RESULT_CONTINUE -> {
// dont close fragment
}
}
}
}
I want to run the code only once when the composable is loaded. So I am using LaunchedEffect with key as true to achieve this.
LaunchedEffect(true) {
// do API call
}
This code is working fine but whenever there is any configuration change like screen rotation this code is executed again. How can I prevent it from running again in case of configuration change?
The simplest solution is to store information about whether you made an API call with rememberSaveable: it will live when the configuration changes.
var initialApiCalled by rememberSaveable { mutableStateOf(false) }
if (!initialApiCalled) {
LaunchedEffect(Unit) {
// do API call
initialApiCalled = false
}
}
The disadvantage of this solution is that if the configuration changes before the API call is completed, the LaunchedEffect coroutine will be cancelled, as will your API call.
The cleanest solution is to use a view model, and execute the API call inside init:
class ScreenViewModel: ViewModel() {
init {
viewModelScope.launch {
// do API call
}
}
}
#Composable
fun Screen(viewModel: ScreenViewModel = viewModel()) {
}
Passing view model like this, as a parameter, is recommended by official documentation. In the prod code you don't need to pass any parameter to this view, just call it like Screen(): the view model will be created by default viewModel() parameter. It is moved to the parameter for test/preview capability as shown in this answer.
I assume the best way is to use the .also on the livedata/stateflow lazy creation so that you do guarantee as long as the view model is alive, the loadState is called only one time, and also guarantee the service itself is not called unless someone is listening to it. Then you listen to the state from the viewmodel, and no need to call anything api call from launched effect, also your code will be reacting to specic state.
Here is a code example
class MyViewModel : ViewModel() {
private val uiScreenState: : MutableStateFlow<WhatEverState> =
MutableStateFlow(WhatEverIntialState).also {
loadState()
}
fun loadState(): StateFlow<WhatEverState>> {
return users
}
private fun loadUsers() {
// Do an asynchronous operation to fetch users.
}
}
When using this code, you do not have to call loadstate at all in the activity, you just listen to the observer.
You may check the below code for the listening
class MyFragment : Fragment {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setContent {
StartingComposeTheme {
Box(modifier = Modifier.fillMaxSize()) {
val state by viewModel.uiScreenState.collectAsState()
when (state) {
//do something
}
}
}
}
}
}
}}
#Islam Mansour answer work good for dedicated viewModel to UI but my case is shared ViewModel by many UIs fragments
In my case above answers does not solve my problem for calling API for just only first time call when user navigate to the concerned UI section.
Because I have multiple composable UIs in NavHost as Fragment
And my ViewModel through all fragments
so, the API should only call when user navigate to the desired fragment
so, the below lazy property initialiser solve my problem;
val myDataList by lazy {
Log.d("test","call only once when called from UI used inside)")
loadDatatoThisList()
mutableStateListOf<MyModel>()
}
mutableStateListOf<LIST_TYPE> automatically recompose UI when data added to this
variable appeded by by lazy intialized only once when explicilty called
I am working with RecyclerView and using Retrofit to fetch the data from Server. I am using Kotlin with MVVM Design Pattern. I have used LiveData it was working fine. But with Stateflow causing issues when we navigate to another Fragment and Comes back to the Same Fragment again. It just fetches the same data again. Below is the code for ViewModel and the observer:
//View Model
private val _allTimeSheetsResponse =
MutableStateFlow<ResponsesResult<AllTimeSheetsResponse>>(ResponsesResult.Empty)
val allTimeSheetsResponse : StateFlow<ResponsesResult<AllTimeSheetsResponse>> get() = _allTimeSheetsResponse
fun getAllTimeSheets(auth: String) =
viewModelScope.launch {
timeSheetsRepository.getAllTimeSheets(auth).collect {
_allTimeSheetsResponse.value = it
}
}
//Observer
lifecycleScope.launchWhenStarted{
timeSheetsViewModel.allTimeSheetsResponse.collect { timeSheetsResponse ->
when (timeSheetsResponse) {
is ResponsesResult.Loading -> binding.progressBarLayout.show()
is ResponsesResult.Failure -> {
binding.progressBarLayout.gone()
binding.nothingFoundLayout.show()
handleApiError(timeSheetsResponse)
}
is ResponsesResult.Success -> {
binding.progressBarLayout.gone()
if (timeSheetsResponse.value.payload.isNotEmpty()) {
showAllTimeSheetsRecyclerAdapter.submitList(timeSheetsResponse.value.payload)
} else {
binding.nothingFoundLayout.show()
}
}
else -> Unit
}
}
}
Because you call getAllTimeSheets many times (eg. onCreateView or onViewCreated). Trying call it when accessing allTimeSheetsResponse` for the first time.
Your ViewModel's getAllTimeSheets() function starts a new coroutine to collect from the repo's cold Flow each time you call it, so each time the Fragment comes back, presumably. You should remove this function and simply convert the repo's cold Flow directly to a StateFlow:
val allTimeSheetsResponse: StateFlow<ResponsesResult<AllTimeSheetsResponse>> =
timeSheetsRepository.getAllTimeSheets(auth)
.stateIn(viewModelScope, SharingStarted.Eagerly, ResponsesResult.Empty)
You can pass the auth parameter into the ViewModel's factory through to its constructor.
When you are using the navigationComponent and call navController.navigate() to open a fragment, in the background destination fragment replaces with the old destination's fragment. so old fragment will keep in the fragmentManager backStack. but its view will destroy. and when navigate back, old fragment comes from backStack (not created again) and just its view creates.
So it's better to call getAllTimeSheets() in Fragment's onCreate. (to call one time). When fetching done, all data will set in _allTimeSheetsResponse
And then you should observe allTimeSheetsResponse in onViewCreated with viewLifecycleOwner scope.
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.allTimeSheetsResponse.onEach { response ->
// do sth with response
}.launchIn(viewLifecycleOwner.lifecycleScope)
}
fun getAllTimeSheets(auth: String) :StateFlow<ResponsesResult<AllTimeSheetsResponse>> {
var mutableStateFlow = MutableStateFlow<ResponsesResult<AllTimeSheetsResponse>>(ResponsesResult.Empty)
viewModelScope.launch {
timeSheetsRepository.getAllTimeSheets(auth).collect {
mutableStateFlow.value = it
}
}
return mutableStateFlow
}
I have a Fragment that I want to do a fetch once on its data, I have used distinctUntilChanged() to fetch just once because my location is not changing during this fragment.
Fragment
private val viewModel by viewModels<LandingViewModel> {
VMLandingFactory(
LandingRepoImpl(
LandingDataSource()
)
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val sharedPref = requireContext().getSharedPreferences("LOCATION", Context.MODE_PRIVATE)
val nombre = sharedPref.getString("name", null)
location = name!!
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupRecyclerView()
fetchShops(location)
}
private fun fetchShops(localidad: String) {
viewModel.setLocation(location.toLowerCase(Locale.ROOT).trim())
viewModel.fetchShopList
.observe(viewLifecycleOwner, Observer {
when (it) {
is Resource.Loading -> {
showProgress()
}
is Resource.Success -> {
hideProgress()
myAdapter.setItems(it.data)
}
is Resource.Failure -> {
hideProgress()
Toast.makeText(
requireContext(),
"There was an error loading the shops.",
Toast.LENGTH_SHORT
).show()
}
}
})
}
Viewmodel
private val locationQuery = MutableLiveData<String>()
fun setLocation(location: String) {
locationQuery.value = location
}
val fetchShopList = locationQuery.distinctUntilChanged().switchMap { location ->
liveData(viewModelScope.coroutineContext + Dispatchers.IO) {
emit(Resource.Loading())
try{
emit(repo.getShopList(location))
}catch (e:Exception){
emit(Resource.Failure(e))
}
}
}
Now, if I go to the next fragment and press back, this fires again, I know that maybe this is because the fragment is recreating and then passing a new instance of viewmodel and thats why the location is not retained, but if I put activityViewModels as the instance of the viewmodel, it also happends the same, the data is loaded again on backpress, this is not acceptable since going back will get the data each time and this is not server efficient for me, I need to just fetch this data when the user is in this fragment and if they press back to not fetch it again.
Any clues ?
I'm using navigation components, so I cant use .add or do fragment transactions, I want to just fetch once on this fragment when creating it first time and not refetching on backpress of the next fragment
TL;DR
You need to use a LiveData that emits its event only once, even if the ui re-subscribe to it. for more info and explanation and ways to fix, continue reading.
When you go from Fragment 1 -> Fragment 2, Fragment 1 is not actually destroyed right away, it just un-subscribe from your ViewModel LiveData.
Now when you go back from F2 to F1, the fragment will re-subscribe back to ViewModel LiveData, and since the LiveData is - by nature - state holder, then it will re-emit its latest value right away, causing the ui to rebind.
What you need is some sort of LiveData that won't emit an event that has been emitted before.
This is common use case with LiveData, there's a pretty nice article talking about this need for a similar LiveData for different types of use cases, you can read it here.
Although the article proposed a couple of solutions but those can be a bit of an overkill sometimes, so a simpler solution would be using the following ActionLiveView
// First extend the MutableLiveData class
class ActionLiveData<T> : MutableLiveData<T>() {
#MainThread
override fun observe(owner: LifecycleOwner, observer: Observer<T?>) {
// Being strict about the observer numbers is up to you
// I thought it made sense to only allow one to handle the event
if (hasObservers()) {
throw Throwable("Only one observer at a time may subscribe to a ActionLiveData")
}
super.observe(owner, Observer { data ->
// We ignore any null values and early return
if (data == null) return
observer.onChanged(data)
// We set the value to null straight after emitting the change to the observer
value = null
// This means that the state of the data will always be null / non existent
// It will only be available to the observer in its callback and since we do not emit null values
// the observer never receives a null value and any observers resuming do not receive the last event.
// Therefore it only emits to the observer the single action so you are free to show messages over and over again
// Or launch an activity/dialog or anything that should only happen once per action / click :).
})
}
// Just a nicely named method that wraps setting the value
#MainThread
fun sendAction(data: T) {
value = data
}
}
You can find more explainiation for ActionLiveData in this link if you want.
I would advise using the ActionLiveData class, I've been using it for small to medium project size and it's working alright so far, but again, you know your use cases better than me. :)
I have a fragment A which sends a search query to the network, and if the result is positive uses Android navigation component to navigate to fragment B, and its done using observers.
After navigation to fragment B, i click on "<-" arrow on the top of the screen, but instead of navigating back to fragment A it reloads fragment B again. And if using the native "back" button on the device, the app crashes with "illegalArgumentException navigation destination unknown" error.
I check the internet for clues on this issue, but all i learned is that this happens because i am using .observe in onViewCreated() and when i go back, it gets called again, and because livedata has something in it already, it just navigates me back to B.
I have tried observing in onActivityCreated(), and using getViewLifeCycleOwner, but no success... the only thing that helped is checking if livedata has observers and returning if true, before using .observe, but it seems incorrect.
This is the viewModel:
private val getAssetResult = MutableLiveData<GeneralResponse<Asset>>()
private val updateAssetResult = MutableLiveData<GeneralResponse<Int>>()
private val deleteAssetResult = MutableLiveData<GeneralResponse<Int>>()
init {
state.value = ViewState(false)
Log.d(TAG, "State in init: $state")
}
fun getAssetResult(): LiveData<GeneralResponse<Asset>>{
return getAssetResult
}
fun findAsset(req: GetAssetRequest) {
scope.launch {
setProgressIndicator(true)
val result = repository.getAsset(req)
getAssetResult.postValue(result)
setProgressIndicator(false)
}
}
This is the fragment:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProvider(this).get(EditAssetViewModel::class.java)
setupViewModel()
initFields()
}
private fun setupViewModel() {
if (viewModel.getAssetResult().hasObservers()) // <- This is the part that prevents the app from crashing.
return
viewModel.getAssetResult().observe(this, Observer {
if (it == null) return#Observer
handleSearchResult(it)
})
if (viewModel.getState().hasObservers())
return
viewModel.getState().observe(this, Observer { handleState(it) })
}
private fun handleSearchResult(response: GeneralResponse<Asset>) {
if (response.singleValue == null) {
Toast.makeText(context!!, response.errorMessage, Toast.LENGTH_SHORT).show()
return
}
targetFragment?.let { it ->
val bundle = bundleOf("asset" to response.singleValue)
when(it) {
"UpdateLocation" ->
Navigation.findNavController(view!!).navigate(R.id.updateLocation, bundle)
"EditAsset" -> {
Navigation.findNavController(view!!).navigate(R.id.editAsset, bundle)
}
}
}
}
if i remove this part from the setupViewModel function:
if (viewModel.getAssetResult().hasObservers())
return
the app will either crash when clicked "back" using the device button or go back to fragment A, just to be navigated back to fragment B because of the .observe function.
Override the method onBackPressed() to handle the "<-" arrow
Seems like the LiveData that you use to signal to fragment A that it should navigate to fragment B is actually an event. An event happens only once and once it is consumed (navigation event is done), it is gone. Therefore, after navigating you need to send a message to the viewmodel that the navigation took place and that the corresponding data holder should be (e.g.) null again. In Fragment A you check that the new value is unequal to null, and only if this is the case, you issue the navigation event. This would prevent fragment A to immediatelly switch to B again in the back scenario.
If you want to learn more about ways to use live data for events, please refer to this article.