
https://speakerdeck.com/ryunakayama/swiftuinishi-sitaxin-akitekutiyanodao-ru-nitiao-mu https://github.com/chatwork/svvs-sample
Observation
によりデータバインディングが簡潔に書ける(ように見える…。完全に互換しているかはちょっと不安)- Backendを含めてactorや@MainActorの利用箇所について考えた
- Viewのエラーハンドリングは省略
- テストを導入する場合は、例えばUserStoreに対してUserStoreProtocolを定義して、initでDIする方針

//
// ContentView.swift
// DataStoreDemo
//
// Created by HIROKI IKEUCHI on 2025/01/23.
//
import SwiftUI
import SwiftID
// MARK: - Model
struct User: Sendable, Identifiable, Hashable, Codable {
let id: ID
var name: String
var isBookmarked: Bool
struct ID: StringIDProtocol {
var rawValue: String
init(rawValue: String) {
self.rawValue = rawValue
}
}
static func createRandom() -> User {
let id = UUID().uuidString
return .init(id: "\(id)", name: "User: \(id.prefix(5))", isBookmarked: false)
}
}
// MARK: - View
struct ContentView: View {
@State private var viewState: UserViewState
init(userStore: UserStore? = nil) {
_viewState = .init(wrappedValue: UserViewState(userStore: userStore ?? .shared))
}
var body: some View {
List {
Section("Action") {
Button("Add") {
viewState.handleAddButtonTapped()
}
.frame(maxWidth: .infinity, alignment: .center)
Button("Delete", role: .destructive) {
viewState.handleDeleteButtonTapped()
}
.frame(maxWidth: .infinity, alignment: .center)
.disabled(viewState.users.isEmpty)
Button("Delete All", role: .destructive) {
viewState.handleDeleteAllButtonTapped()
}
.frame(maxWidth: .infinity, alignment: .center)
.disabled(viewState.users.isEmpty)
Toggle("Only Bookmarked", isOn: $viewState.showOnlyBookmarkedUsers)
}
Section("Users") {
ForEach(viewState.filteredUsers, id: \.self) { user in
HStack {
Text(user.name)
Spacer()
Button {
viewState.handleToggleUserBookmark(user)
} label: {
Image(systemName: user.isBookmarked ? "bookmark.fill" : "bookmark")
}
.buttonStyle(.plain)
.foregroundStyle(.tint)
}
}
}
}
.onAppear {
viewState.onAppear()
}
}
}
// MARK: - ViewState
@MainActor @Observable
final class UserViewState {
let userStore: UserStore
var showOnlyBookmarkedUsers: Bool = false
// init(userStore: UserStore? = nil) {
// self.userStore = userStore ?? .shared
// }
init(userStore: UserStore = UserStore.shared) {
self.userStore = userStore
}
var users: [User] {
userStore.values
}
var filteredUsers: [User] {
if showOnlyBookmarkedUsers {
return users.filter { $0.isBookmarked }
} else {
return users
}
}
func handleAddButtonTapped() {
Task {
let newUser = User.createRandom()
try await userStore.addValue(newUser)
}
}
func handleDeleteButtonTapped() {
Task {
guard let lastUser = users.last else {
return
}
try await userStore.deleteValue(lastUser)
}
}
func handleDeleteAllButtonTapped() {
Task {
try await userStore.deleteValues(users)
}
}
func handleToggleUserBookmark(_ user: User) {
Task {
var user = user
print(user)
user.isBookmarked.toggle()
print(user)
try await userStore.updateValue(user)
}
}
func onAppear() {
Task {
do {
try await userStore.loadAllValues()
} catch {
print(error.localizedDescription)
}
}
}
}
// MARK: - Store
@MainActor @Observable
final class UserStore {
static let shared: UserStore = .init()
private(set) var values: [User] = []
let userRepository: UserRepository
init(userRepository: UserRepository = .shared) {
self.userRepository = userRepository
}
// MARK: - CRUD
// MARK: Create
func addValue(_ user: User) async throws {
try await userRepository.addValue(user)
withAnimation {
self.values.append(user)
}
}
// MARK: Read
func loadAllValues() async throws {
let values = try await userRepository.fetchAllValues()
withAnimation {
self.values = values
}
}
// MARK: Update
func updateValue(_ newValue: User) async throws {
guard let index = values.firstIndex(where: { $0.id == newValue.id }) else {
return
}
try await userRepository.updateValue(newValue)
withAnimation {
values[index] = newValue
}
}
// MARK: Delete
func deleteValue(_ user: User) async throws {
guard let index = values.firstIndex(where: { $0.id == user.id }) else {
return
}
try await userRepository.deleteValues(for: [user.id])
withAnimation {
_ = values.remove(at: index)
}
}
func deleteValues(_ users: [User]) async throws {
let deleteUserIDs = users.map { $0.id }
try await userRepository.deleteValues(for: deleteUserIDs)
withAnimation {
values.removeAll(where: { deleteUserIDs.contains($0.id) })
}
}
}
// MARK: - DataBase
final class UserRepository {
static let shared: UserRepository = .init()
let backend: Backend
init(backend: Backend = .shared) {
self.backend = backend
}
// MARK: - CRUD
// MARK: Create
func addValue(_ user: User) async throws {
try await backend.add(user)
}
// MARK: Read
func fetchValue(for ids: [User.ID]) async throws -> [User] {
return try await backend.read(for: ids)
}
func fetchAllValues() async throws -> [User] {
try await backend.readAll()
}
// MARK: Update
func updateValue(_ user: User) async throws {
try await backend.add(user)
}
// MARK: Delete
func deleteValues(for ids: [User.ID]) async throws {
try await backend.delete(for: ids)
}
}
final actor Backend {
static let shared: Backend = .init()
// 今回はRealmSwiftやCoreDataの代わりにUserDefaultsを使用
var users: [User] {
get {
guard let users: [User] = UserDefaults.standard.codableItem(forKey: "users") else {
return []
}
return users
}
set {
UserDefaults.standard.setCodableItem(newValue, forKey: "users")
}
}
// MARK: - CRUD
// MARK: - Create/Update
// RealmSwiftのようなmodifiedフラグを引数に持たせても良さそう
func add(_ user: User) async throws {
if let index = users.firstIndex(where: { $0.id == user.id }) {
users[index] = user // Update
} else {
users.append(user) // Create
}
}
// MARK: Read
func read(for ids: [User.ID]) async throws -> [User] {
return users.filter { ids.contains($0.id) }
}
func readAll() async throws -> [User] {
return users
}
// MARK: Delete
func delete(for ids: [User.ID]) async throws {
users.removeAll(where: { ids.contains($0.id) })
}
}
// MARK: Backend+Error
enum BackendError: Error {
case writeFailed(String)
case readFailed(String)
}
extension BackendError: LocalizedError {
var errorDescription: String? {
switch self {
case .writeFailed(let message):
return "Backend Write Failed: \(message)"
case .readFailed(let message):
return "Bacnend Read Failed: \(message)"
}
}
}
// MARK: - Utils
extension UserDefaults {
func codableItem<T>(forKey defaultName: String) -> T? where T: Codable {
let jsonDecoder = JSONDecoder()
guard let itemData = self.data(forKey: defaultName),
let item = try? jsonDecoder.decode(T.self, from: itemData) else {
return nil
}
return item
}
func setCodableItem<T>(_ value: T, forKey defaultName: String) where T: Codable {
let jsonEncoder = JSONEncoder()
guard let valueData = try? jsonEncoder.encode(value) else {
return
}
self.set(valueData, forKey: defaultName)
}
}
#Preview {
ContentView()
}