Data Oriented Combine
The translation of the article was prepared especially for students of the advanced course "iOS Developer" .
In the previous series of posts, we successfully built a platform on top of SwiftUI, with which you can freely observe the sequence of values passing through publisher Combine.We also created a series of examples demonstrating several Combine default operators that are capable of modifying and converting values in sequences such filter
as map
, drop
and scan
. In addition, we have presented a few operators that connect ( Zip
and CombineLatest
) or unify ( Merge
and Append
) sequence.At this point, some of you might be tired of having to organize and maintain so much code for each example (at least I'm already tired). See how many of them are in the combine-magic-swiftui repository in the tutorial folder? Each of the examples is a representation of SwiftUI. Each of them simply transfers one or several publishers to StreamView
, and StreamView
signs publishers at the click of a button.Therefore, I should be able to programmatically generate a list of publishers at application startup and reuse StreamView
, as in the screenshot below.
However, the problem with this solution is scalability, when you need to create many publishers.My solution to this problem is to somehow store these publishers. If I can serialize them somehow, I can save them. If I manage to save them, I can not only modify them without changing the code, but I can also share them with other devices that support Combine .Storage and transfer of operators Combine
Now let's look at our goals here more specifically. Since we have a list of streams and operators in a format Publisher
, we would like to be able to save them in any kind of storage - for example, on a hard disk or in a database.Obviously, we also need to be able to convert the stored data back to the publisher, but in addition, we want to be able to exchange, transfer and distribute these publishers with operators from one place to another.After we set up such a structure, as you might have guessed, in a distributed environment, a centralized service can begin to manage the computational logic for a group of clients.
Codable Structure
So how do we do this? We will start by developing a structure that is serializable and deserializable. The Swift protocol Codable
allows us to do this through JSONEncoder
and JSONDecoder
. Moreover, the structure must correctly represent data and behaviors for the smallest unit of value in the stream, up to complex chains of operators.Before moving on to understand what components are necessary for the structures that we are going to create, let's recall the main stream that we created in the previous series of posts .Stream of numbers
This is the easiest stream; however, if you look deeper, you will notice that this is not just a sequence of arrays. Each of the round blocks has its own delay operator ( delay
), which determines the actual time when it should be transmitted. Each value in Combine looks like this:Just(value).delay(for: .seconds(1), scheduler: DispatchQueue.main)
And in general, it all looks like this: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 is delayed for a second, and the same statement is added to the next value delay
.Therefore, we learn two things from our observations.- A stream is not the smallest unit in a structure. The smallest is the value of the stream.
- Each stream value can have unlimited operators that control when and what value is transmitted.
Create your StreamItem
Since the value of the stream and its operators are the smallest unit, we begin by creating its structure. Let's call her StreamItem
.struct StreamItem<T: Codable>: Codable {
let value: T
var operators: [Operator]
}
StreamItem
includes the value of the stream and an array of operators. According to our requirements, we want to be able to preserve everything in the structure so that both value
, and StreamItem
comply with the protocol Codable
.The stream value must be universal to accommodate any type of value.Create your StreamModel
We will discuss the structure for operators later. Let's connect the array StreamItem
to StreamModel
.struct StreamModel<T: Codable>: Codable, Identifiable {
var id: UUID
var name: String?
var description: String?
var stream: [StreamItem<T>]
}
StreamModel
contains an array of StreamItem
s. StreamModel
also has identifier, name and description properties. Again, everything in StreamModel
should be codable for storage and distribution.Create an operator structure
As we mentioned earlier, operators delay
can change the transmission time StreamItem
.enum Operator {
case delay(seconds: Double)
}
We consider the operator delay
as an enumeration ( enum
) with one associated value in order to store the delay time.Of course, the enumeration Operator
must also match Codable
, which includes encoding and decoding related values. See full implementation below.enum Operator {
case delay(seconds: Double)
}
extension Operator: Codable {
enum CodingKeys: CodingKey {
case delay
}
struct DelayParameters: Codable {
let seconds: Double
}
enum CodingError: Error { case decoding(String) }
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let delayParameters = try? container.decodeIfPresent(DelayParameters.self, forKey: .delay) {
self = .delay(seconds: delayParameters.seconds)
return
}
throw CodingError.decoding("Decoding Failed. \(dump(container))")
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .delay(let seconds):
try container.encode(DelayParameters(seconds: seconds), forKey: .delay)
}
}
}
We now have a good structure to represent this sequential stream, which generates 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 StreamModel to Publisher
Now we have created an instance of the stream; however, if we do not convert it into a publisher, everything will be meaningless. Let's try.First of all, each operator model refers to the actual Combine operator, which should be added to this publisher and returned to 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()
}
}
}
At the moment there is only one type of operator - delay
. We will add more as we go.Now we can start using publishers for everyone 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 the value Just
, generalize it using the method eraseToAnyPublisher
, and then use the publishers from all related operators.At the level StreamModel
we get the publisher of the entire stream.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 it right: we use the method append
to combine publishers.Visualization, editing and again visualization of a stream
Now we can simply decode the publisher, transfer and create a StreamView (see how we did it in previous posts ). And last but not least: now we can simply edit StreamModel
, add additional ones StreamItem
with new values and even share this model with other devices via the Internet.See the demo below. Now we can make changes to the stream without changing the code.
Next chapter: Serializing / Deserializing Filters and Map Operators
In the next part, we are going to add more operators to the operator enumeration and start applying them at the thread level.Until next time, you can find the source code here in this combine-magic-swifui repository in the combine-playground folder.We are waiting for your comments and invite you to an open webinar on the topic "iOS-application on SwiftUI using Kotlin Mobile Multiplatform".