SECRET OF CSS

Fixing Problems of Jetpack Compose Navigation | by Vitaly Peryatin | Jul, 2022


Common pitfalls of navigation in Jetpack Compose and how we handled them

Our team has been using Compose for six months in the Afterglow app. In this article, I would like to share the difficulties we have faced and present the solutions we have found.

I found 3 of the most popular navigation solutions: Jetpack Compose Navigation, Voyager, Decompose.

I won’t compare each of the solutions in detail here. Moreover, none of them is the best and only correct. I’d rather tell you which of the libraries is more suitable for your project.

  • Voyager — a very convenient and simple library. Great for small and pet projects. It’s also suitable for more serious projects where you are ready to take on the following risks: there is no support for navigation for iOS, weak library support, there are problems with application crashes.
  • Decompose — is the only popular library that provides full-fledged multiplatform navigation. A good stable solution, with excellent support from the author. It will fit well into your project if you already use the MVIKotlin library from Arkadii Ivanov. However, many people note that it is very difficult to understand the usage of the library at first. If you undertake to involve the library in your project, be prepared to spend several working days on its implementation. If you have a large team, you will also have to write simplified navigation documentation and prepare LiveTemplates to get rid of a lot of template code.
  • Jetpack Compose Navigation — a reliable library for most projects. If the solutions described above did not suit you, feel free to take the navigation library from Google. I was very skeptical about this library, knowing the problems of Jetpack Navigation in XML-world. However, the library proved to be excellent. There is no critical problems. It’s easy to implement it in any project and implement any level of navigation complexity. Jetpack Navigation does not block the ability to write KMM applications, since navigation itself occurs at the framework level (on View layer).

As a result, we chose Jetpack Navigation, as it was best suited for our project and carried a minimum of risks. I note Voyager can also be suitable for many projects, but as a startup we didn’t dare to take a library, which had a lot of potential problems described on GitHub Issues

Immediately after embedding Jetpack Navigation into the application, you will notice that you won’t be able to configure animations out of box.

Animations in Jetpack Navigation from Google are in experiment status. And for such experiments, Google has a separate collection of extensions under Compose called Accompanist.

With the help of Accompanist Navigation Animation, you can set up any animation of transition between screens in the Compose style in 10 minutes.

If background of Activity Window differs from background of Compose screens, then after adding transition animation, you will notice that the screens seem to flicker during the transition. This is due to the fact that Compose does NOT overlay one screen on another, as in the case of navigation between Activities or performing ‘add’ transactions when navigating between Android Fragments. Compose first plays the animation of destroying the screen, and after it immediately plays the animation of creating a new screen. And if you added fade animation, then somewhere in the middle of the transition you will notice the background of the Activity Window, which is different from the background of the Compose screens.

To fix this flicker is simple: remove the background from the Compose screens, and add it as the background of the Activity window. If you use your own background on each screen, then at the end of the Compose animation of the screen that the user switched to, set the background to the Activity window of the current screen.

You can change the Activity Window background in the following way from Activity:

window.setBackgroundDrawable(BitmapDrawable)

You can change the Activity Window background from Compose:

val activity = LocalContext.current as Activity
LaunchedEffect(activity) {
activity.window
.setBackgroundDrawable(BitmapDrawable)
}

Memory leaks in Multi-Activity

0*0IEFmJIQZpBFArQ0

In 2018, Konstantin Tskhovrebov wrote a well-known article about the convenience of using the Single Activity approach. A lot of programmers perceived it negatively and still haven’t switched to this approach, not seeing the advantages in it.

I like using the Single Activity, but for Afterglow navigation, we made the decision to navigate between multiple Activities within separate modules. We assumed that the inter-Activity navigation approach is robust and has been tested on thousands of projects.

Nothing bad will happen (so we thought until we connected LeakCanary to the project)! It turned out that when the Activity configuration was recreated, the entire Compose graph was leaking. And the reference to the Activity was held by the Recomposer (one of the internal key entities of Compose).

We asked Android Community for an answer, but we didn’t receive any solution. Later, we found one funny tweet in which we weren’t the only ones berating Compose for memory leaks.

1*Ls ACzHU zjLiSuPNmmaZg

Experimentally, we found out that problems with memory leaks in Compose appear when several Activities are alive in the application at the same time. We switched completely to the Single Activity approach and forgot about memory leaks forever.

By the way, memory leaks are also possible when navigating between Fragments that use Compose. However, in this case, memory leaks can be fixed by changing the strategy for clearing Compose from memory in ComposeView:

setViewCompositionStrategy(
DisposeOnLifecycleDestroyed(viewLifecycleOwner)
)

SharedViewModel within a limited Flow of screens

Let’s say we have nested navigation within some kind of general app navigation. Inside this navigation, we have several screens that should have a general SharedViewModel. There are several similar questions on StackOverflow, but in the end, we didn’t find any really suitable answers. The reasons why the answers from StackOverflow did not suit us:

  • ViewModel doesn’t observe the lifecycle of a Compose function and isn’t cleared when necessary
  • It will impossible to embed the SharedViewModel in nested navigation. For example, you can’t create Compose function like FlowScreen and place nested navigation inside it. Jetpack Navigation doesn’t allow you to do it.
  • It is difficult to get an instance of SharedViewModel from a Compose function. It requires writing a lot of non-obvious code to access the ViewModel.

Therefore, we solved the issue differently. We have created a SharedViewModelHolder outside the navigation, which holds a link to the ViewModel.

Extension functions have been added to it to create a new ViewModel and get an existing one. As a result, we got a very simple and reusable code for working with SharedViewModel. Also, we don’t embed ourselves in the behavior of Compose, which correctly processes the ViewModel lifecycle for us within the framework of Compose functions. At the same time, the navigation graph clearly shows where the ViewModel will be created and destroyed.

Вложенный граф навигации

GitHub Gist: SharedViewModelHolder.kt

Opening a screen chain

Often in an application, you have to open a screen chain for some event. For example, when you click on a notification, you need to open the following chain of screens: the main screen, the list of chats, and the screen of a specific chat in which the message came. There is no separate mechanism in Compose Navigation that would allow a single transaction to open a screen chain, so we will open each screen with a separate transaction.

Remember that the Single Activity is welcome in Compose. This approach is also going to help us a lot here. We have AppActivity — the main and only Activity of the app. It holds a link to AppViewModel, inside of which lies a Channel (from Kotlin Coroutines) with a list of screens to be opened. AppActivity subscribes to this channel and, upon receipt of a new list (chain) of screens, in a loop makes a transition to each of these screens through the navigate() method. If someone needs to open a new screen chain, then a list of new screens (routes) to which you need to switch is transmitted to AppActivity via Intent.

I’ll show key points in the code:

GitHub Gist: Open Screen Chain code fragment

Jetpack Navigation recommends passing arguments via routes. This is a very elegant way to transfer data. The route shows what the user passed to the next screen. It is easy to log. Compose under the hood restores the arguments after the death of the process.

But there is a problem: you can’t pass complex objects in Compose Navigation. On the one hand, reference data types can’t be persisted so that they survive the death of a process (unless they have been serialized beforehand).

On the other hand, Android developers are already accustomed to using the Serializable and Parcelable types to pass complex data between screens. However, even these can’t be passed in Compose.

There is support for NavType.ParcelableType, but in fact, it’s impossible to transfer data through it without custom serialization into a string. This question has been asked many times on StackOverflow: here and here. But the creators of Compose Navigation are still confident that it is impossible to transfer complex data between screens.

Caution: Passing complex data structures over arguments is considered an anti-pattern. Each destination should be responsible for loading UI data based on the minimum necessary information, such as item IDs. This simplifies process recreation and avoids potential data inconsistencies.

But I don’t think so, and I fully share the position Arkadii Ivanov:

…, <primitive id as string or number> is not always enough and Parcelable is very useful. For example, for a user’s screen, in addition to its identifier, information about the context from which this screen is opened may be required. If this is a friend from my profile, or a user from the search screen, or from somewhere else. There may be a need to display them differently. The key for the query can be a set of parameters. In this case, it may make sense to make a sealed class with different combinations in general. And yes, if the design changes, then it is enough to change this class and all places of use will stop compiling. But if the identifier type becomes a string instead of a number, then everything will crash at runtime. And with deeplinks it all works fine, if you know how.

By the way, we will save exactly Parcelable, and not Serializable or any other reference data type, because Parcelable is the most optimal way to serialize data in Android: it is easy to create and it quickly serialize into a fairly compact form.

There are plenty of ways to transfer Parcelable between screens on the Internet, but they all come down to 3 ones:

1) Keeping Parcelable in the previous back stack:

On the current screen…

navController.currentBackStackEntry?.arguments = Bundle().apply {
putParcelable("article", article)
}
navController.navigate("article")

On the next screen…

val article = navController.previousBackStackEntry?.arguments
?.getParcelable<Article>("article")

Cons: when closing the previous screen via popBackStack(), data for the next screen will be lost

2) Passing data through Bundle, ignoring screen route:

fun NavController.navigate(
route: String,
args: Bundle,
navOptions: NavOptions? = null,
navigatorExtras: Navigator.Extras? = null
) {
val routeLink = NavDeepLinkRequest
.Builder
.fromUri(NavDestination.createRoute(route).toUri())
.build()
val deepLinkMatch = graph.matchDeepLink(routeLink)
if (deepLinkMatch != null) {
val destination = deepLinkMatch.destination
val id = destination.id
navigate(id, args, navOptions, navigatorExtras)
} else {
navigate(route, navOptions, navigatorExtras)
}
}

Cons:
1. it is impossible to continue transmitting simple data via route, as is customary in Compose (there will be difficulties with logging navigation).
2. This way is unreliable, because we use methods that are intended only for internal purposes of the Navigation library. They can be closed at any time.

3) Serialization of data as string inside route from outside or inside NavType:

class AssetParamType : NavType<Device>(isNullableAllowed = false) {override fun get(bundle: Bundle, key: String): Device? {
return bundle.getParcelable(key)
}
override fun parseValue(value: String): Device {
return Gson().fromJson(value, Device::class.java)
}
override fun put(bundle: Bundle, key: String, value: Device) {
bundle.putParcelable(key, value)
}
}

Cons:
1. I have to write a lot of extra code
2. Serialization and deserialization takes a lot of time and it noticeably affects the rendering speed of Compose with the naked eye

After researching all the ways described above, we realized that none of them suits us, and decided to come up with our own solution that works quickly and doesn’t go against the principles of the library.

If we don’t go into the details of the implementation of our wrapper for navigation, then the principle is based on storing data in parcelableArguments of the HashMap<String, Parcelable> type.

When receiving data on the next screen, we necessarily wrap parcelableArguments in rememberSaveable{} so that Parcelable arguments can survive the death of the process.

You can follow the link below in GitHub Gist and learn more about our approach. There I tried to lay out the most important code snippets to solve the problem of passing Parcelable objects.

GitHub Gist: Pass parcelable arguments

We’ve covered the main issues you may encounter when implementing navigation in Compose. We found solutions that anyone can easily integrate into their project. We hope that now you will suffer less and have more fun using Compose!

Let’s make this article even more useful for all of us together. Write how you would solve the problems described in the article. I would like your feedback!





News Credit

%d bloggers like this: