Skip to content

Latest commit

 

History

History
401 lines (305 loc) · 14.4 KB

Traits.md

File metadata and controls

401 lines (305 loc) · 14.4 KB

Traits (formerly Units)

This document will try to describe what traits are, why they are a useful concept, and how to use and create them.

General

Why

Swift has a powerful type system that can be used to improve the correctness and stability of applications and make using Rx a more intuitive and straightforward experience.

Traits help communicate and ensure observable sequence properties across interface boundaries, as well as provide contextual meaning, syntactical sugar and target more specific use-cases when compared to a raw Observable, which could be used in any context. For that reason, Traits are entirely optional. You are free to use raw Observable sequences everywhere in your program as all core RxSwift/RxCocoa APIs support them.

Note: Some of the Traits described in this document (such as Driver) are specific only to the RxCocoa project, while some are part of the general RxSwift project. However, the same principles could easily be implemented in other Rx implementations, if necessary. There is no private API magic needed.

How they work

Traits are simply a wrapper struct with a single read-only Observable sequence property.

struct Single<Element> {
    let source: Observable<Element>
}

struct Driver<Element> {
    let source: Observable<Element>
}
...

You can think of them as a kind of builder pattern implementation for Observable sequences. When a Trait is built, calling .asObservable() will transform it back into a vanilla observable sequence.


RxSwift traits

Single

A Single is a variation of Observable that, instead of emitting a series of elements, is always guaranteed to emit either a single element or an error.

  • Emits exactly one element, or an error
  • Doesn't share side effects

One common use case for using Single is for performing HTTP Requests that could only return a response or an error, but a Single can be used to model any case where you only care for a single element, and not for an infinite stream of elements.

Creating a Single

Creating a Single is similar to creating an Observable. A simple example would look like this:

func getRepo(_ repo: String) -> Single<[String: Any]> {
    return Single<[String: Any]>.create { single in
        let task = URLSession.shared.dataTask(with: URL(string: "https://api.github.com/repos/\(repo)")!) { data, _, error in
            if let error = error {
                single(.error(error))
                return
            }

            guard let data = data,
                  let json = try? JSONSerialization.jsonObject(with: data, options: .mutableLeaves),
                  let result = json as? [String: Any] else {
                single(.error(DataError.cantParseJSON))
                return
            }

            single(.success(result))
        }

        task.resume()

        return Disposables.create { task.cancel() }
    }
}

After which you could use it in the following way:

getRepo("ReactiveX/RxSwift")
    .subscribe { event in
        switch event {
            case .success(let json):
                print("JSON: ", json)
            case .error(let error):
                print("Error: ", error)
        }
    }
    .disposed(by: disposeBag)

Or by using subscribe(onSuccess:onError:) as follows:

getRepo("ReactiveX/RxSwift")
    .subscribe(onSuccess: { json in
                   print("JSON: ", json)
               },
               onError: { error in
                   print("Error: ", error)
               })
    .disposed(by: disposeBag)

The subscription provides a SingleEvent enumeration which could be either .success containing a element of the Single's type, or .error. No further events would be emitted beyond the first one.

It's also possible using .asSingle() on a raw Observable sequence to transform it into a Single.

Completable

A Completable is a variation of Observable that can only complete or emit an error. It is guaranteed to not emit any elements.

  • Emits zero elements
  • Emits a completion event, or an error
  • Doesn't share side effects

A useful use case for Completable would be to model any case where we only care for the fact an operation has completed, but don't care about a element resulted by that completion. You could compare it to using an Observable<Void> that can't emit elements.

Creating a Completable

Creating a Completable is similar to creating an Observable. A simple example would look like this:

func cacheLocally() -> Completable {
    return Completable.create { completable in
       // Store some data locally
       ...
       ...

       guard success else {
           completable(.error(CacheError.failedCaching))
           return Disposables.create {}
       }

       completable(.completed)
       return Disposables.create {}
    }
}

After which you could use it in the following way:

cacheLocally()
    .subscribe { completable in
        switch completable {
            case .completed:
                print("Completed with no error")
            case .error(let error):
                print("Completed with an error: \(error.localizedDescription)")
        }
    }
    .disposed(by: disposeBag)

Or by using subscribe(onCompleted:onError:) as follows:

cacheLocally()
    .subscribe(onCompleted: {
                   print("Completed with no error")
               },
               onError: { error in
                   print("Completed with an error: \(error.localizedDescription)")
               })
    .disposed(by: disposeBag)

The subscription provides a CompletableEvent enumeration which could be either .completed - indicating the operation completed with no errors, or .error. No further events would be emitted beyond the first one.

Maybe

A Maybe is a variation of Observable that is right in between a Single and a Completable. It can either emit a single element, complete without emitting an element, or emit an error.

Note: Any of these three events would terminate the Maybe, meaning - a Maybe that completed can't also emit an element, and a Maybe that emitted an element can't also send a Completion event.

  • Emits either a completed event, a single element or an error
  • Doesn't share side effects

You could use Maybe to model any operation that could emit an element, but doesn't necessarily have to emit an element.

Creating a Maybe

Creating a Maybe is similar to creating an Observable. A simple example would look like this:

func generateString() -> Maybe<String> {
    return Maybe<String>.create { maybe in
        maybe(.success("RxSwift"))

        // OR

        maybe(.completed)

        // OR

        maybe(.error(error))

        return Disposables.create {}
    }
}

After which you could use it in the following way:

generateString()
    .subscribe { maybe in
        switch maybe {
            case .success(let element):
                print("Completed with element \(element)")
            case .completed:
                print("Completed with no element")
            case .error(let error):
                print("Completed with an error \(error.localizedDescription)")
        }
    }
    .disposed(by: disposeBag)

