Deep Linking at Scale on iOS

How the iOS team at Just Eat built a scalable architecture to support navigation and deep linking.

Originally published on the Just Eat Engineering Blog.

In this article, we propose an architecture to implement a scalable solution to Deep Linking on iOS using an underlying Flow Controller-based architecture, all powered by a state machine and the Futures & Promises paradigm to keep the code more readable.

At Just Eat, we use a dedicated component named NavigationEngine that is domain-specific to the Just Eat apps and their use cases. A demo project named NavigationEngineDemo that includes the NavigationEngine architecture (stripped out of many details not necessary to showcase the solution) is available on GitHub.

Overview

Deep linking is one of the most underestimated problems to solve on mobile. A naïve explanation would say that given some sort of input, mobile apps can load a specific screen, but it only has practical meaning when combined with Universal Links on iOS and App Links on Android. In such cases, the input is a URL that would load a web page on the companion website.

Let's use an example from Just Eat: opening the URL https://www.just-eat.co.uk/area/ec4m-london on a web browser would load the list of restaurants in the UK London area for the postcode EC4M. Deep linking to the mobile apps using the same URL should give a similar experience to the user.

In reality, the problem is more complex than what it seems at first glance; non-tech people - and sometimes even developers - find it hard to grasp. Loading a web page in a browser is fundamentally different from implementing dedicated logic on mobile to show a UIViewController (iOS) or Activity (Android) to the user and populate it with information that will most likely be gathered from an API call.

The logic to perform deep linking starts with parsing the URL, understanding the intent, constructing the user journey, performing the navigation to the target screen passing the info all the way down, and ultimately loading any required data asynchronously from a remote API. On top of all this, it also has to consider the state of the app: the user might have previously left the app in a particular state and dedicated logic would be needed to deep link from the existing to the target screen. A scenario to consider is when the user is not logged in and therefore some sections of the app may not be available.

Deep linking can actually be triggered from a variety of sources:

  • Safari web browser
  • any app that allows tapping on a link (iMessage, Notes, etc.)
  • any app that explicitly tries to open the app using custom URL schemes
  • the app itself (to perform jumps between sections)
  • TodayExtension
  • Shortcut items (Home Screen Quick Actions)
  • Spotlight items

It should be evident that implementing a comprehensive and scalable solution that fully addresses deep linking is far from being trivial. It shouldn't be an after-thought but rather be baked into the app architecture from the initial app design.

It should also be quite glaring what the main problem that needs to be solved first is: the app Navigation.

Navigation itself is not a problem with a single solution (if it was, the solution would be provided by Apple/Google and developers would simply stick to it). A number of solutions were proposed over the years trying to make it simpler and generic to some degree - Router, Compass, XCoordinator to name just a few open-source components.

I proposed the concept of Flow Controllers in my article Flow Controllers on iOS for a better navigation control back in 2014 when the community had already (I believe) started shifting towards similar approaches. Articles such as Improve your iOS Architecture with FlowControllers (by Krzysztof Zabłocki), A Better MVC, Part 2: Fixing Encapsulation (by Dave DeLong), Flow Coordinators in iOS (by Dennis Walsh), and even as recently as 2019, Navigation with Flow Controllers (by Majid Jabrayilov) was published.

To me, all the proposals share one main common denominator: flow controllers/coordinator and their API are necessarily domain-specific. Consider the following methods taken from one of the articles mentioned above referring to specific use cases:

func showLoginViewController() { ... }
func showSignupViewController() { ... }
func showPasswordViewController() { ... }

With the support of colleagues and friends, I tried proposing a generic and abstract solution but ultimately hit a wall. Attempts were proposed using enums to list the supported transitions (as XCoordinator shows in its README for instance) or relying on meta-programming dark magic in Objective-C (which is definitely the sign of a terrible design), neither of which satisfied me in terms of reusability and abstraction. I ultimately realized that it's perfectly normal for such problem to be domain-specific and that we don't necessarily have to find abstract solutions to all problems.

Terminology

For clarity on some of the terminology used in this article.

  • Deep Linking: the ability to reach specific screens (via a flow) in the app either via a Deep Link or a Universal Link.

  • Deep Link: URI with custom scheme (e.g. just-eat://just-eat.co.uk/login, just-eat-dk://just-eat.co.uk/settings) containing the information to perform deep linking in the app. When it comes to deep links, the host is irrelevant but it's good to keep it as part of the URL since it makes it easier to construct the URL using URLComponents and it keeps things more 'standard'.

  • Universal Link: URI with http/https scheme (e.g. https://just-eat.co.uk/login) containing the information to perform deep linking in the app.

  • Intent: the abstract intent of reaching a specific area of the app. E.g. goToOrderDetails(OrderId).

  • State machine transition: transitions in the state machine allow navigating to a specific area in the app (state) from another one. If the app is in a state where the deep linking to a specific screen should not be allowed, the underlying state machine should not have the corresponding transition.

Solution

NavigationEngine is the iOS module (pod) used by the teams at Just Eat, that holds the isolated logic for navigation and deep linking. As mentioned above, the magic sauce includes the usage of:

  • FlowControllers to handle the transitions between ViewControllers in a clear and pre-defined way.

  • Stateful state machines to allow transitions according to the current application state. More information on FSM (Finite State Machine) here and on the library at The easiest State Machine in Swift.

  • Promis to keep the code readable using Futures & Promises to help avoiding the Pyramid of doom. Sticking to such a paradigm is also a key aspect for the whole design since every API in the stack is async. More info on the library at The easiest Promises in Swift.

  • a pretty heavy amount of 🧠

NavigationEngine maintains separation of concerns between URL Parsing, Navigation, and Deep Linking. Readers can inspect the code in the NavigationEngineDemo project that also includes unit tests with virtually 100% code coverage. Following is an overview of the class diagram of the entire architecture stack.

Architecture class diagram

While the navigation is powered by a FlowController-based architecture, the deep linking logic is powered by NavigationIntentHandler and NavigationTransitioner (on top of the navigation stack).

Note the single entry point named DeepLinkingFacade exposes the following API to cover the various input/sources we mentioned earlier:

public func handleURL(_ url: URL) -> Future<Bool>
public func openDeepLink(_ deepLink: DeepLink) -> Future<Bool>
public func openShortcutItem(_ item: UIApplicationShortcutItem) -> Future<Bool>
public func openSpotlightItem(_ userActivity: NSUserActivityProtocol) -> Future<Bool>

Here are the sequence diagrams for each one. Refer to the demo project to inspect the code.

As mentioned earlier, the important concept to grasp is that there is simply no single solution to Navigation. I've noticed that such a topic quickly raises discussions and each engineer has different, sometimes strong opinions. It's more important to agree on a working solution that satisfies the given requirements rather than forcing personal preferences.

Our NavigationEngine relies on the following navigation rules (based on Flow Controllers):

  • FlowControllers wire up the domain-specific logic for the navigation
  • ViewControllers don't allocate FlowControllers
  • Only FlowControllers, AppDelegate and similar top-level objects can allocate ViewControllers
  • FlowControllers are owned (retained) by the creators
  • FlowControllers can have children FlowControllers and create a parent-child chain and can, therefore, be in a 1-to-many relationship
  • FlowControllers in parent-child relationships communicate via delegation
  • ViewControllers have weak references to FlowControllers
  • ViewControllers are in a 1-to-1 relationship with FlowControllers
  • All the FlowController domain-specific API must be future-based with Future<Bool> as return type
  • Deep linking navigation should occur with no more than one animation (i.e. for long journeys, only the last step should be animated)
  • Deep linking navigation that pops a stack should occur without animation

In the demo project, there are a number of *FlowControllerProtocols, each corresponding to a different section/domain of the hosting app. Examples such as RestaurantsFlowControllerProtocol and OrdersFlowControllerProtocol are taken from the Just Eat app and each one has domain specific APIs, e.g:

func goToSearchAnimated(postcode: Postcode?, cuisine: Cuisine?, animated: Bool) -> Future<Bool>
func goToOrder(orderId: OrderId, animated: Bool) -> Future<Bool>
func goToRestaurant(restaurantId: RestaurantId) -> Future<Bool>
func goToCheckout(animated: Bool) -> Future<Bool>

Note that each one:

  • accepts the animated parameter
  • returns Future<Bool> so that flow sequence can be combined

Flow controllers should be combined sensibly to represent the app UI structure. In the case of Just Eat we have a RootFlowController as the root-level flow controller orchestrating the children. A FlowControllerProvider, used by the NavigationTransitioner, is instead the single entry point to access the entire tree of flow controllers.

NavigationTransitioner provides an API such as:

func goToLogin(animated: Bool) -> Future<Bool>
func goFromHomeToSearch(postcode: Postcode?, cuisine: Cuisine?, animated: Bool) -> Future<Bool>

This is responsible to keep the underlying state machine and what the app actually shows in sync. Note the goFromHomeToSearch method being verbose on purpose; it takes care of the specific transition from a given state (home).

One level up in the stack, NavigationIntentHandler is responsible for combining the actions available from the NavigationTransitioner starting from a given NavigationIntent and creating a complete deep linking journey. It also takes into account the current state of the app. For example, showing the history of the orders should be allowed only if the user is logged in, but it would also be advisable to prompt the user to log in in case he/she is not, and then resume the original action. Allowing so provides a superior user experience rather than simply aborting the flow (it's what websites achieve by using the referring URL). Here is the implementation of the .goToOrderHistory intent in the NavigationIntentHandler:

case .goToOrderHistory:
    switch userStatusProvider.userStatus {
    case .loggedIn:
        return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future<Bool> in
            self.navigationTransitioner.goToOrderHistory(animated: true)
        }
    case .loggedOut:
        return navigationTransitioner.requestUserToLogin().then { future in
            switch future.state {
            case .result:
                return self.handleIntent(intent) // go recursive
            default:
                return Future<Bool>.futureWithResolution(of: future)
            }
        }
}

Since in the design we make the entire API future-based, we can potentially interrupt the deep linking flow to prompt the user for details or simply gather missing information from a remote API. This is crucial and allows us to construct complex flows.

By design, all journeys start by resetting the state of the app by calling goToRoot. This vastly reduces the number of possible transitions to take care of as we will describe in more detail in the next section dedicated to the underlying state machine.

State Machine

As you might have realized by now, the proposed architecture makes use of an underlying Finite State Machine to keep track of the state of the app during a deep linking journey.

Here is a simplified version of the state machine configurations used in the Just Eat iOS apps.

In the picture, the red arrows are transitions that are available for logged in users only, the blue ones are for logged out users only, while the black ones can always be performed.

Note that every state should allow going back to the .allPoppedToRoot state so that, regardless of what the current state of the app is, we can always reset the state and perform a deep linking action starting afresh. This drastically simplifies the graph, avoiding unnecessary transitions such as the one shown in the next picture.

Notice that intents (NavigationIntent) are different from transitions (NavigationEngine.StateMachine.EventType). An intent contains the information to perform a deep linking journey, while the event type is the transition from one FSM state to another (or the same).

NavigationTransitioner is the class that performs the transitions and applies the companion navigation changes. A navigation step is performed only if the corresponding transition is allowed and completed successfully. If a transition is not allowed, the flow is interrupted, reporting an error in the future. You can showcase a failure in the demo app by trying to follow the Login Universal Link (https://just-eat.co.uk/login) after having faked the login when following the Order History Universal Link (https://just-eat.co.uk/orders).

Usage

NavigationEngineDemo includes the whole stack that readers can use in client projects. Here are the steps for a generic integration of the code.

Add the NavigationEngine stack (NavigationEngineDemo/NavigationEngine folder) to the client project. This can be done by either creating a dedicated pod as we do at Just Eat or by directly including the code.

Include Promis and Stateful as dependencies in your Podfile (assuming the usage of Cocoapods).

Modify according to your needs, implement classes for all the *FlowControllerProtocols, and connect them to the ViewControllers of the client. This step can be quite tedious depending on the status of your app and we suggest trying to mimic what has been done in the demo app.

Add CFBundleTypeRole and CFBundleURLSchemes to the main target Info.plist file to support Deep Links. E.g.

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleTypeRole</key>
    <string>Editor</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>je-internal</string>
      <string>justeat</string>
      <string>just-eat</string>
      <string>just-eat-uk</string>
    </array>
  </dict>
</array>
  • Add the applinks (in the Capabilities -> Associated Domains section of the main target) you'd like to support. This will allow iOS to register the app for Universal Links on the given domains looking for the apple-app-site-association file at the root of those domains once the app is installed. E.g.

Implement concrete classes for DeepLinkingSettingsProtocol and UserStatusProviding according to your needs. Again, see the examples in the demo project. The internalDeepLinkSchemes property in DeepLinkSettingsProtocol should contain the same values previously added to CFBundleURLSchemes, while the universalLinkHosts should contain the same applinks: values defined in Capabilities -> Associated Domains.

Setup the NavigationEngine stack in the AppDelegate's applicationDidFinishLaunching. To some degree, it should be something similar to the following:

var window: UIWindow?
var rootFlowController: RootFlowController!
var deepLinkingFacade: DeepLinkingFacade!

var userStatusProvider = UserStatusProvider()
let deepLinkingSettings = DeepLinkingSettings()

func applicationDidFinishLaunching(_ application: UIApplication) {

    // Init UI Stack
    let window = UIWindow(frame: UIScreen.main.bounds)
    let tabBarController = TabBarController.instantiate()

    // Root Flow Controller
    rootFlowController = RootFlowController(with: tabBarController)
    tabBarController.flowController = rootFlowController

    // Deep Linking core
    let flowControllerProvider = FlowControllerProvider(rootFlowController: rootFlowController)
    deepLinkingFacade = DeepLinkingFacade(flowControllerProvider: flowControllerProvider,
                                          navigationTransitionerDataSource: self,
                                          settings: deepLinkingSettings,
                                          userStatusProvider: userStatusProvider)

    // Complete UI Stack
    window.rootViewController = tabBarController
    window.makeKeyAndVisible()
    self.window = window
}
  • Modify NavigationTransitionerDataSource according to your needs and implement its methods. You might want to have a separate component and not using the AppDelegate.
extension AppDelegate: NavigationTransitionerDataSource {
    func navigationTransitionerDidRequestUserToLogin() -> Future<Bool> {
        <#async logic#>
    }
    ...
}
  • Implement the entry points for handling incoming URLs/inputs in the AppDelegate:
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
    // from internal deep links & TodayExtension
    deepLinkingFacade.openDeeplink(url).finally { future in
        <#...#>
    }
    return true
}

func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    switch userActivity.activityType {
    // from Safari
    case NSUserActivityTypeBrowsingWeb:
        if let webpageURL = userActivity.webpageURL {
            self.deepLinkingFacade.handleURL(webpageURL).finally { future in
                <#...#>
            }
            return true
        }
        return false

    // from Spotlight
    case CSSearchableItemActionType:
        self.deepLinkingFacade.openSpotlightItem(userActivity).finally { future in
            let originalInput = userActivity.userInfo![CSSearchableItemActivityIdentifier] as! String
            <#...#>
        }
        return true

    default:
        return false
    }
}

func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
    // from shortcut items (Home Screen Quick Actions)
    deepLinkingFacade.openShortcutItem(shortcutItem).finally { future in
        let originalInput = shortcutItem.type
        <#...#>
        completionHandler(future.hasResult())
    }
}

