androidkotlinandroid-fragmentschannel

Android/Kotlin: How to make 2 fragments listen on the same Channel?


I have the following requirement:

  1. An event feeder gets events from all sorts of places.
  2. Two different screens need to show information depending on what events come in.
  3. One type of event is "go to the other screen".
  4. Only 1 screen is shown at any time.
  5. No events get lost, and they must display in the order they came in.

I implemented as follows:

  1. The event generator writes to a Channel using send().
  2. Two fragments in a ViewPager2 handle display.
  3. Each fragment, in its onViewCreated(), starts a thread on its lifecycleScope that runs an infinite for-loop reading from the (same!) Channel, and handles whatever is there.
  4. The app starts in fragment 0, and shifts the ViewPager2 position to 1 when told to do so by the event feeder.

This doesn't work. After fragment 0 shifts focus to fragment 1, its channel reader keeps spinning and doesn't "pass the baton" to the channel reader of fragment 1. Both for-loops seem to read the bus arbitrarily.

I tried introducing a "semaphore", some variable that knows "who should read from the bus", but that doesn't work: If I do the test inside the for-loop, it's too late (the message was already taken from the bus and I lost it); and if I test outside the for-loop it has no effect obviously.

Any tip on how to accomplish this? Many thanks

Adding fragment code:

class CoachingStep0Fragment : Fragment() {

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

override fun onCreateView(
    inflater: LayoutInflater, container: ViewGroup?,
    savedInstanceState: Bundle?
): View {
    _binding = FragmentCoachingStep0Binding.inflate(inflater, container, false)
    return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    val taskData =
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
            arguments?.getSerializable("task", CoachingUiState::class.java)
        else
            arguments?.getSerializable("task") as CoachingUiState

    if (taskData != null) {
        binding.Step0TaskName.text = taskData.taskName
    }
    binding.textViewPsmPrompt.visibility = View.VISIBLE
    // setUpBusReceiver()
}

override fun onResume() {
    super.onResume()
    setUpBusReceiver()

}

private fun setUpBusReceiver() {
    lifecycleScope.launch {
        for (msg in EventBusProvider.coaching) {
            when (msg) {
                is EventBusAction.PlayPlex -> {
                    binding.textViewPsmPrompt.text = msg.param
                }
                is EventBusAction.DisplayScreen -> {
                    requireActivity().findViewById<ViewPager2>(R.id.coaching_pager).currentItem = 1
                }
            }
        }
    }
}

The second fragment is identical except its number is 1.


Solution

  • As explained in the Restartable Lifecycle-aware coroutines docs:

    Even though the lifecycleScope provides a proper way to cancel long-running operations automatically when the Lifecycle is DESTROYED, you might have other cases where you want to start the execution of a code block when the Lifecycle is in a certain state, and cancel when it is in another state. For example, you might want to collect a flow when the Lifecycle is STARTED and cancel the collection when it's STOPPED. This approach processes the flow emissions only when the UI is visible on the screen, saving resources and potentially avoiding app crashes.

    For these cases, Lifecycle and LifecycleOwner provide the suspend repeatOnLifecycle API that does exactly that.

    In this case, ViewPager2 only moves exactly one fragment to the RESUMED state at a time - your current page. Your code isn't failing because you started listening too soon (you didn't need to use onResume at all), but because you failed to stop listening when your fragment was PAUSED (e.g., when it stopped being the current page).

    That means your code should actually look like:

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
    
        // Bundle.getSerializable is actually broken on API 33,
        // so you should always use the BundleCompat method
        val taskData = BundleCompat.getSerializable(
            requireArguments(),
            "task"
            CoachingUiState::class.java
        )
    
        if (taskData != null) {
            binding.Step0TaskName.text = taskData.taskName
        }
        binding.textViewPsmPrompt.visibility = View.VISIBLE
    
        // Re-add this line back in
        setUpBusReceiver()
    }
    
    private fun setUpBusReceiver() {
        viewLifecycleOwner.lifecycleScope.launch {
            // This is the line that causing the containing code
            // to only run when you move up to RESUMED and cancel
            // as soon as you get PAUSED, thus ensuring that only
            // the current page is listening
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
                for (msg in EventBusProvider.coaching) {
                    when (msg) {
                        is EventBusAction.PlayPlex -> {
                            binding.textViewPsmPrompt.text = msg.param
                        }
                        is EventBusAction.DisplayScreen -> {
                            requireActivity()
                                .findViewById<ViewPager2>(R.id.coaching_pager)
                                .currentItem = 1
                        }
                    }
                }
            }
        }
    }