Or by using subscribe(onSuccess:onError:onCompleted:) as follows:

generateString()
    .subscribe(onSuccess: { element in
                   print("Completed with element \(element)")
               },
               onError: { error in
                   print("Completed with an error \(error.localizedDescription)")
               },
               onCompleted: {
                   print("Completed with no element")
               })
    .disposed(by: disposeBag)

It's also possible using .asMaybe() on a raw Observable sequence to transform it into a Maybe.


RxCocoa traits

Driver

This is the most elaborate trait. Its intention is to provide an intuitive way to write reactive code in the UI layer, or for any case where you want to model a stream of data Driving your application.

  • Can't error out
  • Observe occurs on main scheduler
  • Shares side effects (shareReplayLatestWhileConnected)

Why is it named Driver

Its intended use case was to model sequences that drive your application.

E.g.

  • Drive UI from CoreData model
  • Drive UI using values from other UI elements (bindings) ...

Like normal operating system drivers, in case a sequence errors out, your application will stop responding to user input.

It is also extremely important that those elements are observed on the main thread because UI elements and application logic are usually not thread safe.

Also, a Driver builds an observable sequence that shares side effects.

E.g.

Practical usage example

This is a typical beginner example.

let results = query.rx.text
    .throttle(0.3, scheduler: MainScheduler.instance)
    .flatMapLatest { query in
        fetchAutoCompleteItems(query)
    }

results
    .map { "\($0.count)" }
    .bind(to: resultCount.rx.text)
    .disposed(by: disposeBag)

results
    .bind(to: resultsTableView.rx.items(cellIdentifier: "Cell")) { (_, result, cell) in
        cell.textLabel?.text = "\(result)"
    }
    .disposed(by: disposeBag)

The intended behavior of this code was to:

  • Throttle user input
  • Contact server and fetch a list of user results (once per query)
  • Bind the results to two UI elements: results table view and a label that displays the number of results

So, what are the problems with this code?:

  • If the fetchAutoCompleteItems observable sequence errors out (connection failed or parsing error), this error would unbind everything and the UI wouldn't respond any more to new queries.
  • If fetchAutoCompleteItems returns results on some background thread, results would be bound to UI elements from a background thread which could cause non-deterministic crashes.
  • Results are bound to two UI elements, which means that for each user query, two HTTP requests would be made, one for each UI element, which is not the intended behavior.

A more appropriate version of the code would look like this:

let results = query.rx.text
    .throttle(0.3, scheduler: MainScheduler.instance)
    .flatMapLatest { query in
        fetchAutoCompleteItems(query)
            .observeOn(MainScheduler.instance)  // results are returned on MainScheduler
            .catchErrorJustReturn([])           // in the worst case, errors are handled
    }
    .shareReplay(1)                             // HTTP requests are shared and results replayed
                                                // to all UI elements

results
    .map { "\($0.count)" }
    .bind(to: resultCount.rx.text)
    .disposed(by: disposeBag)

results
    .bind(to: resultsTableView.rx.items(cellIdentifier: "Cell")) { (_, result, cell) in
        cell.textLabel?.text = "\(result)"
    }
    .disposed(by: disposeBag)

Making sure all of these requirements are properly handled in large systems can be challenging, but there is a simpler way of using the compiler and traits to prove these requirements are met.

The following code looks almost the same:

let results = query.rx.text.asDriver()        // This converts a normal sequence into a `Driver` sequence.
    .throttle(0.3, scheduler: MainScheduler.instance)
    .flatMapLatest { query in
        fetchAutoCompleteItems(query)
            .asDriver(onErrorJustReturn: [])  // Builder just needs info about what to return in case of error.
    }

results
    .map { "\($0.count)" }
    .drive(resultCount.rx.text)               // If there is a `drive` method available instead of `bindTo`,
    .disposed(by: disposeBag)              // that means that the compiler has proven that all properties
                                              // are satisfied.
results
    .drive(resultsTableView.rx.items(cellIdentifier: "Cell")) { (_, result, cell) in
        cell.textLabel?.text = "\(result)"
    }
    .disposed(by: disposeBag)

So what is happening here?

This first asDriver method converts the ControlProperty trait to a Driver trait.

query.rx.text.asDriver()

Notice that there wasn't anything special that needed to be done. Driver has all of the properties of the ControlProperty trait, plus some more. The underlying observable sequence is just wrapped as a Driver trait, and that's it.

The second change is:

.asDriver(onErrorJustReturn: [])

Any observable sequence can be converted to Driver trait, as long as it satisfies 3 properties:

  • Can't error out
  • Observe on main scheduler
  • Sharing side effects (shareReplayLatestWhileConnected)

So how do you make sure those properties are satisfied? Just use normal Rx operators. asDriver(onErrorJustReturn: []) is equivalent to following code.

let safeSequence = xs
  .observeOn(MainScheduler.instance)       // observe events on main scheduler
  .catchErrorJustReturn(onErrorJustReturn) // can't error out
  .shareReplayLatestWhileConnected         // side effects sharing
return Driver(raw: safeSequence)           // wrap it up

The final piece is using drive instead of using bindTo.

drive is defined only on the Driver trait. This means that if you see drive somewhere in code, that observable sequence can never error out and it observes on the main thread, which is safe for binding to a UI element.

Note however that, theoretically, someone could still define a drive method to work on ObservableType or some other interface, so to be extra safe, creating a temporary definition with let results: Driver<[Results]> = ... before binding to UI elements would be necessary for complete proof. However, we'll leave it up to the reader to decide whether this is a realistic scenario or not.

ControlProperty / ControlEvent

  • Can't error out
  • Subscribe occurs on main scheduler
  • Observe occurs on main scheduler
  • Shares side effects