Simplified Android Development Using Simple-Stack
It’s been a long time since I’ve wanted to write this article. Time to go at it, shall we? :)
People always seem to be surprised and say, “wait, you DON’T use Jetpack ViewModel, Jetpack Navigation, and Dagger-Hilt? Then what DO you use?”
This article intends to answer that. We use Simple-Stack.
(If you prefer watching talks, then a talk on the same subject is available at https://www.youtube.com/watch?v=5ACcin1Z2HQ)
What is Simple-Stack?
Simple-Stack is a navigation framework that I’ve been working on since the beginning of 2017, although its roots go back to 2015 — when we were still experimenting with multiple versions of Mortar & Flow. (The 90s kids might still remember).
Following up on the original proposition by Square, “Advocating Against Android Fragments” and “Simpler Android Apps with Flow and Mortar”, we’ve been using Single-Activity approach in our apps since 2015.
Now, the history of Flow and Mortar isn’t all roses. I’ve heard people say “we tried it and it didn’t work for us”. I can attest to that: we had to manage our own fork, but even with that, we hit some of its limitations over time.
We needed something reliable and versioned, so we rewrote Flow’s original behavior, and intended to improve upon its limitations. This rewrite is what resulted in the creation of Simple-Stack.
Why Simple-Stack?
Simple-Stack is based on the idea that you intend to track your navigation state as a list of immutable and Parcelable classes — screen identifiers (which the library calls “keys”).
As long as the underlying navigation implementation allows for “navigation from any state to any state”, it is possible to wrap that implementation using Simple-Stack.
For example, something I hear often is that “navigation with Fragments is hard”.
I always point out that in our code, it’s been like this since 2016:
backstack.goTo(new SecondScreen(detailId)); // java
No explicit fragment transaction, no Bundle, no newInstance()
, just calling a method. How is this possible? Isn’t Fragment navigation hard? Well, it can be, but Fragments are powerful, and they can be abstracted away.
Going back is very similar:
backstack.goBack(); // java
No finish()
, no magic.
Setting up Simple-Stack with Fragments
Of course, over the years, the API of Simple-Stack has improved. This is something you’d expect from a library that started with 0.1.0 and is now 2.5.0.
Originally, Simple-Stack set out to make working with compound viewgroups as our core UI easy, but this article focuses on its usage with Fragments.
In a few simple steps, we can achieve simple, type-safe navigation between Fragments.
As it says in the readme, the initial setup is to add dependencies:
and add the integration point into the Activity:
With that, we’ve installed Simple-Stack’s Navigator
, which will make the backstack
available to us, pretty much anywhere (and handles state restoration, lifecycle events, and back presses — along with fragment navigation in general).
If we define FirstKey
, this will show our first Fragment on our screen, and it looks like this:
By default, DefaultFragmentKey
will use javaClass.name
as the fragment tag, but this can be customized.
To make the FirstKey
accessible via getKey()
, we can make our Fragment extend from KeyedFragment
:
Of course, we don’t need to extend KeyedFragment
, as getKey()
is the same as requireArguments().getParcelable(DefaultFragmentKey.ARGS_KEY)
, hiding it is for convenience.
How would adding a new screen work?
To add a new fragment, what we do is:
Create a key, create a fragment, and we can navigate to it via backstack.goTo(SecondKey())
. Fairly formulaic.
Arguments can be passed to the key directly. As long as they are parcelable, @Parcelize
will handle it for us.
Navigating there is a simple method call.
Finding the current fragment, handling back in Fragments
There are times when we need to pass back events to a Fragment.
My recipe for that so far has been to define an interface, like so:
Implement BackHandler
on the Fragment, if it intends to handle back presses:
And we can now dispatch back to our current shown Fragment, even without the use of a BackPressDispatcher:
In this case, we could access the current fragment, ask it whether it intends to handle back, and if yes, then don’t pass it to the Activity — in a fairly transparent and easy-to-understand manner.
Showing a title for a given screen
You might find yourself trying to configure a global view, one that belongs to the Activity, as you navigate between your Fragments.
In this case, some people might choose to override Fragment.onStart()
and tinker with the Activity right there and then.
However, should a Fragment really know about the Activity’s views? Wouldn’t it be great if we could handle this in the Activity directly?
Well, this is easy with Simple-Stack as well. We just need to put the title resource ID in the key, and handle it along with the navigation state change we’re already handling.
Sharing data between screens
Here’s where things get a little more interesting. While I’ve already mentioned this in two of my previous articles:
- “The beautiful story of Android Developers, multiple Activities, and the Chained Elephant”
- “All the thing we’re doing wrong but take for granted: a retrospective glance at Android development”
It’s worth iterating over it once more, as this is where the replacement for Jetpack ViewModel (and in simple cases, even of Dagger/Koin) comes in.
Scoped services, global services
Simple-Stack defines the concept of “scopes”.
The idea is that as you navigate forward and back, your navigation state can declare what scopes each screen expect to exist as you navigate there — and if it doesn’t exist yet, the Backstack
will create it.
Each scope can hold “scoped services”, which have a lifecycle automatically managed based on the navigation state (and can also support saved state persistence and restoration).
For the simpler cases, we can use the DefaultServiceProvider
, and implement DefaultServiceProvider.HasServices
on our key. This will allow us to lookup()
services from the Backstack that are registered in any of the previous scopes.
Any service registered in the GlobalServices
will be available too, living in the parent scope of all scopes. When constructing scoped services bound to screens, we can use serviceBinder.lookup()
to inherit services from previously bound scopes.
With that, we have a FirstScopedModel
that inherits the someDao
from global services, is able to navigate, gets arguments, and could even handle lifecycle or state restoration if it implemented Bundleable
and ScopedServices.Registered
respectively.
If we need to bind additional scopes that are shared between screens that are not directly a result of the screen history, we can use ScopeKey.Child
. It’s similar in concept to NavGraph-scoped ViewModels.
Other recipes to consider
Clearing the task stack (jumping to first element)
If we want to set a completely new history (for example, for deep-linking), we can use:
backstack.setHistory(
History.of(SomeScreen(), OtherScreen()),
StateChange.FORWARD
)
If we just want to jump to the first element, we can use:
backstack.jumpToRoot()
Result passing
We can use the inheritance of scoped services, as the previous screen’s services are guaranteed to exist.
Therefore, if we implement an interface on a previous, expected service, we can grab that and pass results to it directly.
Unit testing
As the Backstack
class does not rely on Android in any way, and keys don’t depend on Android in any way, it’s easy to test for interactions with the backstack and assert the result.
This is why the library itself is heavily tested via automated tests, too.
Scoping in a multi-module setup
In that case, we created an implementation of ScopedServices
that would delegate over to Dagger’s provider map multibinding to create the scoped services for a given scope.
But that was mostly necessary because the keys were global, even though that’s most likely not the best way to structure navigation code across modules (as it becomes a global dependency of all modules, while the keys become separate from the feature they belong to).
Next time, I’d rather expose an interface from the feature module, and handle navigation in the app module. That way, no such tricks are needed.
Is it ready for Compose?
Technically yes, although I still can’t figure out composable animations. I’ll update this gist when I figure it out.
This actually gives us all of the above. Navigation between screens, scoping support (via backstack.lookup()
), and it goes without saying — state restoration across process death.
Conclusion
I hope this article helped clarify some mysteries regarding how we currently use Simple-Stack.
The default settings for Fragments work well for simple animations (the default is horizontal slide, although it’s customizable) — and animations generally aren’t that flashy in business apps. Shared element transitions would, unfortunately, need a bit more tricks (due to FragmentTransaction’s design), although there’s a sample for it in the repo.
When we needed more complex animations, we actually opted to use compound viewgroups instead, as they’re more versatile in that regard.
Still, the end result is that we can access the backstack
throughout the project (activity, fragments, views, scoped services) as needed, and its lifecycle is predictable (it lives in the Activity retained scope, and handles process death).
Using Simple-Stack allows us to abstract away the Android lifecycle to such degree, that navigation actions become a simple method call. Result passing becomes a method call. Parameter passing between screens becomes a constructor parameter of a class. Type-safe and customizable navigation, with lifecycle-aware state management.
You can see the gist of how to use Simple-Stack in this sample project, although it’ll be eerily similar to this article.
Hope that helped explain how it works, why we’re using it, and maybe even how it compares against the current alternate approaches. If you have any further questions, just comment below :)
You can also check out the discussion threads, here for /r/androiddev, and here for /r/android_devs.