【Swift】UIKit × CombineでViewとViewModelを分けてみた

# はじめに
FatなViewControllerを生成してしまったので、Combineを使いViewとViewModelに分けてみました。

# 開発環境
Xcode 13.2.1
Swift 5.5.2

# Combineって（使ったものだけざっくりと）
**Combineを使うと**
イベント処理のコードを一元化し、 ネストしたクロージャや規約ベースのコールバックといった面倒なテクニックを排除し、 コードを読みやすく、保守しやすくすることができる。

**CurrentValueSubjectって**
値を保持して、値が変わるたびに新しい要素を公開する
保持した値は、`.value`で取得できる

```swift
final class CurrentValueSubject<Output, Failure> where Failure : Error
```


**PassthroughSubjectって**
値を保持せず、値が入ってきたら都度公開する

```swift
final class PassthroughSubject<Output, Failure> where Failure : Error
```


**.send / .sink って**
`CurrentValueSubject`、`PassthroughSubject`に
値を送る場合は`.send`、

```swift
final func send(_ input: Output)
```


値を購読する場合は`.sink`を使う

```swift
func sink(receiveCompletion: @escaping ((Subscribers.Completion<Self.Failure>) -> Void),
     receiveValue: @escaping ((Self.Output) -> Void)) -> AnyCancellable
```


**.assing って**
流れてくるデータをオブジェクトにバインディング（代入）する時に使う

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

# 実装
#### 1. ボタンの色を変える処理

**ViewController**

```Swift
import UIKit
import Combine

final class TestViewController: UIViewController {

    @IBOutlet weak var mainButton: UIBarButtonItem!

    private let viewModel = TestViewModel()
    private var subscriptions = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()
        bind()
    }
    
    func bind() {
        viewModel.allBarButtonIsOn
            .map { $0 ? .colorButtonOn : .colorButtonOff }
            .assign(to: \.tintColor, on: allBarButton)
            .store(in: &subscriptions)
    }

    @IBAction func mainButtonPressed(_ sender: UIBarButtonItem) {
        viewModel.allButtonPressed.send()
    }
}
```

**ViewModel**

```swift
import UIKit
import Combine

class TestViewModel {
    let mainButtonPressed = PassthroughSubject<Void, Never>()
    private(set) var mainButtonIsOn = CurrentValueSubject<Bool, Never>(true)

    var subscriptions = Set<AnyCancellable>()

    init() {
        mutate()
    }
    
    func mutate() {
        mainButtonPressed.sink { [weak self] _ in
            guard let self = self else { return }
            self.mainButtonIsOn.send(!self.mainButtonIsOn.value)
        }.store(in: &subscriptions)
    }
}
```
とても無駄の多いことをやっている印象があるが、処理を追加する際は綺麗に整理できるのかも。`.assign`が使える時は使ってシンプルにしていきたい。

#### 2. TableViewのデータを更新する処理

**ViewController**

```Swift
final class TestViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {

    @IBOutlet weak var customTableView: UITableView!

    private let viewModel = TestViewModel()
    private var subscriptions = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()
        customTableView.delegate = self
        customTableView.dataSource = self
        customTableView.register(UINib(nibName: "TestCell", bundle: nil), forCellReuseIdentifier: "TestCell")
        bind()
    }
    
    func bind() {
        viewModel.dataArray.sink { [weak self] _ in
            guard let self = self else { return }
            self.customTableView.reloadData()
        }.store(in: &subscriptions)
    }

    @IBAction func mainButtonPressed(_ sender: UIBarButtonItem) {
        viewModel.dataReload.send()
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.dataArray.value.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let customCell = tableView.dequeueReusableCell(withIdentifier: "TestCell", for: indexPath) as! DataTableViewCell
        customCell.setData(lesson: viewModel.dataArray.value[indexPath.row])
        return customCell
    }
}
```

**ViewModel**

```swift
class TestViewModel {
    let dataReload = PassthroughSubject<Void, Never>()
    private(set) var lessonsArray = CurrentValueSubject<[TableData], Never>([])

    private var subscriptions = Set<AnyCancellable>()
    private let coreDataMangaer = CoreDataManager.shared

    init() {
        mutate()
    }
    
    func mutate() {
        dataReload.sink { [weak self] _ in
            guard let self = self else { return } 
            self.lessonsArray.send(self.coreDataMangaer.loadAllData())
        }.store(in: &subscriptions)
    }
}
```
TableViewのデータ取得をViewModelで全てやる事でとても見やすくなった.


#### 3. 画面遷移の処理

**ViewController**

```Swift
final class TestViewController: UIViewController {

    @IBOutlet weak var mainButton: UIBarButtonItem!

    private let viewModel = TestViewModel()
    private var subscriptions = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()
        bind()
    }
    
    func bind() {
        viewModel.transiton.sink { [weak self] transition in
            guard let self = self else { return }
            switch transition {
            case .setting:
                let storyboard = UIStoryboard(name: "Setting", bundle: nil)
                let vc = storyboard.instantiateViewController(identifier: "Setting")
                self.navigationController?.pushViewController(vc, animated: true)
            case let .detail(tableData):
                let storyboard = UIStoryboard(name: "Detail", bundle: nil)
                let vc = storyboard.instantiateViewController(identifier: "Detail")
                guard let detailVC = vc as? DetailViewController else { return }
                detailVC.lessonData = lessonData
                detailVC.delegate = self
                self.navigationController?.pushViewController(vc, animated: true)
            }
        }
    }

    @IBAction func mainButtonPressed(_ sender: UIBarButtonItem) {
        viewModel.mainButtonPressed.send()
    }
}
```

**ViewModel**

```swift
enum ViewTransition {
    case setting
    case detail(TableData)
}

class TestViewModel {
    let mainButtonPressed = PassthroughSubject<Void, Never>()
    private(set) var transiton = PassthroughSubject<ViewTransition, Never>()

    var subscriptions = Set<AnyCancellable>()

    init() {
        mutate()
    }
    
    func mutate() {
        mainButtonPressed.sink { [weak self] tableData in
            guard let self = self else { return }
            self.transiton.send(.setting)
        }.store(in: &subscriptions)
    }
}
```
画面の遷移先が一目で分かるように整理できた。遷移先追加もしやすそう。


# 感想
View-ViewModelに分けた事で機能追加する時にどこを修正すれば良いか見やすくなった気がします。
またテストも書きやすくなっているはず。（まだ書いてないので分からない）
間違いあれば指摘いただけると嬉しいです。

# 参考記事
https://developer.apple.com/documentation/combine

https://zenn.dev/usamik26/books/combine-uikit

https://zenn.dev/yorifuji/articles/ios-swift-combine