Skip to content

Instantly share code, notes, and snippets.

@pommdau
Last active January 27, 2025 02:13
Show Gist options
  • Save pommdau/aee34b75e7cdc06c1df2c3b1fb47a7e4 to your computer and use it in GitHub Desktop.
Save pommdau/aee34b75e7cdc06c1df2c3b1fb47a7e4 to your computer and use it in GitHub Desktop.
SVVSSample.md

疑問点

image

Refs

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する方針
image
//
//  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()
}
@pommdau
Copy link
Author

pommdau commented Jan 27, 2025

General Idea

import Foundation

typealias BackendItemType = Identifiable & Equatable & Sendable & Codable

final class UserRepository: RepositoryProtocol, Sendable {
    
    typealias Item = User
    typealias Backend = UserBackend
    
    static let shared: UserRepository = .init()
    let backend: UserBackend
    
    init(backend: UserBackend = .shared) {
        self.backend = backend
    }
}

protocol RepositoryProtocol {
    associatedtype Item: BackendItemType
    associatedtype Backend: BackendProtocol where Backend.Item == Item
    static var shared: Self { get }
    var backend: Backend { get }
    func add(_ item: Item) async throws
}

extension RepositoryProtocol {
    func add(_ item: Item) async throws {
        try await backend.add(item)
    }
}

// MARK: - Backend

final actor UserBackend: BackendProtocol {
    typealias Item = User
    static var shared: UserBackend = .init()
}

protocol BackendProtocol: Actor {
    associatedtype Item: BackendItemType
    static var shared: Self { get }
    func add(_ item: Item) async throws
}

extension BackendProtocol {
    private var itemsUserDefaultsKey: String {
        let className = String(describing: type(of: self))
        return "\(className)-items"
    }
    var items: [Item] {
        get {
            guard let items: [Item] = UserDefaults.standard.codableItem(forKey: itemsUserDefaultsKey) else {
                return []
            }
            return items
        }
        set {
            UserDefaults.standard.setCodableItem(newValue, forKey: itemsUserDefaultsKey)
        }
    }
    
    // RealmSwiftのようなmodifiedフラグを引数に持たせても良さそう
    func add(_ item: Item) async throws {
        if let index = items.firstIndex(where: { $0.id == item.id }) {
            items[index] = item // Update
        } else {
            items.append(item) // Create
        }
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment