🍡

SwiftUI + VIPER + Observationを組み合わせた実装サンプル例

に公開

1. はじめに

iOS 17 で正式リリースされた Observation Frameworkは、@ObservableマクロとSwiftコンパイラのマクロ機構を活用し、従来のObservableObject / @Publishedを置き換える より軽量で型安全なリアクティブレイヤー を提供します。

https://developer.apple.com/documentation/observation

一方、クリーンアーキテクチャの系譜に連なる VIPER は、

🌾 VIPERの略は下記の通り

  • V: ... View(SwiftUI)
  • I: ... Interactor
  • P: ... Presenter
  • E: ... Entity
  • R: ... Router

の様に5つの責務にアプリを分割し、機能単位の疎結合化とテスト容易性 を追求するアーキテクチャとして知られています。

本記事では、Observation Frameworkがもたらす "状態管理のシンプルさ" と、VIPERが実現する "責務分離の厳格さ" を両立させたサンプルアプリを題材にして、

  1. Router → Entity → Interactor → Presenter → Viewというデータフローの全体像
  2. @Observableによる差分検知と画面遷移の連携ポイント

を順に解説をしていきます。

2. 今回のサンプル概要&参考資料

前章でVIPERとObservation の相性やねらいを整理しましたが、実際にコードを追う前に 「どこに何があるのか」 「どのファイルが何を担当しているのか」 を把握しておくと良さそうに思います。

2-1. サンプル概要:

【動作確認用コード】

https://github.com/fumiyasac/SimpleObservationViperExample

【画像キャプチャ】

認証用画面① 認証用画面②
認証用画面① 認証用画面②
TabBar一覧画面① TabBar一覧画面②
TabBar一覧画面① TabBar一覧画面②

2-2. 参考記事:

https://www.youtube.com/watch?v=jS01NbyPoVY&list=PLDVNuhuDdqyeSMpFdfV-gOSBMZfTdTjRH

https://fortee.jp/iosdc-japan-2023/proposal/99cc507c-4fd4-4d5c-be72-a21a0a11bee3

https://tech.stmn.co.jp/entry/2023/09/01/132141

https://medium.com/cr8resume/viper-architecture-for-ios-project-with-simple-demo-example-7a07321dbd29

2-3. 実装方針図解ノート:

3. コードから読み解く実装ポイント解説

ここからは GitHub 上のサンプルコードを手がかりに、Router → Entity → Interactor → Presenter → Viewの順で責務の流れをトレースしながら、@Observableがどこで画面再描画をトリガーし、VIPER のレイヤー分離とどう噛み合っているかを具体的に探っていきます。

3-1. 画面遷移ハンドリング用のRouter定義:

1. 画面遷移を司るRouter定義:

AppRouter.swift
import Foundation
import Observation

@Observable class AppRouter {

    // MARK: - Enum

    enum Route {
        case authenticate
        case mainTabBar
    }

    // MARK: - Property

    var currentRoute: Route = .authenticate

    // MARK: - Function

    func navigateToMainTabBar() {
        currentRoute = .mainTabBar
    }
}

2. @main部分における認証時とそうでない時のハンドリング処理:

ObservationViperExampleApp.swift
import SwiftUI

@main
struct ObservationViperExampleApp: App {

    // MARK: - Property

    // MEMO: AppDelegate
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    // Router定義をここで設定する
    @State private var router = AppRouter()

    // MARK: - Body

    var body: some Scene {
        WindowGroup {
            Group {
                switch router.currentRoute {
                case .authenticate:
                    AuthenticationView(
                        presenter: AuthenticationPresenter(
                            interactor: AuthenticationInteractor(),
                            router: router
                        )
                    )
                case .mainTabBar:
                    TabView {
                        FeedView(
                            presenter: FeedPresenter(
                                interactor: FeedInteractor()
                            )
                        )
                        .tabItem {
                            VStack {
                                Image(systemName: "newspaper.circle.fill")
                                Text("Feed")
                            }
                        }
                        .tag(0)
                        GalleryView(
                            presenter: GalleryPresenter(
                                interactor: GalleryInteractor()
                            )
                        )
                        .tabItem {
                            VStack {
                                Image(systemName: "photo.circle.fill")
                                Text("Gallery")
                            }
                        }
                        .tag(1)
                        GuidanceView()
                            .tabItem {
                                VStack {
                                    Image(systemName: "bubble.right.circle.fill")
                                    Text("Guidance")
                                }
                            }
                            .tag(2)
                    }
                    .accentColor(Color(uiColor: UIColor(code: "#f88c75")))
                }
            }
        }
    }
}

