iOSアプリ開発ガイド2 - Q&A編
この記事はiOSアプリ開発ガイド3記事の中編となるQ&A集です。前編を先に読むことをお勧めします。
アーキテクチャに関するよくある質問
Q1: 小規模なプロジェクトでもこの複雑なアーキテクチャが必要ですか?
A: 小規模プロジェクトでは、全レイヤーを完全に分離する必要はないかもしれません。しかし、基本的な責任分離(ViewModel、UseCaseなど)は将来の拡張性のために取り入れることをお勧めします。一度に見る必要があるコードを減らすことは認知負荷の軽減に繋がりますし、AIコーディングにも有利です。
Q2: UseCaseとRepositoryの違いは何ですか?
A: UseCaseとRepositoryはそれぞれ異なる責任を持っています
Repository:
- データの取得・保存に関する操作を抽象化する
- データソース(リモートAPI、ローカルDB)の詳細を隠蔽する
- データの変換(DTOからエンティティへの変換など)を行う
- キャッシュ戦略の実装を担当する
UseCase:
- ビジネスロジックとドメインルールを実装する
- 必要に応じて複数のRepositoryを組み合わせる
- データの整合性やバリデーションを確認する
- アプリ固有のビジネスフローを制御する
例えば、「ユーザープロフィールを表示する」という機能において
-
UserRepository
: ユーザーデータをAPIやデータベースから取得する -
UserProfileUseCase
: ユーザーデータに加えて、権限確認や関連データの取得、適切なデータフォーマットへの変換などを行う
Q3: ViewModelをプロトコルとして定義する利点は何ですか?
A: ViewModelをプロトコルとして定義する主な利点は次のとおりです。
- インターフェースと実装の分離: Viewは具体的な実装ではなく、インターフェースに依存するため、実装の詳細を変更しても影響が少なくなります
- テスト容易性: モックのViewModelを容易に作成でき、Viewのテストが簡単になります
- 拡張性: 異なる実装(例:本番用、プレビュー用、テスト用)を提供できます
- 関心の分離: ViewModelのインターフェースは、Viewが必要とするプロパティとメソッドのみを公開します
- コンパイル時の安全性: プロトコルに準拠していない実装は、コンパイル時にエラーになります
例えば、プレビュー用の簡易ViewModelを作成できます
// プロトコル定義
protocol ProfileViewModel: ObservableObject {
var username: String { get }
var bio: String { get }
var isLoading: Bool { get }
func fetchProfile()
func updateBio(_ newBio: String)
}
// 本番実装
class ProfileViewModelImpl: ProfileViewModel {
// 実装...
}
// プレビュー用
class PreviewProfileViewModel: ProfileViewModel {
@Published var username: String = "JohnDoe"
@Published var bio: String = "iOSエンジニア"
@Published var isLoading: Bool = false
func fetchProfile() { /* 何もしない */ }
func updateBio(_ newBio: String) { bio = newBio }
}
// View
struct ProfileView<ViewModel: ProfileViewModel>: View {
@ObservedObject var viewModel: ViewModel
var body: some View {
// Viewの実装...
}
}
// プレビュー
struct ProfileView_Previews: PreviewProvider {
static var previews: some View {
ProfileView(viewModel: PreviewProfileViewModel())
}
}
Q4: UseCaseを常にactor として実装する必要がありますか?
A: UseCaseを必ずactorとして実装する必要はありませんが、並行アクセスからの保護が必要な場合や、内部状態を持つ場合にはactorを使用することを推奨します。
actor を使用するケース
- UseCaseが内部状態を管理している(例:キャッシュやカウンターなど)
- 複数のスレッドから同時にアクセスされる可能性がある
- 複雑な非同期処理を行う
actor を使用しないケース
- UseCaseが純粋な関数としての振る舞いを持ち、内部状態を持たない
- スレッド安全性の懸念がない
- パフォーマンスが特に重要な場合
例えば、検索履歴を管理するUseCaseはactorとして実装すると良いでしょう。
actor SearchHistoryInteractor: SearchHistoryUseCase {
private var recentSearches: [String] = []
private let maxHistoryItems = 10
func addSearchTerm(_ term: String) {
// 重複を削除
recentSearches.removeAll { $0 == term }
// 先頭に追加
recentSearches.insert(term, at: 0)
// 最大数を超える場合は古いものを削除
if recentSearches.count > maxHistoryItems {
recentSearches = Array(recentSearches.prefix(maxHistoryItems))
}
}
func getRecentSearches() -> [String] {
return recentSearches
}
func clearHistory() {
recentSearches.removeAll()
}
}
一方、単純なデータ変換や取得のみを行うUseCaseには、通常の構造体やクラスで十分です。
struct DateFormatterUseCase {
func formatToLocalDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
formatter.locale = Locale.current
return formatter.string(from: date)
}
}
Q5: Coordinatorパターンと今回のRouterの違いは何ですか?
A: CoordinatorパターンとRouterパターンは両方とも画面遷移の責任を分離するために使用されますが、いくつかの違いがあります
Coordinator:
- アプリ全体の画面フローを管理する
- 階層構造を持ち、親子関係がある(子Coordinatorを持つことができる)
- 一般的にアプリ全体の流れを制御するために使用される
- 画面遷移だけでなく、機能間の調整も担当する
Router (Wireframe):
- 単一の機能モジュール内の画面遷移を担当する
- 一般的に平坦な構造で、階層関係は持たない
- 特定の機能や画面の遷移に特化している
- モジュール間の依存関係の注入も担当することが多い
私たちのアーキテクチャでは、各機能モジュールにRouterを設け、それぞれのモジュール内の画面遷移とDIを担当させています。これにより、モジュールの独立性を高め、再利用性を向上させています。
将来的に機能が増えてアプリが複雑になった場合、アプリ全体のナビゲーションフローを管理するためにCoordinatorを導入し、各機能モジュールのRouterと連携させることも検討できます。
// Router(現在の実装)
protocol ProfileRouter {
func showEditProfile(for userId: String)
func showSettings()
func dismiss()
}
// Coordinator(将来的な拡張)
class MainCoordinator: Coordinator {
private let navigationController: UINavigationController
private var childCoordinators: [Coordinator] = []
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func start() {
showDashboard()
}
func showDashboard() {
let router = DashboardRouter(navigationController: navigationController)
let viewController = DashboardAssembler.createModule(router: router)
navigationController.pushViewController(viewController, animated: true)
}
func startProfileFlow(for userId: String) {
let profileCoordinator = ProfileCoordinator(navigationController: navigationController)
childCoordinators.append(profileCoordinator)
profileCoordinator.start(with: userId)
}
}
実装上の疑問点と回答
Q6: ViewControllerとSwiftUIのViewの使い分けはどのようにすべきですか?
A: 基本的な使い分け方法は以下のような感じかなと思いますが、私は今のところSwiftUI単体では使わず、レイアウトを全てSwiftUIで実装した場合でも親としてUIViewControllerを残しています。
逆に既存の画面をStoryboardなどで実装しており、移行が難しい場合やフレームワークの都合などでそもそもSwiftUI化が向かない場合はその画面はUIViewControllerだけ使うのもなしではないと思います。
-
新規画面の開発
- 可能な限りSwiftUI Viewで実装する
- UIKit特有の機能が必要な場合のみViewControllerを使用する
-
既存画面の拡張
- 既存のViewControllerを活用しつつ、内部のコンテンツ表示にSwiftUIを使用する
- 大きな書き換えよりも、段階的にSwiftUIの割合を増やす
-
具体的な使い分け
-
SwiftUIを使用するケース
- ユーザー入力フォーム
- リスト表示
- カスタムUI要素
- アニメーション
-
ViewControllerを使用するケース
- 複雑なナビゲーション構造
- カメラやARのような特殊なUIKit機能
- 既存のUIKitライブラリとの統合
- iOS 15以前をサポートする必要がある複雑なUI
-
Q7: SwiftUI状態管理の仕組み(@State, @StateObject, @ObservedObject)をいつ使い分けるべきですか?
A: SwiftUIの状態管理プロパティラッパーには、それぞれ適切なユースケースがあります。
-
@State
- 単一のView内で完結する単純な状態管理に使用
- 値型(構造体、列挙型など)を保持
- 他のViewと共有する必要がない一時的な状態
struct LoginView: View { @State private var username = "" @State private var password = "" @State private var isShowingAlert = false var body: some View { // ... } }
-
@StateObject
- Viewが所有するObservableObjectのインスタンスに使用
- Viewのライフサイクル全体で保持される
- Viewが最初に作成されるときに一度だけ初期化される
struct ProductView: View { @StateObject private var viewModel = ProductViewModel() var body: some View { // ... } }
-
@ObservedObject
- 外部から渡されるObservableObjectに使用
- Viewは所有権を持たず、他の場所から提供される
- Viewが再構築されるとリセットされる可能性がある
struct ProductView: View { @ObservedObject var viewModel: ProductViewModel var body: some View { // ... } }
-
@EnvironmentObject
- 環境を通じて間接的に提供されるObservableObject
- アプリ全体やView階層全体で共有される状態
- 深い階層のViewに直接依存を渡す必要がない場合に便利
struct RootView: View { @StateObject private var appState = AppState() var body: some View { ContentView() .environmentObject(appState) } } struct NestedView: View { @EnvironmentObject var appState: AppState var body: some View { // appStateを使用 } }
基本原則
- Viewが作成するObservableObjectには
@StateObject
を使用 - 外部から渡されるObservableObjectには
@ObservedObject
を使用 - 広範囲で共有するグローバルな状態には
@EnvironmentObject
を使用 - View内でのみ使用する単純な状態には
@State
を使用
Q8: Swiftの非同期処理(async/await)とCombineはどのように使い分けるべきですか?
A: Swift ConcurrencyとCombineはどちらも非同期処理を扱いますが、異なる強みがあります。使い分けの基本原則は以下の通りです。
Swift Concurrency(async/await)を使用するケース
- 単発の非同期処理(APIリクエスト、データベース操作など)
- シーケンシャルな処理フロー
- エラーハンドリングが重要な処理
- バックグラウンド処理とタスクキャンセル
- Swift 5.5以上のみをサポートする場合
class UserService {
func fetchUser(id: String) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw APIError.invalidResponse
}
return try JSONDecoder().decode(User.self, from: data)
}
func fetchAndCacheUser(id: String) async throws -> User {
// シーケンシャルな処理
let user = try await fetchUser(id: id)
try await saveToCache(user)
return user
}
}
// ViewModel内での使用
func loadUserProfile() {
isLoading = true
Task {
do {
let user = try await userService.fetchUser(id: userId)
// UIの更新は常にMainActorで
await MainActor.run {
self.user = user
self.isLoading = false
}
} catch {
await MainActor.run {
self.error = error.localizedDescription
self.isLoading = false
}
}
}
}
Combineを使用するケース
- 連続する値のストリームの処理(ユーザー入力、センサーデータなど)
- 複数のデータソースの組み合わせと変換
- 宣言的なデータフロー
- 反応型プログラミング
- バックポートが必要な場合(iOS 13以降)
class SearchViewModel: ObservableObject {
@Published var searchQuery = ""
@Published var searchResults: [SearchResult] = []
@Published var isLoading = false
@Published var error: String?
private var cancellables = Set<AnyCancellable>()
private let searchService: SearchService
init(searchService: SearchService) {
self.searchService = searchService
// 検索クエリの変更を監視して検索を実行
$searchQuery
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.filter { !$0.isEmpty }
.handleEvents(receiveOutput: { [weak self] _ in
self?.isLoading = true
self?.error = nil
})
.flatMap { [weak self] query -> AnyPublisher<[SearchResult], Error> in
guard let self = self else {
return Fail(error: SearchError.unknown).eraseToAnyPublisher()
}
return self.searchService.search(query: query)
.catch { error -> AnyPublisher<[SearchResult], Error> in
return Fail(error: error).eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
.receive(on: RunLoop.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.error = error.localizedDescription
}
},
receiveValue: { [weak self] results in
self?.searchResults = results
self?.isLoading = false
}
)
.store(in: &cancellables)
}
}
両方を組み合わせる場合
- Combineで宣言的なデータフローを設定し、処理の実行はasync/awaitで行う
- UIの状態更新はCombineで行い、バックグラウンド処理はasync/awaitで行う
class ProductViewModel: ObservableObject {
@Published var products: [Product] = []
@Published var isLoading = false
@Published var errorMessage: String?
private let productService: ProductService
private var cancellables = Set<AnyCancellable>()
init(productService: ProductService) {
self.productService = productService
}
func loadProducts() {
isLoading = true
errorMessage = nil
// async/awaitをCombineワークフローに統合
Future<[Product], Error> { promise in
Task {
do {
let result = try await self.productService.fetchProducts()
promise(.success(result))
} catch {
promise(.failure(error))
}
}
}
.receive(on: RunLoop.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.errorMessage = error.localizedDescription
}
},
receiveValue: { [weak self] products in
self?.products = products
}
)
.store(in: &cancellables)
}
}
まとめ
Q&A編は以上です。テストや既存プロジェクトの別アーキテクチャからの移行ステップなどを応用編として公開したいと思います。
お読みいただきありがとうございました。
Discussion