Persist Business Logic With Swift Combine

Data-driven Combine

Kevin Cheng
Dec 31, 2019 · 6 min read

In the previous series, we successfully built this platform on top of SwiftUI, where you can freely observe the sequences of values flowing through the Combine publisher.

We’ve also created examples demonstrating a handful of default Combine operators that are capable of changing and transforming values within sequences, such as filter, map, drop, and scan. Moreover, we introduced a couple of more operators that join (Zip and CombineLatest) or unify (Merge and Append) sequences.

At this point, some of you might be a little sick of having to manage or maintain so much code for each of the examples (at least I am). See how many of them are in this combine-magic-swiftui repo under the tutorial folder? Each of the examples are SwiftUI views. Each one simply feeds one or a few publishers to the StreamView, and StreamView subscribes the publishers when the subscribe button is tapped.

So I should be able to programmatically generate a list of publishers on the app’s start and reuse StreamView, like in the screenshot below.

However, the problem with this solution is scalability when there are a lot of publishers to create.

My solution to this issue is I have to somehow persist these publishers. If I can somehow serialize these publishers, I’ll be able to persist them. If I manage to persist them, not only will I be able to modify them without changing code, I’ll also be able to share them with other devices that support Combine.


Persist and Transfer Combine Operators

Obviously, we’d also need to be able to convert the stored data back to the publisher, but moreover, we want to be able to share, transfer, and distribute these publishers with operators from one place to another.

Once we’ve set up this kind of structure, as you’ve probably already pictured, in a distributed environment, a centralized service can start driving computing logics for a bunch of clients.


Codable Structure

Before we move forward, in order to understand what components are needed for the structures we’re about to create, let’s recap a basic stream we’ve built from the previous series.


Stream of Numbers

This is the most straightforward stream; however, if you look deeper you’ll observe this isn’t just a simple sequence of an array. Each of the circular boxes has its own delay operator that drives the actual timing when it should be emitted. Each value in Combine looks like:

Just(value).delay(for: .seconds(1), scheduler: DispatchQueue.main)

And the entire thing looks like:

let val1 = Just(1).delay(for: .seconds(1), scheduler:   DispatchQueue.main)
let val2 = Just(2).delay(for: .seconds(1), scheduler: DispatchQueue.main)
let val3 = ....
let val4 = ....
let publisher = val1.append(val2).append(val3).append(val4)

Each value delays a second, and the next value has the same delay operator appended.

Therefore, we learn two things here from observations.

  1. The stream isn’t the smallest unit in the structure. The stream value is.
  2. Each stream value can have unlimited operators that manipulate what and when a value is being emitted.

Create Your StreamItem

struct StreamItem<T: Codable>: Codable {  let value: T  var operators: [Operator]}

StreamItem includes a stream value and an array of operators. As per our requirements, we want to be able to persist everything in the structure so both value and StreamItem conform to the Codable protocol.

The stream value needs to be generic in order to accommodate values in any type.


Create Your StreamModel

struct StreamModel<T: Codable>: Codable, Identifiable {  var id: UUID  var name: String?  var description: String?  var stream: [StreamItem<T>]}

StreamModel holds an array of StreamItem(s). StreamModel also has an ID, name, and description properties for identifying and descriptive purposes. Again, everything within StreamModel has to be Codable for persistence and distribution.


Create Operator Structure

enum Operator {
case delay(seconds: Double)
}

We treat the delay operator as an enum with one associated value to persist the delay time.

Certainly, the perator enum also needs to conform to Codable, which includes encoding and decoding the underline associate values. See the complete implementation below.

Now, we have a good structure to represent this serial stream that emits values from 1 to 4 with a second delay interval.

let streamA = (1...4).map { StreamItem(value: $0,operators: [.delay(seconds: 1)]) }let serialStreamA = StreamModel(id: UUID(), name: "Serial Stream A",description: nil, stream: streamA)

Convert the StreamModel to Publisher

First of all, each operator model links to an actual Combine operator that should add to a given publisher and return the operated publisher.

extension Operator {func applyPublisher<T>(_ publisher: AnyPublisher<T, Never>) -> AnyPublisher<T, Never> {  switch self {
case .delay(let seconds):
return publisher.delay(for: .seconds(seconds), scheduler: DispatchQueue.main).eraseToAnyPublisher()
}
}
}

There is only one type of operator, delay, for now. We’ll add more as we go.

Now we can start applying publishers to each StreamItem.

extension StreamItem {  func toPublisher() -> AnyPublisher<T, Never> {    var publisher: AnyPublisher<T, Never> =
Just(value).eraseToAnyPublisher()
self.operators.forEach {
publisher = $0.applyPublisher(publisher)
}
return publisher
}
}

We start with a Just value, generalize it with the eraseToAnyPublisher method, and then apply publishers from all the associated operators.

On the StreamModel level, this is how we get the entire stream’s publisher.

extension StreamModel {  func toPublisher() -> AnyPublisher<T, Never> {    let intervalPublishers = 
self.stream.map { $0.toPublisher() }
var publisher: AnyPublisher<T, Never>? for intervalPublisher in intervalPublishers { if publisher == nil {
publisher = intervalPublisher
continue
}
publisher =
publisher?.append(intervalPublisher).eraseToAnyPublisher()
}
return publisher ?? Empty().eraseToAnyPublisher()
}
}

You guessed right: We use the append method to concatenate publishers together.


Visualize Stream, Edit, and Visualize Again

See the demo below. We’re now able to make changes of the stream without changing code.


Next Chapter: Serialize/Deserialize Filter and Map Operators

Until next time, you can find the source code here in this combine-magic-swifui repo under the combine-playground folder.

Better Programming

Advice for programmers.

Kevin Cheng

Written by

iOS engineer / entrepreneur / freelancer / San Francisco

Better Programming

Advice for programmers.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade