Preventing Accumulating Combine Publishers
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.
Note: This is a general concept that isn’t specific to Ditto, but really towards all applications that might use Combine or RxSwift.
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 fromAS
toAS
, it is fired only once. But if the value goes fromAS
toLH
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 aliveQueryPublisher
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 theliveQueryPublisher
and decode them to Flight. - Instead of using .
sink
, we.assign
theflights: [Flight]
to theself.flights
variable. - Finally, we store the entire chain of commands as
Set<AnyCancellable>()
.
The summary:
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
.
Was this page helpful?