A responsive programming library is implemented using Swift

  • 2020-06-07 05:23:58
  • OfStack

preface

I developed entirely using Swift throughout 2017. Developing with Swift was such an enjoyable experience that I didn't want to touch OC at all. Recently I wanted to do a library for responsive programming, so I Shared it.

In the absence of good resources, learning responsive programming can be a pain. When I started learning, I did all kinds of tutorials. It turns out that only a small fraction of what is useful, and a small fraction of what is superficial, does not add much to the overall architecture understanding.

Reactive Programing

When it comes to responsive programming, ReactiveCocoa and RxSwift are arguably the third best open source libraries in iOS development today. Instead of talking about ReactiveCocoa and RxSwif today, let's write our own responsive programming library. Responsive programming is easy to understand if you are familiar with observer patterns.

Responsive programming is a programming paradigm for data flow and change propagation.

User input, click events, variable values, and so on can be viewed as a stream, and you can observe this stream and do something based on it. The act of "listening on" a stream is called subscribing. The response is based on this idea.

Without further ado, roll up your sleeves and get to work.

Let's take a network request for user information as an example:


func fetchUser(with id: Int, completion: @escaping ((User) -> Void)) {
 DispatchQueue.main.asyncAfter(deadline: DispatchTime.now()+2) {
  let user = User(name: "jewelz")
  completion(user)
 }
}

As usual, we pass in a callback function in the request method and get the result in the callback. In the response, we listen for the request, and when the request is complete, the observer gets updated.


func fetchUser(with id: Int) -> Signal {}

Sending a network request can look like this:


fetchUser(with: "12345").subscribe({ 
})

Before completing Signal, we need to define the data structure that will be returned after subscription. Here, I only care about the data in the success and failure states, so I can write like this:


enum Result {
 case success(Value)
 case error(Error)
}

Now we are ready to implement our Signal:


final class Signal {
 fileprivate typealias Subscriber = (Result) -> Void
 fileprivate var subscribers: [Subscriber] = [] 
 func send(_ result: Result) {
 for subscriber in subscribers {
  subscriber(result)
 }
 } 
 func subscribe(_ subscriber: @escaping (Result) -> Void) {
 subscribers.append(subscriber)
 }
}

Write a quick example for test 1:


let signal = Signal()
signal.subscribe { result in
 print(result)
}
signal.send(.success(100))
signal.send(.success(200))
// Print
success(100)
success(200)

Our Signal is working, but there is still a lot of room for improvement. We can use the 1 factory method to create 1 Signal and make send private:


static func empty() -> ((Result) -> Void, Signal) {
 let signal = Signal()
 return (signal.send, signal)
}
fileprivate func send(_ result: Result) { ... }

Now we need to use Signal like this:


let (sink, signal) = Signal.empty()
signal.subscribe { result in
 print(result)
}
sink(.success(100))
sink(.success(200))

Then we can bind Signal to UITextField by adding one calculation attribute to UITextField in Extension:


extension UITextField {
 var signal: Signal {
 let (sink, signal) = Signal.empty()
 let observer = KeyValueObserver(object: self, keyPath: #keyPath(text)) { str in
  sink(.success(str))
 }
 signal.objects.append(observer)
 return signal
 }
}

observer in the above code is a local variable, which will be destroyed after the call of signal, so the object needs to be saved in Signal, and an array can be added to Signal to save the object that needs to extend its life cycle. KeyValueObserver is a simple encapsulation of KVO. Its implementation is as follows:


final class KeyValueObserver: NSObject {
 
 private let object: NSObject
 private let keyPath: String
 private let callback: (T) -> Void 
 init(object: NSObject, keyPath: String, callback: @escaping (T) -> Void) {
 self.object = object
 self.keyPath = keyPath
 self.callback = callback
 super.init()
 object.addObserver(self, forKeyPath: keyPath, options: [.new], context: nil)
 } 
 override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
 guard let keyPath = keyPath, keyPath == self.keyPath, let value = change?[.newKey] as? T else { return } 
 callback(value)
 } 
 deinit {
 object.removeObserver(self, forKeyPath: keyPath)
 }
}

You can use it now textField.signal.subscribe({}) Observe how UITextField has changed.

