Sync Data in SwiftUI Using NSUbiquitousKeyValueStore

fatbobman ( 东坡肘子)
ITNEXT
Published in
8 min readSep 26, 2023

--

Photo by Greg Rakozy on Unsplash

What is NSUbiquitousKeyValueStore?

NSUbiquitousKeyValueStore can be understood as a network-synced version of UserDefaults. It is a member of the CloudKit service project and can easily share data across different devices (with the same iCloud account) with simple configuration.

In most cases, NSUbiquitousKeyValueStore behaves very similarly to UserDefaults:

  • Both are based on key-value storage.
  • Only strings can be used as keys.
  • Any property list object types can be used as values.
  • Similar methods for reading and writing.
  • Both initially save data in memory, and the system will persist the data from memory at an appropriate time (developers typically do not need to intervene in this process).

Even if you have not used UserDefaults before, you can grasp its basic usage by spending a few minutes reading the official documentation.

A Chinese version of this post is available here.

Don’t miss out on the latest updates and excellent articles about Swift, SwiftUI, Core Data, and SwiftData. Subscribe to fatbobman’s Swift Weekly and receive weekly insights and valuable content directly to your inbox.

The differences between UserDefaults and NSUbiquitousKeyValueStore

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

When using UserDefaults, developers can use register(defaults: [String: Any]) to set default values for keys. However, NSUbiquitousKeyValueStore does not provide a similar mechanism. For types that do not return optional values, it is recommended to avoid using shorthand methods to retrieve values.

For example, you can use code similar to the following to retrieve an integer value for a key named “count”:

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)

// The return value of longLong is not an optional value, so it should be avoided to use the following convenient way to obtain the value
// NSUbiquitousKeyValueStore.default.longLong(forKey: "count") has a default value of 0
  • NSUbiquitousKeyValueStore has more restrictions.

Apple does not recommend using NSUbiquitousKeyValueStore to store large amounts of data that frequently change and are critical to the app’s operation.

The maximum storage capacity of NSUbiquitousKeyValueStore is 1MB per user, and the number of key-value pairs stored should not exceed 1024.

The efficiency of network synchronization in NSUbiquitousKeyValueStore is average. Under smooth conditions, the synchronization of a key-value pair can be completed in about 10–20 seconds. If the data changes frequently, iCloud will automatically reduce the synchronization frequency, and the synchronization time may be extended to several minutes. When developers are testing and making multiple data modifications within a short period of time, it is highly likely to experience slow synchronization.

Although NSUbiquitousKeyValueStore does not provide atomic support for data synchronization, in most cases, it will try to ensure the data integrity when users switch iCloud accounts, log in to iCloud accounts again, or reconnect after losing network connection. However, in some cases, data may not be updated and devices may not be synchronized, for example:

When the app is running normally, the user chooses to turn off iCloud synchronization for the app in the system settings. After that, all modifications to NSUbiquitousKeyValueStore in the app will not be uploaded to the server, even if the user restores the app’s iCloud synchronization function later.

  • NSUbiquitousKeyValueStore requires a developer account.

To enable iCloud sync functionality, you need to have a developer account.

  • NSUbiquitousKeyValueStore does not yet provide a convenient usage method under SwiftUI.

From iOS 14 onwards, Apple has introduced AppStorage for SwiftUI. Similar to @State, views can now respond to changes in values stored in UserDefaults by using @AppStorage.

In most cases, we can consider @AppStorage as a SwiftUI wrapper for UserDefaults. However, in some cases, @AppStorage does not behave exactly the same as UserDefaults (not only in terms of supported data types).

Configuration

Before using NSUbiquitousKeyValueStore in your code, we first need to configure the project to enable iCloud’s key-value storage functionality.

  • In the Signing & Capabilities section of the TARGET project, set the correct Team.
  • In Signing & Capabilities, click on the top left corner +Capability to add iCloud functionality.
  • In the iCloud functionality, select Key-value storage.

After selecting the key-value store, Xcode will automatically create entitlements file for the project. And it will set the corresponding value for iCloud Key-Value Store as $(TeamIdentifierPrefix)$(CFBundleIdentifier).

TeamIdentifierPrefix is your developer Team (add a . at the end), which can be obtained from the upper right corner of the Developer Account Certificates, Identifiers & Profiles (composed of alphanumeric characters and dots XXXXXXXX.):

CFBundleIdentifier is the Bundle Identifier of the app.

If you want 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 easiest way to access the iCloud Key-value Store of other apps is by adding a key with a value of $(TeamIdentifierPrefix)$(CFBundleIdentifier) to the plist file and then using Bundle.main.object(forInfoDictionaryKey:) to view it.

