Unidirectional Data Flow

Applications must respond to input from multiple sources: data being loaded from the back end as well as inputs from the user. If you do not have a plan in place to combine these sources, you could code yourself into a mess of complex logic that is difficult to maintain.

Unidirectional data flow is an architecture pattern that has risen to prominence and that plays nicely with the reactive patterns you have been using with Flow and StateFlow. Unidirectional data flow tries to simplify application architecture by encapsulating these two forces – data from the back end and input from the user – and clarifying their responsibilities.

Data comes from a variety of sources, such as the network, a database, or a local file. It is often generated as part of a transformation of application state, such as the user’s authentication state or the contents of their shopping cart. These sources of state send the data down to the UI, where the UI can render it.

Once data is displayed as UI, the user can interact with it through various forms of input. The user can check boxes, press buttons, enter text – and all that input is sent back up to those sources of state, mutating them in response to the user’s actions. These two streams travel in opposite directions, forming a circular stream of information (Figure 13.10).

Figure 13.10  Unidirectional data flow

Unidirectional data flow

You are going to implement the business logic in CrimeDetailFragment using the unidirectional data flow pattern. The source of state for CrimeDetailFragment will be a ViewModel. It will hold a reference to a StateFlow, which will hold the latest version of the particular crime the user is viewing. The CrimeDetailFragment will observe that StateFlow, updating its UI whenever the crime updates.

As the user edits the details of the current crime, the CrimeDetailFragment will send that user input up to its ViewModel. After updating the crime’s data, the ViewModel will send the updated crime back to the CrimeDetailFragment. Looping and looping, the state and the UI will always remain in sync.

Before adding new code to implement this pattern, clear the decks by deleting some code you no longer need. Delete the plain, boring crime class property in CrimeDetailFragment, as well as any lines of code that reference it. Also, delete the onCreate code.

Listing 13.15  Deleting references to the old crime (CrimeDetailFragment.kt)

private const val TAG = "CrimeDetailFragment"

class CrimeDetailFragment : Fragment() {
    ...
    private lateinit var crime: Crime

    private val args: CrimeDetailFragmentArgs by navArgs()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        crime = Crime(
            id = UUID.randomUUID(),
            title = "",
            date = Date(),
            isSolved = false
        )

        Log.d(TAG, "The crime ID is: ${args.crimeId}")
    }
    ...

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

        binding.apply {
            crimeTitle.doOnTextChanged { text, _, _, _ ->
                crime = crime.copy(title = text.toString())
            }

            crimeDate.apply {
                text = crime.date.toString()
                isEnabled = false
            }

            crimeSolved.setOnCheckedChangeListener { _, isChecked ->
                crime = crime.copy(isSolved = isChecked)
            }
        }
    }
    ...
}

As you have seen before, fragments are not well suited for handling state, because they are re-created during configuration changes. Create a CrimeDetailViewModel, extending the ViewModel class. Expose the state of the detail screen as a StateFlow holding a Crime. As you saw in Chapter 12, the StateFlow class does a good job of providing consumers with the freshest data. As you update the StateFlow, those changes will be pushed out to the CrimeDetailFragment.

Listing 13.16  Bare skeleton for CrimeDetailViewModel (CrimeDetailViewModel.kt)

class CrimeDetailViewModel : ViewModel() {
    private val crimeRepository = CrimeRepository.get()

    private val _crime: MutableStateFlow<Crime?> = MutableStateFlow(null)
    val crime: StateFlow<Crime?> = _crime.asStateFlow()
}

Recall from Chapter 12 that you want to expose your data as a StateFlow and not a MutableStateFlow. This will help reinforce your unidirectional data flow: The source of data cannot be directly mutated by its consumers. And, as you will see, this also allows you to expose functions in a more deliberate fashion that gives consumers ways to send up user input.

Keeping the properties within the Crime as read-only vals instead of read/write vars also helps reinforce unidirectional data flow. While it does not truly make the Crime class immutable, it does push consumers to create copies of data instead of directly mutating an instance. All of this works together to keep the flow of data streaming in one direction.

Your CrimeDetailViewModel will need to know the ID of the crime to load when it is created. There are a few ways to go about getting it this ID, but the most effective is to declare the ID as a constructor parameter so that your CrimeDetailViewModel can start loading the data as soon as it is created.

Previously, you have not used the constructor when creating an instance of your various ViewModels. Instead, you have used the viewModels property delegate to obtain an instance, so that you get the same instance across configuration changes. By default, when using the viewModels property delegate, your ViewModel can only have a constructor with either no arguments or with a single SavedStateHandle argument.

But there is a way to add additional arguments to a ViewModel: creating a class that implements the ViewModelProvider.Factory interface. This interface allows you to control how a ViewModel is created and provided to fragments and activities. The ViewModelProvider.Factory interface is an example of the factory software design pattern: as a real-life car factory knows how to make cars, ViewModelProvider.Factory knows how to make ViewModel instances.

