Migrating MVVM architecture from RxSwift to Combine
It’s been almost two years that Combine has been introduced to the Apple developer community. As many developer, you want to migrate your codebase to it. You don’t want to be left behind but you’re not sure where to start, maybe not sure if you want to jump to SwiftUI either. Nothing to worry, let’s see step by step how to migrate an iOS sample app using UIKit and RxSwift to Combine.
If you’ve missed it and lived under a digital rock the last two years, Apple introduced Combine, a functional reactive programming framework to observe asynchronous events over time. In short, it’s Apple’s version of ReactiveX (Rx).
Today, a lot of app codebase already use RxSwift / ReactiveSwift in the same goal: observe events and react to it. So should you migrate to it? Well that’s a good question.
Short answer (like always), “it depends”.
Aside of those framework being stable and mature, and with Swift ABI stability, there is no reason they will stop working in near future, which is great news.
At the same time, the community is activity supporting and maintaining it: RxSwift 6 just got release recently. It’s unlikely the community will drop to support it in near future.
Combine is also limited in its available OS version. On the iOS side, it’s available from iOS13 only. So if you are looking at lower compatibility, it’s probably too early for your project.
That being said, if you want to stay close to Apple’s API and technologies, then eventually you’ll need to get familiar to Combine and SwiftUI. You might not need it today or tomorrow, but this day will come.
Finally, this reduce the dependencies to third party libraries and help your team in long term to onboard all engineers on same tools and framework. But once again, those frameworks are already pretty stable.
So depending of your appetite to move to something new, to go through this learning curve, to reduce external dependencies, to onboard new joiners, to update tests and any other steps we can think of, then you can make that choice with full knowledge of pros and cons.
So you thought about it, and you are sure to move your UIKit app from RxSwift to Combine? Awesome, let’s dive in.
For this example, I’ll be migrating a small currency app written with RxSwift and using MVVM pattern to Combine instead. We won’t touch SwiftUI today.
First steps
First step is to update the protocols and implementations to use Combine. Since RxSwift forward errors differently that Combine, I’ll be moving my code from Observable<Converter>
to AnyPublisher<Converter, ErrorResult>
.
// old code
import RxSwift
protocol CurrencyServiceObservable : class {
func fetchConverter() -> Observable<Converter>
}
// new code
import Combine
protocol CurrencyServicePublisher : class {
func fetchConverter() -> AnyPublisher<Converter, ErrorResult>
}
So far so good.
In the current version, the decoding from json to currency data structure was done separately, but even though we have a specific data type, we can still use Codable
protocol, instead. This will be important later on.
So I’ll be removing all the parser component to take advantage of Codable
.
/// old code
import RxSwift
// used for data structure
protocol Parsable {
static func parseObject(dictionary: [String: AnyObject]) -> Result<Self, ErrorResult>
}
// used for reactive parsable content
extension ParserHelper {
static func parse<T: Parsable>(data: Data) -> Observable<[T]> {
// ...
}
}
extension Converter : Parsable {
static func parseObject(dictionary: [String : AnyObject]) -> Result<Converter, ErrorResult> {
if let base = dictionary["base"] as? String,
let date = dictionary["date"] as? String,
let rates = dictionary["rates"] as? [String: Double] {
let finalRates : [CurrencyRate] = rates.flatMap({ CurrencyRate(currencyIso: $0.key, rate: $0.value) })
let conversion = Converter(base: base, date: date, rates: finalRates)
return Result.success(conversion)
} else {
return Result.failure(ErrorResult.parser(string: "Unable to parse conversion rate"))
}
}
}
In this new version, we have full Codable
support.
// new code
extension Converter: Codable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.base = try container.decode(String.self, forKey: .base)
self.date = try container.decode(String.self, forKey: .date)
self.rates = try container.decode([String: Double].self, forKey: .rates)
.compactMap { CurrencyRate(currencyIso: $0.key, rate: $0.value) }
}
}
Now we can update our data fetcher to implement Combine with Codable
. I’ve used a local file instead of network request in this example.
// old code
import RxSwift
extension FileDataService : CurrencyServiceObservable {
func fetchConverter() -> Observable<Converter> {
// giving a sample json file
guard let data = FileManager.readJson(forResource: "sample") else {
return Observable.error(ErrorResult.custom(string: "No file or data"))
}
return ParserHelper.parse(data: data)
}
}
// new code
import Combine
extension FileDataService : CurrencyServicePublisher {
func fetchConverter() -> AnyPublisher<Converter, ErrorResult> {
// giving a sample json file
guard let data = FileManager.readJson(forResource: "sample") else {
return Fail(outputType: Converter.self,
failure: ErrorResult.custom(string: "No file or data")
)
.eraseToAnyPublisher()
}
return Just(data)
.decode(type: Converter.self, decoder: JSONDecoder())
.mapError { error in
ErrorResult.parser(string: "Unable to parse data")
}
.eraseToAnyPublisher()
}
}
In this new version, once we got the data, Combine has an api to directly decode component using ..decode(type: Decodable.Protocol, decoder: TopLevelDecoder)
. That makes our code much more elegant, that having a whole parser component separated.
At the same time we can handle decoding error and map them to a new format if we want to treat them differently in the UI.
Like Just
that creates a “one time” observable value, I use Fail
to return an early error if the data are not readable.
Since each publishers are a specific struct
type, it’s important to use eraseToAnyPublisher()
to expose the downstream subscriber as AnyPublisher
, otherwise the compiler won’t let you build as is.
Our services and models are ready to be consume, we can safely update our viewmodel.
ViewModel and Combine
For my previous implementation of MVVM pattern, I was using an Input & Output to define a flow of events, the Input from the View component and compute it into Output.
To stay consistent with this approach and use latest Combine properties, I used PassthroughSubject
for input events we won’t hold on to the value, and Publishers
for output events using @Published
.
import Combine
class CurrencyViewModel {
weak private var service: CurrencyServicePublisher?
let reload: PassthroughSubject<Void, Never>
@Published private(set) var rates: [CurrencyRate]
@Published private(set) var errorMessage: String?
init(service: CurrencyServicePublisher = FileDataService()) {
self.service = service
rates = []
reload = PassthroughSubject<Void, Never>()
bindReloadToFetchConverter()
}
func bindReloadToFetchConverter() {
reload.compactMap { [weak self] _ in // (1)
self?.service?.fetchConverter()
}
.switchToLatest() // (2)
.map(\.rates) // (3)
.catch({ [weak self] error -> Just<[CurrencyRate]> in // (4)
print("Error", error)
self?.errorMessage = (error as? ErrorResult)?.localizedDescription ?? error.localizedDescription
return Just([CurrencyRate]())
})
.subscribe(on: RunLoop.main) // (5)
.assign(to: &$rates) // (6)
}
}
The important part here is to keep @Published
properties with private setter, to make sure it won’t be updated outside this class.
The rest is pretty similar with the first implementation:
- (1) - a new
reload
event triggers to fetch new converter - (2) - from a publisher with
<Converter, ErrorResult>
, we switch to the inner publisher,<Converter>
- (3) - we map to
<[CurrencyRate]>
instead and discard rest of data structure - (4) - we catch error separately to update the
errorMessage
and result with an empty array - (5) - we subscribe on main thread since we want to update UI components
- (6) - we assign it to our publishers
$rates
Note that with assign(to:)
, we don’t need to hold it to a cancellable, Combine will do it for you. This function has been introduced in iOS14, to potentially replace assign(to:on:)
which can leak data since the root object is used as strong reference.
Finally, the great part using @Published
is to be able to either expose the value or it’s publisher, so it still quite easy to unit test viewModel.rates
value.
Our viewModel is updated, we can finalize the binding in the view side.
View and Combine
In my previous example, I’ve directly manage to bind the data to the UITableView
data source using RxCocoa.
// old code
import UIKit
import RxSwift
import RxCocoa
class CurrencyViewController: UIViewController {
@IBOutlet weak var tableView : UITableView!
let viewModel = CurrencyViewModel()
private let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
title = "Β£ Exchange rate"
bindViews()
viewModel.input.reload.accept(())
}
private func bindViews() {
// bind data to tableview
viewModel.output.rates
.drive(self.tableView.rx.items(cellIdentifier: "CurrencyCell", cellType: CurrencyCell.self)) { (row, currencyRate, cell) in
cell.currencyRate = currencyRate
}
.disposed(by: disposeBag)
viewModel.output.errorMessage
.drive(onNext: { [weak self] errorMessage in
guard let strongSelf = self else { return }
strongSelf.showError(errorMessage)
})
.disposed(by: disposeBag)
}
// ...
}
If Combine has been first introduced with SwiftUI to easily reload UI components, it wasn’t designed to bring the same flexibility to UIKit. There is no equivalent to this code in UIKit. I would need to bind it back to a dedicated data source which would build the each cell.
Fortunately for us, the open source community came to the rescue and bridges the gap of this missing piece with CombineDataSources. Note that this package is still marked as work in progress.
import UIKit
import Combine
import CombineDataSources
class CurrencyViewController: UIViewController {
@IBOutlet weak var tableView : UITableView!
let viewModel = CurrencyViewModel()
private var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
title = "Β£ Exchange rate"
bindViews()
viewModel.reload.send(())
}
private func bindViews() {
viewModel.$rates
.bind(subscriber: self.tableView.rowsSubscriber(cellIdentifier: "CurrencyCell", cellType: CurrencyCell.self, cellConfig: { (cell, indexPath, currencyRate) in
cell.currencyRate = currencyRate
}))
.store(in: &cancellables)
viewModel.$errorMessage
.sink { [weak self] message in
self?.showError(message)
}
.store(in: &cancellables)
}
Since we don’t assign any values to the view controller, we can’t use assign(to:)
, so we need to store each subscription into a Set<AnyCancellable>
to hold on to it, like we did for disposable
in RxSwift.
Awesome! The view implementation hasn’t changed the logic, only the subscriptions migrate to the new framework.
Overall, the logic and stream of events hasn’t really changed, which is the great part of this code migration. Combine is close enough to other functional reactive programming framework to be fairly straightforward to migrate to it.
Apple made it also really easy to leverage other part of UIKit and Foundation like decode
or subscribing to specific threads.
That being said, it doesn’t yet have the full maturity that other open source competitors. You might find yourself cornered and have to hack around to reload UIKit components or rely on third party packages to close the gap of what hopefully will be fixed in future versions. It’s something to keep in mind.
This code is available on Github under combine
branch.