|
1 | 1 | import Foundation |
2 | 2 |
|
3 | | -/// `DataCache` is intended to store state which may be used by |
4 | | -/// more than one *component* and/or fetched from remote. |
| 3 | +/// `DataCache` stores shared mutable state that can be read by multiple components |
| 4 | +/// and/or fetched from remote. |
5 | 5 | /// |
6 | | -/// An Application should contain one shared application-wide cache, but each |
7 | | -/// coordinator may also create a private data cache. |
| 6 | +/// Because `DataCache` is `@MainActor`, all reads and writes are synchronous from any |
| 7 | +/// `@MainActor` context (coordinators, component models). |
8 | 8 | /// |
9 | | -/// The data from data cache should be taken as a subscription and modified |
10 | | -/// only via provided `update` methods. As a general rule, value types should |
11 | | -/// be used as a `Model`. |
| 9 | +/// Observation is handled by the `@Observable` macro: any `@Observable` or SwiftUI |
| 10 | +/// context that reads `dataCache.value` (or a keyPath of it) will automatically |
| 11 | +/// re-evaluate when the value changes. |
12 | 12 | /// |
13 | | -/// - Experiment: This API is in preview and subject to change. |
14 | | -/// - ToDo: How the `DataCache` may interact with persistence such as |
15 | | -/// `CoreData` or `SwiftData` is an open question and subject of further |
16 | | -/// research. |
17 | | -public actor DataCache<Model: Equatable & Sendable> { |
18 | | - |
19 | | - // MARK: Stored state |
| 13 | +/// Mutate the cache only via the provided `update` and `populate` methods. |
| 14 | +/// As a general rule, value types should be used as the `Model`. |
| 15 | +/// |
| 16 | +/// An application should contain one shared application-wide cache stored in the |
| 17 | +/// `Container`, but each coordinator may also create a private data cache. |
| 18 | +@Observable |
| 19 | +@MainActor |
| 20 | +public final class DataCache<Model: Equatable & Sendable> { |
20 | 21 |
|
21 | 22 | /// The data held by this data cache. |
22 | 23 | public private(set) var value: Model |
23 | 24 |
|
24 | | - private var subscribers: [UUID: AsyncStream<Model>.Continuation] = [:] |
25 | | - |
26 | | - // MARK: Init |
27 | | - |
28 | 25 | public init(value: Model) { |
29 | 26 | self.value = value |
30 | 27 | } |
31 | 28 |
|
32 | | - deinit { |
33 | | - for continuation in subscribers.values { |
34 | | - continuation.finish() |
35 | | - } |
36 | | - subscribers.removeAll() |
37 | | - } |
38 | | - |
39 | | - // MARK: Observation (Swift Concurrency) |
40 | | - |
41 | | - /// Observe changes of the cache value. |
42 | | - /// |
43 | | - /// - Parameter skipInitial: When `true`, the returned stream does not yield the current value |
44 | | - /// immediately. It only yields subsequent changes. |
45 | | - /// |
46 | | - /// This stream yields whenever `value` changes via any of the `update`/`populate` methods. |
47 | | - /// |
48 | | - /// Each call creates a new stream ("one stream per subscriber"). |
49 | | - /// |
50 | | - /// The stream uses `bufferingNewest(1)` because this is "state": consumers typically only care |
51 | | - /// about the latest value, and we want to avoid unbounded buffering if updates happen faster |
52 | | - /// than the consumer can process them. |
53 | | - public func values(skipInitial: Bool = false) -> AsyncStream<Model> { |
54 | | - let id = UUID() |
55 | | - return AsyncStream(Model.self, bufferingPolicy: .bufferingNewest(1)) { continuation in |
56 | | - // Register subscriber inside the actor. |
57 | | - subscribers[id] = continuation |
58 | | - |
59 | | - // Yield the current value immediately unless the caller asked to skip it. |
60 | | - if !skipInitial { |
61 | | - continuation.yield(value) |
62 | | - } |
63 | | - |
64 | | - continuation.onTermination = { [weak self] _ in |
65 | | - Task { // Hop back into the actor to remove subscriber. |
66 | | - await self?.removeSubscriber(id: id) |
67 | | - } |
68 | | - } |
69 | | - } |
70 | | - } |
71 | | - |
72 | | - // MARK: Updates |
73 | | - |
74 | | - /// Atomically update the whole data cache. Use this method if you need |
75 | | - /// to perform number of changes at once. |
| 29 | + /// Replace the whole model. Use this method when you need to update multiple |
| 30 | + /// properties at once. No-op if the value is unchanged. |
76 | 31 | public func update(with value: Model) { |
77 | 32 | guard value != self.value else { return } |
78 | 33 | self.value = value |
79 | | - broadcast(self.value) |
80 | 34 | } |
81 | 35 |
|
82 | | - /// Atomically update one variable. |
83 | | - /// |
84 | | - /// - ToDo: Investigate whether we can use variadic generics to improve the API. |
85 | | - /// No change is emitted when the value is the same. |
| 36 | + /// Replace one property via keyPath. No-op if the value is unchanged. |
86 | 37 | public func update<T: Equatable>(_ keyPath: WritableKeyPath<Model, T>, with value: T) { |
87 | 38 | guard value != self.value[keyPath: keyPath] else { return } |
88 | 39 | self.value[keyPath: keyPath] = value |
89 | | - broadcast(self.value) |
90 | 40 | } |
91 | 41 |
|
92 | | - /// Populate one variable of Collection type. |
93 | | - /// - Description: The method will append new elements to the existing collection. The elements which are already |
94 | | - /// in the collection as well as in the new collection will be updated. No change is emitted when the new collection is empty |
95 | | - /// or when the merged result is the same as the current value. |
| 42 | + /// Merge a collection by Identifiable identity. |
| 43 | + /// |
| 44 | + /// - Existing items whose ID appears in `newItems` are updated in place (order preserved). |
| 45 | + /// - Items in `newItems` whose ID is absent from the current collection are appended. |
| 46 | + /// - Items already in the collection but absent from `newItems` are kept unchanged. |
| 47 | + /// - No write occurs when the merged result is equal to the current collection. |
96 | 48 | public func populate<T>( |
97 | 49 | _ keyPath: WritableKeyPath<Model, T>, |
98 | 50 | with newItems: T |
99 | | - ) where T: RangeReplaceableCollection, T.Element: Equatable { |
| 51 | + ) where T: RangeReplaceableCollection & MutableCollection, T.Element: Identifiable & Equatable { |
100 | 52 | guard !newItems.isEmpty else { return } |
101 | | - let current = self.value[keyPath: keyPath] |
102 | | - let merged = mergedCollection(current: current, newItems: newItems) |
103 | | - guard !current.elementsEqual(merged) else { return } |
| 53 | + let original = self.value[keyPath: keyPath] |
| 54 | + let merged = merging(original, with: newItems) |
| 55 | + guard !merged.elementsEqual(original) else { return } |
104 | 56 | self.value[keyPath: keyPath] = merged |
105 | | - broadcast(self.value) |
106 | 57 | } |
107 | 58 |
|
108 | | - /// Populate one optional variable of Collection type. |
109 | | - /// |
110 | | - /// - Description: The method will append new elements to the existing collection. The elements which are already |
111 | | - /// in the collection as well as in the new collection will be updated. No change is emitted when the new collection is empty |
112 | | - /// or when the merged result is the same as the current value. |
| 59 | + /// Optional-collection variant of `populate(_:with:)`. |
113 | 60 | public func populate<T>( |
114 | 61 | _ keyPath: WritableKeyPath<Model, T?>, |
115 | 62 | with newItems: T |
116 | | - ) where T: RangeReplaceableCollection, T.Element: Equatable { |
| 63 | + ) where T: RangeReplaceableCollection & MutableCollection, T.Element: Identifiable & Equatable { |
117 | 64 | guard !newItems.isEmpty else { return } |
118 | | - let current = self.value[keyPath: keyPath] ?? T() |
119 | | - let merged = mergedCollection(current: current, newItems: newItems) |
120 | | - guard !current.elementsEqual(merged) else { return } |
| 65 | + let original = self.value[keyPath: keyPath] ?? T() |
| 66 | + let merged = merging(original, with: newItems) |
| 67 | + guard !merged.elementsEqual(original) else { return } |
121 | 68 | self.value[keyPath: keyPath] = merged |
122 | | - broadcast(self.value) |
123 | 69 | } |
124 | 70 |
|
125 | | - // MARK: Private Helpers |
126 | | - |
127 | | - private func removeSubscriber(id: UUID) { |
128 | | - subscribers[id]?.finish() |
129 | | - subscribers[id] = nil |
130 | | - } |
131 | | - |
132 | | - private func broadcast(_ value: Model) { |
133 | | - for continuation in subscribers.values { |
134 | | - continuation.yield(value) |
| 71 | + private func merging<T>( |
| 72 | + _ current: T, |
| 73 | + with newItems: T |
| 74 | + ) -> T where T: RangeReplaceableCollection & MutableCollection, T.Element: Identifiable & Equatable { |
| 75 | + var result = current |
| 76 | + let newItemsDict = Dictionary(newItems.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last }) |
| 77 | + let existingIds = Set(current.map(\.id)) |
| 78 | + |
| 79 | + for index in result.indices { |
| 80 | + if let updated = newItemsDict[result[index].id] { |
| 81 | + result[index] = updated |
| 82 | + } |
135 | 83 | } |
136 | | - } |
137 | | - |
138 | | - private func mergedCollection<T>( |
139 | | - current: T, |
140 | | - newItems: T |
141 | | - ) -> T where T: RangeReplaceableCollection, T.Element: Equatable { |
142 | | - var result = T() |
143 | | - result.reserveCapacity(current.count + newItems.count) |
144 | 84 |
|
145 | | - let filteredExisting = current.filter { existingItem in |
146 | | - !newItems.contains(existingItem) |
| 85 | + var appendedIds = existingIds |
| 86 | + for item in newItems where appendedIds.insert(item.id).inserted { |
| 87 | + result.append(newItemsDict[item.id]!) |
147 | 88 | } |
148 | | - result.append(contentsOf: filteredExisting) |
149 | | - result.append(contentsOf: newItems) |
150 | 89 |
|
151 | 90 | return result |
152 | 91 | } |
|
0 commit comments