Skip to content

Chapter 2. Drive.

Dmitriy Shulzhenko edited this page Oct 11, 2020 · 25 revisions

State

Let's begin with the State. It can be a shared app component state or a simple view state.

I will not explain why state management is important to write better organised code. Here I will focus on how I usually use it.

It is not possible to achieve perfect characteristics all of the times due to legacy Apple patters, but I hope that things will change over time with the help of Combine.

Rules for "perfect" State. :

  • It is Observable. Every other component knows about State changes and can react immediately - perform actions or side effects
  • It is immutable for the outside world. This means that other parts can only read it, observe and react, not to change directly.

Here is simple possible definition of State

import RxSwift
import RxCocoa

protocol BookDetailState {
    var data: Driver<BookDetailData> { get }
    var close: Signal<Void> { get }
}

Where data is used to display some cells and close as navigation trigger.

Action

State must be changed only through Actions. It is simply just has to pass information and trigger state change.

Primitive action may be like this one:

protocol BookDetailAction {
    func close()
    func toggleLikeState()
}

Driver

Driver holds State and mutates it in response to Action. The same as ViewModel in MVVM.

typealias BookDetailDriving = BookDetailState & BookDetailAction & DisposeContainer
final class BookDetailDriver: BookDetailDriving {
    let bag = DisposeBag()
    private let closeRelay = PublishRelay<Void>()
    private let dataRelay = BehaviorRelay<BookDetailData?>(value: nil)
    
    private let id: Int
    private let api: ApiProvider
    private let analytics: Analytics
    
    var data: Driver<BookDetailData> { dataRelay.unwrap().asDriver() }
    
    var closeSignal: Signal<Void> { closeRelay.asSignal() }
    
    init(id: Int,
         api: ApiProvider,
         analytics: Analytics) {
        self.id = id
        self.api = api
        self.analytics = analytics
        bind()
    }
    
    func close() {
        closeRelay.accept(())
    }
    
    func toggleLikeState() {
        // 1. Mutate
        guard let value = dataRelay.value?.toggleLike() else { return }
        dataRelay.accept(value)
        // 2. Persist
        api.updateBookDetails(value)
            .subscribe()
            .disposed(by: bag)
        // 3. Track
        analytics.track(.likeUpdate(value.like, id: value.id))
    }
    
    private func bind() {
        // 4. Fetch initial data
        api.fetchBookDetails(forBookId: id)
            .unwrap()
            .compactMap(BookDetailData.init)
            .bind(onNext: dataRelay.accept)
            .disposed(by: bag)
    }
}

Now Driver has the following responsibilities: • Fetch initial data from cloud (4) and update it when needed (2). • Perform changes to internal state and store it. • Track events to analytics

Binder

Even though all dependencies are used as protocols. Driver can have less responsibility. I will show you how to separate it with Binder.

First lets add one more action.

protocol BookDetailAction {
...
    func update(data: BookDetailData)
...
}
final class BookDetailDriver: BookDetailDriving {
...
    func update(data: BookDetailData) {
        dataRelay.accept(data)
    }
...
}

Now we can move away network related logic from Driver.

final class BookAPIBinder: Disposable {
    private unowned let driver: BookDetailDriving
    private let api: ApiProvider
    private let id: Int

    init(driver: BookDetailDriving, id: Int, api: ApiProvider) {
        self.driver = driver
        self.id = id
        self.api = api
        
        bind()
    }
    
    private func bind() {
        // 1. Fetch initial
        let initialData = api.fetchBookDetails(forBookId: id)
            .unwrap()
            .compactMap(BookDetailData.init)
        
        // 2. Update and persist cloud state
        let updatedData = driver.data.asObservable()
            .skip(1)
            .flatMapLatest(api.updateBookDetails)
        
        driver.bag.insert(
            initialData.bind(onNext: driver.update),
            updatedData.subscribe()
        )
    }
}

This way BookAPIBinder is responsible for work with the API and updating data. We can use the same pattern and create Binder for Analytics things.

final class BookAnalyticsBinder: Disposable {
    private unowned let driver: BookDetailDriving
    private let analytics: Analytics
    private let id: Int

    init(driver: BookDetailDriving, id: Int, analytics: Analytics) {
        self.driver = driver
        self.id = id
        self.analytics = analytics
        
        bind()
    }
    
    private func bind() {
        // 1. Track event
        let event = driver.data
            .distinctUntilChanged(\.like)
            .map { Event.likeUpdate($0.like, id: $0.id) }
        
        driver.bag.insert(
            event.bind(onNext: analytics.track)
        )
    }
}

Next I will show how to bind our ViewController with the Driver.

Clone this wiki locally