Using NSUbiquitousKeyValueStore with SwiftUI

Published on

Get weekly handpicked updates on Swift and SwiftUI!

NSUbiquitousKeyValueStore is an official solution provided by Apple for sharing key-value data across devices. This article provides a brief introduction to its usage, with a focus on how to conveniently use NSUbiquitousKeyValueStore in SwiftUI.

What is NSUbiquitousKeyValueStore

NSUbiquitousKeyValueStore can be understood as a network-synchronized version of UserDefaults. It is part of the CloudKit service suite, allowing for the sharing of data across different devices (under the same iCloud account) with minimal configuration.

NSUbiquitousKeyValueStore behaves very similarly to UserDefaults in most situations:

  • Both are key-value storage systems.
  • They use strings as keys.
  • Any property list object type can be used as a value.
  • They have similar methods for reading and writing data.
  • Both initially save data in memory, with the system periodically persisting this data to disk (a process usually not requiring developer intervention).

Even if you have never used UserDefaults, a few minutes of reading the official documentation should be enough to understand the basics.

Differences from UserDefaults

  • NSUbiquitousKeyValueStore does not offer a method to register default values.

    While UserDefaults allows developers to set default values with register(defaults:[String:Any]), NSUbiquitousKeyValueStore does not offer a similar mechanism. For types that do not return optional values, one should avoid using convenient methods to retrieve values.

    For example, use code like below to get an integer value for the key “count”:

Swift
func getInt(key: String, defaultValue: Int) -> Int {
    guard let result = NSUbiquitousKeyValueStore.default.object(forKey: key) as? Int else {
        return defaultValue
    }
    return result
}

let count = getInt(key: "count", defaultValue: 30)

// Avoid using convenient methods like the following for non-optional return values
// NSUbiquitousKeyValueStore.default.longLong(forKey: "count") defaults to 0
  • NSUbiquitousKeyValueStore has more limitations

    Apple does not recommend using NSUbiquitousKeyValueStore for data that is large, frequently changing, and critical to the app’s functioning.

    The maximum storage capacity of NSUbiquitousKeyValueStore is 1 MB per user, with no more than 1024 key-value pairs allowed.

    The efficiency of network synchronization for NSUbiquitousKeyValueStore is average. Under smooth conditions, a key-value pair can be synchronized in about 10-20 seconds. If the data changes frequently, iCloud will automatically reduce the synchronization frequency, potentially extending synchronization times to several minutes. Developers may experience slow synchronization during testing due to frequent data modifications in a short time.

    Although NSUbiquitousKeyValueStore does not provide atomic support for data synchronization, in most cases, it tries to ensure data integrity when users switch or re-login to their iCloud accounts, or reconnect to the network after being offline. However, in some cases, issues like data not updating or devices not syncing may occur, such as:

    When the app is running normally, and the user opts to turn off iCloud synchronization for the app in the system settings. After this, any modifications to NSUbiquitousKeyValueStore within the app will not be uploaded to the server, even if the user later restores iCloud synchronization for the app.

  • NSUbiquitousKeyValueStore requires a developer account

    An Apple Developer account is necessary to enable iCloud synchronization features.

  • NSUbiquitousKeyValueStore does not yet offer convenient methods for use in SwiftUI

    Starting with iOS 14, Apple provided AppStorage for SwiftUI. Similar to @State, @AppStorage allows views to respond promptly to changes in values stored in UserDefaults.

In most cases, @AppStorage can be considered a SwiftUI wrapper for UserDefaults, but in certain situations, @AppStorage’s behavior does not completely align with that of UserDefaults (not just in terms of supported data types).

Configuration

Before using NSUbiquitousKeyValueStore in code, certain project configurations are required to enable iCloud’s key-value storage feature.

  • In the project TARGET’s Signing & Capabilities, set the correct Team.

    Team setting in Xcode

  • In Signing & Capabilities, click +Capability at the top left to add the iCloud feature.

    Adding iCloud Capability in Xcode

  • In the iCloud feature settings, select Key-value storage.

    Selecting Key-value storage in iCloud settings

After selecting key-value storage, Xcode automatically creates an entitlements file for the project and sets the appropriate value for iCloud Key-Value Store as $(TeamIdentifierPrefix)$(CFBundleIdentifier).

Entitlements file settings in Xcode

The TeamIdentifierPrefix is your developer Team ID (ending with a .), which can be obtained from the upper right corner of Developer Account Certificates, Identifiers & Profiles (consisting of letters, numbers, and a dot XXXXXXXX.):

Developer Team ID

The CFBundleIdentifier is the app’s Bundle Identifier.

To use the same iCloud Key-value Store in other apps or extensions, you can manually modify the corresponding content in the entitlements file.

The most convenient way to obtain another app’s iCloud Key-value Store is to add a key in the plist with the value $(TeamIdentifierPrefix)$(CFBundleIdentifier) and check it using Bundle.main.object(forInfoDictionaryKey:).

It is certain that data can be synchronized between different apps or app extensions under the same developer account (and the same iCloud account) pointing to the same iCloud Key-Value Store. I haven’t been able to test the scenario where different developer accounts point to the same iCloud Key-Value Store. I would appreciate if someone could test this and inform me, thank you.

Using NSUbiquitousKeyValueStore in SwiftUI Views

In this section, we will implement real-time responsiveness of SwiftUI views to changes in NSUbiquitousKeyValueStore without using any third-party libraries.

The basic workflow of NSUbiquitousKeyValueStore is as follows:

  • Save key-value pairs to NSUbiquitousKeyValueStore.
  • NSUbiquitousKeyValueStore first saves key-value data in memory.
  • The system periodically persists the data to disk (developers can explicitly call this operation by using synchronize()).
  • The system periodically sends the changed data to iCloud.
  • iCloud and other devices synchronize the updated data as needed.
  • Devices persist the network-synchronized data locally.
  • After synchronization is complete, a NSUbiquitousKeyValueStore.didChangeExternallyNotification notification is sent to inform developers.

Apart from the network synchronization steps, the workflow is almost identical to UserDefaults.

Without using third-party libraries, SwiftUI views can be linked to changes in NSUbiquitousKeyValueStore by bridging @State data.

The following code creates a string named “text” in NSUbiquitousKeyValueStore and links it to the variable text in the view:

Swift
struct ContentView: View {
    @State var text = NSUbiquitousKeyValueStore().string(forKey: "text") ?? "empty"

    var body: some View {
        TextField("text:", text: $text)
            .textFieldStyle(.roundedBorder)
            .padding()
            .task {
                for await _ in NotificationCenter.default.notifications(named: NSUbiquitousKeyValueStore.didChangeExternallyNotification) {
                    if let text = NSUbiquitousKeyValueStore.default.string(forKey: "text") {
                        self.text = text
                    }
                }
            }
            .onChange(of: text, perform: { value in
                NSUbiquitousKeyValueStore.default.set(value, forKey: "text")
            })
    }
}

The code within task serves the same purpose as the following code. For more details on how they work, you can refer to the article Collaboration between Combine and async/await:

Swift
.onReceive(NotificationCenter.default.publisher(for: NSUbiquitousKeyValueStore.didChangeExternallyNotification)) { _ in
    if let text = NSUbiquitousKeyValueStore.default.string(forKey: "text") {
        self.text = text
    }
}

The didChangeExternallyNotification’s userinfo also contains other information, such as the reason for the notification and the names of the changed keys.

In reality, it’s not feasible to drive views with the above method for every key in NSUbiquitousKeyValueStore. In the next article, we will attempt to use a more convenient method to integrate with SwiftUI.

Using NSUbiquitousKeyValueStore Like @AppStorage

Although the code from the previous section is a bit complex, it points out the direction for linking NSUbiquitousKeyValueStore with views—by associating changes in NSUbiquitousKeyValueStore with data that can cause view refreshes (like State, ObservableObject, etc.), you can achieve an effect similar to @AppStorage.

In principle, this is not complicated, but supporting all types still requires a lot of detailed work. Fortunately, Tom Lokhorst has already implemented all of this for us. By using his CloudStorage library, we can easily use NSUbiquitousKeyValueStore in views. The code from the previous section would transform as follows when using the CloudStorage library:

Swift
@CloudStorage("text") var text = "empty"

The usage is entirely the same as @AppStorage.

Many developers may think of Zephyr when choosing a third-party library to support NSUbiquitousKeyValueStore. Zephyr does a great job in handling the linkage between UserDefaults and NSUbiquitousKeyValueStore, but due to the uniqueness of @AppStorage (not a complete wrapper for UserDefaults in the true sense), Zephyr’s support for @AppStorage is currently problematic, and I do not recommend using it.

Centralized Management of NSUbiquitousKeyValueStore Keys and Values

As the number of UserDefaults and NSUbiquitousKeyValueStore key-value pairs created in an app increases, introducing them individually in views can make the data difficult to manage. Therefore, it is necessary to find a way suitable for SwiftUI to configure and manage key-value pairs in a centralized manner.

In the article Mastering @AppStorage in SwiftUI, I introduced a method for the unified management and centralized injection of @AppStorage. For example:

Swift
class Defaults: ObservableObject {
    @AppStorage("name") public var name = "fatbobman"
    @AppStorage("age") public var age = 12
}

// In the view, inject centrally
@StateObject var defaults = Defaults()
...
Text(defaults.name)
TextField("name", text: defaults.$name)

So, can we follow this approach and include @CloudStorage?

May 2022 Update: I have revised @CloudStorage in line with the implementation of @Published. Now, the behavior of @CloudStorage is completely consistent with @AppStorage. For more details, please refer to Going Beyond @Published:Empowering Custom Property Wrappers.

Unfortunately, I still have not understood how @AppStorage achieves a behavior similar to @Published from a coding perspective. Therefore, we can only use a relatively clumsy method to achieve our goal.

I made some modifications to CloudStrorage, adding a notification mechanism at several data change points. In classes that conform to ObservableObject, responding to this notification and calling objectWillChange.send() simulates the characteristics of @AppStorage.

The modified CloudStorage code can be downloaded here.

My submitted PR has been accepted by the original author and can be downloaded from there.

Swift
class Settings: ObservableObject {
       @AppStorage("name") var name = "fat"
       @AppStorage("age") var age = 5
       @CloudStorage("readyForAction") var readyForAction = false
       @CloudStorage("speed") var speed: Double = 0
}

struct DemoView: View {
    @StateObject var settings = Settings()
    var body: some View {
        Form {
            TextField("Name", text: $settings.name)
            TextField("Age", value: $settings.age, format: .number)
            Toggle("Ready", isOn: $settings.readyForAction)
                .toggleStyle(.switch)
            TextField("Speed", value: $settings.speed, format: .number)
        }
        .frame(width: 400, height: 400)
    }
}

Due to the special nature of SwiftUI system component wrappers, please be particularly careful about the way @CloudStorage Binding data is called in the view when managing @AppStorage and @CloudStorage data in the aforementioned manner.

Only $storage.cloud should be used. Using storage.$cloud will lead to binding data not refreshing the wrappedValue, resulting in incomplete data updates in the view.

Conclusion

NSUbiquitousKeyValueStore, as its name suggests, makes app data ubiquitous. With minimal configuration, this functionality can be added to your app, so those interested can start implementing it!

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways