Architecture Compose

Migrating Architecture Blueprints to Jetpack Compose

This blog post tells the story of how the team migrated Architecture Blueprints to Jetpack Compose.

As part of our effort to modernize the app architecture guidance, we want to experiment with different UI patterns to see what works best, find similarities and differences between the alternatives, and ultimately consolidate those learnings as best practices.

To make our findings as easy to follow as possible, we needed a sample that has a familiar business case and isnā€™t too complicated. Andā€¦ who doesnā€™t know about TODO apps? We chose Architecture Blueprints! Historically, Blueprints served as an experimental playground for architecture choices. What a great fit for this as well!

Architecture Blueprints app in action

The patterns we want to experiment with are obviously affected by the different APIs available nowadays. The new kids in town are Jetpack Composeā€™s State APIs! As Compose seamlessly works with any Unidirectional Data Flow pattern, weā€™ll use Compose to render UI to make a fair comparison.

This blog post tells the story of how the team migrated Architecture Blueprints to Jetpack Compose. As LiveData is also considered an alternative in our experiments, we left the sample as it was at the time of the migration. In this refactoring, the ViewModel classes and the data layer were left untouched.

āš ļø The architecture used in this LiveData-based codebase doesnā€™t fully follow the latest architecture best practices. In particular, LiveData shouldnā€™t be used in the data or domain layers ā€” Flows and coroutines should be used instead.

Now that the context is clear, letā€™s dive in on how we approached refactoring Blueprints to Jetpack Compose. You can check out the full code on the main branch.

āœļø Planning a gradual migration

Before doing any actual coding work, the team created a migration plan to make sure everyone was on board with the proposed changes. The ultimate goal was to have Blueprints as a single-activity application with screens as composable functions, and using the recommended Compose Navigation library to move between screens.

Luckily, Blueprints was already a single-activity app that used Jetpack Navigation to move between different screens implemented with Fragments. To migrate to Compose, we followed the Navigation interoperability guidance that recommends hybrid apps to use the fragment-based Navigation component and use fragments to hold view-based screens, Compose screens, and screens that use both views and Compose. Unfortunately, itā€™s not possible to mix Fragment and Compose destinations in the same Navigation graph.

The goal of a gradual migration is to ease code reviews and maintain a shippable product throughout the migration. The migration plan involved three steps:

  1. Migrate the content of each screen to Compose. Each screen could be individually migrated to Compose, including their UI tests. Fragments then become the container/host of each migrated screen.

  2. Migrate the app to Navigation Compose ā€” which removes all Fragments from the project ā€” and migrate the Activity UI logic to root composables. End-to-end tests are also migrated at this point.

  3. Remove View system dependencies.

And thatā€™s what we did! šŸ§‘ā€šŸ’» Fast-forward ā© two weeks, we migrated the Statistics screen (PR), Add/Edit task screen (PR), Task detail screen (PR), and the Tasks screen (PR); and we merged the final PR which migrated Navigation and Activity logic to Compose, including removing unused View system dependencies.

How we gradually migrated Blueprints to Compose

šŸ’” Migration highlights

During the migration, we encountered some Compose-specific quirks worth highlighting:

šŸ§Ŗ UI tests

Once you start adding Compose to your app, tests that assert Compose UI need to use Compose testing APIs.

For screen-level UI tests, instead of using the launchFragmentInContainer<FragmentType> API, we used the createAndroidComposeRule<ComponentActivity> API that allows us to grab string resources in tests. These tests run in both Espresso and Robolectric. Because Compose already supports all of this, no extra changes were required. For example, you can compare the code in AddEditTaskFragmentTest that was migrated to AddEditTaskScreenTest. Note that if you use ComponentActivity, you need to depend on the androidx.compose.ui:ui-test-manifest artifact.

For end-to-end or integration tests, we didnā€™t find any problems either! Thanks to the Espresso and Compose interoperability, we use Espresso assertions to check Views, and Compose APIs to check Compose UI. Hereā€™s how the AppNavigationTest looked at one point during the migration to Compose.

šŸ¤™ ViewModel events

We did have problems with the way ViewModel events were handled in Blueprints. Blueprints implemented an Event wrapper solution to send commands from the ViewModel to the UI. However, thatā€™s not something that works nicely in Compose. Our recent guidance recommends modeling those ā€œeventsā€ as state, and thatā€™s what we did during the migration.

Looking at the showing messages on the screen event use case, we replaced the Event<Int> type of the LiveData to Int? This also models the scenario where there is no message to show to the user. In this particular use case, the ViewModel also requires a confirmation from the UI whenever the message has been displayed. See the diff between both implementations in the following code:

/* Copyright 2022 Google LLC. 
   SPDX-License-Identifier: Apache-2.0 */

class AddEditTaskViewModel(
  private val tasksRepository: TasksRepository
) : ViewModel() {

-  private val _snackbarText = MutableLiveData<Event<Int>>()
-  val snackbarText: LiveData<Event<Int>> = _snackbarText

+  private val _snackbarText = MutableLiveData<Int?>()
+  val snackbarText: LiveData<Int?> = _snackbarText

+  fun snackbarMessageShown() {
+    _snackbarText.value = null
+  }
}

