• Fundamentals

    • SwiftData History는 데이터 스토어의 변화를 추적하는 쉽고 효율적인 방법을 제시한다.
    • 이를 이용해서 만들 수 잇는 것들
      • 앱이 오프라인에 있을 때 있던 변화들을 추적해서 나중에 온라인되면 서버랑 효율적으로 통신
      • app extension(위젯 등)에서 일어난 변경을 추적해서 앱에 반영
      • 추가 및 삭제된 데이터를 파악하여 효율적으로 리로딩
    • 동작 원리
      • 스토어에 일어나 변경을 질의하고 추척한다

      • 모델이 저장될 떄마다 transaction이 저장되고, 여기에 변경에 대한 메타데이터가 다 들어 있다.

      • Transaction이 일어난 순서대로 정렬되어 있고 일어난 변경들을 그룹화하고 있다

        • Transaction 내부의 변경도 일어난 순서대로 유지된다.

        스크린샷 2024-07-12 오전 10.27.41.png

        스크린샷 2024-07-12 오전 10.29.24.png

      • 토큰이라는 개념을 사용해서 transaction의 북마크처럼 사용한다.

        • 히스토리에서 마지막으로 처리된 transaction을 추적하는데 사용한다.
        • 특정 저장소 범위에서만 유효하고, 히스토리가 삭제되면 만료되어 fetch에 사용할 수 없다.
          • 이 경우는 SwiftDataError.historyTokenExpired 에러가 던져진다. 이를 보고 토큰을 만료시키면 된다.
      • 모델이 삭제되면 데이터가 버려지는데, 이때 히스토리를 처리하기 위해서 필요한 데이터가 같이 삭제될 수 있다.

        • 그래서 히스토리는 특정 attribute를 tombstone value로 따로 남겨서 삭제된 모델에 대한 처리를 할 수 있게 한다.

        • preserveValueOnDeletion attribute를 사용하면 된다.

          import SwiftData
          
          @Model 
          class Trip {
              #Unique<Trip>([\\.name, \\.startDate, \\.endDate])
              
              @Attribute(.preserveValueOnDeletion)
              var name: String
              var destination: String
          
              @Attribute(.preserveValueOnDeletion)
              var startDate: Date
          
              @Attribute(.preserveValueOnDeletion)
              var endDate: Date
              
              var bucketList: [BucketListItem] = [BucketListItem]()
              var livingAccommodation: LivingAccommodation?
          } 
          
  • Transactions and changes

    • 기능 예시
      • 서버에서 변경을 받아서 저장하고, 이 변경사항을 앱과 위젯에서 확인한다.
      • 위젯에서 읽음 처리를 하면 앱에서도 반영한다.
    • 업데이트 사이클
      • HistoryDescriptor로 히스토리 가져오기

        • defaultStore를 쓰기 때문에 토큰도 DefaultHistoryToken을 쓴다.
        private func findTransactions(after token: DefaultHistoryToken?, author: String) -> [DefaultHistoryTransaction] {
            var historyDescriptor = HistoryDescriptor<DefaultHistoryTransaction>() 
            if let token {
                historyDescriptor.predicate = #Predicate { transaction in
                    (transaction.token > token) && (transaction.author == author)
                }
            }
            
            var transactions: [DefaultHistoryTransaction] = []
            let taskContext = ModelContext(modelContainer)
            do {
                transactions = try taskContext.fetchHistory(historyDescriptor)
            } catch let error {
                print(error)
            }
        
            return transactions
        }
        
      • 변경 사항 처리

        private func findTrips(in transactions: [DefaultHistoryTransaction]) -> (Set<Trip>, DefaultHistoryToken?) {
                let taskContext = ModelContext(modelContainer)
                var resultTrips: Set<Trip> = []
                for transaction in transactions {
                    for change in transaction.changes {
                        let modelID = change.changedPersistentIdentifier
                        let fetchDescriptor = FetchDescriptor<Trip>(predicate: #Predicate { trip in
                            trip.livingAccommodation?.persistentModelID == modelID
                        })
                        let fetchResults = try? taskContext.fetch(fetchDescriptor)
                        guard let matchedTrip = fetchResults?.first else {
                            continue
                        }
                        switch change {
                        case .insert(_ as DefaultHistoryInsert<LivingAccommodation>):
                            resultTrips.insert(matchedTrip)
                        case .update(_ as DefaultHistoryUpdate<LivingAccommodation>):
                            resultTrips.update(with: matchedTrip)
                        case .delete(_ as DefaultHistoryDelete<LivingAccommodation>):
                            resultTrips.remove(matchedTrip)
                        default: break
                        }
                    }
                }
                return (resultTrips, transactions.last?.token)
            }
        
      • UI 업데이트

        private func findUnreadTrips() -> Set<Trip> {
            let tokenData = UserDefaults.standard.data(forKey: UserDefaultsKey.historyToken)
            
            var historyToken: DefaultHistoryToken? = nil
            if let tokenData {
                historyToken = try? JSONDecoder().decode(DefaultHistoryToken.self, from: tokenData)
            }
            let transactions = findTransactions(after: historyToken, author: TransactionAuthor.widget)
            let (unreadTrips, newToken) = findTrips(in: transactions)
            
            if let newToken {
                let newTokenData = try? JSONEncoder().encode(newToken)
                UserDefaults.standard.set(newTokenData, forKey: UserDefaultsKey.historyToken)
            }
            return unreadTrips
        }
        
        struct ContentView: View {
            @Environment(\\.scenePhase) private var scenePhase
            @State private var showAddTrip = false
            @State private var selection: Trip?
            @State private var searchText: String = ""
            @State private var tripCount = 0
            @State private var unreadTripIdentifiers: [PersistentIdentifier] = []
        
            var body: some View {
                NavigationSplitView {
                    TripListView(selection: $selection, tripCount: $tripCount,
                                 unreadTripIdentifiers: $unreadTripIdentifiers,
                                 searchText: searchText)
                    .toolbar {
                        ToolbarItem(placement: .topBarLeading) {
                            EditButton()
                                .disabled(tripCount == 0)
                        }
                        ToolbarItemGroup(placement: .topBarTrailing) {
                            Spacer()
                            Button {
                                showAddTrip = true
                            } label: {
                                Label("Add trip", systemImage: "plus")
                            }
                        }
                    }
                } detail: {
                    if let selection = selection {
                        NavigationStack {
                            TripDetailView(trip: selection)
                        }
                    }
                }
                .task {
                    unreadTripIdentifiers = await DataModel.shared.unreadTripIdentifiersInUserDefaults
                }
                .searchable(text: $searchText, placement: .sidebar)
                .sheet(isPresented: $showAddTrip) {
                    NavigationStack {
                        AddTripView()
                    }
                    .presentationDetents([.medium, .large])
                }
                .onChange(of: selection) { _, newValue in
                    if let newSelection = newValue {
                        if let index = unreadTripIdentifiers.firstIndex(where: {
                            $0 == newSelection.persistentModelID
                        }) {
                            unreadTripIdentifiers.remove(at: index)
                        }
                    }
                }
                .onChange(of: scenePhase) { _, newValue in
                    Task {
                        if newValue == .active {
                            unreadTripIdentifiers += await DataModel.shared.findUnreadTripIdentifiers()
                        } else {
                            // Persist the unread trip names for the next launch session.
                            await DataModel.shared.setUnreadTripIdentifiersInUserDefaults(unreadTripIdentifiers)
                        }
                    }
                }
                #if os(macOS)
                .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
                    Task {
                        unreadTripIdentifiers += await DataModel.shared.findUnreadTripIdentifiers()
                    }
                }
                .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
                    Task {
                        await DataModel.shared.setUnreadTripIdentifiersInUserDefaults(unreadTripIdentifiers)
                    }
                }
                #endif
            }
        }
        
  • Custom stores

    • 위에서 사용한 타입들은 모두 DefaultStore용
    • 커스텀 스토어를 위해서는 필요한 타입들을 모두 구현해야한다.
      • HistoryTransaction
      • HistoryChange
      • HistoryToken
    • 쓰기 연산을 통합하고 순서를 잘 유지해야 하기 때문에 Transaction의 범위는 잘 정의되어야 한다.
      • DefaultStore에서는 모든 변경이 단일 Transaction에 저장된다.
      • Transaction을 만들때 서버와 공유하는 ID를 정의해야 한다.
    • Transaction뿐 아니라 변경의 범위도 잘 정의되어야 한다.
      • DefaultStore는 모델 인스턴스로 제한하고 있다.
      • 변경을 추적할 ID도 잘 써야한다.
      • 변경 타입도 다 필요하지 않을 수 있다.
        • 시계열 로그 등을 쓰는 경우는 update나 delete는 필요 없다.
      • 삭제시 데이터를 남겨야 한다면 어떻게 저장될지도 고민해야 한다.
    • 커스텀 스토어에 HistoryProviding 프로토콜도 구현해야 한다.
      • 스토어의 행들을 모아서 transaction와 change 셋을 구성할 수 있어야 한다.
      • 히스토리 보관 기간을 잘 관리해야 한다.
        • 기본적으로는 대규모 변경도 잘 관리하지만, 특정한 경우는 히스토리를 안남기고 싶을 수도 있다.
    • 토큰도 HistoryToken 프로토콜을 구현한 타입을 사용해야 한다.
      • 여러개의 스토어를 내부적으로 쓰고 있다면 커스텀 토큰이 사용된 스토어 정보도 들어 있어야 한다.