Using Combine to Achieve MVVM
Hello
I'm Koyama from the KINTO Technologies Mobile App Development Group. I work on mobile app development and maintenance as an iOS engineer.
Today, I'd like to talk about how MVVM was adopted for Global KINTO's mobile app development.
MVVM and Combine
There are a few things I'd like to touch on briefly before I begin.
MVVM
MVVM is an architecture in software development. It consists of Model-View-ViewModel, which is derived from the MVC model.
The details are a little complex, so a simple overview is as follows:
- Model
The part that's responsible for collecting data and carrying out simple processing. It doesn't do any complicated processing. - View
The part responsible for rendering the screen. It receives the data needed to produce the screen, then simply draws it. - ViewModel
This part is in the middle between the Model and View, and is responsible for bringing together multiple pieces of Model data and holding values needed to maintain the View.
That's the general idea (or at least my personal take on it).
I found even this much of it difficult to understand when I first started working for iOS. So, if you apply this to iOS development, this is how it looks:
- Model
Brings together all of the data that needs to be handled. Rather than using a dictionary-based approach, you might as well create a single Model instead. The Model only contains simple methods related to the data inside it. - View
This can be a ViewController or an individual View. It only contains code related to rendering, and simply displays the data it receives without manipulating it at all. Basically, the aim is to have no if statements. - ViewModel
Receives triggers from the ViewController and returns data. A ViewModel is in a one-to-one relationship with a ViewController, and in relation to an individual View, it can also take on the role of a higher-level ViewController's ViewModel, depending on the situation.
This is what I'd like to achieve. In today's article, I particularly want to talk about the differences between a View and a ViewModel.
Combine
First launched in June 2019, Combine is a framework for creating Apple's official reactive architecture. Before then, reactive architectures were mostly achieved using the third-party RxSwift, but now, an official, strongly supported library has been released.
"Hang on a moment. What is a reactive architecture anyway?"
Some of you may be wondering this. (I certainly did.) Here's what Wikipedia has to say:
reactive programming is a declarative programming paradigm concerned with data streams and the propagation of change. -- Wikipedia | Reactive programming
Not very easy to understand, is it? I've heard that to easily understand this, you should picture it in terms of spreadsheet software.
Suppose you enter a simple formula like this:
A | B | C |
---|---|---|
10 | 20 | =A1+B1 |
Once you've entered it, the result goes in cell C1 (of course).
A | B | C |
---|---|---|
10 | 20 | 30 |
But what do you think will happen if you change the value in cell A1 to 20?
A | B | C |
---|---|---|
20 | 20 | 40 |
Of course, cell C1 will change to 40.
A change in cell A1 has spread to cell C1 without us directly modifying C1 at all. This is what's called reactive architecture. Based on this, I could now clearly see that you just declare the data processing (=A1+B1
) first, then run the actual data through it (assigning 20 to A1) at the end.
Background
The preamble got a bit long. This is why we decided to adopt Combine and MVVM.
Back when I first started working on a Global KINTO development project, we'd just switched from using outsourced code to producing our own in-house. The codebase that had already been written were on a “spaghetti code” state, so it was extremely difficult to maintain. Global variables were being called all over the place, and there was no way to tell which class depended on which.
Fortunately, we had a fairly small amount of code and screens, so we tried to solve the problem by reviewing the overall architecture. At the same time, the Mobile App Development Group had some knowledge of using Combine-based MVVM. So, we decided to proceed based on the MVVM architecture.
The aim was to design things thoroughly based on the architecture so that even new team members could easily understand the projects and safely modify them.
And now comes the implementation!
Since we've decided to use MVVM, we'll separate the roles between the View and the ViewModel.
This implementation uses UIkit.
View - Part1
Instead of doing any processing in the View, we only want to tell the ViewModel the timing of event firings. To do this, we'll use Combine's Publisher. For example, if you need to send the timing of viewDidLoad, define the PassthroughSubject using the Void type.
private let didLoad: PassthroughSubject<Void, Never> = .init()
Make it so that this gets sent when viewDidLoad is called.
override func viewDidLoad() {
super.viewDidLoad()
didLoad.send()
}
It's also a smart approach to use tapPublisher[^combineCocoa] when a button is tapped. First, connect the button to the Outlet and prepare the Publisher used for sending. If you want to pass a String value from the displayed screen when the button is pressed, define Publisher as String instead of Void.
@IBOutlet weak var button: UIButton!
@IBOutlet weak var textArea: UITextField!
private let tapButton: PassthroughSubject<String, Never> = .init()
Then, use tapPublisher to detect the event and send it to the ViewModel.
button
.tapPublisher
.sink { [weak self] in
self?.tapButton.send(textArea.text ?? "")
}
.store(in: &cancellables)
Next, let's take a look at the ViewModel.
ViewModel
The ViewModel retrieves and generates information based on the event triggers received from the View, then returns the necessary information to the latter. Here, we'll create it using Combine's Operator and Subscriber.
First, to prepare to connect it with the View side, define Input and Output structs separately from the ViewModel.
Input is the data transferred from the View, and Output is the resulting data you want to pass from the ViewModel. When exchanging data, processing will be possible only if the data and error types have been properly set, so use the type AnyPublisher across the board.
AnyPublisher<String, Never> // Want text when the button is tapped!
}
struct ViewModelOutput { let onShowWelcome:
AnyPublisher<String, Never> // Want it to display the Welcome text on the screen let onShowText: AnyPublisher<String, Never> // Want it to display arbitrary text on the screen let onShowOnlyFirst screen:
AnyPublisher <Void, Never> // Want it to display a fixed value on the screen only when the button is pressed for the first time } ```
Next, let's create the part that processes the data. This amounts to adding the part to receive input and turn it into output. For example, if you want viewDidLoad to trigger a specific API, connect viewDidLoad's Publisher to Operator and run an API request.
```swift
func transform(input: ViewModelInput) -> ViewModelOutput {
let apiResponse = input
.viewDidLoad
.flatMap { api.requestData() }
Similarly, when a button is tapped, if you want to generate data based on the text on the screen at the time, use map.
let text = input
.tapButton
.map { createText(text: $0) }
You might also want something to happen when viewDidLoad is called and a button is tapped at the same time. In cases like that, Publishers can be connected to each other. There are many ways to connect them, but we'll use zip this time.
let buttonTap = input
.tapButton
let didLoadAndButtonTap = didLoad
.zip(buttonTap)
.map { _ in }
Operators are already available in Combine and its extended libraries, so complex processing can be achieved while still keeping the code simple. There are a lot of Operators, but I've listed the ones I think it's useful to remember below.
- map
- flatMap
- compactMap
- filter
- share
- materialize [^combineExt]
- merge
- zip
- combineLatest
- withLatestFrom [^combineExt]
And finally, add the proper return that goes with the output's type. When you use Combine, the types will keep getting expanded each time an Operator is connected, so in the end, return the created data to View after gather it all together into AnyPublisher.
return .init(
onShowWelcome: apiResponse.eraseToAnyPublisher(),
onShowText: text.eraseToAnyPublisher(),
onShowOnlyFirst: didLoadAndButtonTap.eraseToAnyPublisher()
)
}
View - Part 2
The ViewModel side is now complete, so connect it to the View and render the screen. Let’s call the transform method we prepared earlier to get the results of the processing carried out in ViewModel.
let output = viewModel.transform(
input: .init(
viewDidLoad: didLoad.eraseToAnyPublisher(),
tapButton: tapButton.eraseToAnyPublisher()
)
)
Once we've gotten the processing results and decided what sort of screen rendering to do for each, we're all done.
output .onShowText .receive(on: DispatchQueue.main) .sink { [weak self] in self?.textArea.text = $0 } .store(in: &cancellables)
output .onShowOnlyFirst .receive(on: DispatchQueue.main) .sink { [weak self] in self?.showCustomMessage("First!") } .store(in: &cancellables) ```
# Finished code
Here's the finished code.
```swift:ViewController.swift class ViewController: UIViewController {
@IBOutlet weak var button: UIButton!
@IBOutlet weak var textArea: UITextField!
private var viewModel: ViewModelProtocol!
private let didLoad: PassthroughSubject<Void, Never> = .init()
private let tapButton: PassthroughSubject<String, Never> = .init()
private var cancellables: Set<AnyCancellable> = .init()
func configure(viewModel: ViewModelProtocol) {
self.viewModel = viewModel
}
override func viewDidLoad() {
super.viewDidLoad()
bind()
didLoad.send()
}
func bind() {
let output = viewModel.transform(
input: .init(
viewDidLoad: didLoad.eraseToAnyPublisher(),
tapButton: tapButton.eraseToAnyPublisher()
)
)
button
.tapPublisher
.sink { [weak self] in
self?.tapButton.send(textArea.text ?? "")
}
.store(in: &cancellables)
output
.onShowWelcome
.receive(on: DispatchQueue.main)
.sink { [weak self] in
self?.textArea.text = $0
}
.store(in: &cancellables)
output
.onShowText
.receive(on: DispatchQueue.main)
.sink { [weak self] in
self?.textArea.text = $0
}
.store(in: &cancellables)
output
.onShowOnlyFirst
.receive(on: DispatchQueue.main)
.sink { [weak self] in
self?.showCustomMessage("First!") // Assumptions for which methods are prepared in advance
}
.store(in: &cancellables)
}
} ```
```swift:ViewModel.swift protocol ViewModelProtocol { func transform(input: ViewModelInput) -> ViewModelOutput }
struct ViewModelInput { let viewDidLoad: AnyPublisher<Void, Never> let tapButton: AnyPublisher<String, Never> }
struct ViewModelOutput { let onShowWelcome: AnyPublisher<String, Never> let onShowText: AnyPublisher<String, Never> let onShowOnlyFirst: AnyPublisher<Void, Never> }
struct ViewModel: ViewModelProtocol { let api: SomeApiProtocol
init(api: SomeApiProtocol) {
self.api = api
}
func transform(input: ViewModelInput) -> ViewModelOutput {
let didLoad = input
.viewDidLoad
share()
let apiResponse = didLoad
.flatMap { api.requestData() } // Future<String, Never> return is assumed
let buttonTap = input
.tapButton
share()
let text = buttonTap
.map { createText(text: $0) }
let didLoadAndButtonTap = didLoad
.zip(buttonTap)
.map { _ in }
return .init(
onShowWelcome: apiResponse.eraseToAnyPublisher(),
onShowText: text.eraseToAnyPublisher(),
onShowOnlyFirst: didLoadAndButtonTap.eraseToAnyPublisher()
)
}
func createText(text: String) -> String {
"\(text) show!!"
}
} ```
Clearly dividing the roles between the View and ViewModel like this helps keep the code nice and organized. You can use the same structure for any View, which makes the code easier to read and maintain. Also, having the roles all set in stone makes forcible coding less likely. However, one con is that if you haven't done much reactive programming with (e.g.) Combine or RxSwift before, you'll find it **as difficult as to understand as a totally different language** from the Swift you've known up to then.
In Global KINTO's iOS projects, all Views are created based on this structure. It's easy to read and maintain, making it less likely to deviate from even when creating new Views.
# In conclusion
The Mobile App Development Group will share knowledge like in this article with other products besides Global KINTO ones, and take on the challenge of new architectures that are completely different from this one. I want to keep rising to the challenge of using various iOS development methods as part of my work in the Mobile App Development Group.
[^combineCocoa]: 拡張ライブラリの[CombineCocoa](https://github.com/CombineCommunity/CombineCocoa)を使用しています
[^combineExt]: 拡張ライブラリの[CombineExt](https://github.com/CombineCommunity/CombineExt)を使用しています
関連記事 | Related Posts
Combineを使ってMVVMを実現した話
Mobile App Development Using Kotlin Multiplatform Mobile (KMM)
[IOS] [SwiftUI] Converting the KINTO Kantan Moushikomi App to SwiftUI
Mobile App Development Using Kotlin Multiplatform Mobile (KMM) and Compose Multiplatform
Kotlin Multiplatform Mobile (KMM)を使ったモバイルアプリ開発
iOSアプリ開発を爆速で内製化した話
We are hiring!
【iOSエンジニア】モバイルアプリ開発G/東京
モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。
【iOSエンジニア】モバイルアプリ開発G/大阪
モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。