Incrementally adopting new iOS APIs in an established codebase

Robert Clark
The Startup
Published in
6 min readDec 2, 2019

Each year Apples iOS platform extends existing apis and usually introduces entirely new frameworks. These new technologies offer developers capabilities which have the potential to both raise productivity and reduce code complexity.

When we consider adopting these new technologies, crucial questions are if, when and how to go about it. These decisions are often complex and typically involve roles beyond developers — such as testers, tech leads and product owners.

In this article I’ll discuss some benefits of adopting new apis early, then I’ll work through some typical challenges, and finally I’ll walk through an example of adopting a new api incrementally and safely.

For the example I’ll use iOS 13’s Combine framework and will be coalescing multiple asynchronous network requests around a legacy api stack.

Benefits

Firstly, what are some of the main realisable benefits when we adopt new apis early?

  • Developers and the wider team gain valuable experience in the short term, which enables us to make better informed decisions sooner.
  • The technologies in our codebase remain fresh and appealing to devs. This raises team morale, helps attract talent, and provides opportunities to enjoy learning new things.
  • Whilst achieving the previous benefits, we also control risk because we’re preserving a safe & known fallback solution.
  • Finally, by clearly and safely demarcating between newer and older code paths, in the future we can easily prune obsoleted code as deployment targets are lifted.

Challenges

Let’s discuss some challenges which usually crop up when considering adopting a new technology.

Our apps deployment target won’t be lifted to iOS 13 for some time, so what should we do about older platforms?

It’s quite normal that a significant number of users will be using an earlier version of iOS, which means code running on their devices can’t use newer technologies.

Option 1 — Don’t deploy any functionality for older platforms

This may be possible if you have a feature which isn’t critical and doesn’t have to reach all of your audience. Perhaps a tips or promo screen mightn’t need to be seen by all your users. If this is the case, simply use the new technologies for this feature and place an availability check (such as #if available(iOS 13, *)) to conditionally execute the new code.

Option 2 — Deploy both new & legacy solutions together

Developing fallback code allows us to provide new functionality to all users. In our example here, the newer Combine variant will run on iOS 13 whilst the legacy solution will be used on earlier versions of iOS. The trade-off is that there’s more up front effort as some of the functionality will need to be developed and tested twice.

What if our application architecture prevents multiple solutions?

Although sticking to a single architecture can be beneficial for consistency, one trade-off is that it can prevent trialing newer technologies until they’re able to be deployed to all users.

Solution — If you work with this type of constraint, I recommend introducing the new technology within just a single layer of the architecture first. With our Combine example I’ll be introducing it within an Interactor. This layer’s responsibility is encapsulated so we keep the change surface area low.

But there’s additional complexity and a learning curve!

Yes, having multiple ways to solve the same problem in a codebase means more code to understand!

Keep in mind however that a solution using newer technology should compare favourably to a legacy solution — all other things being equal. (Think about how complex that custom view controller was before UICollectionViewController was available!). So yes, granted, there’s more complexity during the transitionary period, however the future state will be simpler and we should be arriving there sooner for reasons already given.

Example

Let’s say in our codebase we already have legacy networking apis which use a completion closure to return data to the caller. We have a simple Api protocol for clients to adopt:

Api protocol

We have also defined a generic fetch function in a protocol extension:

The fetch function body dances around the URLSessionDataTask response and invokes the completion handler with either a result or an error. The completion handler has signature (Dto?, Error?) which is a fairly common pattern, particularly in code bases with roots in Objective-C.

Our concrete api classes adopt Api and fill in the blanks to conform.

Here for example to fetch widgets we define the WidgetsDto which is returned by the concrete WidgetsApi class. Assume also that we have a DiscountsApi which follows the same pattern. We’ll see them both used together in a moment.

Next, our Interactor. Its responsibility is to query both the widgets api and discounts api, then figure out which widgets are currently discounted and return that information to its delegate.

The LegacyDiscountedWidgetsInteractor uses nested callbacks to make sequential api calls

Let’s quickly walk through whats going on here.

  1. Clients of the Interactor ask it to do work by invoking getDiscountedWidgets() which in turn callsfetch on the WidgetsApi.
  2. Should the widgets api call fail, call back to delegate with the error and finish
  3. Otherwise pass the WidgetsDto into fetchDiscounts which calls fetch on the DiscountsApi.
  4. Similarly if this second api fails, call back to the delegate with the error
  5. Finally, compute the results and pass the DiscountedWidgets to the delegate

Note: This code doesn’t show all the functions & types (such as DiscountedWidgets or computeDiscountedWidgets) as we’re focusing on areas where we can adopt Combine.

Adopting Combine

How do we write a iOS 13+ Combine version of the same Interactor? I’ll assume the reader has some basic knowledge of Combine. A great place to get an overview is from the WWDC 2019 videos Introducing Combine and Combine in Practice.

Combine has the perfect type we need here and its called Future. This is a specific type of Publisher which eventually produces one value and then finishes or fails. If we can represent our api invocations as Futures then we have created the building blocks for our new Interactor. Let’s create these building blocks in an extension on Api:

Extending the completion handler driven Api to make Futures
  1. By importing a system framework in our source we are weak-linking — which means that Combine doesn’t need to be present at runtime. Since we won’t be trying to use the Combine framework on iOS 12 or earlier, this is safe.
  2. We mark this extension for iOS 13+ by using an available declaration attribute.
  3. Create the Future providing its type information.
  4. In the futures initialisation block we invoke our existing fetch function just as before.
  5. In the success case, fulfil the promise with the .success type and its value
  6. Otherwise in the failure case, fulfil using the .error type and the Error instance

Now that we can make Api requests as Futures, let’s create a new Combine-driven Interactor to orchestrate things:

Combine driven DiscountedWidgetsInteractor
  1. We need to keep a reference to the cancellable to prevent our subscription from being deallocated early.
  2. We use Publishers.CombineLatest and our new makeFuture() factory methods to trigger both api requests concurrently. If they both succeed we pass this along as a tuple of (DiscountsDto, WidgetsDto).
  3. Map the dtos to the same utility function used by the legacy Interactor
  4. .sink creates the subscription and handles the results. Here the receiveCompletion block handles the error case and we inform the delegate.
  5. The final closure is the happy day flow — here we handle the result of the map operator from earlier, and inform our delegate of the result.

Again this article isn’t about the benefits of the declarative nature of Combine, however I did make the argument earlier that a solution built on newer technology should compare favourably to an alternative. Personally I believe this is evident here — we have one place to handle errors; a clearer declarative event sequence; and concurrent api requests now occurring in parallel, seamlessly combined into a single output.

There’s just one thing left to do, and that’s to determine which Interactor to use at runtime via an availability check. Here’s one way this could look within a Presenter’s initializer:

You can find more detail about availability checks and attributes in this great Hacking with Swift article.

Conclusion

We’re all playing in a landscape of ever-evolving iOS Apis. Here I’ve discussed a strategy for dealing with this in established codebases.

I’ve given several benefits to adopting new technologies early. This is not to say we should adopt all new technologies in platform SDKs all the time, but when strong candidates come along it is definitely worth considering adopting them early.

Finally we went through a typical Interactor example, and showed how to keep the impact and risk low by separating legacy and modern implementations which conform to the same external api. This is an incremental approach because we’ve ring-fenced changes within a single area of responsibility in our apps architecture, and furthermore within just a single feature.

I encourage you to try this approach with your team!

--

--

Robert Clark
The Startup

iOS architecture & team specialist. Living with family in Wellington, NZ