Preventing Accumulating Combine Publishers
Note: This is a general concept that isn't specific to Ditto, but really towards all applications that might use Combine or RxSwift.
A common issue we see in reactive apps is a failure to dispose of resources as conditions change. Perhaps your app sees a large accumulation of publishers that infinitely grow. Let's take a look at this common pitfall in a simple example.
Let's say we have a collection of flights and each document has a structure like this:
Creating a Memory Leak with Combine
Now our application code has a ViewModel that creates live queries based on a change of the carrier property.
Each time the carrier changes, we register a new liveQueryPublisher with the args: ["carrier": carrier] query argument.
Like all good Combine best practices, we store the publisher references in a var cancellables = Set<AnyCancellables>().
Here's our problem, each time we change the carrier to a new value, you will notice that your app's memory usage will gradually rise. It will be even worse if new documents change while this happens. Why is this?
Our app is to filter the @Published var flights: [Flight] variable to match the current carrier.
However, each time the carrier changes, we are registering a new liveQueryPublisher without disposing of the previous live query. There lies the root cause of the accumulation of publishers.
A Hacky Fix
A hacky way to fix this is by storing the liveQueryPublisher() into another set of cancelables and disposing of them whenever the carrier changes.
Do not include a .sink within .sink.
Hooray! This solves the problem! You'll notice that there is now only 1 live query regardless of the carrier changes. However, this has made the application more complex and hard to read, as every sink requires managing cancellable states. If you add more live queries this gets complicated quite fast.
The Preferred Approach
How do we maintain only liveQueryPublisher even if the carrier changes in the cleanest Combine-friendly way? Let's see:
Let's review what we did (see the code comments for the correlated bullet numbers):
- We observe the carrier, but only if the values change by using removeDuplicates(). This means if the carrier value goes from AS to AS, it is fired only once. But if the value goes from AS to LH it's fired twice. It will compare the new and the old value and emit once the values are different.
- Once we get the carrier, we .map it to a liveQueryPublisher while feeding in the query arguments. Note that this .map isn't returning documents, but a Publisher. This key difference is important in step 3.
- The next operator is the most critical, we add a switchToLatest(). This operator will switch the chain of operators to the latest publisher (from the last .map). Any previous publisher will be disposed of or canceled. This is the operator that does all the work!
- We can now .map the docs results of the liveQueryPublisher and decode them to Flight.
- Instead of using .sink, we .assign the flights: [Flight] to the self.flights variable.
- Finally, we store the entire chain of commands as Set<AnyCancellable>().
Use .map to return a Publisher and follow it immediately with switchToLatest() to ensure the publishers do not accumulate and that only one is registered down the chain of operators. Using both is not only helpful with Ditto-based apps but also any application that uses Combine. This will prevent quite a lot of memory leaks in the future.
If you are familiar with RxSwift, .map + switchToLatest is the same as flatMapLatest.