😃

SwiftUI向け汎用的エラーハンドラー作ったった

2025/01/08に公開

はじめに

こんにちはゆきおです。
会社のConnpassイベントやらテックブログやらでこちらまで気が回らず、久しぶりの投稿となります。
2025年も頑張って発信していきます。

今回は3Dからは離れて、iOS開発に関する記事となります。
Webやら3Dやらやっておりますが一応iOSが自分の主軸のつもりですので…

ということで今回はどんなアプリでも付きものとなる 「エラーハンドリング」 について
日々考えていたので形にしてみました。

それなりのユースケースを想定して、「これがあればエラーハンドリングに関してササっと実装できるよね」っていうのを目指して作っております。

痒いとこに手が届くやつを目指して作りましたので参考までにご覧ください。
今回はClaudeさんの手を借りてSwiftUI向けに実装しております。

ErrorHandler

まずいろんなエラーを想定して、パターンや表示方法を定義しました。

Enum

// MARK: - Error Types
/// アプリケーション全体で使用するエラー型
enum AppError: Error, Identifiable {
    var id: String { localizedDescription }
    
    case network(underlying: Error)
    case invalidResponse
    case noData
    case decodingError(underlying: Error)
    case invalidData(reason: String)
    case validation(reason: String)
    case unauthorized
    case forbidden
    case notFound
    case unknown(Error)
    case invalidCredentials
    
    var userMessage: String {
        switch self {
        case .network:
            return "通信エラーが発生しました。通信環境をご確認ください。"
        case .invalidResponse, .noData:
            return "データの取得に失敗しました。"
        case .decodingError:
            return "データの処理中にエラーが発生しました。"
        case .invalidData(let reason):
            return "不正なデータです: \(reason)"
        case .validation(let reason):
            return "入力内容に問題があります: \(reason)"
        case .unauthorized:
            return "認証が必要です。再度ログインしてください。"
        case .forbidden:
            return "この操作を実行する権限がありません。"
        case .notFound:
            return "お探しの情報が見つかりませんでした。"
        case .unknown:
            return "予期せぬエラーが発生しました。"
        case .invalidCredentials:
            return "メールアドレスもしくはパスワードが違います。"
        }
    }
}

デザインの都合で文言を変える必要があらばここを変えていきます。

あとはよくあるパターンとして、ウィンドウを出すか赤文字でテキスト出すかというのも案件で実際に出会したのでそれも想定して定義しておきます。

// MARK: - Error Display Type
enum ErrorDisplayType {
    case alert(ErrorAction)   // アラートウィンドウで表示
    case inline(String)       // インラインで表示
}

アラートの場合は「OK」とか「戻る」とか出てくるので後述するActionを引数に持ち、赤文字アラートなど出したいときはinlineで引数にデザインで降りてきた文言を渡してあげましょう。

Action

// MARK: - Error Action Type
/// エラー発生時のアクション定義
enum ErrorAction {
    case retry(() async -> Void, buttonTitle: String = "再試行")
    case navigateBack(buttonTitle: String = "戻る")
    case dismiss(buttonTitle: String = "OK")
    
    var buttonTitle: String {
        switch self {
        case .retry(_, let title):
            return title
        case .navigateBack(let title):
            return title
        case .dismiss(let title):
            return title
        }
    }
}

ウィンドウを出して、ボタンを押した時にどうするかを定義しました。
文言も変えられるようにしています。
APIでエラーが起きた時に再試行するとか、ページを一つ戻るとか、無視するかです。

Logging

// MARK: - Error Logging Protocol
/// エラーログ記録のプロトコル
protocol ErrorLogging {
    func log(_ error: Error, file: String, line: Int, function: String)
}

/// 開発環境用のエラーロガー
class DevelopmentErrorLogger: ErrorLogging {
    func log(_ error: Error, file: String, line: Int, function: String) {
        print("🚨 Error occurred:")
        print("📝 File: \(file)")
        print("📍 Line: \(line)")
        print("⚡️ Function: \(function)")
        print("💥 Error: \(error)")
        
        if let appError = error as? AppError {
            print("📱 User Message: \(appError.userMessage)")
        }
    }
}

/// 本番環境用のエラーロガー
class ProductionErrorLogger: ErrorLogging {
    func log(_ error: Error, file: String, line: Int, function: String) {
        // Crashlyticsなどへのログ送信
        // Analytics.logError(error)
    }
}

こちらはログを管理します。
開発環境であれば発生箇所など特定できるよう細かくログを出し、本番であればFirebaseなどと連携するでしょうから、Crashlyticsなどに使用する想定でとりあえず実装しています。

ErrorHandler

// MARK: - Error Handler
/// エラー処理を管理するクラス
final class ErrorHandler: ObservableObject {
    @Published private(set) var currentError: AppError?
    @Published private(set) var errorAction: ErrorAction?
    @Published private(set) var inlineErrorMessage: String?
    
    private let logger: ErrorLogging
    
    init(logger: ErrorLogging? = nil) {
        self.logger = logger ?? {
#if DEBUG
            return DevelopmentErrorLogger()
#else
            return ProductionErrorLogger()
#endif
        }()
    }
    
    @MainActor
    func handle(
        _ error: Error,
        displayType: ErrorDisplayType,
        file: String = #file,
        line: Int = #line,
        function: String = #function
    ) {
        logger.log(error, file: file, line: line, function: function)
        
        currentError = (error as? AppError) ?? .unknown(error)
        
        switch displayType {
        case .alert(let action):
            errorAction = action
            inlineErrorMessage = nil
        case .inline(let message):
            inlineErrorMessage = message
            errorAction = nil
        }
    }
    
    @MainActor
    func dismissError() {
        currentError = nil
        errorAction = nil
        inlineErrorMessage = nil
    }
}

メインのエラー処理を管理するクラスです。
これのhandle()メソッドをViewModelとかで呼び出してエラーハンドリングを行います。

Enviroment Key

// MARK: - Navigation Path Environment
/// ナビゲーションパス用の環境値キー
private struct NavigationPathKey: EnvironmentKey {
    static let defaultValue: Binding<NavigationPath> = .constant(NavigationPath())
}

extension EnvironmentValues {
    var navigationPath: Binding<NavigationPath> {
        get { self[NavigationPathKey.self] }
        set { self[NavigationPathKey.self] = newValue }
    }
}

// MARK: - Error Handler Environment
/// エラーハンドラー用の環境値キー
private struct ErrorHandlerKey: EnvironmentKey {
    static let defaultValue = ErrorHandler()
}

extension EnvironmentValues {
    var errorHandler: ErrorHandler {
        get { self[ErrorHandlerKey.self] }
        set { self[ErrorHandlerKey.self] = newValue }
    }
}

画面間での状態共有や画面遷移のために実装しました。(いまいち理解が及んでいません

View Modifier

// MARK: - Error Alert View Modifier
struct ErrorAlert: ViewModifier {
    @ObservedObject var errorHandler: ErrorHandler
    @Environment(\.navigationPath) private var navigationPath
    @Environment(\.dismiss) private var dismiss
    
    func body(content: Content) -> some View {
        content
            .alert("エラー",
                   isPresented: Binding(
                    get: { errorHandler.currentError != nil && errorHandler.errorAction != nil },
                    set: { if !$0 { handleDismiss() } }
                   ),
                   actions: {
                if let action = errorHandler.errorAction {
                    Button(action.buttonTitle) {
                        handleAction(action)
                    }
                }
            }, message: {
                if let error = errorHandler.currentError {
                    Text(error.userMessage)
                }
            })
    }
    
    private func handleAction(_ action: ErrorAction) {
        switch action {
        case .retry(let retryAction, _):
            Task {
                await retryAction()
                errorHandler.dismissError()
            }
        case .navigateBack:
            handleNavigateBack()
        case .dismiss:
            handleDismiss()
        }
    }
    
    private func handleNavigateBack() {
        errorHandler.dismissError()
        if navigationPath.wrappedValue.count > 0 {
            navigationPath.wrappedValue.removeLast()
        } else {
            dismiss()
        }
    }
    
    private func handleDismiss() {
        errorHandler.dismissError()
    }
}

表示されるウィンドウをコンポーネント化し、ケースによってボタンを押した時の動作を定義します。

View Extension

// MARK: - View Extension
extension View {
    /// エラーアラートを表示するモディファイア
    /// - Parameter errorHandler: エラーハンドラーのインスタンス
    /// - Returns: エラーアラートが設定されたView
    func errorAlert(errorHandler: ErrorHandler) -> some View {
        modifier(ErrorAlert(errorHandler: errorHandler))
    }
}

ビューを拡張します。

全体としてはこんな感じです。

import SwiftUI

// MARK: - Error Types
/// アプリケーション全体で使用するエラー型
enum AppError: Error, Identifiable {
    var id: String { localizedDescription }
    
    case network(underlying: Error)
    case invalidResponse
    case noData
    case decodingError(underlying: Error)
    case invalidData(reason: String)
    case validation(reason: String)
    case unauthorized
    case forbidden
    case notFound
    case unknown(Error)
    case invalidCredentials
    
    var userMessage: String {
        switch self {
        case .network:
            return "通信エラーが発生しました。通信環境をご確認ください。"
        case .invalidResponse, .noData:
            return "データの取得に失敗しました。"
        case .decodingError:
            return "データの処理中にエラーが発生しました。"
        case .invalidData(let reason):
            return "不正なデータです: \(reason)"
        case .validation(let reason):
            return "入力内容に問題があります: \(reason)"
        case .unauthorized:
            return "認証が必要です。再度ログインしてください。"
        case .forbidden:
            return "この操作を実行する権限がありません。"
        case .notFound:
            return "お探しの情報が見つかりませんでした。"
        case .unknown:
            return "予期せぬエラーが発生しました。"
        case .invalidCredentials:
            return "メールアドレスもしくはパスワードが違います。"
        }
    }
}

// MARK: - Error Display Type
enum ErrorDisplayType {
    case alert(ErrorAction)   // アラートウィンドウで表示
    case inline(String)       // インラインで表示
}

// MARK: - Error Action Type
/// エラー発生時のアクション定義
enum ErrorAction {
    case retry(() async -> Void, buttonTitle: String = "再試行")
    case navigateBack(buttonTitle: String = "戻る")
    case dismiss(buttonTitle: String = "OK")
    
    var buttonTitle: String {
        switch self {
        case .retry(_, let title):
            return title
        case .navigateBack(let title):
            return title
        case .dismiss(let title):
            return title
        }
    }
}

// MARK: - Error Logging Protocol
/// エラーログ記録のプロトコル
protocol ErrorLogging {
    func log(_ error: Error, file: String, line: Int, function: String)
}

/// 開発環境用のエラーロガー
class DevelopmentErrorLogger: ErrorLogging {
    func log(_ error: Error, file: String, line: Int, function: String) {
        print("🚨 Error occurred:")
        print("📝 File: \(file)")
        print("📍 Line: \(line)")
        print("⚡️ Function: \(function)")
        print("💥 Error: \(error)")
        
        if let appError = error as? AppError {
            print("📱 User Message: \(appError.userMessage)")
        }
    }
}

/// 本番環境用のエラーロガー
class ProductionErrorLogger: ErrorLogging {
    func log(_ error: Error, file: String, line: Int, function: String) {
        // Crashlyticsなどへのログ送信
        // Analytics.logError(error)
    }
}

// MARK: - Error Handler
/// エラー処理を管理するクラス
final class ErrorHandler: ObservableObject {
    @Published private(set) var currentError: AppError?
    @Published private(set) var errorAction: ErrorAction?
    @Published private(set) var inlineErrorMessage: String?
    
    private let logger: ErrorLogging
    
    init(logger: ErrorLogging? = nil) {
        self.logger = logger ?? {
#if DEBUG
            return DevelopmentErrorLogger()
#else
            return ProductionErrorLogger()
#endif
        }()
    }
    
    @MainActor
    func handle(
        _ error: Error,
        displayType: ErrorDisplayType,
        file: String = #file,
        line: Int = #line,
        function: String = #function
    ) {
        logger.log(error, file: file, line: line, function: function)
        
        currentError = (error as? AppError) ?? .unknown(error)
        
        switch displayType {
        case .alert(let action):
            errorAction = action
            inlineErrorMessage = nil
        case .inline(let message):
            inlineErrorMessage = message
            errorAction = nil
        }
    }
    
    @MainActor
    func dismissError() {
        currentError = nil
        errorAction = nil
        inlineErrorMessage = nil
    }
}

// MARK: - Navigation Path Environment
/// ナビゲーションパス用の環境値キー
private struct NavigationPathKey: EnvironmentKey {
    static let defaultValue: Binding<NavigationPath> = .constant(NavigationPath())
}

extension EnvironmentValues {
    var navigationPath: Binding<NavigationPath> {
        get { self[NavigationPathKey.self] }
        set { self[NavigationPathKey.self] = newValue }
    }
}

// MARK: - Error Handler Environment
/// エラーハンドラー用の環境値キー
private struct ErrorHandlerKey: EnvironmentKey {
    static let defaultValue = ErrorHandler()
}

extension EnvironmentValues {
    var errorHandler: ErrorHandler {
        get { self[ErrorHandlerKey.self] }
        set { self[ErrorHandlerKey.self] = newValue }
    }
}

// MARK: - Error Alert View Modifier
struct ErrorAlert: ViewModifier {
    @ObservedObject var errorHandler: ErrorHandler
    @Environment(\.navigationPath) private var navigationPath
    @Environment(\.dismiss) private var dismiss
    
    func body(content: Content) -> some View {
        content
            .alert("エラー",
                   isPresented: Binding(
                    get: { errorHandler.currentError != nil && errorHandler.errorAction != nil },
                    set: { if !$0 { handleDismiss() } }
                   ),
                   actions: {
                if let action = errorHandler.errorAction {
                    Button(action.buttonTitle) {
                        handleAction(action)
                    }
                }
            }, message: {
                if let error = errorHandler.currentError {
                    Text(error.userMessage)
                }
            })
    }
    
    private func handleAction(_ action: ErrorAction) {
        switch action {
        case .retry(let retryAction, _):
            Task {
                await retryAction()
                errorHandler.dismissError()
            }
        case .navigateBack:
            handleNavigateBack()
        case .dismiss:
            handleDismiss()
        }
    }
    
    private func handleNavigateBack() {
        errorHandler.dismissError()
        if navigationPath.wrappedValue.count > 0 {
            navigationPath.wrappedValue.removeLast()
        } else {
            dismiss()
        }
    }
    
    private func handleDismiss() {
        errorHandler.dismissError()
    }
}

// MARK: - View Extension
extension View {
    /// エラーアラートを表示するモディファイア
    /// - Parameter errorHandler: エラーハンドラーのインスタンス
    /// - Returns: エラーアラートが設定されたView
    func errorAlert(errorHandler: ErrorHandler) -> some View {
        modifier(ErrorAlert(errorHandler: errorHandler))
    }
}

これを何か適当なサンプルアプリに適用してみます。
色々パターンはありますが、今回はログインの挙動のみ紹介します。

まずログイン画面にて、メールアドレスやパスワードが間違っていた場合は「メールアドレスもしくはパスワードが違います」というようなメッセージを、赤文字でテキストエリアの下に出します。

またそれ以外のエラー(何も入力されていない)などはウィンドウを出すというふうに実装していきます。

TopLevel

@main
struct ErrorHandlerApp: App {
    @StateObject private var errorHandler = ErrorHandler()
    @State private var navigationPath = NavigationPath()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.navigationPath, $navigationPath)  // NavigationPathの提供
                .environment(\.errorHandler, errorHandler)       // ErrorHandlerの提供
        }
    }
}

まずはアプリ全体で共有できるようEnviromentとして登録します。

MockAPI

import Foundation

class MockAPIService {
    enum APIError: Error {
        case networkError
        case invalidData
        case unauthorized
    }
    
    func fetchUsers(shouldFail: Bool = false) async throws -> [User] {
        // ネットワーク遅延をシミュレート
        try await Task.sleep(nanoseconds: 1_000_000_000)
        
        if shouldFail {
            throw AppError.network(underlying: APIError.networkError)
        }
        
        return [
            User(id: 1, name: "山田太郎", email: "taro@example.com"),
            User(id: 2, name: "鈴木花子", email: "hanako@example.com")
        ]
    }
    
    func loginUser(email: String, password: String) async throws {
        try await Task.sleep(nanoseconds: 1_000_000_000)
        
        if email.isEmpty || password.isEmpty {
            throw AppError.validation(reason: "メールアドレスとパスワードを入力してください")
        }
        
        if email != "test@example.com" || password != "password" {
            throw AppError.invalidCredentials
        }
    }
    
    func fetchUserPosts(userId: Int, shouldFail: Bool = false) async throws -> [Post] {
        try await Task.sleep(nanoseconds: 1_000_000_000)
        
        if shouldFail {
            throw AppError.noData
        }
        
        return [
            Post(id: 1, title: "投稿1", content: "これは1つ目の投稿です"),
            Post(id: 2, title: "投稿2", content: "これは2つ目の投稿です")
        ]
    }
}

LoginViewModel

import Foundation

class LoginViewModel: ObservableObject {
    @Published var email = ""
    @Published var password = ""
    @Published var isLoading = false
    @Published var isLoggedIn = false
    @Published var inlineErrorMessage: String?
    
