📝

【SwiftUI】Repositoryパターン

2024/01/18に公開
2

Repositoryパターンとは

Repositoryパターンはデータ管理において、アプリケーションのビジネスロジックとデータアクセス層を分離することで、コードの再利用性を高め、メンテナンスを容易にします。Repositoryパターンはデータソース(UserDefaults、Core Data、Firebaseなど)へのすべてのアクセスをカプセル化し、データ操作を抽象化することで、他のアプリケーション層から独立させます。これにより、データソースが変更された場合でも、アプリのビジネスロジック層に大きな影響を与えることなく、スムーズに対応できるようになります。

サンプルコード

@main
struct RepositoryPatternApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView(
                viewModel: ContentViewModel(
                    repository: UserDefaultsPersonRepository()
                )
            )
        }
    }
}

struct Person: Codable, Identifiable, Hashable {
    let name: String
    var age: Int
    let id: UUID

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

/// データソースに対する制約
/// これによりViewModelがデータソースの変更の影響を受けにくくなります。
protocol PersonRepository {
    func save(persons: [Person]) throws
    func fetchAllPersons() throws -> [Person]
}

final class MockUserDefaultsPersonRepository: PersonRepository {
    func save(persons: [Person]) throws {
        print("Mock save function called.")
    }

    func fetchAllPersons() throws -> [Person] {
        print("Mock fetchAllPersons called.")
        let persons: [Person] = [
            .init(name: "Naruto", age: 17),
            .init(name: "Sasuke", age: 17),
            .init(name: "Sakura", age: 17),
            .init(name: "Kakashi", age: 31),
        ]
        return persons
    }
}

enum UserDefaultsPersonRepositoryError: Error {
    case dataNotFound
    case decodingError
    case encodingError
}

final class UserDefaultsPersonRepository: PersonRepository {
    private let userDefaultsKey = "person"

    func save(persons: [Person]) throws {
        do {
            let encodedData = try JSONEncoder().encode(persons)
            UserDefaults.standard.set(encodedData, forKey: userDefaultsKey)
        } catch {
            print("💥", error)
            throw UserDefaultsPersonRepositoryError.encodingError
        }
    }

    func fetchAllPersons() throws -> [Person] {
        guard let personData = UserDefaults.standard.data(forKey: userDefaultsKey)
        else { throw UserDefaultsPersonRepositoryError.dataNotFound }
        do {
            let persons = try JSONDecoder().decode([Person].self, from: personData)
            return persons
        } catch {
            print("💥", error)
            throw UserDefaultsPersonRepositoryError.decodingError
        }
    }
}

@MainActor
protocol ContentViewModelProtcol: ObservableObject {
    var persons: [Person] { get }
    func appendPerson(name: String)
    func removePerson(at offset: IndexSet)
}

@MainActor
final class ContentViewModel: ContentViewModelProtcol {
    @Published private(set) var persons: [Person] = []
    private let repository: PersonRepository

    init(repository: PersonRepository) {
        do {
            self.repository = repository
            self.persons = try fetchAllPersons()
        } catch {
            print("💥", error)
        }
    }

    func appendPerson(name: String) {
        do {
            let addtionalPerson = Person(name: name, age: Int.random(in: 10...50))
            var newPersons = persons
            newPersons.append(addtionalPerson)
            try savePersons(newPersons)
            persons = newPersons
        } catch {
            print("💥", error)
        }
    }

    func removePerson(at offset: IndexSet) {
        let prePersons = persons
        do {
            for i in offset {
                let removeTarget = persons[i]
                persons.removeAll { person in
                    person.id == removeTarget.id
                }
                try savePersons(self.persons)
            }
        } catch {
            persons = prePersons
            print("💥", error)
        }
    }

    private func savePersons(_ persons: [Person]) throws {
        try repository.save(persons: persons)
    }

    private func fetchAllPersons() throws -> [Person] {
        return try repository.fetchAllPersons()
    }
}

struct ContentView<ViewModel: ContentViewModelProtcol>: View {
    @StateObject var viewModel: ViewModel

    var body: some View {
        NavigationStack {
            List {
                ForEach(viewModel.persons) { person in
                    let text = person.name + " (" + person.age.description + ")"
                    Text(text)
                }
                .onDelete { viewModel.removePerson(at: $0) }
            }
            .navigationTitle("Persons")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("追加") {
                        let num = Int.random(in: 10000...99999)
                        viewModel.appendPerson(name: "No. \(num)")
                    }
                }
            }
        }
    }
}

#Preview {
    ContentView(
        viewModel: ContentViewModel(
            repository: MockUserDefaultsPersonRepository()
        )
    )
}

サンプルコードの説明

このサンプルコードでは、Personというシンプルなデータモデルを扱うPersonRepositoryプロトコルを設計しました。UserDefaultsPersonRepositoryは、実際にUserDefaultsを使用してPersonオブジェクトを保存・取得する具体的な実装を提供します。また、テストやデモ用のMockUserDefaultsPersonRepositoryは、開発プロセスを簡略化するためのダミーデータを提供します。

ContentViewModelは、このリポジトリを使用してデータ操作(追加、削除、取得)を行います。ViewModelは、UIコンポーネントとデータソースの間の橋渡しをする役割を担い、UI層はデータの詳細から独立しています。

まとめ

このアプローチにより、データソースの変更が必要な場合(UserDefaultsからFirebaseへの移行など)にも、UI層やビジネスロジック層の大幅な変更を行うことなく、スムーズに対応できます。また、単体テストやUIテストが容易になり、アプリの品質とメンテナンス性が向上します。SwiftUIとRepositoryパターンを組み合わせることで、より強固で柔軟なiOSアプリの開発が可能になります。

Discussion

hrsma2ihrsma2i

質問です。データソースごとの差異、例えば

  • Firestore: id に @DocumentID つけて自動生成する。
  • SwiftData: struct ではなく class を使う必要がある。さらに class に @Model を付ける必要がある。

がある場合、 Person struct をデータソースに依存させないよう、データソースごとに別の struct や class を定義しなければいけないのでしょうか?そして、「データソースに依存しない struct ↔ データソースごとの struct/class」の変換は、各リポジトリの save や fetchAllPersons メソッド内で行うことになるのでしょうか?

平成n年生まれ@iOSエンジニア志望平成n年生まれ@iOSエンジニア志望

その認識で間違いないと思います。私だったらデータベースでPersonDTOを定義し、そのメンバーメソッドとしてconvertToPersonを定義し、savefetchAllPersons内で使用しますね。