For CriminalIntent, you will create a CrimeDetailViewModelFactory, and it will know how to create CrimeDetailViewModel instances. Unlike the ViewModel subclasses you have seen so far, classes that implement the ViewModelProvider.Factory interface can take in constructor parameters.

In CrimeDetailViewModel.kt, create the CrimeDetailViewModelFactory class. Then pass in the crime’s ID through its constructor and use it to load the crime from the database into the crime StateFlow class property.

Listing 13.17  Building a factory for CrimeDetailViewModel (CrimeDetailViewModel.kt)

class CrimeDetailViewModel(crimeId: UUID) : ViewModel() {
    private val crimeRepository = CrimeRepository.get()

    private val _crime: MutableStateFlow<Crime?> = MutableStateFlow(null)
    val crime: StateFlow<Crime?> = _crime.asStateFlow()

    init {
        viewModelScope.launch {
            _crime.value = crimeRepository.getCrime(crimeId)
        }
    }
}

class CrimeDetailViewModelFactory(
    private val crimeId: UUID
) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return CrimeDetailViewModel(crimeId) as T
    }
}

Here, you create an instance of your CrimeDetailViewModelFactory by invoking its constructor, passing in the crime’s ID in as a constructor parameter. Once you have the crime’s ID as a class property for CrimeDetailViewModelFactory, you use it when creating instances of CrimeDetailViewModel. That is how you will be able to pass in the crime ID to CrimeDetailViewModel through its constructor.

The last part of this work uses the new CrimeDetailViewModelFactory class to access the CrimeDetailViewModel in CrimeDetailFragment. Under the hood, the viewModels property delegate is a function. This function has two parameters, each of them a lambda with a default value.

Override the default value for the last parameter and have viewModels return an instance of your new CrimeDetailViewModelFactory.

Listing 13.18  Accessing your CrimeDetailViewModel (CrimeDetailFragment.kt)

class CrimeDetailFragment : Fragment() {
    ...
    private val args: CrimeDetailFragmentArgs by navArgs()

    private val crimeDetailViewModel: CrimeDetailViewModel by viewModels {
        CrimeDetailViewModelFactory(args.crimeId)
    }
    ...
}

With that, you have everything set up to start displaying crime information. As you did back in Chapter 12 with CrimeListFragment, you will use repeatOnLifecycle to collect from the crime’s StateFlow. To make things a little more readable, update your UI in a private function called updateUi.

Most of the updateUi function will look similar to the code you had before. The one piece that is a little different is where you set the text on the EditText. There, you need to check whether the existing value and the new value being passed in are different. If they are different, then you update the EditText. If they are the same, you do nothing. This will prevent an infinite loop when you start listening to changes on the EditText.

Listing 13.19  Updating your UI (CrimeDetailFragment.kt)

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

        binding.apply {
            ...
        }

        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
                crimeDetailViewModel.crime.collect { crime ->
                    crime?.let { updateUi(it) }
                }
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }

    private fun updateUi(crime: Crime) {
        binding.apply {
            if (crimeTitle.text.toString() != crime.title) {
                crimeTitle.setText(crime.title)
            }
            crimeDate.text = crime.date.toString()
            crimeSolved.isChecked = crime.isSolved
        }
    }
}

Run the app and select a crime. You will see the crime’s details displayed onscreen.

Now that you have the UI displaying the crime’s data, you need a way to send user input back up to the CrimeDetailViewModel. You could create individual functions to update each property on the crime (for example, a setTitle to update the crime’s title and a setIsSolved to update the solved status), but that would be tedious.

Instead, write one function that takes in a lambda expression as a parameter. In the lambda expression, have the CrimeDetailViewModel provide the latest crime available and the CrimeDetailFragment update it in a safe manner. This will allow you to safely expose the crime as a StateFlow (instead of a MutableStateFlow) while still being able to easily update the crime as the user inputs data.

Listing 13.20  Updating your crime (CrimeDetailViewModel.kt)

class CrimeDetailViewModel(crimeId: UUID) : ViewModel() {
    ...
    init {
        viewModelScope.launch {
            _crime.value = crimeRepository.getCrime(crimeId)
        }
    }

    fun updateCrime(onUpdate: (Crime) -> Crime) {
        _crime.update { oldCrime ->
            oldCrime?.let { onUpdate(it) }
        }
    }
}

Finally, hook the UI up to your new function. This will complete the loop of your unidirectional data flow.

Listing 13.21  Responding to user input (CrimeDetailFragment.kt)

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

        binding.apply {
            crimeTitle.doOnTextChanged { text, _, _, _ ->
                crimeDetailViewModel.updateCrime { oldCrime ->
                    oldCrime.copy(title = text.toString())
                }
            }

            crimeDate.apply {
                isEnabled = false
            }

            crimeSolved.setOnCheckedChangeListener { _, isChecked ->
                crimeDetailViewModel.updateCrime { oldCrime ->
                    oldCrime.copy(isSolved = isChecked)
                }
            }
        }
        ...
    }
    ...
}

Run the app one more time. Select a crime and edit the title or toggle the checkbox. You will see your UI update. Now, your UI and your CrimeDetailViewModel will always stay in sync.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset