Mar 29, 2022

Rewriting Android App’s Back-Handling Logic

Our Kiwi.com app is quite old and vast. It is in development for about 7 years; each year we’re adding more features and code, each year the app is being worked on by more developers.

We evolved to a custom back-aware logic in our activities and fragments. Basically, back press was handled in the activity that may have been modified to delegate to its own fragments. Both fragments and activities could decide to delegate to their view-models. Delegation was also handling the optional consumption. In the end, VM may not have consumed that back-press and activity would continue going back.

Motivation

Our overall ideal approach is to build reusable components. E.g., search filters widget may be reused for both — flight search results and hotel search results screens. Those screens are independent, but having shared filters widget helps build them. We need this filter component to be independent of the outer environment, in other words, using FiltersFragment (Filters composable) shouldn’t require any additional back-handling wiring in its owning parent activity/fragment/composable.

Recently, we have started exploring and implementing JetPack Compose. (Don’t forget to check out our Orbit Compose library.) This pretty new system will be, in the end, free from our base activities and fragments. So, we want to connect back handling in those various widgets together.

OnBackPressedDispatcher

AndroidX has introduced OnBackPressedDispatcher⁣ – an official component to tackle back handling. This solution is also independent of your navigation stack/library (this is a great feature, we would not have be blocked if it required some specific navigation stack). It seems that this dispatcher solves all our aforementioned problems:

  • Official solution. It is an official solution. That’s an advantage, newcomers does not have to learn new internal system and will either know this pattern already, or they will have available a great amount of resources.
  • Nesting solution. The dispatcher properly works through the whole fragment hierarchy. What’s more, the later you register your custom back-press handling behavior, the more it takes precedence. Simply, our nested components can now properly modify back-handling logic without modifying their parent’s code.
  • Universal solution. The OnBackPressedDispatcher is supported in both View- and Compose-based toolkits. So we may continue combining those toolkits together until we are fully composed.

OnBackPressedDispatcher utilizes JetPack’s Lifecycle. The callbacks are “awake” only if the current screen/widget is visible (technically, if Lifecycle’s state is STARTED). As soon as the fragment/activity/composable gets its lifecycle stopped, the callback is unregistered and when the Lifecycle owner is destroyed, the callback gets properly freed.

class MyActivity : AppCompatActivity() {
override fun onCreate(state: SavedInstanceState) {
super.onCreate(state)
// ...
onBackPressedDispatcher.addCallback(this) {
// your custom back handling logic
}
}
}

Adding a callback in fragment is a bit more complicated, we have introduced custom helpers to ease the registration, an extension function Fragment.addBackPressCallback {} — see them at the end.

Compulsory Consumption

One of the most problematic aspect of OnBackPressedDispatcher’s API is a compulsory consumption. When your callback gets called, there is not much to do not to consume. Still, there are two options:

  1. Disable the callback temporarily: the callback may be explicitly enabled & disabled. It is an additional callback’s rule, the lifecycle rule still applies. The workaround is to disable itself, re-trigger back-press action and then enable itself again. This solution has one obvious limitation — only a single callback of currently active callbacks may utilize it.
  2. The second option is to remove the callback and re-trigger back-press action. But that may not work for you too if you will stay in the activity/fragment/destination in the end.
onBackPressedDispatcher.addCallback(this) {
// your custom back handling logic
val consumed = TODO()
if (!consumed) {
isEnabled = false
[email protected]()
isEnabled = true
}
}

Generally speaking, making a side effect is pretty difficult. Our side effects are usually “tracking” related, so they are enabled for the whole time. Other ways (Fragment.onDestroy/Fragment.onStop) are not optimal to track “user’s going back” event: a fragment (or its view) may get destroyed just because it is replaced by another fragment (i.e., a forward action) or because the app gets killed in background (Android’s resource management).

Reactive enabling

Since the consumption is optional, we have introduced a reactive way to dynamically enable/disable the callbacks. We simply expose a Flow<Boolean> from our ViewModels and use this property when registering the callback (using our own helper method). See the extension function at the end of this post.

class BookingViewModel : ViewModel() {
val isBackable: Flow<Boolean> = TODO()
}class BookingFragment : Fragment() {
override fun onViewCreated(...) {
addBackPressCallback(viewModel.isBackable) {
}
}
}

Dialogs Support

The issue described here is slowly getting solved, currently the required androidx changes should be merged and released as an alpha of androidx.appcompat. Though, we haven’t updated & tried those changes yet.

We started facing the first issue when we have realized that dialogs (DialogFragmentsdoes not support OnBackPressedDispatcher. We explored possibilities and came up with a solution to add an explicit callback and override to our BaseDialogFragment. The first task is to catch the “back” event. Then we simply register a custom back-press callback for all our dialogs. Of course, do not forget to take in account cancelability of the dialog, as we did.

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) requireActivity().onBackPressedDispatcher.addBackPressCallback(viewLifecycleOwner) {
if (isCancelable) {
dismiss()
}
} dialog?.setOnKeyListener { _, keyCode, event ->
if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
requireActivity().onBackPressed()
return@setOnKeyListener true
}
false
}
}

Activity’s quirk with OnBackPressedDispatcher

Having solved the dialog’s issue OnBackPressedDispatcher seemed to be finally the thing what we needed until we let our testers test the implementation more closely. They have found two not-working use-cases, both caused by the very same bug.

Booking scenario: We have registered a back-press callback, allowing to show customers a dialog if they really want to leave the booking. This callback is in our booking activity. Later, we open a dialog fragment that registers its own custom back-handler allowing (as shown earlier). This dialog may open another webview activity. When the user returned from the webview activity, a back-press action triggered booking’s activity handler instead of the dialog fragment’s one — i.e., the app started to ask users if they want to leave booking instead of “silently” closing the dialog fragment. The issue was that booking’s back-press callback was re-enabled later than dialog fragment’s one, so it took the precedence.

We faced a similar situation in our deeplink flow, closing a login activity returned to a fragment, which decided that the whole flow is finished and programmatically triggered a new back-press action. However, although the fragment was already in STARTED state, its activity was not and therefore its back-handler was not re-enabled yet. This led ultimately to wrong back handling.

Both those issues are triggered by buggy behavior of Activity’s lifecycle; the activity’s lifecycle may not be STARTED even when its children fragments are STARTED already. This contradicts common sense and even the documentation:

A fragment’s lifecycle state can never be greater than its parent. For example, a parent fragment or activity must be started before its child fragments. Likewise, child fragments must be stopped before their parent fragment or activity.

This is a known bug for almost three years, and it is still not fixed. We ended up utilizing reflection to access another internal activity’s lifecycle that is in a correct state. Since we are using a custom extension function for adding callbacks, this was a single-place fix and reused this mFragmentLifecycleRegistry.

Deeplink Considerations

Navigate-Up and Navigate-Back are two different navigation patterns, yet they share the behavior for most of the time. The difference happens when the user lands in the app through a deeplink. Pressing back should take the user to the previous app, not the previous screen in the same app.

Let’s get back to the booking dialog case — asking users if they want to leave the booking. FragmentManager registers its own back press callback if there is anything on its backstack. Opening an app with a deeplink ends with empty backstack, therefore pressing back will not try to navigate inside the app, but will fall back to going back to the previous app. That is correct and works nicely.

During registration of your own back-press callback, you should consider the deeplink scenario. In other words, if the user has landed in our booking from deeplink, we shouldn’t register the dialog action for back-press handling. Asking users if they want to leave the booking would be UX unfriendly. Users would confirm so, yet they would be taken to the previous app and after returning they would find the very same (still unclosed) booking.

Be aware, the up-navigation is another case and back-press handling logic for that should be registered rather always. Last but not least, note that having an empty back stack doesn’t mean landing from a deeplink, it may be a simple app-process-death flow.

Helpers

As you have seen in the post, we depend on our own set of extension functions allowing us a simple back-press callback registration. Here’s the code:

public fun Fragment.addBackPressCallback(
enabled: Flow<Boolean>? = null,
onBackPressed: OnBackPressedCallback.() -> Unit,
) {
requireActivity().onBackPressedDispatcher.addBackPressCallback(
lifecycleOwner = viewLifecycleOwner,
enabled = enabled,
onBackPressed = onBackPressed,
)
}public fun FragmentActivity.addBackPressCallback(
enabled: Flow<Boolean>? = null,
onBackPressed: OnBackPressedCallback.() -> Unit,
) {
onBackPressedDispatcher.addBackPressCallback(
lifecycleOwner = { fragmentLifecycleRegistryField.get(this) as Lifecycle },
enabled = enabled,
onBackPressed = onBackPressed,
)
}private val fragmentLifecycleRegistryField: Field by lazy {
val field = FragmentActivity::class.java.getDeclaredField("mFragmentLifecycleRegistry")
field.isAccessible = true
field
}public fun OnBackPressedDispatcher.addBackPressCallback(
lifecycleOwner: LifecycleOwner,
enabled: Flow<Boolean>? = null,
onBackPressed: OnBackPressedCallback.() -> Unit,
) {
val callback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
onBackPressed()
}
}
addCallback(lifecycleOwner, callback) enabled
?.flowWithLifecycle(lifecycleOwner.lifecycle)
?.onEach { isEnabled ->
callback.isEnabled = isEnabled
}
?.launchIn(lifecycleOwner.lifecycleScope)
}

Future considerations

As you may have read, dialogs’ support is on the way. What’s more, the latest Android Tiramisu Developer Preview 2 introduces a very similar concept directly in the Android framework’s Activity: OnBackInvokedDispatcher. Almost the same name, almost the same API, yet currently with major differences:

  • No enabled property on the callback;
  • No (view)lifecycle support — the lifecycle stuff is not a system’s framework, so its API cannot use it. But this can be probably mimicked by registering and unregistering the callback when lifecycle state gets changed.
  • New priority support: your callback may get marked as more important and will be triggered even it’s not the latest registered one.

The API may not be final, so maybe it gets better. What we see is that the back-handling story has not been fully solved yet. The name change from Pressed to Invoked reflects the obvious shift in the way we control our phones — back swipe gesture is more common nowadays.

Conclusion

The overall refactoring took us several weeks with repeated testing, whether we did not break more than we had fixed. Using OnBackPressedDispatcher opens new opportunities, and it is definitely a step forward, yet it seems that the activity’s lifecycle issue may significantly break your app behavior.

Define your abstractions to ease back-related implementation, and do not forget to think-through deeplink and app process-death cases.

Did you encounter similar problems? Do you have another solution? Feel free to share with us! Happy coding.

This post is crosspost of my hrach.dev blog.

Search
Share
Featured articles
Generating SwiftUI snapshot tests with Swift macros
Don’t Fix Bad Data, Do This Instead