Even though this might seem like more work at first glance, it guarantees that the message is displayed on the screen!

In the UI code, the way to make sure the event is handled only once is by calling event.getContentIfNotHandled(). This approach works okayish in Fragments but it completely breaks in Compose when you are writing natural Compose code! Because recompositions can happen at any time in Compose, the event wrapper is not a valid solution. If the event is processed and the function gets recomposed (something that happened very regularly while testing this approach), then the snackbar will be canceled, and the user might miss the message. Thatā€™s an unacceptable UX issue! The event wrapper solution shouldnā€™t be used in Compose apps.

Note that you can come up with Compose code that avoids recomposing parts of the function in certain scenarios, however, the event wrapper solution is limiting how the UI must be implemented in that case. Using the event wrapper solution in Compose is discouraged.

See the following code snippet with the before (event wrapper) and after (event as state) code. Because showing messages on the screen is UI logic and our screen composables were getting complex, we used a plain state holder class to manage that complexity (e.g. see AddEditTaskState).

/* Copyright 2022 Google LLC. 
   SPDX-License-Identifier: Apache-2.0 */

// FRAGMENTS CODE CONSUMING THE EVENT WRAPPER SOLUTION

- class AddEditTaskFragment : Fragment() {
-   override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-     ...
-     viewModel.snackbarText.observe(
-       lifecycleOwner,
-       Observer { event ->
-         event.getContentIfNotHandled()?.let {
-           showSnackbar(context.getString(it), Snackbar.LENGTH_SHORT)
-         }
-       }
-     )
-   }
- }


// COMPOSE CODE CONSUMING USER MESSAGES AS STATE

// State holder for the AddEditTask composable.
// This class handles AddEditTask's UI elements' state and UI logic.
+ class AddEditTaskState(...) {
+   init {
+     // Listen for snackbar messages
+     viewModel.snackbarText.observe(viewLifecycleOwner) { snackbarMessage ->
+       if (snackbarMessage != null) {
+       // If there's a previous message showing on the screen
+       // stop showing it in favor of the new one to be displayed
+       currentSnackbarJob?.cancel()
+       val snackbarText = context.getString(snackbarMessage)
+       currentSnackbarJob = coroutineScope.launch {
+         scaffoldState.snackbarHostState.showSnackbar(snackbarText)
+         viewModel.snackbarMessageShown()
+       }
+     }
+   }
+ }

šŸ‘Œ When in doubt, choose app correctness

While refactoring, it might be tempting to migrate everything at hand to Compose. While thatā€™s totally fine, you shouldnā€™t sacrifice your appā€™s User Experience or correctness. The whole point of doing a gradual migration is that the app is always in a shippable state.

This happened to us while migrating some screens to Compose. We didnā€™t want to do too many migrations at the same time, and migrated some of the screens to Compose before migrating from the Event wrapper. Instead of handling the Event wrapper in Compose and providing a suboptimal experience, we kept handling those messages in the Fragment while the rest of the code for the screen was in Compose. See, for example, the state of the TasksFragment during the migration.

šŸ§ Challenges

Not everything went as smoothly as it might have seemed. Even though converting Fragment content to Compose is straightforward, migrating from Navigation Fragments to Navigation Compose took a bit more time and consideration.

Thereā€™s a need to expand and improve the guidance on different aspects that will make migrating to Compose easier in the future. This work sparked conversations and we hope to have new guidance on this soon! šŸŽŠ

As a Navigation beginner āœ‹ and the person that handled the migration to Navigation Compose, I faced the following challenges:

  • No code in the docs showed how to navigate with optional arguments! Thanks to Tiviā€™s navigation graph, I found my way and solved the issue (follow the issue to improve the docs here).

  • Migrating from a XML-based Navigation graph and SafeArgs to Kotlin DSL should be a straightforward, mechanical task. However, it wasnā€™t that easy for me considering I didnā€™t work on the original implementation. Some guidance about how to do it properly wouldā€™ve helped me (follow the issue to improve the docs here).

  • More than a challenge, this point is a gotcha! Navigation UI does some work for you already when it comes to navigating. As this is not available in Compose, you need to keep an eye on that and do it manually. For example, keeping the backstack clean when navigating between Drawerā€™s screens requires special NavigationOptions (see example here). This is already covered in the docs, but you need to be aware that you need it!

šŸ§‘ā€šŸ« Conclusions

Overall, migrating from Navigation Fragments to Navigation Compose was a fun task to do! Funnily enough, we spent more time waiting for peer reviews than migrating the project itself! Creating the migration plan and getting everyone on the same page definitely helped to set expectations early and alert peers of lengthy incoming reviews.

We hope you enjoyed reading about our approach to migrating to Compose, and weā€™re looking forward to sharing more about the experiments and improvements weā€™ll be doing in Architecture Blueprints.

If youā€™re interested in seeing Blueprints with Compose code, take a look at the dev-compose branch. And in case you want to go through the PRs of the gradual migration, hereā€™s the list:

šŸ‘‹