SwiftUI移行について真剣に考える

18 min読了の目安(約16900字TECH技術記事

WWDC19にて突如として公表された宣言的UIフレームワークであるSwiftUI、発表当時は多くのエンジニアが喜び興奮しましたが、実際に触ってみると痒いところに手が届かず、なかなかプロダクションに導入するのは難しいなという印象をみなさん受けたのではないでしょうか (そもそもiOS13の壁もありますが...)

しかしながら、翌年のWWDC20ではSwiftUIのさらなるアップデートが多数公開され、WidgetのようなSwiftUIでしか作れない機能もリリースされました

SwiftUIはメインのUIフレームワークとして今後もますますアップデートが重ねられ、本番導入への難易度は徐々に下がり、またこれから先3年, 4年と経てばSwiftUIにしか利用できないAPIの範囲はさらに拡大していくのではないかと個人的には考えています

それまでにSwiftUIへの緩やかな移行の方針をチームで検討しておく必要はあるでしょう

そこでようやっと本題ですが、本記事では SwiftUI移行について具体的にどのような流れで行えば良いかを真剣に考えてみようと思います

頭の中だけで結論を導き出すのはやはり難しいので、今回は以下のような手順で考えていこうと思います

  1. SwiftUI + Combineで簡単なアプリを作る
  2. SwiftUI製のViewをUIKitで書き換える
  3. Combineで書かれたBindingの処理をRxSwiftで置き換える
  4. 1〜3のステップからUIKit→SwiftUI移行の方針を逆算する

それではいきましょう!

(ちなみにこちらの記事は ミクシィグループ Advent Calendar 16日目の記事になります)

作成するアプリについて

以下のような簡単なTODOアプリを作ろうと思います

設計は単純なMVVMで、保存したデータはCoreDataで永続化します

作成したアプリは以下のリポジトリにて公開しています

1. SwiftUI + Combine

早速コードを見ていきましょう

以下はタスクのリスト表示をするViewです

TasksView.swift
struct TasksView: View {
    @ObservedObject var viewModel: TasksViewModel
    @State var shouldShowNewTaskButton = false

    var body: some View {
        VStack(alignment: .leading) {
            List {
                ForEach(viewModel.taskCellViewModels) { taskCellViewModel in
                    TaskCell(viewModel: taskCellViewModel)
                }
                .onDelete { indexSet in
                    viewModel.onTaskDeleted(atOffsets: indexSet)
                }
                if shouldShowNewTaskButton {
                    TaskCell(viewModel: .init(task: .init(), taskRepository: Factory.create())) { result in
                        if case .success(let task) = result {
                            viewModel.onTaskAdded(task: task)
                        }
                        shouldShowNewTaskButton.toggle()
                    }
                }
            }
            Button(action: { shouldShowNewTaskButton.toggle() }) {
                HStack {
                    Image(systemName: "plus.circle.fill")
                        .resizable()
                        .frame(width: 20, height: 20)
                    Text("New Task")
                }
            }
            .padding()
            .accentColor(Color(UIColor.systemBlue))
        }
        .onAppear {
            viewModel.onAppear()
        }
    }
}

続いて、以下がViewModelの定義になります
リストの各セルに対して渡すTaskCellViewModelの配列を持ち、RepositoryとやりとりしてViewに渡すタスクの情報取得やタスク追加, 更新や削除などを行います

TasksViewModel.swift
class TasksViewModel: ObservableObject {
    @Published var taskCellViewModels = [TaskCellViewModel]()

    private let taskRepository: TaskRepository
  
    private var cancellables = Set<AnyCancellable>()

    init(taskRepository: TaskRepository) {
        self.taskRepository = taskRepository
    }

    func onAppear() {
        fetchTasks()
    }

    func onTaskDeleted(atOffsets indexSet: IndexSet) {
        removeTasks(atOffsets: indexSet)
    }

    func onTaskAdded(task: Task) {
        addTask(task: task)
    }

    private func fetchTasks() {
        taskRepository.fetch()
            .replaceError(with: [])
            .map { [taskRepository] tasks in
                tasks.map { TaskCellViewModel(task: $0, taskRepository: taskRepository) }
            }
            .subscribe(on: DispatchQueue.global())
            .receive(on: DispatchQueue.main)
            .assign(to: \.taskCellViewModels, on: self)
            .store(in: &cancellables)
    }

    private func removeTasks(atOffsets indexSet: IndexSet) {
        indexSet.lazy.map { self.taskCellViewModels[$0] }.forEach { taskCellViewModel in
            taskRepository.delete(taskID: taskCellViewModel.task.id)
                .subscribe(on: DispatchQueue.global())
                .receive(on: DispatchQueue.main)
                .sink(receiveCompletion: { print($0) }, receiveValue: { [unowned self] _ in
                    self.taskCellViewModels.remove(atOffsets: indexSet)
                })
                .store(in: &cancellables)
        }
    }

    private func addTask(task: Task) {
        taskRepository.save(task: task)
            .subscribe(on: DispatchQueue.global())
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { print($0) }, receiveValue: { [unowned self] _ in
                self.taskCellViewModels.append(TaskCellViewModel(task: task, taskRepository: self.taskRepository))
            })
            .store(in: &cancellables)
    }
}