It can be confirmed that within the same developer account, as long as it points to the same iCloud Key-Value Store, data can be synchronized between different apps or app extensions (using the same iCloud account). I am unable to test the scenario where different developer accounts point to the same iCloud Key-Value Store. If anyone with the means could help test this and let me know, I would appreciate it. Thank you.

In SwiftUI, use NSUbiquitousKeyValueStore

In this section, we will implement real-time response 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 initially saves the key-value data in memory
  • The system opportunistically persists the data to the disk (developers can explicitly call this operation by invoking synchronize())
  • The system opportunistically sends the changed data to iCloud
  • iCloud and other devices opportunistically synchronize the modified data
  • The device persists the network-synchronized data locally
  • After synchronization is complete, the NSUbiquitousKeyValueStore.didChangeExternallyNotificationnotification is sent to alert developers

Except for the step of network synchronization, the workflow is almost the same as UserDefaults.

In SwiftUI views, you can bridge the changes of NSUbiquitousKeyValueStore with the view by using @State to bind the data, without using any third-party libraries.

The following code creates a string with the key name “text” in NSUbiquitousKeyValueStore and associates it with the variable text in the view:

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 in the task has the same functionality as the code below. If you are interested in understanding its specific usage, you can refer to the article Collaboration between Combine and async/await.

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

In the userinfo of didChangeExternallyNotification, there are also some other information, such as the reason for the message prompt and the name of the changed key.

In fact, it is not practical to drive views using the above approach for every key in NSUbiquitousKeyValueStore. In the following article, we will try to use a more convenient method to integrate with SwiftUI.

In light of the fact that my blog, Fatbobman’s Blog, now offers all articles in English, starting from April 1, 2024, I will no longer continue updating articles on Medium. You are cordially invited to visit my blog for more content.

Use NSUbiquitousKeyValueStore just like @AppStorage.

Although the code in the previous section may seem cumbersome, it has already indicated the direction of NSUbiquitousKeyValueStore’s interaction with views — associating the changes in NSUbiquitousKeyValueStore with data that can cause view refreshing (such as State and ObservableObject), similar to @AppStorage.

The principle is not complicated, but a lot of detailed work is still needed to support all types. Fortunately, Tom Lokhorst has implemented all of this for us. Using his library, CloudStorage, we can easily use NSUbiquitousKeyValueStore in our views.

The code in the previous section, after using the CloudStorage library, will become:

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

The usage is exactly the same as @AppStorage.

Many developers may first think of Zephyr when choosing a third-party library that supports NSUbiquitousKeyValueStore. Zephyr does a great job in handling the linkage between UserDefaults and NSUbiquitousKeyValueStore. However, due to the uniqueness of @AppStorage (not a complete wrapper of UserDefaults in the true sense), Zephyr sometimes encounters issues when supporting @AppStorage.

Centralized management of NSUbiquitousKeyValueStore keys and values

As the number of UserDefaults and NSUbiquitousKeyValueStore key-value pairs created in the app increases, managing the data becomes difficult by individually introducing them into views. Therefore, there is a need to find a way that is suitable for SwiftUI to unify configuration and centralize the management of key-value pairs.

In the article Mastering AppStorage in SwiftUI, I have introduced a method to manage and inject @AppStorage uniformly and centrally. For example:

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

@StateObject var defaults = Defaults()
...
Text(defaults.name)
TextField("name",text:defaults.$name)

So, can we continue with this approach to include @CloudStorage?I have made modifications to @CloudStorage based on the implementation of @Published. Now, the behavior of @CloudStorage is completely consistent with @AppStorage. You can read the details in the article titled Going Beyond @Published: Empowering Custom Property Wrappers.

The PR I submitted has been accepted by the original author, and you can download it from the original author’s repository.

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 the SwiftUI system component wrappers, when using the above method to manage @AppStorage and @CloudStorage data, please pay special attention to how you call @CloudStorage binding data in views.

You can only use $storage.cloud to access the data. Using storage.$cloud will result in the binding data not refreshing the wrappedValue, causing incomplete data updates in the view.

Summary

NSUbiquitousKeyValueStore, as its name suggests, allows your app’s data to be everywhere. With just a little configuration, you can add this feature to your app. So, for those who have the need, take action now!

If you found this article helpful or enjoyed reading it, consider making a donation to support my writing. Your contribution will help me continue creating valuable content for you.
Donate via Patreon, Buy Me aCoffee or PayPal.

Want to Connect?

@fatbobman on Twitter.

--

--

Blogger | Sharing articles at https://fatbobman.com | Publisher of a weekly newsletter on Swift at http://https://weekly.fatbobman.com