Skip to content

Commit 646f9c8

Browse files
authored
Merge pull request #54 from futuredapp/housekeep/main-actor-data-cache
Housekeep/main actor data cache
2 parents 80f8cf0 + 99f24cc commit 646f9c8

17 files changed

Lines changed: 401 additions & 352 deletions

File tree

‎.swiftlint.yml‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
disabled_rules:
2+
- line_length
3+
- trailing_closure
4+
excluded:
5+
- Templates
6+
- .build
7+
- .claude
8+
analyzer_rules:
9+
- unused_declaration
10+
- unused_import
11+
opt_in_rules:
12+
- array_init
13+
- closure_end_indentation
14+
- closure_spacing
15+
- collection_alignment
16+
- contains_over_filter_count
17+
- contains_over_filter_is_empty
18+
- contains_over_first_not_nil
19+
- contains_over_range_nil_comparison
20+
- convenience_type
21+
- discouraged_object_literal
22+
- empty_collection_literal
23+
- empty_count
24+
- empty_string
25+
- empty_xctest_method
26+
- enum_case_associated_values_count
27+
- explicit_init
28+
- fallthrough
29+
- fatal_error_message
30+
- file_name_no_space
31+
- first_where
32+
- flatmap_over_map_reduce
33+
- force_unwrapping
34+
- identical_operands
35+
- implicit_return
36+
- implicitly_unwrapped_optional
37+
- joined_default_parameter
38+
- last_where
39+
- legacy_multiple
40+
- legacy_random
41+
- let_var_whitespace
42+
- literal_expression_end_indentation
43+
- lower_acl_than_parent
44+
- modifier_order
45+
- multiline_arguments
46+
- multiline_function_chains
47+
- multiline_literal_brackets
48+
- multiline_parameters
49+
- multiline_parameters_brackets
50+
- nimble_operator
51+
- no_extension_access_modifier
52+
- number_separator
53+
- object_literal
54+
- operator_usage_whitespace
55+
- optional_enum_case_matching
56+
- overridden_super_call
57+
- override_in_extension
58+
- pattern_matching_keywords
59+
- prefer_self_type_over_type_of_self
60+
- private_action
61+
- private_outlet
62+
- prohibited_super_call
63+
- reduce_into
64+
- redundant_nil_coalescing
65+
- required_enum_case
66+
- single_test_class
67+
- sorted_first_last
68+
- sorted_imports
69+
- static_operator
70+
- strict_fileprivate
71+
- switch_case_on_newline
72+
- toggle_bool
73+
- unneeded_parentheses_in_closure_argument
74+
- untyped_error_in_catch
75+
- vertical_parameter_alignment_on_call
76+
- vertical_whitespace_closing_braces
77+
- yoda_condition
78+
79+
# Rule configurations
80+
identifier_name:
81+
excluded:
82+
- id
83+
- x
84+
- y
85+
- z
86+
- pr
87+
88+
type_body_length: 400
89+
90+
# Disable errors, allow only warnings
91+
cyclomatic_complexity:
92+
warning: 13
93+
type_name:
94+
max_length: 50
95+
force_cast: warning
96+
force_try: warning
97+
function_parameter_count: 5
98+
large_tuple:
99+
warning: 3
100+
error: 4

‎Package.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// swift-tools-version: 6.1
22

3-
import PackageDescription
43
import CompilerPluginSupport
4+
import PackageDescription
55

