App navigation at scale: introducing DuckRouter

App navigation at scale: introducing DuckRouter

App navigation at scale: introducing DuckRouter

Jul 2, 2024

Jul 2, 2024

Software

Software

By Jasper van Riet

At Onsi, we’re heavy users of Flutter for our Android and iOS experience, providing users with a high-quality user experience when accessing their on-demand pay, insurance and rewards, whilst using a relatively small amount of resources. Like any mobile app, our page navigation functionality is a core part of our codebase.

Our first implementation of page navigation used go_router, a routing package supported by the Flutter team. This implementation allowed us to iterate quickly, by having a central directory of routes, all indexed by an internal routing path. Adding deep-linking support on top of that was trivial: changing some configuration options was enough to get our internal app URLs working nicely.

final _router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => HomeScreen(),
    ),
  ],
);

Easy!

Evolution of our routing needs

As our application evolved, our page structure became more intricate. Initially simple with paths like /home and /deals, we soon encountered challenges when keeping the bottom bar visible across pages. To show the bottom bar on a child page, we had to travel to an absolute path, e.g. /home/transactions. Linking the same page from elsewhere would result in adding another path: /home/pay/transactions. All of these are URLs, so it’s easy to forget about declaring one, or making a typo when doing so. Adding a new entry point soon became a web of complexity, having to check whether the path was declared, and not being able to spot when refactoring broke navigation due to the use of the string paths.

While we crafted abstractions to hide this complexity, we ultimately found that URL-based routing presented too many opportunities for introducing bugs, even with extensive automated testing. Managing the growing number of paths and ensuring consistency across different navigation flows became increasingly difficult and error-prone.

Our application was also becoming more reliant on deep-linking to facilitate user journeys. User journeys such as "Your pay is available" would take users from a notification into the app, and would not involve much actual navigation inside the app. Unfortunately, as this capability became more common we encountered significant issues with deep-linking in go_router. GoRouter implements deep-linking by having the deep-linking URL open the respective internal GoRoute URI. This is very intuitive. However, we encountered issues with this approach when having multiple route interceptors in the app. For example, when opening the app, we ask the user to verify via a passcode  (a feature necessary for most fin-tech applications). This meant that the deep-linked location was lost. We could work around this, but we found that any solution would end up compromising other parts of our routing solution. There had to be a better way.

Introducing DuckRouter

We are excited to announce that today, we are open-sourcing a new router: DuckRouter. Named for the rubber duck required to decipher Flutter's routing APIs, DuckRouter offers features like fully type-checked routes, a dynamic route registry, easy route interception, and solid deep-linking. Since migrating the app over to DuckRouter, we have found our routing woes to be solved, with internal stakeholders often commenting specifically on the reliability of our deep-linking.

DuckRouter is an intent-based router, similar to the intent system on Android. To navigate somewhere in the app, simply extend a Location:

class Page1Location extends Location {
  const Page1Location() : super(path: 'page1');

  @override
  LocationBuilder get builder => (context) => const Page1Screen();
}

Now navigate to this location:

DuckRouter.of(context).navigate(to: const Page1Location());

By modelling routes as data classes, DuckRouter provides type safety - it's impossible to accidentally navigate to a non-existent route. DuckRouter also uses a dynamic registry and thus does not need routes declared beforehand. The dynamic registry avoids conflicts from duplicated route declarations. It also avoids the opposite: forgetting to declare a route.

We combine these concepts with Interceptors. An interceptor is a model that is called upon routing, and that can decide to take action. See below for an example of an interceptor that detects if the user is logged in. If not, they are redirected to the login page:

class AuthInterceptor extends LocationInterceptor {
  @override
  Location? execute(Location to, Location? from) {
    if (to is! MustBeLoggedIn || userService.isLoggedIn) {
      return null;
    }

    return const LoginLocation();
  }
}

This basic example demonstrates the key characteristics of DuckRouter:

  • Routes are represented as a model, allowing them to define the types of their arguments. It is not possible to navigate to a route that does not exist.

  • DuckRouter’s use of a dynamic registry avoids issues such as forgetting to declare a route, or conflicts in doing so. Routes can be added dynamically inside a bottom bar state, or can be added to the root Navigator. DuckRouter does not take a stance regarding greater routing structure, it has no opinion on whether one route must be the parent of another, or vice versa.

  • Interceptors provide powerful customisation.

These concepts together combine to create a powerful model that is easy to use. Thanks to our route typing, interceptors can confidently target certain locations. The Location model itself can be extended to further provide functionality, such as automated tracking of screen views in analytics or as above, specifying that a page needs the user to be logged in. We also appreciate that the dynamic registry avoids code conflicts common when having a list of defined routes in one place.

On deep-linking, we have taken an approach that ultimately makes navigation more intentional. DuckRouter handles the interception of a deep link, and provides it to the consumer via a callback. We are then able to parse the path and route to the desired location. In case the desired location is intercepted (e.g. for authentication), we save the deep-link and provide a deep-link interceptor that ensures we navigate to the intended location after the user has successfully authenticated.

Finally, we have been able to significantly reduce our automated testing for routing due to the power of the type checks in place. We no longer need to cross-check whether a location will actually return a page and whether that location then matches a certain string being navigated to.

Powering the Onsi App

DuckRouter powers the core of the Onsi app. Our routing is critical to facilitate our heavy deep-linking-based app experience. To date, we have found DuckRouter to be able to handle anything we throw at it, all while increasing our development velocity. This has allowed us to move quickly without sacrificing quality.

For more specific technical information, such as how to get started or more advanced functionality like a bottom bar, please see https://pub.dev/packages/duck_router

See it in action

Onsi is a UK and EU insurance intermediary. Onsi is a trading name of Collective Society Ltd, Collective Denmark ApS (Onsi Denmark ApS) and Collective Netherlands B.V., who are authorised and regulated by the UK Financial Conduct Authority (No. 923788), the Danish Financial Services Authority (No. 42352985), and the Netherlands Authority for Financial Markets (No. 12049041), respectively. You can check this by visiting the UK Financial Services Register, the Danish Financial Services Register, and the Netherlands Financial Services Register.

Copyright © 2024 Collective Society Ltd, All rights reserved.

Onsi is a UK and EU insurance intermediary. Onsi is a trading name of Collective Society Ltd, Collective Denmark ApS (Onsi Denmark ApS) and Collective Netherlands B.V., who are authorised and regulated by the UK Financial Conduct Authority (No. 923788), the Danish Financial Services Authority (No. 42352985), and the Netherlands Authority for Financial Markets (No. 12049041), respectively. You can check this by visiting the UK Financial Services Register, the Danish Financial Services Register, and the Netherlands Financial Services Register.

Copyright © 2024 Collective Society Ltd, All rights reserved.

Onsi is a UK and EU insurance intermediary. Onsi is a trading name of Collective Society Ltd, Collective Denmark ApS (Onsi Denmark ApS) and Collective Netherlands B.V., who are authorised and regulated by the UK Financial Conduct Authority (No. 923788), the Danish Financial Services Authority (No. 42352985), and the Netherlands Authority for Financial Markets (No. 12049041), respectively. You can check this by visiting the UK Financial Services Register, the Danish Financial Services Register, and the Netherlands Financial Services Register.

Copyright © 2024 Collective Society Ltd, All rights reserved.