【SwiftUI】Repositoryパターン
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
質問です。データソースごとの差異、例えば
がある場合、 Person struct をデータソースに依存させないよう、データソースごとに別の struct や class を定義しなければいけないのでしょうか?そして、「データソースに依存しない struct ↔ データソースごとの struct/class」の変換は、各リポジトリの save や fetchAllPersons メソッド内で行うことになるのでしょうか?
その認識で間違いないと思います。私だったらデータベースで
PersonDTO
を定義し、そのメンバーメソッドとしてconvertToPerson
を定義し、save
やfetchAllPersons
内で使用しますね。