Saving business logic in Swift Combine

Data Oriented Combine


Translation of the article was prepared especially for students. advanced course “iOS Developer”.


In the previous series of posts we have 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 as filter, map, drop and scan. In addition, we introduced several more operators that connect (Zip and CombineLatest) or unify (Merge and Append) sequences.

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 repository combine-magic-swiftui 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 StreamViewas 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 the 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. 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.

  1. A stream is not the smallest unit in a structure. The smallest is the value of the stream.
  2. 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: 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 a structure so that and value, and StreamItem conform to 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 in StreamModel.

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

StreamModel contains an array StreamItems. StreamModel also has identifier, name and description properties. Again, everything in StreamModel must 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 are considering the operator delay as an enumeration (enum) with one associated value to store the delay time.

Listing, of course Operator must also match CodableThat 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.

l

et 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(_ publisher: AnyPublisher) -> AnyPublisher {
  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 {
   var publisher: AnyPublisher =
                  Just(value).eraseToAnyPublisher()
   self.operators.forEach {
      publisher = $0.applyPublisher(publisher)
   }
  return publisher
}
}

We start with value Just, generalize it using the method eraseToAnyPublisher, and then use publishers from all related operators.

At the level StreamModel we get the publisher of the whole stream.

extension StreamModel {
 func toPublisher() -> AnyPublisher {
   let intervalPublishers =
        self.stream.map { $0.toPublisher() }
   var publisher: AnyPublisher?
   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 unite 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) Last but not least, we can now simply edit StreamModeladd additional StreamItem with new values ​​and even share this model with other devices over 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 repository. combine-magic-swifui in the combine-playground folder.

We are waiting for your comments and invite you to open webinar on the topic “iOS-application on SwiftUI using Kotlin Mobile Multiplatform”.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *