This document will try to describe what traits are, why they are a useful concept, and how to use and create them.
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.
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.
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 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.
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 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.
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 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.
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
)
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.
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.
- Can't error out
- Subscribe occurs on main scheduler
- Observe occurs on main scheduler
- Shares side effects