8.
Saving History Data
Written by Caroline Begbie
@AppStorage is excellent for storing lightweight data such as settings and other app initialization. You can store other app data in property list files, in a database such as SQLite or Realm, or in Core Data. Since you’ve learned so much about property list files already, you’ll save the history data to one in this chapter.
The saving and loading code itself is quite brief, but when dealing with data, you should always be aware that errors might occur. As you would expect, Swift has comprehensive error handling so that, if anything goes wrong, your app can recover gracefully.
In this chapter, you’ll learn about error checking techniques as well as saving and loading from a property list file. Specifically, you’ll learn about:
-
Optionals:
nilvalues are not allowed in Swift unless you define the property type as Optional. - Debugging: You’ll fix a bug by stepping through the code using breakpoints.
- Error Handling: You’ll throw and catch some errors, which is just as much fun as it sounds. You’ll also alert the user when there is a problem.
- Closures: These are blocks of code that you can pass as parameters or use as completion handlers.
- Serialization: Last but not least, you’ll translate your history data into a format that can be stored.
Adding the Completed Exercise to History
➤ Continue with your project from the previous chapter, or open the project in this chapter’s starter folder.
➤ Open HistoryStore.swift and examine addDoneExercise(_:). This is where you save the exercise to exerciseDays when your user taps Done.
Currently, on initializing HistoryStore, you create a fake exerciseDays array. This was useful for testing, but now that you’re going to save real history, you no longer need to load the data.
➤ In init(), comment out createDevData().
➤ Build and run your app. Start an exercise and tap Done to save the history. Your app performs addDoneExercise(_:) and crashes with Fatal error: Index out of range.
Xcode highlights the offending line in your code:
if today.isSameDay(as: exerciseDays[0].date) {
This line assumes that exerciseDays is never empty. If it’s empty, then trying to access an array element at index zero is out of range. When users start the app for the first time, their history will always be empty. A better way is to use optional checking.
Using Optionals
Skills you’ll learn in this section: optionals; unwrapping; forced unwrapping; filtering the debug console
Swift Dive: Optionals
In the previous chapter, to remove a key from Preview’s UserDefaults, you needed to assign nil to ratings.
So you defined ratings as an optional String type by adding ? to the type:
@AppStorage("ratings") static var ratings: String?
ratings here can either hold a string value or nil.
You may have learned that Booleans can be either true or false. But an optional Boolean can hold nil, giving you a third alternative.
Swift Tip:
Optionalis actually an enumeration with two cases:some(Wrapped)andnone, wheresomehas a generic value of typeWrappedandnonehas no value.
Checking for nil can be useful to prevent errors. At compile time, Xcode prevents Swift properties from containing nil unless you’ve defined them as optional. At run time, you can check that exerciseDays is not empty by checking the value of the optional first:
if exerciseDays.first != nil {
if today.isSameDay(as: exerciseDays[0].date) {
...
}
}
When first is nil, the array is empty, but if first is not nil, then it’s safe to access index 0 in the array. This is true, because exerciseDays doesn’t accept nil values. You can have arrays with nil values by declaring them like this:
var myArray: [ExerciseDay?] = []
The more common way of checking for nil is to use:
if let newProperty = optionalProperty {
// code executes if optionalProperty is non-nil
}
This places a non-optional unwrapped result into newProperty. Unwrapped here means that newProperty is assigned the contents of optionalProperty as long as optionalProperty is not nil.
Note: When the test is a simple one, you can shorten the test and assignment from
if let varName = varName {...}toif let varName {...}.
➤ Change if today.isSameDay(as: exerciseDays[0].date) { to:
if let firstDate = exerciseDays.first?.date {
if let tells the compiler that whatever follows could result in nil. The property first? with the added ? means that first is an optional and can contain nil.
If exerciseDays is empty, then first? will be nil and your app won’t perform the conditional block, otherwise firstDate will contain the unwrapped first element in exerciseDays.
Swift Dive: Forced Unwrapping
If you’re really sure your data is non-nil, then you can use an exclamation mark ! on an optional. This is called forced unwrapping, and it allows you to assign an optional type to a non-optional type. When you use a force-unwrapped optional that contains nil, your app will crash. For example:
let optionalDay: ExerciseDay? = exerciseDays.first
let forceUnwrappedDay: ExerciseDay = exerciseDays.first!
let errorDay: ExerciseDay = exerciseDays.first
-
optionalDayis of typeExerciseDay?and allowsnilwhenexerciseDaysis empty. -
forceUnwrappedDayis not optional and could cause a runtime error ifexerciseDaysis empty and you force-unwrapfirst. -
errorDaycauses a compile error because you are trying to put an optional which could containnilinto a property that can’t containnil.
Unless you’re really certain that the value will never contain nil, don’t use exclamation marks to force-unwrap it!
Multiple Conditionals
When checking whether you should add or insert the exercise into exerciseDays, you also need a second conditional to check whether today is the same day as the first date in the array.
➤ Change if let firstDate = exerciseDays.first?.date { to:
if let firstDate = exerciseDays.first?.date,
today.isSameDay(as: firstDate) {
You can stack up conditionals, separating them with a comma. Your second conditional evaluates the Boolean condition. If firstDate is not nil, and today is the same day as firstDate, then the code block executes.
➤ At the end of addDoneExercise(_:), add:
print("History: ", exerciseDays)
This will print the contents of exerciseDays to the debug console after adding or inserting history.
➤ Build and run, complete an exercise and tap Done.
Your app doesn’t crash, and your completed exercise prints out in the console.
Note: If you have trouble finding your print commands in the console, you can filter on words that you expect to see, such as in this case History.
Debugging HistoryStore
Skills you’ll learn in this section: breakpoints
Even though the contents of exerciseDays appears correct at the end of addDoneExercise(_:), if you tap History, your history data is blank. This is a real-life frustrating situation where you’re pretty sure you’ve done everything correctly, but the history data refuses to stay put.
Time to put your debugging hat on.
The first and often most difficult debugging step is to find where the bug occurs and be able to reproduce it consistently. Start from the beginning and proceed patiently. Document what should happen and what actually happens.
➤ Build and run, complete an exercise and tap Done. The contents of exerciseDays print out correctly in the debug console. Tap History and the view is empty, when it should show the contents of exerciseDays. This error happens every time, so you can be confident at being able to reproduce it.
An Introduction to Breakpoints
When you place breakpoints in your app, Xcode pauses execution and allows you to examine the state of variables and, then, step through code.
➤ Still running the app, with the first exercise done, in Xcode click the line number to the left of let today = Date() in addDoneExercise(_:). This adds a breakpoint at that line.
➤ Without stopping your app, complete a second exercise and tap Done.
When execution reaches addDoneExercise(_:), it finds the breakpoint and pauses. The Debug navigator shows the state of the CPU, memory and current thread operations. The debug console shows a prompt — (lldb) — allowing you to interactively debug.
Above the debug console, you have icons to control execution:
- Deactivate breakpoints: Turns on and off all your breakpoints.
- Continue program execution: Continues executing your app until it reaches another active breakpoint.
- Step over: If the next line to execute includes a method call, stop again after that method completes.
- Step into/out: If your code calls a method, you can step into the method and continue stepping through it. If you step over a method, it will still be executed, but execution won’t be paused after every instruction.
➤ Click Step over to step over to the next instruction. today is now instantiated and contains a value.
➤ In the debug console, remove any filters, and at the (lldb) prompt, enter:
po today
po exerciseDays
po prints out in the debug console the contents of today and exerciseDays:
In this way, you can examine the contents of any variable in the current scope.
Even though exerciseDays should have data from the previous exercise, it now contains zero elements. Somewhere between tapping Done on two exercises, exerciseDays is getting reset.
➤ Step over each instruction and examine the variables to make sure they make sense to you. When you’ve finished, drag the breakpoint out of the gutter to remove it.
The next step in your debugging operation is to find the source of truth for exerciseDays and when that source of truth gets initialized. You don’t have to look very far in this case, as exerciseDays is owned by HistoryStore.
➤ At the end of init() add:
print("Initializing HistoryStore")
➤ Build and run, and reproduce your error by performing an exercise and tapping Done. In the debug console, filter on History.
Your console should look like this:
Now you can see why exerciseDays is empty after performing an exercise. Something is reinitializing HistoryStore!
➤ Open ContentView.swift. This is where you initialize HistoryStore in an environment object modifier on TabView.
You may remember from the end of the previous chapter that @SceneStorage reinitializes TabView when it stores selectedTab. The redraw re-executes environmentObject(HistoryStore()) and incorrectly initializes HistoryStore with all its data.
You’ve now successfully debugged why your history data is empty. All you have to do now is decide what to do about it.
This first step to fix this is to move the initialization of HistoryStore up a level in the view hierarchy.
➤ Remove .environmentObject(HistoryStore()) from ContentView’s body.
@StateObject
@State, being so transient, is incompatible with reference objects and, as HistoryStore is a class, @StateObject is the right choice here. @StateObject is a read-only property wrapper. You get one chance to initialize it, and you can’t change the property once you set it.
➤ Open HIITFitApp.swift and add a new property to HIITFitApp:
@StateObject private var historyStore = HistoryStore()
➤ In body, add this modifier to ContentView().
.environmentObject(historyStore)
You place the store into the environment. In case you’re confused about all the property wrappers you’ve used so far, you’ll review them in Chapter 11, “Managing Data With Property Wrappers”.
➤ Build and run, perform all four exercises, tapping Done after each, and check your history:
Congratulations! You fixed your first bug! You can now remove all your print statements from HistoryStore.swift with pride and a sense of achievement.
Now you can continue on and save your history so that it doesn’t reset every time you restart your app.
Swift Error Checking
Skills you’ll learn in this section: throwing and catching errors
Saving and loading data is serious business, and if any errors occur, you’ll need to know about them. There isn’t a lot you can do about file system errors, but you can let your users know that there has been an error, and they need to take some action.
➤ Open HistoryStore.swift and add a new enumeration to HistoryStore:
enum FileError: Error {
case loadFailure
case saveFailure
}
These are the two errors that you’ll check for.
To create a method that raises an error, you mark it with throws and add a throw statement.
➤ Add this new method to HistoryStore:
func load() throws {
throw FileError.loadFailure
}
Here, you’ll read the history data from a file on disk. Currently, this method always raises an error, but you’ll come back to it later when you add the loading code. When you throw an error, the method returns immediately and doesn’t execute any following code. It’s the caller that should handle the error, not the throwing method.
Swift Dive: do…catch
To handle an error from a throwing method, you use the expression:
do {
try methodThatThrows()
} catch {
// take action on error
}
If you don’t need to handle any errors specifically, instead of enclosing the try in a do..catch statement, you can call the method with try?. For example, try? load(), converts an error result to nil and execution continues.
If you have several errors, you can add a pattern to catch. For example:
do {
try load()
} catch FileError.loadFailure {
// load failed
} catch {
// any other error
}
Call your load method from HistoryStore’s initializer.
➤ Add this at the end of init():
do {
try load()
} catch {
print("Error:", error)
}
load() throws, so you embed try in a do...catch. If there’s an error, the catch block executes.
➤ Build and run. Currently load() always throws, so in the debug console, you’ll see your printed error: Error: loadFailure. (Remember to clear the debug console Filter if you don’t see the error.)
Alerts
Skills you’ll learn in this section:
Alertview
If loading the history data fails, you could either report a catastrophic error and crash the app or, preferably, you could report an error and continue with no history.
When you release your app, your users won’t be able to see print statements, so you’ll have to provide them with more visible communication. When you want to give the user a choice of actions, you can use an ActionSheet but, for simple notifications, an Alert is perfect. An Alert pops up with a title and a message and pauses app execution until the user taps OK.
➤ Open HistoryStore.swift and add a new property to HistoryStore:
@Published var loadingError = false
When loadingError is true, you’ll show an alert in the view.
➤ In init(), replace print("Error", error) with:
loadingError = true
➤ Open HIITFitApp.swift, and in body, add a new modifier to ContentView:
.alert(isPresented: $historyStore.loadingError) {
Alert(
title: Text("History"),
message: Text(
"""
Unfortunately we can't load your past history.
Email support:
support@xyz.com
"""))
}
When loadingError is true, you show an Alert view with the supplied Text title and message. Surround the string with three """ to format your string on multiple lines.
➤ Build and run. Instead of seeing the failure message in the console, you see an alert.
➤ Tap OK. Alert resets historyStore.loadingError and your app continues with empty history data.
➤ Now that you’ve tested error checking, open HistoryStore.swift and remove throw FileError.loadFailure from load().
Note: You can find out more about error handling in our Swift Apprentice book, which has an entire chapter on the subject.
Saving History
Skills you’ll learn in this section: closures;
map(_:); transforming arrays
You need to have some history data stored in order to load it. So, you’re now going to implement saving data and then come back and complete load().
➤ Add a new property to HistoryStore to describe the URL:
var dataURL: URL {
URL.documentsDirectory
.appendingPathComponent("history.plist")
}
You add the file name to the documents path. This gives you the full URL of the file to which you’ll write the history data.
You’ll save the history data to a property list (plist) file. As mentioned in the previous chapter, the root of a property list file can be a dictionary or an array. Dictionaries are useful when you have a number of discrete values that you can reference by key. But in the case of history, you have an array of ExerciseDay to store, so your root will be an array.
Property list files can only store a few standard types, and ExerciseDay, being a custom type, is not one of them. In Chapter 19, “Saving Files”, you’ll learn about Codable and how to save custom types to files but, for now, the easy way is to separate out each ExerciseDay element into an array of Any and append this to the array that you will save to disk.
➤ Add a new throwing method to HistoryStore:
func save() throws {
var plistData: [[Any]] = []
for exerciseDay in exerciseDays {
plistData.append(([
exerciseDay.id.uuidString,
exerciseDay.date,
exerciseDay.exercises
]))
}
}
For each element in the loop, you construct an array with a String, a Date and a [String]. You can’t store multiple types in an Array, so you create an array of type [Any] and append this element to plistData.
plistData is a type [[Any]]. This is a two dimensional array, which is an array that contains an array. After saving two elements, plistData will look like this:
The for loop maps exerciseDays to plistData. In other words, the loop transforms one set of data to another set of data. As this happens so often in code, Swift provides map(_:), an optimized method on Array, for this transforming of data.
Swift Dive: Closures
map(_:) takes a closure as a parameter so, before continuing, you’ll learn how to use closures. You’ve already used them many times, as SwiftUI uses them extensively.
A closure is simply a block of code between two curly braces. Closures can look complicated, but if you recognize how to put a closure together, you’ll find that you use them often, just as SwiftUI does. Notice a closure’s similarity to a function: Functions are closures — blocks of code — with names.
The closure is the part between the two curly braces {...}. In the example above, you assign the closure to a variable addition.
The signature of addition is (Int, Int) -> Int and declares that you will pass in two integers and return one integer.
It’s important to recognize that when you assign a closure to a variable, the closure code doesn’t execute. The variable addition contains the code return a + b, not the actual result.
To perform the closure code, you execute it with its parameters:
You pass in 1 and 2 as the two integer parameters and receive back an integer:
Another example:
let aClosure: () -> String = { "Hello world" }
This closure takes in no parameters and returns a string.
Your current task is to convert each ExerciseDay element to an element of type [Any].
This is the closure that would perform this conversion for a single ExerciseDay element:
let result: (ExerciseDay) -> [Any] = { exerciseDay in
[
exerciseDay.id.uuidString,
exerciseDay.date,
exerciseDay.exercises
]
}
result is of type (ExerciseDay) -> [Any]. The closure takes in a parameter exerciseDay and combines the ExerciseDay properties into an array of type [Any].
Using map(_:) to Transform Data
Similar to a for loop, map(_:) goes through each element individually, transforms the data to a new element and then combines them all into a single array.
You could send result to map which returns an array of the results:
let plistData: [[Any]] = exerciseDays.map(result)
map(_:) takes the closure result, executes it for every element in exerciseDays and returns an array of the results.
Rather than separating out into a closure variable, it’s more common to declare the map operation together with the closure.
➤ Replace the contents of save() with:
let plistData = exerciseDays.map { exerciseDay in
[
exerciseDay.id.uuidString,
exerciseDay.date,
exerciseDay.exercises
]
}
The full declaration of Array.map(_:) is:
func map<T>(
_ transform: (Self.Element) throws -> T) rethrows -> [T]
- If
map(_:)finds any errors, it will throw. -
Tis a generic type. You’ll discover more about generics in Section 2, but hereTis equivalent to[Any]. -
transform‘s signature is(Self.element) -> T. You’ll recognize this as the signature of a closure to which you pass a single element ofExerciseDayand return an array of type[Any].
This is how your code matches map(_:):
This code gives exactly the same result as the previous for loop. Option-click plistData, and you’ll see that its type is [[Any]], just as before.
One advantage of using map(_:) rather than dynamically appending to an array in a for loop, is that you declare plistData as a constant with let. This is some extra safety, so that you know that you won’t accidentally change plistData further down the line.
An Alternative Construct
When you have a simple transformation, and you don’t need to spell out all the parameters in full, you can use $0, $1, $2, $... as replacements for multiple parameter names.
➤ Replace the previous code with:
let plistData = exerciseDays.map {
[$0.id.uuidString, $0.date, $0.exercises]
}
Here you have one input parameter, which you can replace with $0. When using $0, you don’t specify the parameter name after the first curly brace {.
Again, this code gives exactly the same result. Option-click plistData, and you’ll see that its type is still [[Any]].
Property List Serialization
Skills you’ll learn in this section: property list serialization
Writing Data to a Property List File
You now have your history data in an array with only simple data types that a property list can recognize. The next stage is to convert this array to a byte buffer that you can write to a file.
➤ Add this code to the end of save():
do {
// 1
let data = try PropertyListSerialization.data(
fromPropertyList: plistData,
format: .binary,
options: .zero)
// 2
try data.write(to: dataURL, options: .atomic)
} catch {
// 3
throw FileError.saveFailure
}
Going through the code:
- You convert your history data to a serialized property list format. The result is a
Datatype, which is a buffer of bytes. - You write to disk using the URL you formatted earlier.
- The conversion and writing may throw errors, which you catch by throwing an error.
➤ Call save() from the end of addDoneExercise(_:):
do {
try save()
} catch {
fatalError(error.localizedDescription)
}
If there’s an error in saving, you crash the app, printing out the string description of your error. This isn’t a great way to ship your app, and you may want to change it later.
➤ Build and run and do an exercise. Tap Done and your history file will save.
➤ In Finder, go to your app’s Documents directory, and you’ll see history.plist. Double click the file to open this file in Xcode.
See how the property list file matches with your data:
-
Root: The property list array you saved in
plistData. This is an array of type[[Any]]. -
Item 0: The first element in
exerciseDays. This is an array of type[Any]. -
Item 0: The id converted to
Stringformat. - Item 1: The date of the exercise
- Item 2: The array of exercises that you have performed and tapped Done to save. In this example, the user has exercised on one day with two exercises: Sun Salute and Burpee.
Reading Data From a Property List File
You’re successfully writing some history, so you can now load it back in each time the app starts.
➤ In HistoryStore.swift, replace load() with this code:
func load() throws {
do {
// 1
let data = try Data(contentsOf: dataURL)
// 2
let plistData = try PropertyListSerialization.propertyList(
from: data,
options: [],
format: nil)
// 3
let convertedPlistData = plistData as? [[Any]] ?? []
// 4
exerciseDays = convertedPlistData.map {
ExerciseDay(
date: $0[1] as? Date ?? Date(),
exercises: $0[2] as? [String] ?? [])
}
} catch {
throw FileError.loadFailure
}
}
Loading is very similar to saving, but with some type checking to ensure that your data conforms to the types you are expecting.
Going through the code:
- Read the data file into a byte buffer. This buffer is in the property list format. If history.plist doesn’t exist on disk,
Data(contentsOf:)will throw an error. Throwing an error is not correct in this case, as there will be no history when your user first launches your app. You’ll fix this error at the end of this chapter. - Convert the property list format into a format that your app can read.
- When you serialize from a property list, the result is always of type
Any. To cast to another type, you use the type cast operatoras?. This will returnnilif the type cast fails. Because you wrote history.plist yourself, you can be pretty sure about the contents, and you can castplistDatafrom typeAnyto the[[Any]]type that you serialized out to file. If for some reason history.plist isn’t of type[[Any]], you provide a fall-back of an empty array using the nil coalescing operator??. - With
convertedPlistDatacast to the expected type of[[Any]], you usemap(_:)to convert each element of[Any]back toExerciseDay. You also ensure that the data is of the expected type and provide fall-backs if necessary.
➤ Build and run, and tap History. The history you saved out to your property list file will load in the modal.
Ignoring the Loading Error
➤ Delete history.plist in Finder, and build and run your app.
Because the file doesn’t exist any more, your loading error appears because load() fails on try Data(contentsOf:).
Before throwing an error, you should first check whether history.plist exists. On first run of your app, the plist file will never exist, so you should ignore the loading error.
➤ In HistoryStore.swift, in load(), remove:
let data = try Data(contentsOf: dataURL)
➤ Before do, add this:
guard let data = try? Data(contentsOf: dataURL) else {
return
}
try? returns nil if the operation fails. Using guard, you can jump out of a method if a condition is not met. guard let is similar to if let in that you assign an optional to a non-optional variable and check it isn’t nil. You always provide an else branch with guard, where you specify what to do when the guard conditional test fails. Generally you return from the method, but you could instead use fatalError(_:file:line:) to crash the app.
➤ Build and run the app. The loading error has gone and you can start recording your exercises.
Key Points
- Optionals are properties that can contain
nil. Optionals make your code more secure, as the compiler won’t allow you to assignnilto non-optional properties. You can useguard letto unwrap an optional or exit the current method if the optional containsnil. - Don’t force-unwrap optionals by marking them with an
!. It is tempting to use an!when assigning optionals to a new property because you think the property will never containnil. Instead, try and keep your code safe by assigning a fall-back value with the nil coalescing operator??. For example:let atLeastOne = oldValue ?? 1. - Use breakpoints to halt execution and step through code to confirm that it’s working correctly and that variables contain the values you expect.
- Use
throwto throw errors in methods marked bythrows. - If you need to handle errors, call methods marked by
throwswithdo { try ... } catch { ... }.catchwill only be performed if thetryfails. If you don’t need to handle errors, you can call the method withlet result = try? method().resultwill containnilif there is an error. - Use
@StateObjectto hold your data store. Your app will only initialize a state object once. - Closures are chunks of code that you can pass around just as you would any other object. You can assign them to variables or provide them as parameters to methods.
Arrayhas a number of methods requiring closures to transform its elements into a new array. -
PropertyListSerializationis just one way of saving data to disk. You could also useJSON, or Core Data, which manages objects and their persistence.