N.B. Since a number of tasks are usually performed at startup (both from cold and warm starts), it's suggested to schedule them using operation queues. The deep linking task should be one of the last tasks in the queue to make sure that dependencies are previously set up. Here is the great Advanced NSOperations talk by Dave DeLong from WWDC15.

  • The UniversalLinkConverter class should be modified to match the paths in the apple-app-site-association, which should be reachable at the root of the website (the associated domain). It should be noted that if the app is opened instead of the browser, it would be because the Universal Link can be handled; and redirecting the user back to the web would be a fundamental mistake that should be solved by correctly defining the supported paths in the apple-app-site-association file.

To perform internal app navigation via deep linking, the DeeplinkFactory class should be used to create DeepLink objects that can be fed into either handleURL(_ url: URL) or openDeepLink(_ deepLink: DeepLink).

In-app testing

The module exposes a DeepLinkingTesterViewController that can be used to easily test deep linking within an app.

Simply define a JSON file containing the Universal Links and Deep Links to test:

{
  "universal_links": [
    "https://just-eat.co.uk/",
    "https://just-eat.co.uk/home",
    "https://just-eat.co.uk/login",
    ...
  ],
  "deep_links": [
    "JUSTEAT://irrelev.ant/home",
    "justeat://irrelev.ant/login",
    "just-eat://irrelev.ant/resetPassword?resetToken=xyz",
    ...
  ]
}

