@akifumifukayaです。
先日、発表した内容を記事として残しておこうと思います。
※ 2019/10/21 にXcode 11に対応した内容に更新しました。
MVVM with Combine 本日の発表資料です! #wwdc19https://t.co/JP8qyA06ju
— akifumi (@akifumifukaya) 2019年6月13日
スライドは以下にアップロードしています。
https://speakerdeck.com/akifumifukaya/mvvm-with-combine-in-swiftui
2019年8月5日に発表した資料はこちらです。
https://speakerdeck.com/akifumifukaya/mvvm-with-combine-ef82b6ce-8fae-4bb1-9643-ea613334c8f8
What is Combine?
Combineとは、AppleがWWDC19で発表した新しいフレームワークのことです。
iOS 13.0, macOS 10.15, UIKit for Mac 13.0+, tvOS 13.0, watchOS 6.0+で使用することができます。
イベントを処理するオペレータを組み合わせることで、非同期イベントの処理を行うことができます。
フレームワークの詳細は、以下のドキュメントで見ることができます。
https://developer.apple.com/documentation/combine
Combine Features
Combineの特徴として、以下の4つが挙げられています。
- Generic
- Type safe
- Composition first
- Request driven
Generic, Type safe はSwiftの強みを引き継いでいます。
Composition firstは、チェーンのように処理をつなげて・組み立てながら宣言することができるので、コードが読みやすかったり、処理の流れがわかりやすくなっています。
Request drivenは、必要なタイミングで処理が行われるため、メモリ使用やパフォーマンスにも良い影響を与えます。
※ こちらの内容は、 Introducing Combine から引用しています。
Key Concepts
主要な概念として、以下の3つがあります。
- Publishers
- Subscribers
- Operators
※ こちらの内容は、 Introducing Combine から引用しています。
Publishers
-
Publishers
は時系列に連続した値を伝えることができる型を宣言できます - 値やエラーがどのように生み出されるを定義します
- Value型、つまり
Struct
で定義されています -
Subscriber
に登録することができます
※ こちらの内容は、 Introducing Combine から引用しています。
Subscribers
- Subscribersは
Publisher
からの入力を受け取ることができる型を宣言できます -
Publisher
から値やcompletionを受け取ります - Reference型、つまり
Class
で定義されています
※ こちらの内容は、 Introducing Combine から引用しています。
Operator
- オペレータは
Publisher
です - 値が変化したときの振る舞いを表します
-
Publisher
をサブスクライブすることをupstreamと呼びます -
Subscriber
へ値を送ることをdownstreamと呼びます - Value型、つまり
Struct
で定義されています
※ こちらの内容は、 Introducing Combine から引用しています。
How to actualize MVVM with Combine
では、どのようにCombineを使用してMVVMを実現するかを説明したいと思います。
https://github.com/akifumi/mvvm-with-combine-in-swiftui/blob/master/CombineSample/ContentView.swift
What are requirements?
今回、実現したいことは以下です。
-
TextField
を追加し、テキストを入力できるようにする - 入力されたテキストをバリデーションする
- ロジック部分を
ViewModel
として分け、view
とmodel
を分離する -
view
とViewModel
をつなぎ合わせる -
TextField
のコンテンツをtwo-wayバインディングする
ContentViewにTextFieldを追加する
コードは以下のようになります。
struct ContentView : View {
@State private var username: String = ""
var body: some View {
VStack {
TextField("Placeholder", text: $username, onEditingChanged: { (changed) in
print("onEditingChanged: \(changed)")
}, onCommit: {
print("onCommit")
})
}
.padding(.horizontal)
}
}
VStack
で縦方向のStackView
を作成し、その中にTextField
を配置しています。
変数に@State
を付けることによって、Viewと変数をtwo-wayバインディングで紐付けることができます。
また$変数
のように$
プレフィックスを付けることで、Binding
オブジェクトを取得することができます。
ここからViewModel
に分割していきます。
ViewModelを作成
次にViewModel
を作成します。
final class ContentViewModel : ObservableObject, Identifiable {
@Published
var username: String = ""
}
ViewModelを作成し、ObservableObject
を適応します。
ObservableObject
はvar objectWillChange: Self.ObjectWillChangePublisher { get }
を宣言することを要求しています。
~~```swift
var objectWillChange = PassthroughSubject()
~~今回は単純な変更通知を送るために`PassthroughSubject`を用いて宣言しました。
また`username`という変数を定義し、`willSet`で`objectWillChange.send(())`を呼びます。~~
~~`willSet`が呼ばれると、SwiftUI側へ`View`の更新通知が行うようにしています。~~
**`@Published`を使用すると`objectWillChange`のパブリッシャーは`willSet`で自動的に呼ばれるようになりました。**
<blockquote class="twitter-tweet"><p lang="en" dir="ltr">In Beta 5 ObjectBinding is now defined in Combine as ObservableObject (the property wrapper is now <a href="https://twitter.com/ObservedObject?ref_src=twsrc%5Etfw">@ObservedObject</a>). <br>There is also a new property wrapper <a href="https://twitter.com/published?ref_src=twsrc%5Etfw">@Published</a> were we automatically synthesize the objectWillChange publisher and call it on willSet. <a href="https://t.co/IyXJZr3Ndf">pic.twitter.com/IyXJZr3Ndf</a></p>— Luca Bernardi (@luka_bernardi) <a href="https://twitter.com/luka_bernardi/status/1155944329363349504?ref_src=twsrc%5Etfw">July 29, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
# Publisherを作成し、usernameをバリデーションする
```swift
final class ContentViewModel : ObservableObject, Identifiable {
︙
@Published
var username: String = ""
private var validatedUsername: AnyPublisher<String?, Never> {
return $username
.debounce(for: 0.1, scheduler: RunLoop.main)
.removeDuplicates()
.flatMap { (username) -> AnyPublisher<String?, Never> in
Future<String?, Never> { (promise) in
// FIXME: API request
if 1...10 ~= username.count {
promise(.success(username))
} else {
promise(.success(nil))
}
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}
$username
でusername
の変更を受け取ることができます。
次にusername
にバリデーションを行うために、var validatedUsername: AnyPublisher<String?, Never>
を定義しています。
今回はAPIの実装は行いませんでしたが、Future
を用いることで非同期処理も行うことができます。
今回はusername
が有効であれば、username
を返し、無効であればnil
を返すように実装しました。
ContentViewModel.StatusText を作成
final class ContentViewModel : ObservableObject, Identifiable {
︙
struct StatusText {
let content: String
let color: Color
}
@Published
var status: StatusText = StatusText(content: "NG", color: .red)
}
バリデーションの結果を見やすいように、簡単なラベルを追加します。
StatusText
はテキストとカラーも持つオブジェクトにしました。
ViewModelにonApprear()を追加
ContentView
が表示された際の処理を記述していきます。
final class ContentViewModel : ObservableObject, Identifiable {
︙
private var cancellables: [AnyCancellable] = []
private(set) lazy var onAppear: () -> Void = { [weak self] in
guard let self = self else { return }
self.validatedUsername
.sink(receiveValue: { [weak self] (value) in
if let value = value {
self?.username = value
} else {
print("validatedUsername.receiveValue: Invalid username")
}
})
.store(in: &self.cancellables)
// Update StatusText
self.validatedUsername
.map { (value) -> StatusText in
if let _ = value {
return StatusText(content: "OK", color: .green)
} else {
return StatusText(content: "NG", color: .red)
}
}
.sink(receiveValue: { [weak self] (value) in
self?.status = value
})
.store(in: &self.cancellables)
}
}
username
が有効の場合、username
を有効な値で書き換えています。
self.validatedUsername
.sink(receiveValue: { [weak self] (value) in
if let value = value {
self?.username = value
} else {
print("validatedUsername.receiveValue: Invalid username")
}
})
.store(in: &self.cancellables)
username
が有効の場合はOK、無効な場合はNGと表示するStatusText
を作成し、View
を更新するようにしています。
// Update StatusText
self.validatedUsername
.map { (value) -> StatusText in
if let _ = value {
return StatusText(content: "OK", color: .green)
} else {
return StatusText(content: "NG", color: .red)
}
}
.sink(receiveValue: { [weak self] (value) in
self?.status = value
})
.store(in: &self.cancellables)
sink()
関数でAnyCancellable
が返ってくるので、store
関数で開放されないように保持することができます。
これをしないと更新通知を受け取ることができません。
ViewModelにonDisapprear()を追加
ContentView
が非表示になった際の処理を記述していきます。
final class ContentViewModel : ObservableObject, Identifiable {
︙
private var cancellables: [AnyCancellable] = []
︙
private(set) lazy var onDisappear: () -> Void = { [weak self] in
guard let self = self else { return }
self.cancellables.forEach { $0.cancel() }
self.cancellables = []
}
}
onApprear
でcancellables
に保持していたAnyCancellable
の配列をViewの非表示時に開放してあげます。
ContentViewとContentViewModelを紐付ける
struct ContentView : View {
@ObservedObject var viewModel: ContentViewModel
var body: some View {
VStack {
HStack {
Text($viewModel.status.wrappedValue.content)
.foregroundColor($viewModel.status.wrappedValue.color)
Spacer()
}
TextField("Placeholder", text: $viewModel.username, onEditingChanged: { (changed) in
print("onEditingChanged: \(changed)")
}, onCommit: {
print("onCommit")
})
}
.padding(.horizontal)
.onAppear(perform: viewModel.onAppear)
.onDisappear(perform: viewModel.onDisappear)
}
}
このように記述することでContentView
とContentViewModel
をバインディングしました。
ViewとModelをきれいに分離することができ、とても良いですね!!
まとめ
- Combineの紹介を行いました
- SwiftUI, Combine & MVVM のサンプルコードを作成し、MVVMアーキテクチャをどのように実現するかを紹介しました
- SwiftUIのアーキテクチャのベストプラクティスはこれから生まれてくると思うのでみんなが考えましょう!
- Samples