はじめに
こんにちはゆきおです
前回はやや大規模めなアプリを考慮して、機能ごとにディレクトリを切り、かつロジックは責務の分離を意識した設計というのを考えました。
後になってよく調べたら最近出てきたThe Composable Architectureと似ていることに気づきました。
https://qiita.com/kazuyuki_11E0/items/1a83dcbfaa41c5a6fc5b
TCAでも言われていますが、いわゆるボイラープレート化やファイル数の増加がネックですね。
今回は逆に「めっちゃシンプルに出来ないか?」と思いました。
SwiftUIではちらほらViewModel不要論みたいなものが起こっています。
もはやModelとViewだけの、いわゆるMVパターンですね。
小規模なアプリであればこれで十分じゃねとも思うところがあるので、シンプルな設計について考えていきたいと思います。
設計してみる
今回もGeminiと相談して詰めていきます。
とりあえず「名言をランダムで表示する」というアプリを提示してくれたのでみてみます。
import SwiftUI
// --------------------------------
// 📦 Model (モデル層)
// --------------------------------
// APIから受け取るデータの構造を定義します。
// ロジックは持たず、純粋なデータ形式です。
struct Quote: Codable, Identifiable {
let id: String
let content: String
let author: String
// APIのフィールド名が `id` ではないため、
// `_id` を `id` にマッピングします。
enum CodingKeys: String, CodingKey {
case id = "_id"
case content
case author
}
}
// --------------------------------
// 🖼️ View (ビュー層)
// --------------------------------
// UIの見た目と状態を管理します。
struct QuoteView: View {
// MARK: - State Properties
// Viewが持つべき状態を @State で宣言します。
// これらが変更されると、UIが自動的に再描画されます。
@State private var quote: Quote?
@State private var isLoading = false
@State private var errorMessage: String?
// MARK: - Body
// 現在の状態に基づいて、UIの見た目を宣言的に記述します。
var body: some View {
NavigationStack {
VStack(spacing: 30) {
Spacer()
// 状態に応じて表示を切り替える
if let errorMessage = errorMessage {
// エラー表示
ContentUnavailableView(
"読み込みに失敗しました",
systemImage: "wifi.exclamationmark",
description: Text(errorMessage)
)
} else if isLoading && quote == nil {
// 初回読み込み中の表示
ProgressView("名言を取得中...")
} else if let quote = quote {
// 名言の表示
quoteContent(quote)
} else {
// 初期状態(本来は表示されない)
ContentUnavailableView(
"名言がありません",
systemImage: "text.quote"
)
}
Spacer()
// ボタン
Button(action: {
// ボタンが押されたら非同期タスクとして名言を取得
Task {
await fetchQuote()
}
}) {
// 読み込み中かどうかでボタンの見た目を変更
if isLoading {
ProgressView()
} else {
Label("次の名言", systemImage: "arrow.clockwise")
}
}
.buttonStyle(.borderedProminent)
.disabled(isLoading) // 読み込み中はボタンを無効化
.padding()
}
.navigationTitle("ランダムな名言")
.task {
// Viewが最初に表示されたときに一度だけ実行される
// ここで初回のデータ取得を行います。
await fetchQuote()
}
}
}
// MARK: - Private View Components
// body が大きくなりすぎないように、UI部品を関数として切り出す
@ViewBuilder
private func quoteContent(_ quote: Quote) -> some View {
VStack {
Text(quote.content)
.font(.title)
.multilineTextAlignment(.center)
.padding()
Text("- \(quote.author)")
.font(.headline)
.foregroundColor(.secondary)
}
.padding(.horizontal)
}
// --------------------------------
// ⚙️ Logic (ロジック)
// --------------------------------
// Viewに紐づくロジックを private なメソッドとして記述します。
private func fetchQuote() async {
// 連続タップを防ぐため、すでに読み込み中なら何もしない
guard !isLoading else { return }
isLoading = true
errorMessage = nil // エラーメッセージをリセット
// defer を使うことで、この関数を抜けるときに必ず isLoading が false になる
defer { isLoading = false }
do {
let url = URL(string: "https://api.quotable.io/random")!
let (data, _) = try await URLSession.shared.data(from: url)
let decodedQuote = try JSONDecoder().decode(Quote.self, from: data)
self.quote = decodedQuote
} catch {
self.errorMessage = "ネットワーク接続を確認してください: \(error.localizedDescription)"
}
}
}
// MARK: - Preview
// プレビュー用のコード
#Preview {
QuoteView()
}
モデル、ビュー、コンポーネント、ロジックがひとまとめになっています。
どの規模までならこれが通用するのか考えものですがまあファイル数が激減するので楽ですよね。
ビューやロジックがひとまとまりなのはVue.js風味ではありますね。
MVパターンて流石にモデルとビューは別じゃね?と思いつつ、ViewModel不要ということなのでビュー内にロジックなどまとめるというのがポイントなのかな。
いざプロジェクト開いてみてフォルダが「Model」と「View」だけだったら面食らいますね。
少し構成を考えてみました。
QuoteApp/
├── Models/
│ └── Quote.swift
└── Views/
├── Index/
│ └── QuoteIndexView.swift
└── Components/
├── QuoteDisplay.swift
└── FetchButton.swift
ModelとViewと大きく分けつつ、ViewをIndexとComponentに分けてみました。
Indexに全体のUI+ロジックを持たせつつ、コンポーネントを同じ階層に作っておくパターンですね。
コンポーネントは「表示」とか「イベント通知」とか、極力シンプルな責務を持ちIndexで状態の定義など行うようにしてみました。
import SwiftUI
// 🧠 状態管理とロジック、コンポーネントの統合を担当する親ビュー
struct QuoteIndexView: View {
// MARK: - State Properties
// この画面全体の「状態」をすべてここで管理する
@State private var quote: Quote?
@State private var isLoading = false
@State private var errorMessage: String?
// MARK: - Body
// コンポーネントを組み合わせて画面を構築する
var body: some View {
NavigationStack {
VStack(spacing: 30) {
Spacer()
// 表示用コンポーネントに、現在の状態を渡す
QuoteDisplay(
quote: quote,
isLoading: isLoading,
errorMessage: errorMessage
)
Spacer()
// ボタンコンポーネントに、状態と「実行してほしい処理」を渡す
FetchButton(isLoading: isLoading) {
// ボタンがタップされたらこの処理が実行される
Task {
await fetchQuote()
}
}
}
.navigationTitle("ランダムな名言")
.task {
// 画面の初回表示時にデータを取得する
await fetchQuote()
}
}
}
// MARK: - Logic
// API通信などのロジックはすべてここに記述する
private func fetchQuote() async {
guard !isLoading else { return }
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let url = URL(string: "https://api.quotable.io/random")!
let (data, _) = try await URLSession.shared.data(from: url)
let decodedQuote = try JSONDecoder().decode(Quote.self, from: data)
self.quote = decodedQuote
} catch {
self.errorMessage = "ネットワーク接続を確認してください。"
}
}
}
// MARK: - Preview
#Preview {
QuoteIndexView()
}
まあロジックが複雑化したり大きなビューになってしまったら少し大変な気はしますが、1ファイルにあるのはある意味見通しがいいのかもしれませんね。
「MARK:」など活用すれば移動も出来ますし、これはこれで気に入りました。
ロジックなど何も実装していませんが、それも切り分けることでIndexの見やすさは向上していきそうです。
肥大化していくにつれて「ファイル分けたほうが、、、」ってなる未来が見えますが。
本当に思いつきでやってみただけなので今回はこの辺で。
これも前回の記事同様にブラッシュアップしていきたいと思います。