Skip to content

Instantly share code, notes, and snippets.

@yosshi4486
Last active May 20, 2024 20:43
Show Gist options
  • Save yosshi4486/6ca744cd648e5784df9036f57be44b6e to your computer and use it in GitHub Desktop.
Save yosshi4486/6ca744cd648e5784df9036f57be44b6e to your computer and use it in GitHub Desktop.
Example of syncing key value stores.
//
// SynchronizingKeyValueStore.swift
//
// Created by yosshi4486 on 2022/08/30.
//
import Foundation
/// A key value store that stores a key-value into both local and cloud. Subclass this class and override methods for your application.
///
/// - SeeAlso:
/// [Syncrhonizing App Preferences with iCloud](https://developer.apple.com/documentation/foundation/icloud/synchronizing_app_preferences_with_icloud)
///
class SynchronizingKeyValueStore {
/// The key-value store for local.
var userDefaults: UserDefaults
/// The key-value store for cloud.
var ubiquitousKeyValueStore: NSUbiquitousKeyValueStore
/// The keys of sync key value store.
///
/// The default implementation is empty. Override this method for registering your app sync value's keys.
var allKeys: Set<String> { [] }
/// Creates a new instance by the given `userDefaults` and `ubiquitousKeyValueStore`.
///
/// You can do:
/// - Inject stubs for testing.
/// - Give stores that adopt an App Group feature.
///
/// - Parameters:
/// - userDefaults: The key-value store that stores values for local.
/// - ubiquitousKeyValueStore: The key-value store that stores values for cloud.
init(userDefaults: UserDefaults = .standard, ubiquitousKeyValueStore: NSUbiquitousKeyValueStore = .default) {
self.userDefaults = userDefaults
self.ubiquitousKeyValueStore = ubiquitousKeyValueStore
prepareObservingCloudStoreChanges()
}
/// Registers initial states for local store keys.
///
/// The default implementation is empty. Override this method for registering your app states.
func registerInitialValues() {}
/// Prepares for observing cloud key-value store changes.
///
/// Apple officially claim some steps for observing cloud store changes describing bellow:
/// 1. Register a `NSUbiquitousKeyValueStore.didChangeExternallyNotification`notification
/// 2. Checks whether the `syncronize()` is `true`.
///
/// This class automatically calls this method in the `init(userDefaults:ubiquitousKeyValueStore)`.
func prepareObservingCloudStoreChanges() {
NotificationCenter.default.addObserver(self,
selector: #selector(applyRemoteKeyValueStoreChanges(_:)),
name: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: ubiquitousKeyValueStore)
if ubiquitousKeyValueStore.synchronize() == false {
fatalError("Entitlement error. You have to add iCloud capability in Signing&Capabilities tab, then make check to ")
}
}
/// Propagate the local value that is associated with the given key to cloud.
///
/// This class automatically calls this method in `applyRemoteKeyValueStoreChanges`, if the value should be unified to local value.
func propagateLocalValue(forKey key: String) {
let localObject = userDefaults.object(forKey: key)
ubiquitousKeyValueStore.set(localObject, forKey: key)
}
/// Propagate the remote value that is associated with the given key to local.
///
/// This class automatically calls this method in `applyRemoteKeyValueStoreChanges`, if the value should be unified to cloud value.
func propagateRemoteValue(forKey key: String) {
let cloudObject = ubiquitousKeyValueStore.object(forKey: key)
userDefaults.set(cloudObject, forKey: key)
}
/// Applies the remote store changes that include changes to the remote-store itself. ex) Account change, limit exceed, etc..
@objc func applyRemoteKeyValueStoreChanges(_ notification: Notification) {
/** Reasons can be:
NSUbiquitousKeyValueStoreServerChange:
Value(s) were changed externally from other users/devices.
Get the changes and update the corresponding keys locally.
NSUbiquitousKeyValueStoreInitialSyncChange:
Initial downloads happen the first time a device is connected to an iCloud account,
and when a user switches their primary iCloud account.
Get the changes and update the corresponding keys locally.
Note: If you receive "NSUbiquitousKeyValueStoreInitialSyncChange" as the reason,
you can decide to "merge" your local values with the server values.
NSUbiquitousKeyValueStoreQuotaViolationChange:
Your app’s key-value store has exceeded its space quota on the iCloud server of 1mb.
NSUbiquitousKeyValueStoreAccountChange:
The user has changed the primary iCloud account.
The keys and values in the local key-value store have been replaced with those from the new account,
regardless of the relative timestamps.
> Apple inc., Synchronizing App Preferences with iCloud, PrefsInCloud(Sample Project), ViewController+KVS, ubiquitousKeyValueStoreDidChange, viewed: 2022/08/30
*/
guard
let userInfo = notification.userInfo,
let reasonForChange = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int,
let keys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
else {
return
}
let intersectedKeys = allKeys.intersection(Set(keys))
guard !intersectedKeys.isEmpty else { return }
switch reasonForChange {
case NSUbiquitousKeyValueStoreAccountChange:
// Fallback to local values
intersectedKeys.forEach { propagateLocalValue(forKey: $0) }
case NSUbiquitousKeyValueStoreInitialSyncChange:
// Apply remote values to local. The defaul merge behavior is "server wins" policy, but you can decide the merge policy in your subclass.
intersectedKeys.forEach { propagateRemoteValue(forKey: $0) }
case NSUbiquitousKeyValueStoreQuotaViolationChange:
fatalError("Your app’s key-value store has exceeded its space quota on the iCloud server of 1mb. This is application state design problem. You have to re-design the app's state architecture.")
default:
// Apply remote values into local store.
intersectedKeys.forEach { propagateRemoteValue(forKey: $0) }
}
}
}
@yosshi4486
Copy link
Author

yosshi4486 commented Aug 30, 2022

An example of subclassing.

class ExampleKeyValueStore: SynchronizingKeyValueStore {

    static let isLoggedInKey: String = "isLoggedInKey"

    var isLoggedIn: Bool {

        get {
            userDefaults.bool(forKey: ExampleKeyValueStore.isLoggedInKey)
        }

        set {
            userDefaults.set(newValue, forKey: ExampleKeyValueStore.isLoggedInKey)
            ubiquitousKeyValueStore.set(newValue, forKey: ExampleKeyValueStore.isLoggedInKey)
        }

    }

    override var allKeys: Set<String> {
        [
            ExampleKeyValueStore.isLoggedInKey
        ]
    }

}

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