Search code examples
androidkotlinandroid-fragmentsmvvmandroid-viewmodel

LiveData binding observer being registered multiple times when changing device orientation even when using viewLifeCycleOwner


I have a view model that is data binded to a fragment. The view model is shared with the main activity.

I've button is binded to the view as follows:

<Button
    android:id="@+id/startStopBtn"
    android:text="@{dashboardViewModel.startStopText == null ? @string/startBtn : dashboardViewModel.startStopText}"
    android:onClick = "@{() -> dashboardViewModel.onStartStopButton(context)}"
    android:layout_width="83dp"
    android:layout_height="84dp"
    android:layout_gravity="center_horizontal|center_vertical"
    android:backgroundTint="@{dashboardViewModel.isRecStarted == false ? @color/startYellow : @color/stopRed}"
    tools:backgroundTint="@color/startYellow"
    android:duplicateParentState="false"
    tools:text="START"
    android:textColor="#FFFFFF" />

What I expect to happen is that every time I press the button the function onStartStopButton(context) runs. This works fine as long as I don't rotate the device. When I rotate the device the function is run twice, if I rotate again the function is run 3 times and so on. This is not a problem if I go to another fragment and then back to the dashboard fragment. It looks like the live data observer is getting registered every time I rotate my screen, but not every time I detach and reattach the fragment.

This is true for all the elements in that fragment, whether they are data binded or I manually observe them.

Fragment code:

class DashboardFragment : Fragment() {

    private var _binding: FragmentDashboardBinding? = null
    private val binding get() = _binding!!

    private val dashboardViewModel: DashboardViewModel by activityViewModels()
    
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = FragmentDashboardBinding.inflate(inflater, container, false)
        val root: View = binding.root

        binding.dashboardViewModel = dashboardViewModel
        binding.lifecycleOwner = viewLifecycleOwner

        
        dashboardViewModel.bleSwitchState.observe(viewLifecycleOwner, Observer { switchState -> handleBleSwitch(switchState) })
        dashboardViewModel.yLims.observe(viewLifecycleOwner, Observer { yLims ->
            updatePlotWithNewData(yLims.first, yLims.second)
        })


        Timber.i("Dahsboard on create: DashboardViewModel in fragment: $dashboardViewModel")
        return root
    }
}

The view model:

class DashboardViewModel : ViewModel() {

    //region live data
    private var _isRecStarted = MutableLiveData<Boolean>()
    val isRecStarted: LiveData<Boolean> get() = _isRecStarted

    //private var _bleSwitchState = MutableLiveData<Boolean>()
    val bleSwitchState = MutableLiveData<Boolean>()

    private var _startStopText = MutableLiveData<String>()
    val startStopText: LiveData<String> get() = _startStopText

    private var _yLims = MutableLiveData<Pair<kotlin.Float,kotlin.Float>>()
    val yLims: LiveData<Pair<kotlin.Float,kotlin.Float>> get() = _yLims


    //endregion

    init {
        Timber.d("DashboardViewModel created!")
        bleSwitchState.value = true
    }

    //region start stop button
    fun onStartStopButton(context: Context){
        Timber.i("Start stop button pressed, recording data size: ${recordingRawData.size}, is started: ${isRecStarted.value}")
        isRecStarted.value?.let{ isRecStarted ->
            if (!isRecStarted){ // starting recording
                _isRecStarted.postValue(true)
                _startStopText.postValue(context.getString(R.string.stopBtn))
                startDurationTimer()
            }else{ // stopping recording
                _isRecStarted.postValue(false)
                _startStopText.postValue(context.getString(R.string.startBtn))
                stopDurationTimer()
            }
        } ?: run{
            Timber.e("Error! Is rec started is not there for some reason")
        }

    }
}

The view model is created the first time from the MainActivity as follows:

class MainActivity : AppCompatActivity() {
    private val dashboardViewModel: DashboardViewModel by viewModels()
    
    override fun onCreate(savedInstanceState: Bundle?) {
            Timber.i("DashboardViewModel in main activity: $dashboardViewModel")
    }
}

Edit explaining why the MainActivity is tided to the ViewModel:

The reason why the ViewModel is linked to the main activity is that the main activity handles some Bluetooth stuff for a stream of data, when a new sample arrives then the logic to handle it and update the UI of the dashboard fragment is on the DashboardViewModel. The data still needs to be handled even if the dashboard fragment is not there. So I need to pass the new sample to the DashboardViewModel from the main activity as that is where I receive it. Any suggestions to make this work?


