SwiftUI移行について真剣に考える
WWDC19にて突如として公表された宣言的UIフレームワークであるSwiftUI、発表当時は多くのエンジニアが喜び興奮しましたが、実際に触ってみると痒いところに手が届かず、なかなかプロダクションに導入するのは難しいなという印象をみなさん受けたのではないでしょうか (そもそもiOS13の壁もありますが...)
しかしながら、翌年のWWDC20ではSwiftUIのさらなるアップデートが多数公開され、WidgetのようなSwiftUIでしか作れない機能もリリースされました
SwiftUIはメインのUIフレームワークとして今後もますますアップデートが重ねられ、本番導入への難易度は徐々に下がり、またこれから先3年, 4年と経てばSwiftUIにしか利用できないAPIの範囲はさらに拡大していくのではないかと個人的には考えています
それまでにSwiftUIへの緩やかな移行の方針をチームで検討しておく必要はあるでしょう
そこでようやっと本題ですが、本記事では SwiftUI移行について具体的にどのような流れで行えば良いかを真剣に考えてみようと思います
頭の中だけで結論を導き出すのはやはり難しいので、今回は以下のような手順で考えていこうと思います
- SwiftUI + Combineで簡単なアプリを作る
- SwiftUI製のViewをUIKitで書き換える
- Combineで書かれたBindingの処理をRxSwiftで置き換える
- 1〜3のステップからUIKit→SwiftUI移行の方針を逆算する
それではいきましょう!
(ちなみにこちらの記事は ミクシィグループ Advent Calendar 16日目の記事になります)
作成するアプリについて
以下のような簡単なTODOアプリを作ろうと思います
設計は単純なMVVMで、保存したデータはCoreDataで永続化します
作成したアプリは以下のリポジトリにて公開しています
1. SwiftUI + Combine
早速コードを見ていきましょう
以下はタスクのリスト表示をするViewです
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に渡すタスクの情報取得やタスク追加, 更新や削除などを行います
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 を使って組んでみました)
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移行は以下のような手順になるのかなと考えます
- MVVMに設計を移行
- iOS12を切る
- ViewModelをCombine製に移行
- ViewをSwiftUI製に移行
意外とわかりきった手順になってしまいましたね😅
まとめ
今回は簡単なTODOアプリを作ってみることで、SwiftUI移行について真剣に考えてみました
結論としてMVVM導入を最初のステップとして書きましたが、これはあくまでも一つの案に過ぎません
単純に「新しい画面を作る場合だけSwiftUIで作る」という方針で十分だったりするかもしれませんし、
例えば、本記事ではまだまだ以下のようなことも考慮できていません
- 他のViewとの値の受け渡しは?
- SwiftUIによる複雑度の高いUIをどこまで組める?
- ViewModelのテストの書き方は?
- etc...
また、チームで扱っているプロジェクトの規模によってはFluxやComposable Architectureのようなアプリ全体のデータを扱いやすいアーキテクチャを採用することもあるかと思います
そういった議論をチームでする上でのファーストステップとして、本記事がその議論の叩きとなれば幸いです🙏
改めてですが、今回扱ったTODOアプリのサンプルコードは以下のリポジトリにて公開してあります
Discussion