We have now given a very brief tour of what our new Sharing library has to offer. At its core it provides a @Shared
property wrapper that can be used basically anywhere. It can be used in SwiftUI views, in observable models, in UIKit controllers, and more. And it works exactly as you expect. Changes to the state cause the view to update automatically, and if someone updates the state externally then the @Shared
value also immediately updates.
But storing simple values in user defaults is barely even scratching the surface of what the @Shared
property has to offer. There are other persistence strategies provided by the library beyond just .appStorage
, and everything we have learned so far equally applies to those strategies.
The next one we will consider is the .fileStorage
strategy. It allows you to model shared state in your application whose source-of-truth ultimate lies on the file system. It is appropriate to use for more complex pieces of state that can be serialized to bytes because user defaults is really meant for simple data types, such as strings, booleans, integers, and so on.
We are going to make use of this persistence strategy by building a simple feature that we have explored a number of times on Point-Free, most recently when we explored cross-platform Swift. We are going to make it so that we can request a fact about the number our counter is set to, and then make it so that we can save our favorite facts. We would like those facts to be persisted across launches of the app, and so let’s see how that can be accomplished with the @Shared
property wrapper. It may seem like a silly demo to build, but it helps explore the foundational concepts of side effects and persistence without getting bogged down in too many superfluous details.
Let’s dig in.
We are going to implement this new functionality as a whole new feature. So let’s create a new file called FactFeature.swift…
And let’s start by pasting in the scaffolding for the view of our feature:
import SwiftUI
struct FactFeatureView: View {
var body: some View {
Form {
Section {
Text(<#"0"#>)
Button("Decrement") { }
Button("Increment") { }
}
Section {
Button("Get fact") { }
if <#Is fact loaded?#> {
HStack {
Text(<#"0 is a good number!#>)
Button {
} label: {
Image(systemName: "star")
}
}
}
}
if <#Any saved facts?#> {
Section {
ForEach(<#facts#>) { fact in
Text(<#fact#>)
}
.onDelete { indexSet in
}
} header: {
Text("Favorites")
}
}
}
}
}
#Preview {
FactFeatureView()
}
We can see in the preview that it is a simple form that displays the current count as well as buttons for incrementing and decrementing. It also has a button for fetching a fact for the number, and when a fact is loaded it will display the fact. And further, the fact can be saved to the user’s favorites, which is displayed below in a list. The user can even delete facts from the list.
This view is of course completely non-functional because none of the logic or behavior has been implemented. So, let’s do that now. We will design an observable model that encapsulates the state for this feature and exposes endpoints that can be called from the view to execute the features logic and behavior.
So, let’s start with an @Observable
model:
import SwiftUI
@Observable
class FactFeatureModel {
}
This feature will hold onto a shared count that is persisted to user defaults, which we already have a custom type-safe shared key to represent that:
import Sharing
…
@ObservationIgnored
@Shared(.count) var count
And we will further hold onto a shared array of strings that represents the user’s favorite facts:
@ObservationIgnored
@Shared(<#???#>) var favoriteFacts: [String] = []
But the question is: what persistence strategy do we use?
Technically user defaults does actually support storing arrays of strings in it. And so we could of course do:
@Shared(.appStorage("favoriteFacts")) var favoriteFacts: [String] = []
…and call it a day. Now our .appStorage
persistence strategy doesn’t support arrays of strings, and neither does SwiftUI’s @AppStorage
property wrapper:
@AppStorage("favoriteFacts") var favoriteFacts: [String] = []
But that’s just because overloads haven’t been provided for arrays of strings. It could theoretically be supported, but for some reason SwiftUI’s engineers have decided not to, most likely because it is better to store complex data outside of user defaults.
But even if we assume for a moment that we could store this array of strings in app storage, it’s very rare that a user’s data can be stored as such a simple data type. In fact, by storing the fact as just a string we have lost the information of what number the fact is for. Typically the fact will mention the number inside its string, but then we would have to do extra work to parse the fact string to figure out the number, and that will be an error-prone process.
So rather than storing a raw string, let’s have a first class data type that represents the fact:
struct Fact {
var number: Int
var value: String
}
And maybe we also want to know when this fact was saved:
struct Fact {
var number: Int
var savedAt: Date
var value: String
}
And this type will need to be Codable
so that it can be serialized to bytes to be saved on disk:
struct Fact: Codable {
…
}
And we know we are going to want to show these facts in a list in SwiftUI, and that will require it to be identifiable
struct Fact: Codable, Identifiable {
…
}
And we will use a UUID as the fact’s identity:
struct Fact: Codable, Hashable, Identifiable {
let id: UUID
…
}
And now we are definitely out of luck in trying to store this in app storage:
@Shared(.appStorage("favoriteFacts")) var favoriteFacts: [Fact] = []
This type is just too complex to easily store in user defaults. And this is where the .fileStorage
strategy comes into play:
@Shared(.fileStorage(<#url: URL#>)) var favoriteFacts: [Fact] = []
It allows you to hold onto any Codable
data in your feature, and secretly any changes made to this data will be serialized and saved to disk. And it will even listen for changes to the file on disk and update the state in your feature if the file changes.
It takes a single argument, which is the URL pointing to a location on disk where the file should be stored:
@ObservationIgnored
@Shared(
.fileStorage(
.documentsDirectory.appending(component: "favorite-facts.json")
)
)
var favoriteFacts: [Fact] = []
This is a bit of a mouthful, but we can repeat what we did for .count
by defining a type-safe key that can be used anywhere in our app:
extension SharedKey where Self == FileStorageKey<[Fact]>.Default {
static var favoriteFacts: Self {
Self[
.fileStorage(
.documentsDirectory.appending(component: "favoriteFacts.json")
),
default: []
]
}
}
And now all of this can be shortened to just this:
@ObservationIgnored
@Shared(.favoriteFacts) var favoriteFacts
And this helps us prevent a typo in the URL, or accidentally forgetting which type we are serializing to the file on disk:
@Shared(.favoriteFacts) var favoriteFacts: [String]
Initializer ‘init(_:)
’ requires the type ‘[String]’ and ‘[Fact]’ be equivalent
There is another piece of state we will hold in our feature’s model, but this state does not need to be persisted at all. It’s just local to this one feature, and that is whether or not we are displaying a fact to the user:
var fact: String?
That is all the state for our feature. These 3 fields handle everything that we need to display in the preview. Next we will implement some endpoints on the model that can be called from the view when the user performs some action.
For example, when they tap the “Increment” or “Decrement” button we will increment the count and clear out the currently displayed fact:
func incrementButtonTapped() {
$count.withLock { $0 += 1 }
fact = nil
}
func decrementButtonTapped() {
$count.withLock { $0 -= 1 }
fact = nil
}
And remember that the only way to mutate a shared value is through the withLock
function. We are not allowed to mutate shared state directly.
The reason for this is because the @Shared
property wrapper can essentially be used anywhere in your app, not just SwiftUI views which are @MainActor
bound. It can be used in an observable model, a UIKit view controller, some random helper function, any actor…anywhere! And because of that it does require synchronization to mutate so that you don’t accidentally lose data from interleaving reads and mutations.
@AppStorage
can be mutated directly because 99% of the time one interacts with it in the view, which is @MainActor
bound. But it is technically possible to mutating @AppStorage
in a way that has race conditions, as we showed in our last episode.
And when the fact button is tapped we can perform a network request to fetch a fact for the count and then populate the fact
state so that it appears on the screen:
func factButtonTapped() async {
do {
let fact = try await String(
decoding: URLSession.shared.data(
from: URL(string: "http://numbersapi.com/\(count)")!
).0,
as: UTF8.self
)
withAnimation {
self.fact = fact
}
} catch {
reportIssue(error)
}
}
And you know what… let’s just do things the right way from the very beginning. Making a network request directly in our observable model using URLSession
is not a good idea. Sure it can be OK when prototyping a feature and you want to be fast and loose. But in the log run it is always better to control these kinds of dependencies. This makes it easier to run your features in previews and tests, and makes you less susceptible to the vagaries of the outside world.
We are going to control this dependency by defining an abstract interface for fetching facts for a number:
struct FactClient {
var fetch: @Sendable (Int) async throws -> String
}
We are using a struct to design this interface, and you may be more familiar with using protocols for such things. That is totally fine if you want to do that, but we are going to use a struct for right now.
Further, we are going to register this client with our Dependencies library by conforming it to the DependencyKey
protocol:
import Dependencies
struct FactClient: DependencyKey {
var fetch: @Sendable (Int) async throws -> String
}
And this protocol requires that we provide a live implementation of the dependency that will be used when the app is run in simulators and on devices:
import Dependencies
struct FactClient: DependencyKey {
var fetch: @Sendable (Int) async throws -> String
static let liveValue = FactClient { number in
}
}
And it’s in this liveValue
that it is appropriate to interact with URLSession
and make live network requests:
import Dependencies
import Foundation
struct FactClient: DependencyKey {
var fetch: @Sendable (Int) async throws -> String
static let liveValue = FactClient { number in
try await String(
decoding: URLSession.shared
.data(
from: URL(string: "http://www.numberapi.com/\(number)")!
).0,
as: UTF8.self
)
}
}
And now instead of reaching out to the uncontrolled URLSession
in our feature code to make a live network request, we will add a dependency on the FactClient
:
import Dependencies
…
@ObservationIgnored
@Dependency(FactClient.self) var factClient
And use it instead of URLSession
:
func factButtonTapped() async {
let count = count
do {
let fact = try await factClient.fetch(count)
withAnimation {
self.fact = fact
}
} catch {
reportIssue(error)
}
}
That now only makes our code simpler, but makes it more robust. We can now completely control the manner in which we load facts whenever we want. Whether that be in tests or in previews, which we will be taking advantage of soon.
And it’s worth mentioning that our @Dependency
property wrapper is analogous to the @Environment
property wrapper from SwiftUI. The @Environment
property wrapper allows you to propagate dependencies throughout a view hierarchy, but sadly it does not work outside of a SwiftUI view. Our @Dependency
property wrapper also helps propagate dependencies, though in a different way, and it does work outside of SwiftUI views. That sounds pretty similar to how we approached the @Shared
property wrapper too. We identified that the @AppStorage
property wrapper is powerful, but sadly does not work outside of a SwiftUI view, and so we looked into creating something that does work outside of the view.
OK, we are almost done implementing our observable model. Next we will create an endpoint that the view can invoke when it wants to save the currently displayed fact, and it will also remove the fact if it was previously saved:
func favoriteFactButtonTapped() {
guard let fact
else { return }
withAnimation {
self.fact = nil
$favoriteFacts.withLock {
$0.insert(
Fact(id: UUID(), number: count, savedAt: Date(), value: fact),
at: 0
)
}
}
}
And finally we will have an endpoint for deleting facts that are invoked from the ForEach
:
func deleteFacts(indexSet: IndexSet) {
$favoriteFacts.withLock {
$0.remove(atOffsets: indexSet)
}
}
Note that there are special functions in SwiftUI for removing and moving elements of a collection based on an IndexSet
.
That is all it takes to implement the logic and behavior of our feature. We started with the scaffolding of a view that had all the features we wanted to support, but the view was completely inert. And then we built an observable model that actually implements the logic and behavior of the feature.
And it’s worth calling out that we are mostly dealing with the count
and favoriteFacts
state as if it is just regular state. We can read from it, we can write to it, as long as we use the special withLock
method, but secretly under the hood the state is being persisted to user defaults and the file system.
Let’s now integrate them together because they currently exist separately in their own worlds. Along the way we are going to be able to show off a few more interesting features of the @Shared
property wrapper.
Let’s start by adding the model to the view so that it is able to read state from it and invoke its methods:
struct FactFeatureView: View {
@State var model = FactFeatureModel()
…
}
Right now we are holding onto this as local @State
with a default value so that this view constructs its model and then owns the lifecycle of the model. Alternatively we could have held onto the model as a simple let
with no default:
struct FactFeatureView: View {
let model: FactFeatureModel
…
}
That would put the responsibility to create the the model on whoever constructs this view. Either way can be correct depending on the context, but for now we will keep things simple by using local @State
.
Now that we have a model in the view we can start replacing the placeholder tokens for real data, such as the counter text:
Text("\(model.count)")
And when checking if a fact is currently loaded:
if let fact = model.fact {
HStack {
Text(fact)
…
}
}
And finally, the display of the favorite facts:
if !model.favoriteFacts.isEmpty {
Section {
ForEach(model.favoriteFacts) { fact in
Text(fact.value)
}
…
} header: {
Text("Favorites")
}
}
With just that done we can already get our preview to display data even though the view is still completely inert and has not behavior. For example, right inside the #Preview
macro we can update the shared favorite facts to hold a whole bunch of facts:
#Preview {
@Shared(.favoriteFacts) var favorite = (1...100).map {
Fact(
id: UUID(),
number: $0,
savedAt: Date(),
value: "\($0) is a good number."
)
}
FactFeatureView()
}
And we can even update the default count to start at a much larger number than 0:
#Preview {
@Shared(.count) var count = 101
@Shared(.favoriteFacts) var favorite = (1...100).map {
Fact(id: UUID(), number: $0, value: "\($0) is a good number.")
}
FactFeatureView()
}
With just that our preview is displaying all of this data.
But best of all, these changes to the shared state is completely quarantined to just this preview. Under the hood the @Shared
property wrapper is using our Dependencies library so that it can provide mock versions of the file system and user defaults when run in previews. this means if we wanted an additional preview that was always in the default state, with count set to 0 and favorite facts empty, then all we have to do is this:
#Preview {
FactFeatureView()
}
We don’t need to remember to go in and set the count to 0 and favorite facts to an empty array because this preview’s state is completely separate from the other preview.
So this is looking great, but of course the view is still completely inert. Tapping on any of these buttons does absolutely nothing. We just need to invoke the model’s methods in the various action closures of the view, such as the “Increment” and “Decrement” buttons:
Button("Decrement") { model.decrementButtonTapped() }
Button("Increment") { model.incrementButtonTapped() }
As well as the “Get fact” button:
Button("Get fact") {
Task { await model.factButtonTapped() }
}
And here we have decided to spin up an unstructured task in the view to perform this async work rather than do it in the model. We should strive to keep our model in the structured programming world as much as possible by minimizing the times we make use of escaping closures, such as the Task
initializer. It is better to push those tasks to the view since we are already in an unstructured context, which is the action closure of this button.
But, in doing this we have introduced a potential concurrency problem:
Sending ‘self.model’ risks causing data races
The FactFeatureModel
class is not Sendable
, and so not safe to use in concurrent contexts. At this exact moment it is totally fine to use the model
from this Task
because the view is @MainActor
bound and this Task
inherits that @MainActor
context. In fact, so far all access to the model is from the @MainActor
, and so there isn’t really a chance of a race happening.
However, that doesn’t mean that we won’t ever start accessing the model from the non-main thread. In fact, the mere fact that the factButtonTapped
is async means that the model will be accessed from multiple threads. The thread that this method is executed on before the await
will most likely be different from the thread after the await
. Of course, these two threads are not accessing the model at the same time as everything is serialized in this context. But it would be possible to invoke this method from two different threads, and then there would be concurrent access to the model
There are a few ways we can address. First, and most bluntly, we could just tell Swift that we know what we are doing, and so to allow this concurrent access by using nonisolated(unsafe)
:
Button("Get fact") {
nonisolated(unsafe) let model = model
Task { await model.factButtonTapped() }
}
That certainly silences the warning, but now it is our responsibility to use the model safely. As long as we only access the model directly in the view or non-detached Task
s we will probably be safe, but it’s a big responsibility to take on.
Alternatively we could surgically apply @MainActor
just to the factButtonTapped
:
@MainActor
func factButtonTapped() async {
…
}
This also fixes the problem because now Swift knows that each individual line of code in this method will be called on the @MainActor
and so there is no risk of multiple threads accessing the unprotected state inside the class.
However, these kinds of surgical applications of @MainActor
are usually not worth the effort. Making the entire class @MainActor
bound:
@Observable
@MainActor
class FactFeatureModel {
…
}
…achieves the same result but also makes it so that any method can be invoked from a @Sendable
closure. And sure this means all of the core logic of our model will be executed on the main thread, but that is also true when you cram logic directly into a SwiftUI view. If you really do need to perform CPU intensive work in the model you can always do so on a background thread and then update the model.
Next we have the button that saves or removes the fact from the user’s favorites:
Button {
model.favoriteFactButtonTapped()
} label: {
…
}
And finally we have the onDelete
action closure that is defined on the ForEach
that displays the favorite facts:
.onDelete { indexSet in
model.deleteFacts(indexSet: indexSet)
}
And that is all it takes. Our app should not be fully functional.
Let’s start by running it in the preview and we will see that we can count up and down, but unfortunately the fact fetching functionality does not seem to be working. If we look in the console we will see exactly why:
TourOfSharing/FactFeature.swift:41: Caught error: Error Domain=NSURLErrorDomain Code=-1022 “The resource could not be loaded because the App Transport Security policy requires the use of a secure connection.” …
This is appearing thanks to the fact that we decided to put a reportIssue
in the catch
of our URLSession
request. If we were to run the app in the simulator…
…and try fetching a fact we will see that the console message has actually been upgraded to a purple runtime warning that is noticeably but unobtrusively displayed in Xcode.
And luckily this error tells us exactly what is wrong and is straightforward to fix. We just need to add an entry to our Info.plist to allow arbitrary HTTP loads:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</plist>
And with that done the preview now is fully functional. We can fetch a fact for any number, and save it to our list of favorites.
But also, maybe we don’t want to have to depend on making a live network request just to fetch the fact for a number. What if we don’t have an internet connection or what if the API we are accessing is temporarily down? Shouldn’t we be able to just force our FactClient
to return some data in our preview without making a network request?
And thanks to our Dependencies library this very easy to do. When registering our dependency with the library we can choose to provide a version of the client that is used only in previews:
struct FactClient: DependencyKey, Sendable {
…
static let previewValue = FactClient {
"\($0) is a good number!"
}
…
}
Further, this kind of “mock” fact client that is predictable is going to be useful for tests too, and we can save ourselves some work if we define it as a separate instance that the preview is defined in terms of:
static let goodFacts = FactClient {
"\($0) is a good number!"
}
static let previewValue = goodFacts
Now when we run the preview we get a predictable fact back, and this would continue to work even if I turned off Wi-Fi on my computer.
Of course re-running the preview causes all of the persisted state to reset because each preview is given temporary storage to save data. Controlling dependencies can be great for iterating quickly on features without worrying about the outside world, but at the end of the day your feature does operate in the outside world, and so you may want to run a preview with the live versions of your dependencies sometimes.
Luckily this is quite easy. You can use preview traits to override dependencies for the duration of a single preview. For example, if you want to use the UserDefaults.standard
for the app storage, the live file system for the file storage, and the live fact client, you can simply do the following:
#Preview(
"Live",
traits:
.dependency(\.defaultAppStorage, .standard)
.dependency(\.defaultFileStorage, .fileSystem)
.dependency(FactClient.liveValue)
) {
FactFeatureView()
}
And now when we run the preview we see that we are using the live versions of our dependencies. Data is persisted and a real life network request is made to the numbers API.
And even this can be shortened by using the dependency trait that allows mutating a bunch of dependencies at once:
#Preview(
"Live", traits: .dependencies {
$0.defaultAppStorage = .standard
$0.defaultFileStorage = .fileSystem
$0[FactClient.self] = .liveValue
}
) {
FactFeatureView()
}
This shows just how easy it is to flip any of our dependencies to their live versions, and we really do have infinite flexibility in crafting the exact environment our feature is executed in.
We can even go a step further. What if we don’t want to override dependencies one at a time, but rather just force all dependencies to resolve to their live versions. Then we can simply do:
#Preview("Live", traits: .dependency(\.context, .live)) {
FactFeatureView()
}
This guarantees that our preview runs in the exact execution environment that it would when run in the simulator or device.
And of course we can still run the app in the simulator to see that it really does persist data across launches…
And with the feature now fully built we can demonstrate some more fun features of the @Shared
property wrapper. The first fun thing we want to show off is that the @Shared
property wrapper listens for changes to the file on disk so that it can automatically update itself if it detects a change. To see this let’s print out the location of the file we are persisting to:
.fileStorage(dump(.documentsDirectory.appending(path: "favoriteFacts.json"))),
Running the app in the simulator will show where the file is stored:
▿ file:///…/Documents/favoriteFacts.json
- _url: file:///…/Documents/favoriteFacts.json #0
- super: NSObject
- _parseInfo: nil
- _baseParseInfo: nil
We can open the file in Xcode to see all of the facts we have saved so far:
[
{
"id" : "F47C9EBC-CAE7-499C-A518-309E33318A6C",
"number" : 2,
"savedAt" : 753549105.502176,
"value" : "2 is the price in cents per acre the USA bought Alaska from Russia."
}
]
We can see that not only is the fact saved, but also the corresponding number for the fact, the date it was saved, and its unique ID. And if we save a new fact we will see that a moment later the file’s contents are updated with the new fact.
But the really cool thing is that we can make an edit to this file, like changing the fact for 13:
"value":"13 is a good number that is misunderstood."
…and the moment we hit save the app immediately updates!
And we can even add a fact to the array:
[
{
"id" : "F47C9EBC-CAE7-499C-A518-309E33318A6C",
"number" : 2,
"savedAt" : 753549105.502176,
"value" : "2 is the price in cents per acre the USA bought Alaska from Russia."
},
{
"id" : "deadbeef-dead-beef-dead-beefdeadbeef",
"number" : 1729,
"savedAt" : 753549105.502176,
"value" : "The smallest number expressible as a sum of two cubes in two different ways: 1^3 + 12^3 = 9^3 + 10^3."
}
]
The moment we save this file the fact appears in the UI.
This means you can be guaranteed that the state in your @Shared
variables is always kept up to date with what is stored on the disk. And so it really is like you are sharing state in your application with the file system. They are kept in sync.
If there is one word I would use to describe what we have done so far it would be: “wow”.
In just one line of code we are expressing the idea of sharing a piece of state with the file system. Using @Shared
with file storage looks almost identical to using @Shared
with user defaults, but it works beautifully for more complex data types. Any change made to the shared state is automatically saved to disk, and if anyone else every saves data straight to that file, the @Shared
state in the app will immediately update.
But things get even better. Even though the @Shared
property wrapper typically is interacting with outside systems that we do not control, such as user defaults and the file system, it was still built in a way that makes it possible to test any of your code using @Shared
. And can be done so with no additional setup work too.
It’s amazing to see, so let’s write a very basic test for our feature…next time!