3-2. 認証用Login画面:

1. AccessToken格納用のEntity:

AccessTokenEntity.swift
import Foundation

struct AccessTokenEntity: Hashable, Decodable {

    // MARK: - Property

    let token: String

    // MARK: - Enum

    private enum Keys: String, CodingKey {
        case token
    }

    // MARK: - Initializer

    init(token: String) {
        self.token = token
    }

    init(from decoder: Decoder) throws {

        // JSONの配列内の要素を取得する
        let container = try decoder.container(keyedBy: Keys.self)

        // JSONの配列内の要素にある値をDecodeして初期化する
        self.token = try container.decode(String.self, forKey: .token)
    }

    // MARK: - Hashable

    // MEMO: Hashableプロトコルに適合させるための処理

    func hash(into hasher: inout Hasher) {
        hasher.combine(token)
    }

    static func == (lhs: AccessTokenEntity, rhs: AccessTokenEntity) -> Bool {
        return lhs.token == rhs.token
    }
}

2. 認証処理用のPresenter/Interactorで利用するProtocolを定義したContract:

AuthenticationContract.swift
import Foundation

// MARK: - Protocol

protocol AuthenticationPresenterProtocol {
    var isLoading: Bool { get }
    var errorMessage: String? { get }
    func login(email: String, password: String)
    func validateToken()
}

protocol AuthenticationInteractorProtocol {
    func login(email: String, password: String) async throws -> AccessTokenEntity
    func getStoredToken() -> String?
    func validateToken(_ token: String) async throws -> Bool
}

3. APIリクエスト関連処理&Keychain関連処理をするInteractor:

AuthenticationInteractor.swift
import Foundation

final class AuthenticationInteractor: AuthenticationInteractorProtocol {

    // MARK: - Function

    func login(email: String, password: String) async throws -> AccessTokenEntity {

        do {
            let accessTokenEntity = try await APIClientManager.shared.generateAccessToken(email: email, password: password)
            KeychainAccessManager.shared.saveJsonAccessToken(accessTokenEntity.token)
            return accessTokenEntity
        } catch {
            // MEMO: 厳密にはエラーハンドリングが必要
            throw APIError.error(message: "認証処理に失敗しました。メールアドレスとパスワードのご確認をお願いします。")
        }
    }

    func getStoredToken() -> String? {
        KeychainAccessManager.shared.getAuthenticationHeader()
    }

    func validateToken(_ token: String) async throws -> Bool {
        do {
            let accessTokenEntity = try await APIClientManager.shared.verifyAccessToken()
            return !accessTokenEntity.token.isEmpty
        } catch {
            // MEMO: 厳密にはエラーハンドリングが必要
            throw APIError.error(message: "トークンの有効期限が失効しています。再度ログイン処理をお願いします。")
        }
    }
}

4. View要素の処理とInteractorを接続するPresenter:

AuthenticationPresenter.swift
import Foundation
import Observation

@Observable
final class AuthenticationPresenter: AuthenticationPresenterProtocol {

    // MARK: - Property (Dependency)

    private let interactor: AuthenticationInteractorProtocol
    private let router: AppRouter

    // MARK: - Property (Computed)

    private var _isLoading: Bool = false
    private var _errorMessage: String?

    // MARK: - Property (`@Observable`)

    var isLoading: Bool {
        _isLoading
    }

    var errorMessage: String? {
        _errorMessage
    }

    // MARK: - Initializer

    init(interactor: AuthenticationInteractorProtocol, router: AppRouter) {
        self.interactor = interactor
        self.router = router
    }

    // MARK: - Function

    func login(email: String, password: String) {

        // Loading状態にする
        _isLoading = true
        _errorMessage = nil

        // 入力されたEメール・パスワードで認証処理を実行する
        Task { @MainActor in
            do {
                let _ = try await interactor.login(email: email, password: password)
                // 👉 認証処理が成功した場合にMainTabBar画面に切り替える
                router.navigateToMainTabBar()
            } catch {
                _errorMessage = """
                ログインに失敗しました。
                入力情報に誤りがないかをご確認ください。
                """
            }
        }

        // 処理が完了した後にはLoading状態を元に戻す
        _isLoading = false
    }