66
let package = Package(
77
name: "FuturedKit",

‎Sources/FuturedArchitecture/Architecture/ComponentModel.swift‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import Foundation
1616
/// - Note: Each *component model* should have have *mock* class and *implementation* class.
1717
/// Each *Component* (i.e. View) should have own *component model*. Each instance of component
1818
/// model has to be referenced by no more than 1 *coordinator.*
19+
///
20+
/// - Important: Conforming types must be annotated with `@Observable`. Without it the class will
21+
/// compile but SwiftUI views will not react to state changes.
1922
@MainActor
2023
public protocol ComponentModel: AnyObject {
2124

‎Sources/FuturedArchitecture/Architecture/Coordinator.swift‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,13 @@ public protocol Coordinator: AnyObject {
4040
func onModalDismiss()
4141
}
4242

43-
public extension Coordinator {
43+
extension Coordinator {
4444
/// Convenience function for presenting a modal over the *container*.
4545
/// - Parameters:
4646
/// - destination: The description of the desired view passed to the ``scene(for:)`` function
4747
/// of the *coordinator*.
4848
/// - type: Kind of modal presentation.
49-
func present(modal destination: Destination, type: ModalCoverModelStyle) {
49+
public func present(modal destination: Destination, type: ModalCoverModelStyle) {
5050
switch type {
5151
case .sheet:
5252
self.modalCover = .init(destination: destination, style: .sheet)
@@ -58,11 +58,11 @@ public extension Coordinator {
5858
}
5959

6060
/// Convenience method for dismissing a modal.
61-
func dismissModal() {
61+
public func dismissModal() {
6262
self.modalCover = nil
6363
}
6464

65-
func onModalDismiss() {}
65+
public func onModalDismiss() {}
6666
}
6767

6868
/// `TabCoordinator` provides additional requirements for the use with ``SwiftUI.TabView``.
@@ -73,6 +73,7 @@ public extension Coordinator {
7373
/// which is essentially duplication of `Destination`. Consider, how the API limits the use of tabs.
7474
public protocol TabCoordinator: Coordinator {
7575
associatedtype Tab: Hashable
76+
7677
var selectedTab: Tab { get set }
7778
}
7879

@@ -85,34 +86,34 @@ public protocol NavigationStackCoordinator: Coordinator {
8586
var path: [Destination] { get set }
8687
}
8788

88-
public extension NavigationStackCoordinator {
89+
extension NavigationStackCoordinator {
8990
/// Convenience function used to add new view to the navigation stack.
90-
func navigate(to destination: Destination) {
91+
public func navigate(to destination: Destination) {
9192
self.path.append(destination)
9293
}
9394

9495
/// Convenience function used to remove topmost view from the navigation stack.
95-
func pop() {
96+
public func pop() {
9697
self.path.removeLast()
9798
}
9899

99100
/// Convenience function used to remove all views from the stack, until the provided destination.
100101
/// - Parameter destination: Destination to be reached. If nil is passed, or such destination
101102
/// is not currently on the stack, all views are removed.
102103
/// - Experiment: This API is in preview and subject to change.
103-
func pop(to destination: Destination) {
104+
public func pop(to destination: Destination) {
104105
guard let index = self.path.lastIndex(of: destination) else {
105106
assertionFailure("Destination not found on the stack")
106107
return
107108
}
108109
self.path = Array(path[path.startIndex...index])
109110
}
110111

111-
func popToRoot() {
112+
public func popToRoot() {
112113
path = []
113114
}
114115

115-
func reset() {
116+
public func reset() {
116117
path = []
117118
modalCover = nil
118119
}

‎Sources/FuturedArchitecture/Architecture/DataCache.swift‎

Lines changed: 48 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,152 +1,91 @@
11
import Foundation
22

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.
55
///
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).
88
///
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.
1212
///
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> {
2021

2122
/// The data held by this data cache.
2223
public private(set) var value: Model
2324

24-
private var subscribers: [UUID: AsyncStream<Model>.Continuation] = [:]
25-
26-
// MARK: Init
27-
2825
public init(value: Model) {
2926
self.value = value
3027
}
3128

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.
7631
public func update(with value: Model) {
7732
guard value != self.value else { return }
7833
self.value = value
79-
broadcast(self.value)
8034
}
8135

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.
8637
public func update<T: Equatable>(_ keyPath: WritableKeyPath<Model, T>, with value: T) {
8738
guard value != self.value[keyPath: keyPath] else { return }
8839
self.value[keyPath: keyPath] = value
89-
broadcast(self.value)
9040
}
9141

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.
9648
public func populate<T>(
9749
_ keyPath: WritableKeyPath<Model, T>,
9850
with newItems: T
99-
) where T: RangeReplaceableCollection, T.Element: Equatable {
51+
) where T: RangeReplaceableCollection & MutableCollection, T.Element: Identifiable & Equatable {
10052
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 }
10456
self.value[keyPath: keyPath] = merged
105-
broadcast(self.value)
10657
}
10758

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:)`.
11360
public func populate<T>(
11461
_ keyPath: WritableKeyPath<Model, T?>,
11562
with newItems: T
116-
) where T: RangeReplaceableCollection, T.Element: Equatable {
63+
) where T: RangeReplaceableCollection & MutableCollection, T.Element: Identifiable & Equatable {
11764
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 }
12168
self.value[keyPath: keyPath] = merged
122-
broadcast(self.value)
12369
}
12470

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+
}
13583
}
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)
14484

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]!)
14788
}
148-
result.append(contentsOf: filteredExisting)
149-
result.append(contentsOf: newItems)
15089

15190
return result
15291
}

0 commit comments

Comments
 (0)