リストに表示するセルやRepository周りの定義については割愛しちゃいますが、
とりあえずここでは SwiftUI + Combine によるMVVMの構成についてざっくり理解いただければ良いと思います

2. UIKit + Combine

次に、SwiftUIで作成した先のViewをUIKit製に置き換えてみます

あくまでViewのみの置き換えで、Combine製のViewModelなどはそのまま使いまわします

(WWDC20で発表されたCollectionViewのLists を使って組んでみました)

TasksViewController.swift
final class TasksViewController: UIViewController {
    enum Section {
        case main
    }

    typealias Item = TaskCellViewModel

    private var cancellables = Set<AnyCancellable>()

    let viewModel: TasksViewModel = .init(taskRepository: Factory.create())

    var collectionView: UICollectionView! = nil
    var dataSource: UICollectionViewDiffableDataSource<Section, Item>! = nil

    let newTaskButton = UIButton()

    override func viewDidLoad() {
        super.viewDidLoad()

        configureCollectionView()
        configureNewTaskButton()

        let stack = UIStackView(arrangedSubviews: [
            collectionView,
            newTaskButton
        ])
        stack.axis = .vertical
        stack.alignment = .leading
        stack.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(stack)
        NSLayoutConstraint.activate([
            collectionView.widthAnchor.constraint(equalTo: stack.widthAnchor),
            newTaskButton.heightAnchor.constraint(equalToConstant: 40),
            stack.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
            stack.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
            stack.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
            stack.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor)
        ])

        viewModel.onAppear()
        viewModel.$taskCellViewModels
            .sink { [unowned self] cellViewModels in
                var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
                snapshot.appendSections([.main])
                snapshot.appendItems(cellViewModels)
                self.dataSource.apply(snapshot, animatingDifferences: true)
            }
            .store(in: &cancellables)
    }

    private func configureNewTaskButton() {
        newTaskButton.setImage(
            UIImage(systemName: "plus.circle.fill")?.withRenderingMode(.alwaysTemplate),
            for: .normal
        )
        newTaskButton.setTitle("New Task", for: .normal)
        newTaskButton.tintColor = .systemBlue
        newTaskButton.setTitleColor(.systemBlue, for: .normal)
        newTaskButton.contentEdgeInsets = UIEdgeInsets(
            top: newTaskButton.imageEdgeInsets.top,
            left: newTaskButton.imageEdgeInsets.left,
            bottom: newTaskButton.imageEdgeInsets.bottom,
            right: newTaskButton.imageEdgeInsets.right + 6
        )
        newTaskButton.titleEdgeInsets = UIEdgeInsets(
            top: 0,
            left: 6,
            bottom: 0,
            right: -6
        )
        newTaskButton.addTarget(self, action: #selector(TasksViewController.onNewTaskButtonTapped), for: .touchUpInside)
    }

    private func configureCollectionView() {
        var configuration = UICollectionLayoutListConfiguration(appearance: .plain)
        configuration.trailingSwipeActionsConfigurationProvider = { [unowned self] indexPath in
            let delete = UIContextualAction(style: .destructive, title: "Delete") { action, view, completion in
                self.viewModel.onTaskDeleted(atOffsets: .init(integer: indexPath.row))
            }
            return UISwipeActionsConfiguration(actions: [delete])
        }
        let layout = UICollectionViewCompositionalLayout.list(using: configuration)
        collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)

        collectionView.backgroundColor = .clear

        let cellRegistration = UICollectionView.CellRegistration<TaskCollectionViewListCell, Item> { (cell, indexPath, item) in
            cell.onCommit = { [unowned self] result in
                if case .success(let task) = result {
                    self.viewModel.onTaskAdded(task: task)
                }
            }
            cell.update(with: item)
        }

        dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
            (collectionView: UICollectionView, indexPath: IndexPath, item: Item) -> UICollectionViewCell? in
            return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item)
        }
    }

    @objc func onNewTaskButtonTapped() {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.main])
        let newItem = Item(task: .init(), taskRepository: Factory.create())
        snapshot.appendItems(viewModel.taskCellViewModels + [newItem])
        dataSource!.apply(snapshot, animatingDifferences: true)
    }
}

基本的にはSwitfUIの宣言的なUIと対応する形でUIKit側ではStackViewで宣言しています

またViewModelのBindingも、UIKit側から明示的にCombineのPublisherをsinkしてあげてCollectionViewに反映させています

3. UIKit + RxSwift

さて、いよいよ仕上げです

Combine製のViewModelをRxSwiftに置き換えます

ViewとViewModelの差分としては以下のようになりました

diff --git a/TodoApp/Views/UIKit + Combine/TasksViewController.swift b/TodoApp/Views/UIKit + Combine/TasksViewController.swift
index 704e546..3eb9e76 100644
--- a/TodoApp/Views/UIKit + Combine/TasksViewController.swift
+++ b/TodoApp/Views/UIKit + Combine/TasksViewController.swift
@@ -9,7 +9,7 @@ final class TasksViewController: UIViewController {

     typealias Item = TaskCellViewModel

-    private var cancellables = Set<AnyCancellable>()
+    private let bag = DisposeBag()

     let viewModel: TasksViewModel = .init(taskRepository: Factory.create())

@@ -42,14 +42,14 @@ final class TasksViewController: UIViewController {
         ])

         viewModel.onAppear()
-        viewModel.$taskCellViewModels
-            .sink { [unowned self] cellViewModels in
+        viewModel.taskCellViewModels
+            .subscribe(onNext: { [unowned self] cellViewModels in
                 var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
                 snapshot.appendSections([.main])
                 snapshot.appendItems(cellViewModels)
                 self.dataSource.apply(snapshot, animatingDifferences: true)
-            }
-            .store(in: &cancellables)
+            })
+            .disposed(by: bag)
     }

     private func configureNewTaskButton() {
@@ -103,11 +103,11 @@ final class TasksViewController: UIViewController {
         }
     }

-    @objc func onNewTaskButtonTapped() {
+    @objc private func onNewTaskButtonTapped() {
         var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
         snapshot.appendSections([.main])
         let newItem = Item(task: .init(), taskRepository: Factory.create())
-        snapshot.appendItems(viewModel.taskCellViewModels + [newItem])
+        snapshot.appendItems(viewModel.taskCellViewModels.value + [newItem])
         dataSource!.apply(snapshot, animatingDifferences: true)
     }
 }

diff --git a/TodoApp/ViewModels/TasksViewModel.swift b/TodoApp/ViewModels/TasksViewModel.swift
index 3df8715..5270ef3 100644
--- a/TodoApp/ViewModels/TasksViewModel.swift
+++ b/TodoApp/ViewModels/TasksViewModel.swift
@@ -2,11 +2,11 @@ import Foundation
 import Combine

 class TasksViewModel: ObservableObject {
-    private var cancellables = Set<AnyCancellable>()
+    private let bag = DisposeBag()

-    @Published var taskCellViewModels = [TaskCellViewModel]()
+    let taskCellViewModels = BehaviorRelay<[TaskCellViewModel]>(value: [])

     private let taskRepository: TaskRepository

     init(taskRepository: TaskRepository) {
         self.taskRepository = taskRepository
@@ -26,35 +26,40 @@ class TasksViewModel: ObservableObject {

     private func fetchTasks() {
         taskRepository.fetch()
-            .replaceError(with: [])
             .map { [taskRepository] tasks in
-                tasks.map { TaskCellViewModel(task: $0, taskRepository: taskRepository) }
+                tasks.map { RxTaskCellViewModel(task: $0, taskRepository: taskRepository) }
             }
-            .subscribe(on: DispatchQueue.global())
-            .receive(on: DispatchQueue.main)
-            .assign(to: \.taskCellViewModels, on: self)
-            .store(in: &cancellables)
+            .subscribeOn(ConcurrentDispatchQueueScheduler(qos: .default))
+            .observeOn(MainScheduler.instance)
+            .subscribe(onSuccess: { [unowned self] viewModels in
+                self.taskCellViewModels.accept(viewModels)
+            })
+            .disposed(by: bag)
     }

     private func removeTasks(atOffsets indexSet: IndexSet) {
-        indexSet.lazy.map { self.taskCellViewModels[$0] }.forEach { taskCellViewModel in
-            taskRepository.delete(taskID: taskCellViewModel.task.id)
-                .subscribe(on: DispatchQueue.global())
-                .receive(on: DispatchQueue.main)
-                .sink(receiveCompletion: { print($0) }, receiveValue: { [unowned self] _ in
-                    self.taskCellViewModels.remove(atOffsets: indexSet)
+        indexSet.lazy.map { self.taskCellViewModels.value[$0] }.forEach { taskCellViewModel in
+            taskRepository.delete(taskID: taskCellViewModel.task.value.id)
+                .subscribeOn(ConcurrentDispatchQueueScheduler(qos: .default))
+                .observeOn(MainScheduler.instance)
+                .subscribe(onSuccess: { [unowned self] _ in
+                    var current = self.taskCellViewModels.value
+                    current.remove(atOffsets: indexSet)
+                    self.taskCellViewModels.accept(current)
                 })
-                .store(in: &cancellables)
+                .disposed(by: bag)
         }
     }

     private func addTask(task: Task) {
         taskRepository.save(task: task)
-            .subscribe(on: DispatchQueue.global())
-            .receive(on: DispatchQueue.main)
-            .sink(receiveCompletion: { print($0) }, receiveValue: { [unowned self] _ in
-                self.taskCellViewModels.append(TaskCellViewModel(task: task, taskRepository: self.taskRepository))
+            .subscribeOn(ConcurrentDispatchQueueScheduler(qos: .default))
+            .observeOn(MainScheduler.instance)
+            .subscribe(onSuccess: { [unowned self] _ in
+                var current = self.taskCellViewModels.value
+                current.append(RxTaskCellViewModel(task: task, taskRepository: self.taskRepository))
+                self.taskCellViewModels.accept(current)
             })
-            .store(in: &cancellables)
+            .disposed(by: bag)
     }
 }

意外と差分が小さいですね

基本的なCombineのオペレーターはすべてRxSwiftで表現可能ですので、それらを愚直に置き換えていく作業になりました
Combineの@PublishedはRxRelayのBehaviorRelayで置き換えることにしましたが、ここに関しては人によって異なるポイントかもしれません

UIKit→SwiftUI移行を改めて考える

ここまでTODOアプリをSwiftUI→UIKitに書き換えてきましたが、その逆であるUIKit→SwiftUI移行を行うにしても、やはり足掛かりとして重要なのは ViewとModelを繋ぐ部分のI/F だと感じます

例えばMVPのような手続き的にViewとModelを繋ぐアーキテクチャではなく、BindingによるViewへの繋ぎ込みが作れていると、UIKit→SwiftUI移行についても考えやすいと思います

iOS12をまだ切れていないプロジェクトを例にするならば、SwiftUI移行は以下のような手順になるのかなと考えます

  1. MVVMに設計を移行
  2. iOS12を切る
  3. ViewModelをCombine製に移行
  4. ViewをSwiftUI製に移行

意外とわかりきった手順になってしまいましたね😅

まとめ

今回は簡単なTODOアプリを作ってみることで、SwiftUI移行について真剣に考えてみました

結論としてMVVM導入を最初のステップとして書きましたが、これはあくまでも一つの案に過ぎません

単純に「新しい画面を作る場合だけSwiftUIで作る」という方針で十分だったりするかもしれませんし、
例えば、本記事ではまだまだ以下のようなことも考慮できていません

  • 他のViewとの値の受け渡しは?
  • SwiftUIによる複雑度の高いUIをどこまで組める?
  • ViewModelのテストの書き方は?
  • etc...

また、チームで扱っているプロジェクトの規模によってはFluxやComposable Architectureのようなアプリ全体のデータを扱いやすいアーキテクチャを採用することもあるかと思います

そういった議論をチームでする上でのファーストステップとして、本記事がその議論の叩きとなれば幸いです🙏

改めてですが、今回扱ったTODOアプリのサンプルコードは以下のリポジトリにて公開してあります

参考