    func validateToken() {

        // Tokenがデバイス内に存在するかを確認する
        if let token = interactor.getStoredToken() {

            // Loading状態にする
            _isLoading = true

            // 格納されたJWTの認証状態確認を実施する
            Task { @MainActor in
                do {
                    let isValid = try await interactor.validateToken(token)
                    if isValid {
                        // 👉 トークンの有効期限がまだ続いている場合にMainTabBar画面に切り替える
                        router.navigateToMainTabBar()
                    }
                } catch {
                    _errorMessage = """
                    セッションの有効期限が切れました。
                    再度ログイン処理をお願いします。
                    """
                }
            }

            // 処理が完了した後にはLoading状態を元に戻す
            _isLoading = false
        }
    }
}

5. 画面表示用のView:

AuthenticationView.swift
import SwiftUI

struct AuthenticationView: View {

    // MARK: - Property

    @State private var email = ""
    @State private var password = ""

    // MARK: - Presenter

    private let presenter: AuthenticationPresenterProtocol

    // MARK: - Initializer

    init(presenter: AuthenticationPresenterProtocol) {
        self.presenter = presenter
    }

    // MARK: - Body

    var body: some View {
        NavigationStack {
            VStack(alignment: .leading, spacing: 20) {
                Text("Eメールアドレス:")
                    .font(.callout)
                    .bold()
                    .foregroundStyle(.gray)
                TextField("Email:", text: $email)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                    .autocapitalization(.none)
                Text("パスワード:")
                    .font(.callout)
                    .bold()
                    .foregroundStyle(.gray)
                SecureField("Password:", text: $password)
                    .textFieldStyle(RoundedBorderTextFieldStyle())

                // もしエラーが発生した場合にはエラーメッセージを表示する
                if let errorMessage = presenter.errorMessage {
                    Text(errorMessage)
                        .foregroundColor(.red)
                }

                // Loading状態の場合はIndicatorを表示し、そうでない時はログインボタンを表示する
                if presenter.isLoading {
                    ProgressView()
                } else {
                    HStack {
                        Spacer()
                        Button(action: {
                            presenter.login(email: email, password: password)
                        }, label: {
                            Text("ログイン")
                                .font(.body)
                                .foregroundColor(Color(uiColor: UIColor(code: "#f88c75")))
                                .background(.white)
                                .frame(width: 240.0, height: 48.0)
                                .cornerRadius(24.0)
                                .overlay(
                                    RoundedRectangle(cornerRadius: 24.0)
                                        .stroke(Color(uiColor: UIColor(code: "#f88c75")), lineWidth: 1.0)
                                )
                        })
                        Spacer()
                    }
                    .disabled(email.isEmpty || password.isEmpty)
                    .padding(.vertical, 8.0)
                }
            }
            .padding()
            .onFirstAppear {
                // 初回表示時に1度だけ認証処理を実行する
                presenter.validateToken()
            }
            // Navigation表示に関する設定
            .navigationTitle("🔑Login")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

3-3. TabBar一覧内のFeed画面:

1. 画面表示内容格納用のEntity:

GalleryPhotoEntity.swift
import Foundation

struct GalleryPhotoEntity: Hashable, Decodable {

    // MARK: - Property
    
    let id: Int
    let title: String
    let thumbnail: String
    
    // MARK: - Enum

    private enum Keys: String, CodingKey {
        case id
        case title
        case thumbnail
    }

    // MARK: - Initializer

    init(
        id: Int,
        title: String,
        thumbnail: String
    ) {
        self.id = id
        self.title = title
        self.thumbnail = thumbnail
    }

    init(from decoder: Decoder) throws {

        // JSONの配列内の要素を取得する
        let container = try decoder.container(keyedBy: Keys.self)

        // JSONの配列内の要素にある値をDecodeして初期化する
        self.id = try container.decode(Int.self, forKey: .id)
        self.title = try container.decode(String.self, forKey: .title)
        self.thumbnail = try container.decode(String.self, forKey: .thumbnail)
    }

    // MARK: - Hashable

    // MEMO: Hashableプロトコルに適合させるための処理

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

    static func == (lhs: GalleryPhotoEntity, rhs: GalleryPhotoEntity) -> Bool {
        return lhs.id == rhs.id
            && lhs.title == rhs.title
            && lhs.thumbnail == rhs.thumbnail
    }
}
CategoryRankingEntity.swift
import Foundation

struct CategoryRankingEntity: Hashable, Decodable {

    // MARK: - Property
    
    let id: Int
    let name: String
    let rank: Int
    
    // MARK: - Enum

    private enum Keys: String, CodingKey {
        case id
        case name
        case rank
    }

    // MARK: - Initializer

    init(
        id: Int,
        name: String,
        rank: Int
    ) {
        self.id = id
        self.name = name
        self.rank = rank
    }

    init(from decoder: Decoder) throws {

        // JSONの配列内の要素を取得する
        let container = try decoder.container(keyedBy: Keys.self)

        // JSONの配列内の要素にある値をDecodeして初期化する
        self.id = try container.decode(Int.self, forKey: .id)
        self.name = try container.decode(String.self, forKey: .name)
        self.rank = try container.decode(Int.self, forKey: .rank)
    }

    // MARK: - Hashable

    // MEMO: Hashableプロトコルに適合させるための処理

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

    static func == (lhs: CategoryRankingEntity, rhs: CategoryRankingEntity) -> Bool {
        return lhs.id == rhs.id
            && lhs.name == rhs.name
            && lhs.rank == rhs.rank
    }
}
InformationFeedEntity.swift
import Foundation

struct InformationFeedEntity: Hashable, Decodable {

    // MARK: - Property
    
    let id: Int
    let title: String
    let catchCopy: String
    let summary: String
    let thumbnail: String
    
    // MARK: - Enum

    private enum Keys: String, CodingKey {
        case id
        case title
        case catchCopy = "catch_copy"
        case summary
        case thumbnail
    }

    // MARK: - Initializer

    init(
        id: Int,
        title: String,
        catchCopy: String,
        summary: String,
        thumbnail: String
    ) {
        self.id = id
        self.title = title
        self.catchCopy = catchCopy
        self.summary = summary
        self.thumbnail = thumbnail
    }

    init(from decoder: Decoder) throws {

        // JSONの配列内の要素を取得する
        let container = try decoder.container(keyedBy: Keys.self)

        // JSONの配列内の要素にある値をDecodeして初期化する
        self.id = try container.decode(Int.self, forKey: .id)
        self.title = try container.decode(String.self, forKey: .title)
        self.catchCopy = try container.decode(String.self, forKey: .catchCopy)
        self.summary = try container.decode(String.self, forKey: .summary)
        self.thumbnail = try container.decode(String.self, forKey: .thumbnail)
    }

    // MARK: - Hashable

    // MEMO: Hashableプロトコルに適合させるための処理

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

    static func == (lhs: InformationFeedEntity, rhs: InformationFeedEntity) -> Bool {
        return lhs.id == rhs.id
            && lhs.title == rhs.title
            && lhs.catchCopy == rhs.catchCopy
            && lhs.summary == rhs.summary
            && lhs.thumbnail == rhs.thumbnail
    }
}

2. 画面内容表示用のPresenter/Interactorで利用するProtocolを定義したContract:

FeedContract.swift
import Foundation

// MARK: - Protocol

protocol FeedPresenterProtocol {
    var isLoading: Bool { get }
    var errorMessage: String? { get }
    var pickupFeeds: [PickupFeedEntity] { get }
    var categoryRankings: [CategoryRankingEntity] { get }
    var informationFeeds: [InformationFeedEntity] { get }
    func fetchInitialFeeds()
    func fetchNextInformationFeeds()
}

protocol FeedInteractorProtocol {
    func fetchPickupFeeds() async throws -> [PickupFeedEntity]
    func fetchCategoryRankings() async throws -> [CategoryRankingEntity]
    func fetchInformationFeeds(page: Int) async throws -> InformationFeedPageEntity
}

3. APIリクエスト関連処理をするInteractor:

FeedInteractor.swift
import Foundation

final class FeedInteractor: FeedInteractorProtocol {

    // MARK: - Function

    func fetchPickupFeeds() async throws -> [PickupFeedEntity] {
        return try await APIClientManager.shared.getPickupFeeds()
    }
    
    func fetchCategoryRankings() async throws -> [CategoryRankingEntity] {
        return try await APIClientManager.shared.getCategoryFeeds()
    }
    
    func fetchInformationFeeds(page: Int) async throws -> InformationFeedPageEntity {
        return try await APIClientManager.shared.getInformationFeedPage(page)
    }
}

4. View要素の処理とInteractorを接続するPresenter:

FeedPresenter.swift
import Foundation
import Observation

@Observable
final class FeedPresenter: FeedPresenterProtocol {

    // MARK: - Property (Dependency)

    private let interactor: FeedInteractorProtocol

    // MARK: - Property (Computed)

    private var _isLoading: Bool = false
    private var _errorMessage: String?
    private var _pickupFeeds: [PickupFeedEntity] = []
    private var _categoryRankings: [CategoryRankingEntity] = []
    private var _informationFeeds: [InformationFeedEntity] = []

    @ObservationIgnored
    private var _page: Int = 1

    @ObservationIgnored
    private var _hasNextPage: Bool = true

    // MARK: - Property (`@Observable`)

    var isLoading: Bool {
        _isLoading
    }

    var errorMessage: String? {
        _errorMessage
    }

    var pickupFeeds: [PickupFeedEntity] {
        _pickupFeeds
    }

    var categoryRankings: [CategoryRankingEntity] {
        _categoryRankings
    }

    var informationFeeds: [InformationFeedEntity] {
        _informationFeeds
    }

    // MARK: - Initializer

    init(interactor: FeedInteractorProtocol) {
        self.interactor = interactor
    }

    func fetchInitialFeeds() {
        // Loading状態にする
        _isLoading = true

        Task { @MainActor in
            do {
                async let pickupFeeds = try await interactor.fetchPickupFeeds()
                async let categoryRankings = try await interactor.fetchCategoryRankings()
                async let informationPerPage = try await interactor.fetchInformationFeeds(page: 1)

                _pickupFeeds = try await pickupFeeds
                _categoryRankings = try await categoryRankings
                _informationFeeds = try await informationPerPage.information
                _hasNextPage = try await informationPerPage.hasNextPage
                _page += 1
                _errorMessage = nil

            } catch {
                _errorMessage = """
                初回情報の取得に失敗しました。
                """
            }
        }
        
        // 処理が完了した後にはLoading状態を元に戻す
        _isLoading = false
    }

    func fetchNextInformationFeeds() {

        if _hasNextPage == false {
            return
        }

        Task { @MainActor in
            do {
                let informationPerPage = try await interactor.fetchInformationFeeds(page: _page)
                _informationFeeds += informationPerPage.information
                _hasNextPage = informationPerPage.hasNextPage
                _page += 1
                _errorMessage = nil

            } catch {}
        }
    }
}

5. 画面表示用のView:

FeedView.swift
import SwiftUI

struct FeedView: View {

    // MARK: - Presenter

    private let presenter: FeedPresenterProtocol

    // MARK: - Initializer

    init(presenter: FeedPresenterProtocol) {
        self.presenter = presenter
    }

    // MARK: - Body

    var body: some View {
        // MEMO: UIKitのNavigationController表示を実施する
        NavigationStack {
            Group {
                switch (presenter.isLoading, presenter.errorMessage) {
                case (true, _):
                    // Loading Indicatorを表示する
                    ExecutingConnectionView()
                case (_, presenter.errorMessage) where presenter.errorMessage != nil:
                    // Error Message画面を表示する
                    ConnectionErrorView(
                        tapButtonAction: {
                            presenter.fetchInitialFeeds()
                        }
                    )
                default:
                    // ScrollViewを利用したCarousel表示やPagination処理をするView要素を配置する
                    ScrollView {
                        PickupFeedCarouselView(pickupFeedEntities: presenter.pickupFeeds)
                            .padding(.top, 16.0)
                        CategoryRankingTagsView(categoryRankingEntities: presenter.categoryRankings)
                            .padding(.top, 8.0)
                            .padding(.bottom, 4.0)
                        LazyVStack {
                            ForEach(presenter.informationFeeds, id: \.id) { informationFeedEntity in
                                InformationFeedView(informationFeedEntity: informationFeedEntity)
                                    .onAppear {
                                        if informationFeedEntity.id == presenter.informationFeeds.count {
                                            presenter.fetchNextInformationFeeds()
                                        }
                                    }
                            }
                        }
                    }
                }
            }
            .onFirstAppear {
                presenter.fetchInitialFeeds()
            }
            // Navigation表示に関する設定
            .navigationTitle("🗞️FeedView")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

4. まとめ

SwiftUIとVIPERという一見別々のアプローチを@Observableでうまく結びつけている点が非常に興味深いですね。従来の責務分離を保ちながら、SwiftUI特有のリアクティブな画面更新を自然に取り込めるところが大きなメリットと感じました。

特にPresenterを@Observableとすることで、状態を直接SwiftUIのViewにバインドしやすくなり、コードの見通しが良くなる印象です。

一方で、Routerによる画面遷移やKeychain管理など、アプリの規模が大きくなるほどアーキテクチャの恩恵が大きくなる反面、学習コストはやや高めかもしれません。

それでも、しっかりと責務を分割したいプロジェクトにはとても有効な組み合わせだと思います。

Discussion