Saving business logic in Swift Combine. Part 1

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 filteras map, dropand scan. In addition, we have presented a few operators that connect ( Zipand CombineLatest) or unify ( Mergeand 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 StreamViewsigns 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 JSONEncoderand 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<T: Codable>: Codable {
 let value: T
 var operators: [Operator]
}

StreamItemincludes 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 StreamItemcomply 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 StreamItemto StreamModel.

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

StreamModelcontains an array of StreamItems. StreamModelalso has identifier, name and description properties. Again, everything in StreamModelshould be codable for storage and distribution.

Create an operator structure


As we mentioned earlier, operators delaycan change the transmission time StreamItem.

enum Operator {
 case delay(seconds: Double)
}

We consider the operator delayas an enumeration ( enum) with one associated value in order to store the delay time.

Of course, the enumeration Operatormust 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.

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<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 StreamModelwe 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 appendto 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 StreamItemwith 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".

Source: https://habr.com/ru/post/undefined/


All Articles