【Swift】ゼロからのCombineフレームワーク - UIKitでデータバインディング

# 意外と難しかったCombine

RxSwiftならある程度使えるし、簡単に使えるようになるかな？と思っていました。
しかし、いざ試してみるとなかなか苦労したので、まずは普段の開発に取り入れられる部分から切り出してみました。

# UIKitに値をバインドする

```swift
import Combine
import UIKit

class ViewModel {
    @Published var labelText: String = "Default value."
}

class ViewController: UIViewController {
    var label: UILabel! // 適当な場所（storyboard, viewDidLoadなど）で初期化する
    let viewModel = ViewModel()
    
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        viewModel.$labelText
            .map({ Optional($0) })
            .receive(on: DispatchQueue.main)
            .assign(to: \UILabel.text, on: label)
            .store(in: &cancellables)
    }
}
```

適当なタイミングで、`viewModel.labelText = "Hello!"`などと値を変更すると、自動的に`UILabel`が更新されます。

## 解説

### `@Published`と`Publisher`プロトコル

バインドしたいプロパティの変更を監視するために、`import Combine`したうえで、`@Published`というProperty Wrapperをバインドしたいプロパティに付与します。

```swift:ViewModel
class ViewModel {
    @Published var labelText: String = "Default value."
}
```

Property Wrapperを付与したプロパティをもつクラスには、自動的に`$labelText`のようにプロパティ名の先頭に`$`がついたgetterが生成されます。`@Published`を付与した場合、このgetterは`Published<Output>.Publisher`型になります。

`Publisher`というのが監視される側のプロトコルです（RxSwiftなら`Observable`）。また、この場合`Output = String`です。

### バインド

`Publisher`に準拠したプロパティを`UIView`にバインドするには、`assign`メソッドを使います。

```swift
viewModel.$labelText
    .map({ Optional($0) })
    .receive(on: DispatchQueue.main)
    .assign(to: \UILabel.text, on: label)
    .store(in: &cancellables)
```

### `assign`

`assign`メソッドの定義は次のようになっています。

```swift
public func assign<Root>(to keyPath: ReferenceWritableKeyPath<Root, Self.Output>, on object: Root) -> AnyCancellable
```

`keyPath`には`\UILabel.text`[^1]のようにバインド先の`UIView`の型名とそのプロパティ名を記載します。

[^1]: バックスラッシュはKey-Pathを表現するときの記法です。ドキュメントはこちら [Key-Path Expression](https://docs.swift.org/swift-book/ReferenceManual/Expressions.html#ID563)

`assign`メソッドは`AnyCancellable`という`Cancellable`プロトコルに準拠したクラスのインスタンスを返します。このインスタンスの`cancel`メソッドを呼ぶことでバインド（監視）が解除されます。

また、`AnyCancellable`のインスタンスはメモリから解放されるタイミングでも`cancel`メソッドを呼んでバインドを解除するようです[^2]。そのため、`ViewController`に`Set<AnyCancellable>`型のプロパティを持たせて参照を保持します。追加する際にチェーンでメソッドを呼べるように`store`メソッドが用意されています。

[^2]: ドキュメントに記述を見つけられなかったのですが、参照を保持しなかった場合は`viewDidLoad`を抜けた時点で`cancel`が呼ばれていました。

### `map`

`Publisher`には様々なオペレーターが用意されており、監視している値に変更を加えたり、流れてくる時間や回数などの条件によって流れをせきとめたりすることができます。

`map`はもっとも基本的なオペレーターで、流れてきた値を加工することができます。
ここでは、`UILabel.text: String?`の型に合わせて、`ViewModel.labelText: String`を`Optional`型に変換しています。

### `receive(on: DispatchQueue.main)`

`receive(on:)`はメソッドチェーンのうちで、このメソッド以降のチェーン内の処理が行われるスレッドを指定することができます。

`assign`メソッドはUIの更新処理にあたるので、メインスレッドを指定しています。

# おまけ：SwiftUI版

```swift
import Combine
import SwiftUI

class ViewModel: ObservableObject {
    @Published var labelText: String = ""
}

struct ContentView: View {
    @ObservedObject private var viewModel = ViewModel()
    
    var body: some View {
        NavigationView {
            VStack {
                Text(viewModel.labelText)
                TextField("Title", text: $viewModel.labelText)
                    .multilineTextAlignment(.center)
            }
        }
    }
}
```

## 解説

### `@ObservedObject`/`ObservableObject`

SwiftUIのViewにバインドするには、バインドしたいプロパティをもつ`viewModel`に`@ObservedObject`を付与します。
（`ObservableObject`プロトコルに準拠させることで、`@ObservedObject`が付与できるようになります。）



```swift
struct ContentView: View {
    @ObservedObject private var viewModel = ViewModel()
    ...
}

class ViewModel: ObservableObject {
    @Published var labelText: String = ""
}
```

### バインド

```swift
var body: some View {
    NavigationView {
        VStack {
            Text(viewModel.labelText)
            TextField("Title", text: $viewModel.labelText)
                .multilineTextAlignment(.center)
        }
    }
}
```

`@ObservedObject`を付与したプロパティは`View`に監視されるようになり、値の変更があればUIが自動更新されます。
そのため、通常の`String`型を引数にとるクラスでは`viewModel.labelText`を渡すだけでバインド完了です。

`@ObservedObject`によって生成された`$viewModel`プロパティの`labelText`は`Binding<String>`という型を返します。これを`Binding<String>`型を引数にとるTextFieldに渡すことによって、双方向バインディングとなります。

こちらのSwiftUI版では、`viewModel.labelText`の値を変更すると`Text`と`TextField`の値が更新されますし、キーボードから`TextField`を変更しても、`viewModel.labelText`（と`Text`）の値が更新されます。