Solution

  • As you know, when you instantiate the ViewModel of a Fragment with activityViewModels, it means that the ViewModel will follow the lifecycle of the Activity containing that Fragment. Specifically here is MainActivity.

    So what does ViewModel tied to Activity lifecycle mean in your case?

    When you return to the Fragment, normally LiveData (with ViewModel attached to Fragment lifcycler) will trigger again.

    But when that ViewModel is attached to the Activity's lifecycle, the LiveData will not be triggered when returning to the Fragment.

    That leads to when you return to the Fragment, your LiveData doesn't trigger again.

    And that LiveData only triggers according to the life cycle of the activity. That is, when you rotate the screen, the Activity re-initializes, now your LiveData is triggered.

    EDIT:

    Here, I will give you one way. Maybe my code below doesn't work completely for your case, but I think it will help you in how to control LiveData and ViewModel when you bind ViewModel to Activity.

    First, I recommend that each Fragment should have its own ViewModel and it should not depend on any other Fragment or Activity. Here you should rename the DashboardViewModel initialized by activityViewModels() as ShareViewModel or whatever you feel it is related to this being the ShareViewModel between your Activity and Fragment.

    class DashboardFragment : Fragment() {
    
        // Change this `DashboardViewModel` to another class name. Could be `ShareViewModel`.
        private val shareViewModel: ShareViewModel by activityViewModels()
    
        // This is the ViewModel attached to the DashboardFragment lifecycle.
        private val viewModel: DashboardViewModel by viewModels()
    
        private lateinit var _binding: FragmentDashboardBinding? = null
        private val binding get() = _binding!!
    
        override fun onCreateView(
            inflater: LayoutInflater,
            container: ViewGroup?,
            savedInstanceState: Bundle?
        ): View? {
            _binding = FragmentDashboardBinding.inflate(inflater, container, false)
    
            binding.dashboardViewModel = viewModel
            binding.lifecycleOwner = viewLifecycleOwner
    
            return binding.root
        }
    
        override fun onDestroyView() {
            _binding = null
            super.onDestroyView()
        }
    }
    

    Next, when there is data triggered by the ShareViewModel's LiveData, you will set the value for the LiveData in the ViewModel associated with your Fragment. As follows:

    DashboardViewModel.kt

    class DashboardViewModel: ViewModel() {
        private val _blueToothSwitchState = MutableLiveData<YourType>()
        val blueToothSwitchState: LiveData<YourType> = _blueToothSwitchState
    
        private val _yLims = MutableLiveData<Pair<YourType, YourType>>()
        val yLims: LiveData<Pair<YourType, YourType>> = _blueToothSwitchState
    
        fun setBlueToothSwitchState(data: YourType) {
            _blueToothSwitchState.value = data
        }
    
        fun setYLims(data: Pair<YourType, YourType>) {
            _yLims.value = data
        }
    }
    

    DashboardFragment.kt

    class DashboardFragment : Fragment() {
        ...
        override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
            super.onViewCreated(view, savedInstanceState)
    
            shareViewModel.run {
                bleSwitchState.observe(viewLifeCycleOwner) {
                    viewModel.setBlueToothSwitchState(it)
                }
                yLims.observe(viewLifeCycleOwner) {
                    viewModel.setYLims(it)
                }
            }
    
            viewModel.run {
                // Here, LiveData fires observe according to the life cycle of `DashboardFragment`. 
                // So when you go back to `DashboardFragment`, the LiveData is re-triggered and you still get the observation of that LiveData.
                blueToothSwitchState.observe(viewLifeCycleOwner, ::handleBleSwitch)
                yLims.observe(viewLifeCycleOwner) {
                    updatePlotWithNewData(it.first, it.second)
                }
            }
        }
        ...
    }
    

    Edit 2:

    In case you rotate the device, the Activity and Fragment will be re-initialized. At that time, LiveData will fire observe. To prevent that, use Event. It will keep your LiveData from observing the value until you set the value again for LiveData.

    First, let's create a class Event.

    open class Event<out T>(private val content: T) {
    
        var hasBeenHandled = false
            private set
    
        fun getContentIfNotHandled(): T? = if (hasBeenHandled) {
            null
        } else {
            hasBeenHandled = true
            content
        }
    
        fun peekContent(): T = content
    }
    

    Next, modify the return type of the LiveData that you want to trigger once.

    ShareViewModel.kt

    class ShareViewModel: ViewModel() {
        private val _test = MutableLiveData<Event<YourType>>()
        val test: LiveData<Event<YourType>> = _test
    
        fun setTest(value: YourType) {
            _test.value = Event(value)
        }
    
    }
    

    Add this extension to easily get LiveData's observations.

    LiveDataExt.kt

    fun <T> LiveData<Event<T>>.eventObserve(owner: LifecycleOwner, observer: (t: T) -> Unit) {
        this.observe(owner) { it?.getContentIfNotHandled()?.let(observer) }
    }
    

    Finally in the view, you get the data observed by LiveDatat.

    class DashboardFragment : Fragment() {
        ...
        override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
            super.onViewCreated(view, savedInstanceState)
            
            shareViewModel.test.eventObserve(viewLifeCycleOwner) {
                Timber.d("This is test")
            }
        }
        ...
    }
    

    Note: When using LiveData with Event, make sure that LiveData is not reset when rotating the device. If LiveData is set to value again, LiveData will still trigger even if you use Event.