Skip to content

Commit 12268ed

Browse files
authored
Prefer KVO for app storage observation (#3487)
* wip * wip * wip
1 parent 3879d2c commit 12268ed

File tree

4 files changed

+96
-44
lines changed

4 files changed

+96
-44
lines changed

.github/workflows/ci.yml

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,26 +14,51 @@ concurrency:
1414
cancel-in-progress: true
1515

1616
jobs:
17+
xcodebuild-latest:
18+
name: xcodebuild (16)
19+
runs-on: macos-15
20+
strategy:
21+
matrix:
22+
command: ['']
23+
platform: [IOS, MACOS]
24+
xcode: ['16.0']
25+
steps:
26+
- uses: actions/checkout@v4
27+
- name: Select Xcode ${{ matrix.xcode }}
28+
run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
29+
- name: List available devices
30+
run: xcrun simctl list devices available
31+
- name: Cache derived data
32+
uses: actions/cache@v3
33+
with:
34+
path: |
35+
~/.derivedData
36+
key: |
37+
deriveddata-xcodebuild-${{ matrix.platform }}-${{ matrix.xcode }}-${{ matrix.command }}-${{ hashFiles('**/Sources/**/*.swift', '**/Tests/**/*.swift') }}
38+
restore-keys: |
39+
deriveddata-xcodebuild-${{ matrix.platform }}-${{ matrix.xcode }}-${{ matrix.command }}-
40+
- name: Set IgnoreFileSystemDeviceInodeChanges flag
41+
run: defaults write com.apple.dt.XCBuild IgnoreFileSystemDeviceInodeChanges -bool YES
42+
- name: Update mtime for incremental builds
43+
uses: chetan/git-restore-mtime-action@v2
44+
- name: Debug
45+
run: make XCODEBUILD_ARGUMENT="${{ matrix.command }}" CONFIG=Debug PLATFORM="${{ matrix.platform }}" WORKSPACE=.github/package.xcworkspace xcodebuild
46+
1747
xcodebuild:
18-
name: xcodebuild
48+
name: xcodebuild (15)
1949
runs-on: macos-14
2050
strategy:
2151
matrix:
2252
command: [test, '']
2353
platform: [IOS, MAC_CATALYST, MACOS, TVOS, VISIONOS, WATCHOS]
24-
xcode: [15.2, 15.4, '16.0']
54+
xcode: [15.2, 15.4]
2555
exclude:
2656
- {xcode: 15.2, command: test}
2757
- {xcode: 15.4, command: ''}
2858
- {xcode: 15.2, platform: MAC_CATALYST}
2959
- {xcode: 15.2, platform: TVOS}
3060
- {xcode: 15.2, platform: VISIONOS}
3161
- {xcode: 15.2, platform: WATCHOS}
32-
- {xcode: '16.0', command: ''}
33-
- {xcode: '16.0', platform: MAC_CATALYST}
34-
- {xcode: '16.0', platform: TVOS}
35-
- {xcode: '16.0', platform: VISIONOS}
36-
- {xcode: '16.0', platform: WATCHOS}
3762
include:
3863
- {xcode: 15.2, skip_release: 1}
3964
steps:
@@ -80,7 +105,7 @@ jobs:
80105

81106
examples:
82107
name: Examples
83-
runs-on: macos-14
108+
runs-on: macos-15
84109
steps:
85110
- uses: actions/checkout@v4
86111
- name: Cache derived data
@@ -113,7 +138,7 @@ jobs:
113138
run: make DERIVED_DATA_PATH=~/.derivedData SCHEME="Todos" xcodebuild-raw
114139
- name: VoiceMemos
115140
run: make DERIVED_DATA_PATH=~/.derivedData SCHEME="VoiceMemos" xcodebuild-raw
116-
141+
117142
check-macro-compatibility:
118143
name: Check Macro Compatibility
119144
runs-on: macos-latest

Makefile

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ PLATFORM_WATCHOS = watchOS Simulator,id=$(call udid_for,watchOS,Watch)
1212
PLATFORM = IOS
1313
DESTINATION = platform="$(PLATFORM_$(PLATFORM))"
1414

15+
PLATFORM_ID = $(shell echo "$(DESTINATION)" | sed -E "s/.+,id=(.+)/\1/")
16+
1517
SCHEME = ComposableArchitecture
1618

1719
WORKSPACE = ComposableArchitecture.xcworkspace
@@ -36,11 +38,17 @@ endif
3638

3739
TEST_RUNNER_CI = $(CI)
3840

39-
xcodebuild:
41+
warm-simulator:
42+
@test "$(PLATFORM_ID)" != "" \
43+
&& xcrun simctl boot $(PLATFORM_ID) \
44+
&& open -a Simulator --args -CurrentDeviceUDID $(PLATFORM_ID) \
45+
|| exit 0
46+
47+
xcodebuild: warm-simulator
4048
$(XCODEBUILD)
4149

4250
# Workaround for debugging Swift Testing tests: https://github.com/cpisciotta/xcbeautify/issues/313
43-
xcodebuild-raw:
51+
xcodebuild-raw: warm-simulator
4452
$(XCODEBUILD_COMMAND)
4553

4654
build-for-library-evolution:
@@ -58,7 +66,7 @@ format:
5866
-not -path '*/.*' -print0 \
5967
| xargs -0 swift format --ignore-unparsable-files --in-place
6068

61-
.PHONY: build-for-library-evolution format xcodebuild
69+
.PHONY: build-for-library-evolution format warm-simulator xcodebuild xcodebuild-raw
6270

6371
define udid_for
6472
$(shell xcrun simctl list devices available '$(1)' | grep '$(2)' | sort -r | head -1 | awk -F '[()]' '{ print $$(NF-3) }')

Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKey.swift

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -290,22 +290,41 @@ extension AppStorageKey: PersistenceKey {
290290
didSet: @escaping @Sendable (_ newValue: Value?) -> Void
291291
) -> Shared<Value>.Subscription {
292292
let previousValue = LockIsolated(initialValue)
293-
let userDefaultsDidChange = NotificationCenter.default.addObserver(
294-
forName: UserDefaults.didChangeNotification,
295-
object: self.store.wrappedValue,
296-
queue: nil
297-
) { _ in
298-
let newValue = load(initialValue: initialValue)
299-
defer { previousValue.withValue { $0 = newValue } }
300-
guard
301-
!(_isEqual(newValue as Any, previousValue.value as Any) ?? false)
302-
|| (_isEqual(newValue as Any, initialValue as Any) ?? true)
303-
else {
304-
return
293+
let removeObserver: () -> Void
294+
if key.hasPrefix("@") || key.contains(".") {
295+
let userDefaultsDidChange = NotificationCenter.default.addObserver(
296+
forName: UserDefaults.didChangeNotification,
297+
object: store.wrappedValue,
298+
queue: .main
299+
) { _ in
300+
let newValue = load(initialValue: initialValue)
301+
defer { previousValue.withValue { $0 = newValue } }
302+
func isEqual<T>(_ lhs: T, _ rhs: T) -> Bool? {
303+
func open<U: Equatable>(_ lhs: U) -> Bool {
304+
lhs == rhs as? U
305+
}
306+
guard let lhs = lhs as? any Equatable else { return nil }
307+
return open(lhs)
308+
}
309+
guard
310+
!(isEqual(newValue, previousValue.value) ?? false)
311+
|| (isEqual(newValue, initialValue) ?? true)
312+
else {
313+
return
314+
}
315+
guard !SharedAppStorageLocals.isSetting
316+
else { return }
317+
didSet(newValue)
305318
}
306-
guard !SharedAppStorageLocals.isSetting
307-
else { return }
308-
didSet(newValue)
319+
removeObserver = { NotificationCenter.default.removeObserver(userDefaultsDidChange) }
320+
} else {
321+
let observer = Observer {
322+
guard !SharedAppStorageLocals.isSetting
323+
else { return }
324+
didSet(load(initialValue: initialValue))
325+
}
326+
store.wrappedValue.addObserver(observer, forKeyPath: key, options: .new, context: nil)
327+
removeObserver = { store.wrappedValue.removeObserver(observer, forKeyPath: key) }
309328
}
310329
let willEnterForeground: (any NSObjectProtocol)?
311330
if let willEnterForegroundNotificationName {
@@ -320,12 +339,28 @@ extension AppStorageKey: PersistenceKey {
320339
willEnterForeground = nil
321340
}
322341
return Shared.Subscription {
323-
NotificationCenter.default.removeObserver(userDefaultsDidChange)
342+
removeObserver()
324343
if let willEnterForeground {
325344
NotificationCenter.default.removeObserver(willEnterForeground)
326345
}
327346
}
328347
}
348+
349+
private class Observer: NSObject {
350+
let didChange: () -> Void
351+
init(didChange: @escaping () -> Void) {
352+
self.didChange = didChange
353+
super.init()
354+
}
355+
override func observeValue(
356+
forKeyPath keyPath: String?,
357+
of object: Any?,
358+
change: [NSKeyValueChangeKey: Any]?,
359+
context: UnsafeMutableRawPointer?
360+
) {
361+
self.didChange()
362+
}
363+
}
329364
}
330365

331366
private struct AppStorageKeyID: Hashable {

Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKeyPathKey.swift

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -60,22 +60,6 @@ extension AppStorageKeyPathKey: PersistenceKey, Hashable {
6060
observer.invalidate()
6161
}
6262
}
63-
64-
private class Observer: NSObject {
65-
let didChange: (Value?) -> Void
66-
init(didChange: @escaping (Value?) -> Void) {
67-
self.didChange = didChange
68-
super.init()
69-
}
70-
override func observeValue(
71-
forKeyPath keyPath: String?,
72-
of object: Any?,
73-
change: [NSKeyValueChangeKey: Any]?,
74-
context: UnsafeMutableRawPointer?
75-
) {
76-
self.didChange(change?[.newKey] as? Value)
77-
}
78-
}
7963
}
8064

8165
// NB: This is mainly used for tests, where observer notifications can bleed across cases.

0 commit comments

Comments
 (0)