Binder Architecture with RxSwift
A quick example of The Binder Architecture with RxSwift.
You would like to fetch a Feed
of Podcast
s from an API and display it in a view controller.
The model:
struct Podcast: Codable {
let id: Int
let title: String
}
struct Feed: Codable {
let podcasts: [Podcast]
}
Here is the fetch part:
enum APIError: Error {
case serverError
case parsingError
}
final class API {
func fetchPodcasts() -> Observable<Feed> {
return Observable.create { observer in
let url = URL(string: "http://localhost:9000/podcasts-feed")!
let task = URLSession.shared.dataTask(with: url) {
data, response, error in
guard let data = data, error == nil else {
observer.onError(APIError.serverError)
return
}
do {
let feed = try JSONDecoder().decode(Feed.self, from: data)
observer.onNext(feed)
observer.onCompleted()
} catch let error {
observer.onError(APIError.parsingError)
}
}
task.resume()
return Disposables.create {
task.cancel()
}
}
}
}
fetchPodcasts
vends a basic observable. that when subscribed to will send the fetched feed and then complete itself, or it will send an error and then terminate.
But how do you hook this up to a ViewController
? Does every controller create its own subscription on viewDidLoad
?
We want the view controller to only specify the the views and the business logic (the model, if you will) it is responsible for. It would be nice to keep the binding of the V and the M separate from the controller lifecycle, as much as possible. This gives us the guarantee that the binding is deterministic, declarative, and will not being changed with the controller's lifecycle.
class ViewController: UIViewController {
// specifying the "model"
let feed = Variable<Feed>(Feed(podcasts: []))
// specifying the view
@IBOutlet weak var tableView: UITableView!
// specifying some binding helpers
let disposeBag = DisposeBag()
let didAppear = Variable<Void>(())
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// let the service know we are ready
didAppear.value = ()
}
override func viewDidLoad() {
super.viewDidLoad()
// set up our view
tableView.dataSource = self
}
}
// Work with UIKit. This could be abstracted out to a "service" too.
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return feed.value.podcasts.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
let podcast = feed.value.podcasts[indexPath.item]
cell.textLabel?.text = podcast.title
return cell
}
}
Then, we may have a relevant service for this part of our app:
class Service {
let api = API()
}
To actually do the binding, we can keep it anywhere, but might as well in a static function of the controller:
extension ViewController {
class func make(service: Service) -> UIViewController {
let controller = ViewController()
controller.didAppear
.asObservable()
.bind {
let fetch = service.api.fetchPodcasts()
.share(replay: 1, scope: .forever)
fetch.bind(to: controller.feed)
.disposed(by: controller.disposeBag)
fetch.asObservable()
.observeOn(MainScheduler.instance)
.subscribe(onNext: { _ in controller.tableView.reloadData() })
.disposed(by: controller.disposeBag)
}.disposed(by: controller.disposeBag)
return controller
}
}
Then in the AppDelegate
:
let window = UIWindow()
window.rootViewController = ViewController.make(service: Service())
self.window = window
window.makeKeyAndVisible()
I like this architecture a lot. It does rely heavily on FRP, so may not be a great fit for all codebases. But you can happily apply it in small parts to see how it works for you.