Write an VC test 1 in Playground:


func fetchUser(with id: Int) -> Signal {}
0

Reference Cycles

I added the deinit method in Signal above:


func fetchUser(with id: Int) -> Signal {}
1

Finally, it is found that Signal's destructor method is not executed, which means there are circular references in the above code. In fact, a careful analysis of the implementation of signal in the above extension of UITextField can reveal the problem.


func fetchUser(with id: Int) -> Signal {}
2

In the callback to KeyValueObserver, called sink() Method, and the sink method is signal.send(_:) Method, where the signal variable is captured in the closure, thus forming a circular reference. This is done using weak. The modified code looks like this:


static func empty() -> ((Result) -> Void, Signal) {
 let signal = Signal()
 return ({[weak signal] value in signal?.send(value)}, signal)
}

Run it again and the destructor method of Signal is ready to execute.

The above implementation of a simple responsive programming library. However, there are a lot of problems, such as we should remove the observer at the appropriate time, now our observer is added to the subscribers array, so we don't know which observer to remove, so we replace the number with the dictionary and use UUID as key:


func fetchUser(with id: Int) -> Signal {}
4

We can imitate Disposable in RxSwift to remove the observer. The implementation code is as follows:


func fetchUser(with id: Int) -> Signal {}
5

The original subscribe(_:) returns 1 Disposable:


func fetchUser(with id: Int) -> Signal {}
6

In this way, we can remove the observer by destroying Disposable at the appropriate time.

As a responsive programming library will have map, flatMap, filter, reduce and other methods, so our library is not less, we can simply implement several.

map

map is a simple process of applying a function that returns a wrapper value to a wrapper (Wrapped) value, which can be understood as a structure that can contain other values, such as arrays in Swift, where optional types are wrapper values. They all have overloaded map, flatMap, etc. In the case of arrays, we often use:


func fetchUser(with id: Int) -> Signal {}
7

Now let's implement our map function:


func map(_ transform: @escaping (Value) -> T) -> Signal {
 let (sink, signal) = Signal.empty()
 let dispose = subscribe { (result) in
  sink(result.map(transform))
 }
 signal.objects.append(dispose)
 return signal
}

I also implemented the map function for Result:


func fetchUser(with id: Int) -> Signal {}
9

flatMap

flatMap and map are similar, but there are some differences. For example, Swif t defines map and flatMap as follows:


public func map(_ transform: (Wrapped) throws -> U) rethrows -> U?
public func flatMap(_ transform: (Wrapped) throws -> U?) rethrows -> U?

The difference between flatMap and map is mainly reflected in the return value of the transform function. map accepts a function return type of U and flatMap accepts a function return type of U?. Type. For example, for an optional value, you can call:


let aString: String? = " RMB 99.9"
let price = aString.flatMap{ Float($0)}
// Price is nil

Here we maintain 1 row for arrays and selectable flatMap in flatMap and Swift.

So our flatMap should be defined as: flatMap(_ transform: @escaping (Value) - > Signal) - > Signal.

Understanding the difference between flatMap and map makes it easy to implement:


func flatMap(_ transform: @escaping (Value) -> Signal) -> Signal {
 let (sink, signal) = Signal.empty()
 var _dispose: Disposable?
 let dispose = subscribe { (result) in
  switch result {
  case .success(let value):
  let new = transform(value)
  _dispose = new.subscribe({ _result in
   sink(_result)
  })
  case .error(let error):
  sink(.error(error))
  }
 }
 if _dispose != nil {
 signal.objects.append(_dispose!)
 }
 signal.objects.append(dispose)
 return signal
}

Now we can simulate 1 network request to test flatMap:


fetchUser(with: "12345").subscribe({ 
})
3

Using flatMap, we can easily convert one Signal to another Signal, which is handy when we are dealing with multiple request nesting.

Write in the last

Above through more than 100 lines of code to implement a simple responsive programming library. However, this is not enough for one library. Signal is not atomic yet and should be thread-safe to be an actual library. In addition, our treatment of Disposable is not elegant enough. We can imitate the practice of DisposeBag in RxSwift. These questions can be left to the reader's own reflection.


Related articles: