SwiftUIでMVCのiOSアプリをつくってみた
今週からiOSアプリのアーキテクチャーの勉強を進めています。
手始めにMVCで実装してみることにしました。
全然必然性はないんですが、SwiftUIでやることにしました。
またせっかくなので、async/awaitも使ってみることにしました。
そんな風に自分で要件をモリモリ積んでいったら、思った以上に時間溶けていきましたが、久々にプログラミングに没頭できました。
アーキテクチャーの勉強からは離れましたが、楽しかったです。
参考書
アーキテクチャーに関する知識は、こちら↓を参照しています。
以下の文中で「参考書」と出てきたらこの本のことを指します。
環境
- Xcode: 13.0
- iOS: 15.0
- Swift: 5.5
たぶん会社だと、なかなかこの環境では開発できないですね😅
原初MVCとCocoa MVC
MVCというのは、僕が新人研修で最初の方に習った考え方で、GUIのプログラミングをするときにはまず習う概念ではないでしょうか。
「MVCなんて常識だろ」と思ってましたが、ちゃんと掘っていくと深かったです。
MVCの元祖は、Smalltalkです。
そもそもGUIアプリケーションをつくるときに、UI部分とデータ処理部分をわけよう、というのは素朴な発想だと思います。
それがViewとModelですね。
その仲介役としてControllerを置く、というのが僕の新人研修で受けた説明だったと思います。
MVC自体は理解しやすいシンプルな概念だと思いますが、これが実装していくと、Controllerに何をどこまでやらせるかで悩むことになります。
そもそもViewの作成が、フレームワーク依存になるので、どうしてもどういう環境で開発しているかで、ViewとControllerの役割分担は影響を受けます。
参考書では、SmalltalkのMVCのことを、原初MVC(あるいは原始MVC、Original MVC)と呼んでいます。
詳細は後述します。
Smalltalkの機能に依存した設計でもあったので、その他の開発環境だと原初MVCの概念通りの開発ができませんでした。
故に「MVC」と一口に言っても、環境によってその中身はだいぶ違うことになりました。
iOSアプリ開発のMVCは、MVCの中でもだいぶ独特で、参考書の中ではCocoa MVCと呼んでいます。
出典は下記のAppleのドキュメントからだと思います。
アーカイブ化されたドキュメントなので、今AppleがCocoa MVCについてどう思っているのかは不明です。
2つのMVCについて、実際に書いたコードと一緒に見ていきたいと思います。
サンプルアプリの仕様
参考書だとMVCのサンプルアプリは数字のカウントアップ、カウントダウンができるだけのシンプルなアプリですが、
今後のことも考えるともうちょっと機能が欲しいので、みんな大好きGithubのAPI叩くやつにしました。
入力した文字列に応じて、Githubのユーザー検索かけて、
あるユーザーの列をタップすると、そのユーザーのリポジトリ一覧が見られる、というのが仕様です。
原初MVCで実装してみた
で、原初MVCの実装が↓です
参考書だとUIKitで頑張ってるので、色々無理やりなことをせざるを得なくなってますが、
SwiftUIを使うと、原初MVCもすっきり書けるように感じました。
原初MVCのポイントは、Viewを中心として設計されていることです。
View -> Controller -> Model、そしてModelはViewの参照を持たないため、
何らかの手段でViewはModelを監視して、データの更新タイミングで描画更新を行います。
ControllerとModelの生成もViewがやるので、本当にViewがオラオラしてます。
原初MVCの設計のView
アーキテクチャーのお勉強なので、こんな設計を意識しました。
ざっくりしたクラス図を書くとこんな感じです。
アプリを起動すると、GithubSearcherApp
からUsersSearchView
が呼ばれて、ここからすべてがはじまります。
このViewはテキスト入力ができる検索バーがあるだけのシンプルなViewです。
最初、検索バーといえばUISearchBar
を連想したので、SwiftUIでそれに相当するものがないので、UIKit -> SwiftUIのブリッジをして実装してたのですが、
途中でそんなことをしなくても、TextField
を使うとイベント処理が楽だということに気づきました。
一文字変化するたびにAPIを叩く構造にしたかったので、.onChange()
というモディファイアがありがたかったです。
TextField("user name", text: $searchText)
.onChange(of: searchText) { _ in
UserController(model: model, query: searchText).loadStart()
}
.textFieldStyle(RoundedBorderTextFieldStyle())
.keyboardType(.asciiCapable)
.padding()
原初MVCの設計のController
Viewはユーザーのテキスト編集を受け取ったら、Controllerを生成して、そこに対応するモデルオブジェクトを教えてやります。
SwiftUIを使うと、Controllerがめちゃくちゃ薄くなりました。
「こいつ要る?」とは思いましたが、アーキテクチャーのお勉強なので、残しました。
/// イベントの制御を行う構造体
struct UserController {
let model: GithubModel
let query: String
/// Modelにロード開始を要求する
public func loadStart() {
model.fetchUser(query: query)
}
}
宣言的UIだとController的な仕事をフレームワークが吸収している、と言えるのかもしれません。
なおこの実装だとControllerは使い捨てなので、structにしました。
原初MVCの設計のModel
ModelはView/Controllerのことを知りません。
しかし、ObservableObject
を継承して、Viewから監視するようにしています。
監視対象のクラスのプロパティに、Combineの@Published
をつけると、
値を代入されたタイミングで監視しているSwiftUIのViewの更新がかかります。
なので、Model側は参照を持つ必要がないんですね。
/// GithubのREST APIを叩いて、結果を返すクラス
class GithubModel: ObservableObject {
@Published var users = [User]()
@Published var isNotFound = false
そういえば、@Published
、Combineの中の要素だと思ってたんですが、ライブラリをimportしなくても使えました。
API叩くメソッドはそれなりに長いので、ポイントだけ抜粋しました。
最初普通にURLSession.shared.dataTask()
でやってたんですが、async/awaitで書き直したら相当すっきりしました。
マジでDXいいですねasync/await。
/// QueryをもとにGithubのユーザー検索APIを叩いて、結果をPublishする
public func fetchUser(query: String) {
users = [User]()
error = nil
isNotFound = false
guard !query.isEmpty,
let url = userSearchEndpoint(query: query) else { return }
Task {
let result = await fetch(url: url)
switch result {
case .success(let data):
guard let users = try? JSONDecoder().decode(Users.self, from: data) else {
error = .jsonParseError(String(data: data, encoding: .utf8) ?? "")
return
}
publishUsers(users: users)
case .failure(let error):
self.error = .responseError(error)
}
}
}
async/awaitはググると、ベータの情報が出てきて、正式採用されたものと若干違うところがありました。
たとえば
async {
await fetch()
}
みたいに書け、と書いてあるサイトが結構あったんですが、実際書くとこれだとダメでした。
Task
を使って書いてますが、これが正解なのかは正直よくわからないです。
で、fetch(url:)
の中身なんですが、こんな感じです。
@MainActor
private func fetch(url: URL) async -> Result<Data, Error> {
do {
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: url))
return .success(data)
} catch {
return .failure(error)
}
}
@MainActor
をつけなくても動きはしますが、後続処理で@Publishedのついたプロパティを更新→View更新という流れになるので、
そこでメインスレッドでやれ的なワーニングが出ます。
async/await入れる前は、DispatchQueue.main.async
だらけになって鬱陶しかったんですが、基本的になんか上手いこといってくれて嬉しいですね。
逆にバックグラウンドでいい処理をメインスレッドでやるオーバーヘッドを気にしないといけないんですかね?
async/awaitの構文の思想については、下記の記事を読んだら腑に落ちました。
初見だとちょっと思ってたのと違うなと感じたんですが、throws/tryと対応するものだと考えると、美しく見えてきました。
原初MVCその他
解説が長くなっちゃったので、その他の実装についてはコード読んでください。
リポジトリ一覧とるところも、だいたいユーザーの処理と同じです。
Cocoa MVCで実装してみた
リポジトリはこちらです。
Cocoa MVCの思想については、上述のAppleのアーカイブドキュメントに詳しいですが、Apple的にはViewの再利用性を高めたかったようです。
デザインパターン的にはMediatorパターンに分類されるみたいです。
とにかくViewとModelの再利用性を高めて、両者を完全な疎結合にするため、Controllerに諸々の処理を書け、という設計です。
僕は参考書をちゃんと理解するまで、このCocoa MVCをMVCだと思っていたので、原初MVCの方が理解に苦しみました。
今になって思うと、Cocoa MVCの方が特殊例ですね。
iOS開発の経験がある方なら、ViewController
にひたすらロジックを書いた経験は誰しもあるでしょう。
僕は会社で2000行超えのViewController
を見たこともあります。
FatViewController
と揶揄されますが、Cocoa MVCの設計を理解すると、ViewController
の肥大化は必然だったのでしょう。
Cocoa MVCのViewController
Cocoa MVCの全てはUIViewController
からはじまります。
ViewController
がviewもmodelも持って、ライフサイクルイベントに応じてviewとmodelを操作します。
本来同じ仕様のアプリで、どのぐらいControllerのコード量が違うかを見せつけるために、全部UIKitで書き換えるべきですが、
全部書き換えはキツいなあという気持ちになったので、SwiftUIとブリッジすることで楽をさせていただきました。
import UIKit
import SwiftUI
class ViewController: UIViewController {
private let model = GithubModel()
override func viewDidLoad() {
super.viewDidLoad()
let newViewController = UIHostingController(rootView: UsersSearchView(delegate: self, model: model))
newViewController.view.translatesAutoresizingMaskIntoConstraints = false
newViewController.view.heightAnchor.constraint(equalToConstant: UIScreen.main.bounds.height).isActive = true
newViewController.view.widthAnchor.constraint(equalToConstant: UIScreen.main.bounds.width).isActive = true
view.addSubview(newViewController.view)
}
}
extension ViewController: ViewProtocol {
func loadUser(query: String) {
model.fetchUser(query: query)
}
func loadReository(urlString: String) {
model.fetchRepositories(urlString: urlString)
}
}
ちょっと変則的ですけど、Cocoa MVCと一応呼んでもいいものができました。
Viewにはdelegateで自分の参照を渡しています。
この実装にしたところ、原初MVCのときと違って、リポジトリ一覧のModelが使い捨てじゃなくなったので、イニシャルを書かないといけなくなって、結構めんどくさかったです。
やっぱ参照型は良くないですね。
SwiftUIを使ってるので、あまり肥大化しませんでしたが、本来はViewの画面更新も画面遷移も全部ViewControllerに書くので、真面目に書けば数百行のコードになると思います。
SwiftUI指定で生成したプロジェクトをSceneDelegate起動に戻す
Cocoa MVCそのものとは関係ないんですが、ViewController使うために、AppDelegeate+SceneDelegateによる起動に戻す必要が出ました。
基本的には、新規プロジェクトでUIKitのプロジェクトつくって、自動生成された設定を参考にして変更していったんですが、だいぶ大変でした。
現在、SwiftUIを指定したプロジェクトはAppDelegateもinfo.plistもない状態でした。
AppDelegateはファイルコピって入れるだけなんですが、info.plistはちょっと困りました。
プロジェクトファイルのinfoタブに設定があるので、そこから設定すればOKなんですが、その途中で自動でinfo.plistが生成される?っぽいです。
「Application Scene Manifest」ってプロパティがあるので、それをよしなに設定してあげると、最初に起動するStoryboardか、
Storyboardを消してたらSceneDelegateの中に手動で詰めてるViewが出ることになります。
ただこの設定がどうやらリビルドで更新されないっぽくて、30分ぐらい苦しみました。
シミュレーターを変えるか、シミュレーター上のアプリを消すか、Device > Erase All ...かしないと、前の設定が残るっぽいです。
まとめ
アーキテクチャーの最初の記事ということで、サンプルアプリ作成のときのあれこれも盛りこんだため、長くなってしまいました。
次からはもう少し短く書こうと思います。。。
MVCは普段から書いているつもりでしたが、ちゃんと意識してみると、iOSのMVCの特殊性というのが見えてきました。
次回は同様のアプリをMVPで書いていこうと思います!
Discussion