    private let apiService = MockAPIService()
    
    func resetPassword() {
        password = ""
    }
    
    func resetAll() {
        email = ""
        password = ""
        isLoggedIn = false
        inlineErrorMessage = nil
    }
    
    func setError(_ message: String) {
        inlineErrorMessage = message
    }
    
    func clearError() {
        inlineErrorMessage = nil
    }
    
    func login() async throws {
        isLoading = true
        clearError()
        defer { isLoading = false }
        
        if isLoggedIn {
            resetAll()
            return
        }
        
        try await apiService.loginUser(email: email, password: password)
        isLoggedIn = true
        resetPassword()
    }
}

モックのAPIを実装し、今回はloginUser()を使用します。
ViewModelでログインのためのメソッド、リセットするためのメソッド、失敗時にパスワード欄だけリセットするメソッドなど実装します。

LoginView

import SwiftUI

struct LoginView: View {
    @StateObject private var viewModel = LoginViewModel()
    @Environment(\.errorHandler) private var errorHandler
    
    var body: some View {
        Form {
            Section(
                content: {
                    TextField("メールアドレス", text: $viewModel.email)
                        .textContentType(.emailAddress)
                        .textInputAutocapitalization(.never)
                        .autocorrectionDisabled()
                    
                    SecureField("パスワード", text: $viewModel.password)
                },
                header: { Text("認証情報") },
                footer: {
                    if let errorMessage = errorHandler.inlineErrorMessage {
                        Text(errorMessage)
                            .foregroundColor(.red)
                            .font(.caption)
                    }
                }
            )
            
            Section {
                Button(action: {
                    Task {
                        await handleLogin()
                    }
                }) {
                    if viewModel.isLoading {
                        ProgressView()
                            .frame(maxWidth: .infinity)
                    } else {
                        Text(viewModel.isLoggedIn ? "ログアウト" : "ログイン")
                            .frame(maxWidth: .infinity)
                    }
                }
                .disabled(viewModel.isLoading)
            }
            
            if viewModel.isLoggedIn {
                Section {
                    Text("ログイン中:\(viewModel.email)")
                        .foregroundColor(.green)
                }
            }
        }
        .navigationTitle("ログインテスト")
        .errorAlert(errorHandler: errorHandler)
    }
    
    private func handleLogin() async {
        do {
            try await viewModel.login()
        } catch {
            if let appError = error as? AppError,
               case .invalidCredentials = appError {
                // インライン表示
                viewModel.resetPassword()
                errorHandler.handle(
                    error,
                    displayType: .inline(appError.userMessage)
                )
            } else {
                // アラート表示
                errorHandler.handle(
                    error,
                    displayType: .alert(.dismiss(buttonTitle: "OK"))
                )
            }
        }
    }
}

Viewでエラーメッセージの表示場所やスタイルをお好みで実装し、ログインボタンを押した時の挙動をhandleLogin()で実装します。
同じ「ログインボタンを押す」というユースケースでも、「メールアドレスとパスワードが入力されているが正しくない」ときはinvalidCredentialsケースに入り赤文字で対応する「appError.userMessage」が表示され、何も入力されていない時などはelseブロックに入りウィンドウが表示されます。

入力ミス

何も入力されていない

こんな感じでErrorHandlerの中身を意識せず、ViewModelやView側でチョチョイと書くことで任意の形・文言でエラーメッセージを表示し、その後の挙動も制御することができます。

ウィンドウで表示したいなーってとこなら「displayType: .alert(.dismiss(buttonTitle: "OK"))」の形で
文言をビュー上に表示したいなら「displayType: .inline(appError.userMessage)」の形でってな感じで使い分ければOKです。

文言や形式に関してはViewで、ボタンを押した後のロジック的な部分はViewModelで定義することで責務を分割することを意識しましょう(もっと言えばビジネスロジックに関してはUsecaseやRepositoryまで実装するべきかなとは思います。

終わり

以上で簡単ではありますが自家製エラーハンドラーの紹介でした。
AIを使うことでよく使う機能も簡単なライブラリのようなラッパークラスとしてサクッと実装できてとても便利ですね。
「この機能ってどのアプリでも実装するよなー」みたいな機能があったら共通化して所持しておくと、開発初期段階で一気にブーストできると思うのでぜひチャレンジしてみてください。
※ドキュメント作成も忘れずに

Discussion