Skip to content

Apps Dissected Posts

Building a Caching Custom Combine Operator #2: The .cache() Method

In part 1, we started building our custom combine operator by starting with the foundation: building the Combine chain that implemented our caching operator. In this part, we want to make this something that is reusable, and functions like a built-in Combine operator, by adding it to the Publisher type via an extension.

As a refresher, let’s review how we wanted our .cache() method to work for somebody using it. The upstream chain would provide input for the caching operator, which would either calculate the output from a given operation, or if it’s already been calculated previously, simply pass on the previous value. So the signature would look something like this:

upstream.cache(operation: ...)

Now we just need to convert that into a method declaration in a Publisher extension. Easy, right?

Now we recall the image from part 1, flatMap()’s method declaration:

How do we put together this big bundle of Swift generics? We’ll break that down in this part, and adapt our code to fit the new declaration. By the end, we will have a .cache() method that will cache the results of our operation called on the upstream inputs, whatever the input and output types are. Put on your generics caps, and let’s start.

Building a Caching Custom Combine Operator: The Chain

Combine provides a wide variety of Publishers that can be combined to fulfill a wide variety of use cases. But as with any framework, you may find at some point that you need something custom that Combine does not provide out of the box.

You may have your own custom operation or you may want to wrap another Combine chain in a convenient wrapper. But how? If you look at Apple’s documentation for even how to add a method to the Publisher as an extension, the signature looks something like this:

😖

The steps are a little complicated, because Combine heavily uses a couple of features of Swift to perform its magic, but once you’ve followed along with an example, those difficult steps become much easier to apply to your own unique case.

Over the next several articles, we’ll trace through the development of a caching operator, which will take an upstream input and a Combine chain that performs the operation we’d like to cache, running the operation if the upstream value received hasn’t been seen before, or sending the cached result if it has. In part 1, we will build out the Combine publisher chain that implements the cache operation. In part 2, we’ll learn how we can make it so that somebody can use upstream.cache(...) to add our operator to their own Combine chains. Part 3 will cover taking our cache operator and turning it into a full-blown Publisher type. Part 4 will move away from Combine and talk about how to package that code into a Swift package that can be easily downloaded and included in your own, and everybody else’s app.

Lots to cover, so let’s get going!

Save that sink! A simple solution to a common Combine problem

So you’re starting to work with Combine, because Apple is finally jumping on the reactive programming bandwagon (yay!). So you build your first Combine workflow, and it looks something like this:

func retrieveData() {
    URLSession.shared.dataTaskPublisher(for: url)
      .map({$0.data})
      .eraseToAnyPublisher()
      .sink(receiveCompletion: { (status) in
        switch status {
        case .failure(let error):
          print(error.localizedDescription)
        case .finished:
          break
        }
      })
      { (data) in
        let str = String(data: data, encoding: .utf8)
        print(str!)
      }
}

And then you try and use it, and instead of getting the data response you were expecting, you get an error:

2019-10-25 14:59:34.452071-0400 TestApp[2127:98883] Task <663D6D3A-48B8-49E6-9103-AA1D89513D84>.<1> finished with error [-999] Error Domain=NSURLErrorDomain Code=-999 "cancelled" …

Cancelled? Why did my request get cancelled?

The key in the above code is that you missed something important. I made this exact same mistake when I started using Combine, and sadly I don’t think it will be the last time, because it is just that easy to miss. Let’s talk about the solution, and why you get the error when you don’t do it.

Restoring your sanity reading JSON into Codables

Integrating your app with APIs means you’re going to be dealing with parsing JSON in data objects you can use nicely. iOS provides the Codable protocol to do just that thing, but once you start trying to map JSON to that well-formed protocol, that’s when things fall apart.

JSON doesn’t have standard schemas (at least none that were ever seriously adopted), so you’re stuck with reading documentation like this for the details:

😒 Great, that’s useful… One example response, no field descriptions, types, anything. (And before you ask, this is the GitHub API documentation, so not even large organizations are immune to this problem.)

So you code your app off of these examples, then when you try and use it in the wild, you get:

I followed their documentation, and it didn’t work. How the heck am I ever going to figure out how to make this API JSON into a nice, easy to use Swift struct?!

Thankfully, there are some tricks — and one tool — you can use to make your job a lot easier. We’ll start by deciphering those error messages above, and how to figure out what error they are trying to point out. Then we’ll cover a procedure I like to use whenever I encounter one of these errors to fix them, and even use to avoid these errors in the first place. Let’s dive in.

I create a batch of CloudKit records, but when I try to sort them by date, they’re all jumbled?

If your app stores user data in a database, there are a number of occasions where you may need to make changes to multiple objects at once. The most likely scenario is you are loading the initial database state for a new user as part of the onboarding process, but it may also come at times where a user modifies a bunch of records at once, or an app upgrade requires the app to modify those values somehow.

If you’re using CloudKit as your database, you follow the common advice: per Apple’s recommendation, and for the sake of performance, you want to make as few of these CloudKit operations as possible, so you create or modify records in one batch, using a CKModifyRecordsOperation.

But then if you want to sort these records in order of when they were created, or last modified, using a creationDate or modificationDate NSSortDescriptor, those records are out of order. You inserted them 1-2-3-4-5, but you get 2-4-3-5-1.

Even worse, every time you ask for those records, the order is different. So the next time, it’s 3-1-2-5-4.

I added the records in the order I wanted them; why is CloudKit messing up the order?

There are two things happening that cause this behavior: one of them is specific to CloudKit’s batch creation and modification, and one of them is common behavior across many database systems, including CloudKit. We’ll dig into both issues, and then talk about a couple of ways you can make your records sort by date cleanly.