SwiftUI + VIPER + Observationを組み合わせた実装サンプル例
1. はじめに
iOS 17 で正式リリースされた Observation Frameworkは、@Observable
マクロとSwiftコンパイラのマクロ機構を活用し、従来のObservableObject / @Published
を置き換える より軽量で型安全なリアクティブレイヤー を提供します。
一方、クリーンアーキテクチャの系譜に連なる VIPER は、
🌾 VIPERの略は下記の通り
- V: ... View(SwiftUI)
- I: ... Interactor
- P: ... Presenter
- E: ... Entity
- R: ... Router
の様に5つの責務にアプリを分割し、機能単位の疎結合化とテスト容易性 を追求するアーキテクチャとして知られています。
本記事では、Observation Frameworkがもたらす "状態管理のシンプルさ" と、VIPERが実現する "責務分離の厳格さ" を両立させたサンプルアプリを題材にして、
-
Router → Entity → Interactor → Presenter → View
というデータフローの全体像 -
@Observable
による差分検知と画面遷移の連携ポイント
を順に解説をしていきます。
2. 今回のサンプル概要&参考資料
前章でVIPERとObservation の相性やねらいを整理しましたが、実際にコードを追う前に 「どこに何があるのか」 「どのファイルが何を担当しているのか」 を把握しておくと良さそうに思います。
2-1. サンプル概要:
【動作確認用コード】
【画像キャプチャ】
認証用画面① | 認証用画面② |
---|---|
![]() |
![]() |
TabBar一覧画面① | TabBar一覧画面② |
---|---|
![]() |
![]() |
2-2. 参考記事:
2-3. 実装方針図解ノート:
3. コードから読み解く実装ポイント解説
ここからは GitHub 上のサンプルコードを手がかりに、Router → Entity → Interactor → Presenter → View
の順で責務の流れをトレースしながら、@Observable
がどこで画面再描画をトリガーし、VIPER のレイヤー分離とどう噛み合っているかを具体的に探っていきます。
3-1. 画面遷移ハンドリング用のRouter定義:
1. 画面遷移を司るRouter定義:
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
部分における認証時とそうでない時のハンドリング処理:
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:
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:
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:
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:
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:
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:
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
}
}
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
}
}
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:
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:
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:
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:
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