Then feed it to the view controller as shown below. Alternatively, use a storyboard reference as shown in the demo app.

let deepLinkingTesterViewController = DeepLinkingTesterViewController.instantiate()
deepLinkingTesterViewController.delegate = self
let path = Bundle.main.path(forResource: "deeplinking_test_list", ofType: "json")!
deepLinkingTesterViewController.loadTestLinks(atPath: path)

and implement the DeepLinkingTesterViewControllerDelegate

extension AppDelegate: DeepLinkingTesterViewControllerDelegate {
    func deepLinkingTesterViewController(_ deepLinkingTesterViewController: DeepLinkingTesterViewController, didSelect url: URL) {
        self.deepLinkingFacade.handleURL(universalLink).finally { future in
            self.handleFuture(future, originalInput: universalLink.absoluteString)
        }
    }
}

Conclusion

The solution proposed in this article has proven to be highly scalable and customizable. We shipped it in the Just Eat iOS apps in March 2019 and our teams are gradually increasing the number of Universal Links supported as you can see from our apple-app-site-association.

Before implementing and adopting NavigationEngine, supporting new kinds of links was a real hassle. Thanks to this architecture, it is now easy for each team in the company to support new deep link journeys. The declarative approach in defining the API, states, transitions, and intents forces a single way to extend the code which enables a coherent approach throughout the codebase.