In this chapter, we’ll take a lightning tour of several options for persisting data locally on the device. We can’t comprehensively cover all features of all alternatives in one chapter, but we’ll explore the basics of the different options and the differences in approaches, so you can choose for yourself which option you prefer or which is more appropriate for a project.
Specifically, we’ll explore storing data using
Along the way, we’ll also explore
As you can see, we have much to get through, so let’s get started!
Have you ever modified an app with your preferences—perhaps you turned sound off or you navigated to the scene you’re most interested in—only to find the next time you open the app that everything is back to its defaults? Frustrating!
Your app can use several techniques to remember where the user was and what they prefer. Let’s look at state preservation.
Your app can remember where the user last navigated to and return them to the same place when they reopen the app. What’s more, it’s super easy to set this up!
When the user opens the Bookcase app, they always go directly to the books table view. What if the user prefers the more visual books collection view?
Let’s explore the steps involved in preserving and restoring state by setting up the Bookcase app to remember the user’s scene preference.
Open the Bookcase project where you left it at the end of chapter 10, or check it out at https://github.com/iOSAppDevelopmentwithSwiftinAction/Bookcase.git (Chapter10.7.TabBarController).
The first thing to do is inform UIKit that you want to opt in to preserving and restoring state. You do this in the app delegate.
You’ve probably seen the AppDelegate.swift file in the Project Navigator and wondered what it’s for. Perhaps you’ve also wondered, could it be related to the delegation pattern?
Well, if you wondered that, you’d be right! The app delegate is your app’s customizable delegate, and is the best place to customize how your app responds to important app-level events.
Explore the app delegate file that Xcode automatically generates for each project. Several UIApplicationDelegate methods are already implemented for you, ready for customization.
You can customize app delegate methods such as
func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool { return true } func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool { return true }Now that these methods exist in the app delegate and return true, the system will walk down the view controller hierarchy from the root view controller, looking for view controllers with a restoration identifier. (When the system reaches a view controller without a restoration identifier, it won’t examine its children.) Those view controllers with a restoration identifier will have their state preserved when the app moves to a background state and restored when the app launches.
In addition to tab bar controllers and view controllers, you can also use state preservation and restoration to preserve the state of
View controllers with restore identifiers can also encode and decode additional state data by overriding the encodeRestorableStateWithCoder and decodeRestorableStateWithCoder methods. We’ll look more at encoding and decoding data using Codable shortly.
Sometimes you may want to set a preference that will be preserved for future launches of the app. Perhaps you want to preserve a user’s name, whether the user has turned off sound or music, or the color scheme the user prefers.
User defaults are the perfect place for preserving small, discrete pieces of data such as these. See figure 11.1 for sample preferences screens in various apps that could be stored in user defaults.
You’ll use user defaults in your Bookcase app to keep track of whether the user prefers to see the optional ISBN field in the book detail scene. All that’s necessary is to set the user default for the ISBN field when the user hides or shows the field. When the book detail scene loads, you can get this value from the user defaults and show or hide the field accordingly.
private let isbnKey = "ISBN"
UserDefaults.standard.set(isbnStackView.isHidden, forKey: isbnKey)
isbnStackView.isHidden = UserDefaults.standard.bool(forKey: isbnKey)Now, test if the ISBN preference is persisting in user defaults.
User defaults can store all sorts of Core Data types: Bool, String, Int, Float, NSURL, NSData (binary data), and even arrays or dictionaries of any of the above. User defaults, however, are most useful for small chunks of data. If there’s any sort of complexity to the data, you’re better off looking at the features of alternative approaches, starting with NSKeyedArchiver, which we’ll be looking at shortly.
Using user defaults, record the user’s choice of sort order in the segmented controls in the books table and books collection scenes.
If you’d like to compare your project with mine at this point, you can check mine out at https://github.com/iOSApp-Development-withSwiftinAction/Bookcase.git (Chapter11.1.UserPreferences).
Adding, deleting, and updating items in a table are pointless if your changes don’t stick around for the next time you launch the app! User preferences are great for small, bite-sized pieces of data, but for complex data, you’ll need a more robust solution.
You have many options for storing data locally on your device, including those shown in table 11.1.
Alternative |
Description |
Pros |
Cons |
---|---|---|---|
Structured data files | Parse structured data such as XML or property lists to/from a file. | Simple; output is human readable. | Storing/retrieving data in its entirety (called atomic stores); can have higher memory requirements and be slower due to higher disk access. |
Archiving objects | Archive and unarchive objects in your code directly to/from a file. | Simple, object-oriented approach. | |
SQLite | Perform database operations on a database file. | Powerful and fast; can define relationships between entities; sophisticated queries with familiar and portable syntax. | Overkill for smaller amounts of data. Native SQLite syntax can be unwieldy, but third-party alternatives can resolve this. (See third-party alternative cons.) |
Core Data | Manage model objects, including data persistence. | Powerful and fast; can define relationships between entities; sophisticated queries; track changes; caching; validation. | Can be overkill for smaller amounts of data. High learning curve, boilerplate setup. |
Third-party alternatives | Plenty of third-party solutions can be worth exploring, such as FMDB to help with SQLite or Realm for mobile databases. | Can be useful for automating boilerplate code or common tasks. | Can go out of date or favor; no guarantees of updates. |
Like tools in a toolbox, there isn’t one alternative local storage solution that will be perfect for every project and scenario. The alternative you choose depends on the requirements and complexity of your project, along with your own personal preferences.
Before you choose the right tool for the job, it’s a good idea to understand each alternative.
In the rest of this chapter, we’ll look at several of these alternatives by exploring how they could be used to store books data locally for the Bookcase app.
Before we get into comparing alternatives, let’s perform additional setup to the Bookcase app that will be useful for different options.
Every iOS app has its own little space on the device for storing files. This space is called its sandbox, as access by other apps is generally prohibited. Similarly, your app generally doesn’t have access to the sandboxed file system of other apps.
By default, every app’s sandbox contains several standard directories, including those shown in table 11.2.
Directory |
Description |
---|---|
App bundle | This read-only directory contains the app itself and all resources bundled with it. |
Documents | Files generated by the user that may be accessible to the user directly through file sharing. |
Application support | Files your app can generate to support itself that will be invisible to the user. |
Temporary files | Store files here temporarily while you work with them. |
Caches | Store files here temporarily for possibly improving download speed. |
You’re going to add local storage of the user’s books to your Bookcase app.
It makes sense to store the data in the application support folder, so let’s get a reference to its path.
You can use the FileManager class to handle regular file system activities such as creating, copying, and moving files and directories. You can also use the File-Manager class to return a path for one of the iOS directories.
Use the FileManager’s urls method to get an array of URL objects for the application support directory. Because you only want the first item in the array, use its first property.
Unlike the documents folder, the application support folder isn’t generated for your app automatically, so before returning the URL, check if the folder exists, and if not, create it.
private let appSupportDirectory: URL = { let url = FileManager().urls( 1 for: .applicationSupportDirectory, 1 in: .userDomainMask).first! 1 if !FileManager().fileExists(atPath: url.path) { 2 do { 3 try FileManager().createDirectory(at: url, 4 withIntermediateDirectories: false) 4 } catch { print("(error.localizedDescription)") } } return url 5 }()
Because the createDirectory method can throw an error, you’ll need to surround it in a do-catch statement. (See sidebar “Error handling.”)
private let booksFile = 1 appSupportDirectory.appendingPathComponent("Books") 1
If a method can cause an error, it’s marked with the keyword throws, and then at a point it may throw an error. Errors are defined by an enum that adopts the Error protocol. The following example defines an error, and a method that can throw it:
enum HyperdriveError: Error { case broken case missing } class Spaceship { var hyperdriveOperational: Bool = false func goHyperspace() throws { if !hyperdriveOperational { throw HyperdriveError.broken } } }
To call a method that can throw an error, surround it in a do-catch block, identifying the call with a try keyword. Use the do block to try code that could throw errors, and then catch any errors in the catch block. The following would catch an error in the goHyperspace method. The localizedDescription property offers an explanation of the error.
var spaceship = Spaceship() do { try spaceship.goHyperspace() } catch { print("(error.localizedDescription)") }
Alternatively, you can choose to catch specific errors.
do { try spaceship.goHyperspace() } catch HyperdriveError.broken { print("It's broken!") } catch HyperdriveError.missing { print("It's missing!") }
In many of the local storage alternatives you’ll explore, you’ll store and retrieve data from disk in its entirety, also known as an atomic store. This process is fairly simple and can make sense for small amounts of data. When you start working with thousands of records, or are modeling relationships and filtering data, alternatives that update the data using database operations can be more appropriate (such as SQLite or Core Data).
For now, let’s prepare the Bookcase app to store and retrieve data in its entirety.
// MARK: Local storage func storeBooks() { // Store books array to disk here } func retrieveBooks() -> [Book]? { // Retrieve books array from disk here return nil }Notice that the retrieveBooks method returns an optional array of Book. Obviously, at first there won’t be any books data to retrieve, so the first time the app runs, this method will return nil. Now that you have a method set up to store books, you can call it whenever changes are made to the books array, such as when a book is added, updated, or removed.
storeBooks()
func loadBooks() -> [Book] { return retrieveBooks() ?? sampleBooks() }Generally, when serializing data, each property will require a name to identify it.
internal struct Key { static let title = "title" static let author = "author" static let rating = "rating" static let isbn = "isbn" static let notes = "notes" }
If you’d like to follow along from my project, we’ll explore each local storage alternative beginning from the same starting point at https://github.com/iOSAppDevelopmentwithSwiftinAction/Bookcase.git (Chapter11.2.StoreDataStart).
Now that you have stubs and a struct of Keys, you’re ready to explore various alternatives for storing data.
In this section, you’ll serialize your model objects into a specific structure, such as JSON, XML, or property lists, that can be written to disk in a file. Later, you can read the file back in and deserialize or parse it back into your model object (see figure 11.2).
If you have a good parser, the specific syntax of the format you’re encoding the data into doesn’t matter too much, and the code you write to encode and decode the data will be fairly similar.
You’re probably already familiar with common text-based formats for encoding data, such as JSON or XML. You may not be as familiar with property lists, also known as plists.
Property lists are another way of structuring data, common in iOS. When you create a new project, for example, Xcode automatically generates an Info.plist file containing additional preferences for your project. If you select the Info.plist file in the Project Navigator, you’ll examine the contents of the .plist file in the property list editor by default. To see the underlying structure of the plist, right-click on it and select Open As > Source Code.
You’ll find that under the hood, the .plist file is actually a special type of XML. Each property in the plist is represented by a key followed by a value (see figure 11.3).
You already created a property list earlier in this chapter! Behind the scenes, user defaults are represented by property lists. You can also store an Array or Dictionary to disk as a property list, as long as they contain foundation data types that can be stored in property lists such as String, Data, or other arrays and dictionaries.
You’re going to convert your array of Book objects to an array of dictionaries of strings that can be stored on the device as a property list. You’ll then retrieve the property list and convert it back into an array of Book objects.
var dictionary: [String: String] { return [ Key.title: title, Key.author: author, Key.rating: String(rating), Key.isbn: isbn, Key.notes: notes ] }Now, you can use this property and the map higher-order function to generate an array of dictionaries, from an array of Book objects.
books.map( { $0.dictionary })The Objective-C NSArray data type contains methods for writing and reading to the device that don’t exist in the Swift Array data type. Before writing to disk, you’ll need to cast your array of book dictionaries to an NSArray.
Many core Swift data types are bridged with their Objective-C counterparts, meaning that the two types can be used interchangeably. But in certain cases, Objective-C functionality may be missing in the Swift implementation, and you’ll need to cast your variable to the Objective-C implementation (usually beginning with NS) for access to additional Objective-C methods and properties.
func storeBooks() { (books.map( { $0.dictionary }) as NSArray).write( to: booksFile, atomically: true) }Storing files atomically has more safeguards to ensure that the file being written to isn’t corrupted if a crash occurs. That’s it for storing the property list. Now you can retrieve it! Because you stored each book as a dictionary of strings, when you retrieve your books array, you’ll need to regenerate Book objects from this data.
init?(book: [String: String]) { 1 guard let title = book[Key.title], 2 let author = book[Key.author], 2 let ratingString = book[Key.rating], 2 let rating = Double(ratingString), 23 let isbn = book[Key.isbn], 2 let notes = book[Key.notes] 2 else {return nil} self.init(title: title, 4 author: author, 4 rating: rating, 4 isbn: isbn, 4 notes: notes 4 ) }
The question mark following the init method indicates that this is a failable initializer, meaning that it can return nil. If you’re instantiating an object via a failable initializer, you need to unwrap the object that’s returned.
NSArray(contentsOf: booksFile)
guard let array = NSArray(contentsOf: booksFile) as? [[String: String]] else {return nil}You can then use map to regenerate an array of Book, using the initializer you just created.
array.map( { Book(book: $0) } )
guard let books = array.map( { Book(book: $0) } ) as? [Book] else {return nil}
func retrieveBooks() -> [Book]? { guard let array = NSArray(contentsOf: booksFile) 1 as? [[String: String]] else {return nil} 2 guard let books = array.map( { Book(book: $0) } ) 3 as? [Book] else {return nil} 4 return books }
If you’re using the simulator, you can see the file that your app output! Print the value of booksFile to the console, paste the path into a Spotlight search, and as simple as that, the property list your app output should open in Xcode.
If you’d like to compare your project with mine at this point, you can check mine out at https://github.com/iOSAppDevelopmentwithSwiftinAction/Bookcase.git (Chapter11.3.StoreDataPropertyList).
You may want to keep a version of your project working with property list files before we move on to exploring alternatives.
In this section, we’ll explore storing and retrieving data locally as XML.
We’re going to start fresh from the same starting point as earlier: https://github.com/iOSAppDevelopmentwithSwiftinAction/Bookcase.git (Chapter11.2.StoreDataStart).
iOS comes with a low-level XML parser. Rather than converting the XML to a format that can be more easily manipulated, the XML parser in iOS explores the XML hierarchy, dispatching events to its delegate as it discovers the various elements and attributes contained.
It can be more convenient to use a higher-level XML parser that converts the XML structure to objects that can then be more easily converted to your customized model objects. Because this functionality doesn’t come packaged with the iOS SDK (curiously, it does come with the macOS SDK), I built an XML parser that you can use in your projects. You can check out the parser here (https://github.com/craiggrummitt/Swift-XML.git) and drag the XML.swift file into the Project Navigator.
Now that you have an XML parser in your project, you’ll explore how to use it by serializing the books array to an XML structure that can be stored on the device in a text file. You’ll then retrieve the XML structure, and parse it back into your books array.
var xml: XMLNode { let bookNode = XMLNode(name: "book") bookNode.addChild(name: Key.title, value: self.title) bookNode.addChild(name: Key.author, value: self.author) bookNode.addChild(name: Key.rating, value: String(self.rating)) bookNode.addChild(name: Key.isbn, value: self.isbn) bookNode.addChild(name: Key.notes, value: self.notes) return bookNode }Now that you have the structure for each book node, you could serialize a books array into an entire XML structure.
let booksXML = XMLNode() for book in books { booksXML.addChild(book.xml) }You’re going to access a String representation of the XML document using the XMLNode’s description property. Once you have a String, you can use its write method to write it to disk. As this request can fail, you’ll need to encapsulate it in a do-catch statement.
func storeBooks() { let booksXML = XMLNode() 1 for book in books { 2 booksXML.addChild(book.xml) 3 } do { 4 try booksXML.description.write( 5 to: booksFile, 5 atomically: true, 56 encoding: String.Encoding.utf8) 57 } catch { print("(error)") } }
init?(book: XMLNode) { 1 guard let title = book[Key.title]?.text, 2 let author = book[Key.author]?.text, 2 let ratingString = book[Key.rating]?.text, 2 let rating = Double(ratingString), 23 let isbn = book[Key.isbn]?.text, 2 let notes = book[Key.notes]?.text 2 else {return nil} self.init(title: title, 4 author: author, 4 rating: rating, 4 isbn: isbn, 4 notes: notes 4 ) }
func retrieveBooks() -> [Book]? { guard let xml = XML(contentsOf: booksFile) 1 else { return nil } guard let books = xml[0].children.map( 2 { Book(book: $0)}) as? [Book] 3 else { return nil } return books }
Again, if you run the app, make modifications to books, and run the app again, you should find that your changes have persisted locally, this time as an XML file.
If you’d like to compare your project with mine at this point, you can check mine out at https://github.com/iOSAppDevelopmentwithSwiftinAction/Bookcase.git (Chapter11.4.StoreDataXML).
You should notice several similarities between property lists and XML structures. The code involved in writing and reading a structure of data to disk doesn’t change too much depending on the type of structure. After going through this process twice, you already have an idea of what the process would be to read and write your data as JSON! (We’ll look more at JSON in the next chapter.)
You might want to keep a version of your project working with XML, because we’ll move on to a different alternative next.
In this section, we’ll explore storing model types in your project directly to a local file.
We’ll start fresh again from the same starting point as earlier: https://github.com/iOSAppDevelopmentwithSwiftinAction/Bookcase.git (Chapter11.2.StoreDataStart).
This approach has similarities to storing data as structured data files. You’ll still encode (the equivalent of serializing) model types into a different data format, store and retrieve data from disk, and decode (the equivalent of parsing) data back into model types (see figure 11.4). One important difference is that data is stored as binary files rather than text files, resulting in more-compact files.
Your model types can be made encodable into another format by adopting the Encodable protocol. At the time of writing, Apple provides two encoders: a JSON encoder and a property list encoder.
Once your model object has been encoded, it can then be archived to a format that can be written to disk. To unarchive and decode your data, you’ll need to adopt the Decodable protocol to make your model types decodable. For your convenience, you can make types both encodable and decodable by adopting the Codable protocol.
You’re going to persist books data in your Bookcase app by archiving the books array.
Let’s start by making the Book structure codable.
class Book: Codable {Believe it or not, in many cases that’s all you’ll need to do for the encoder to understand the structure of your model object! As long as every property of your model object is also codable—and many standard types such as String, Int, Double, Bool, and Array already are—then your model type is ready to be automatically encoded. However, the Book structure contains a UIImage property that unfortunately doesn’t adopt the Codable protocol. In chapter 13, we’ll take a closer look at this problem, but for now, the app isn’t yet receiving custom images for books from the user, so let’s tell the compiler to omit this property from encoding and decoding. But how can you omit a property? When you adopt the Codable property, the compiler automatically generates three things in your model object:
enum CodingKeys: String, CodingKey { case title case author case rating case isbn case notes }
Now that your Book structure adopts the Codable protocol, you can encode and archive the array of books to disk. You have a choice of two encoders: JSONEncoder or PropertyListEncoder. Either will work fine. Let’s use the PropertyList-Encoder.
In the storeBooks method in the BooksManager class, get a reference to the PropertyListEncoder, and use it to encode the books array. As encoding can fail, you’ll need to encapsulate it in a do-catch statement.
func storeBooks() { do { 1 let encoder = PropertyListEncoder() 2 let data = try encoder.encode(books) 3 //Archive data here } catch { print("Save Failed") } }
let success = NSKeyedArchiver.archiveRootObject( 1 data, toFile: booksFile.path) 1 print(success ? "Successful save" : "Save Failed") 2
func retrieveBooks()->[Book]? { guard let data = NSKeyedUnarchiver.unarchiveObject( 1 withFile: booksFile.path) 1 as? Data else { return nil } 2 //Decode data here }
do { 1 let decoder = PropertyListDecoder() 2 let books = try decoder.decode([Book].self, from: data) 3 return books } catch { print("Retrieve Failed") return nil }
If you’d like to compare your project with mine at this point, you can check mine out at https://github.com/iOSApp-Developmen-twithSwiftinAction/Bookcase.git (Chapter11.5.StoreData-Archiving). Again, you might like to keep a version of the project storing data locally by archiving objects before we move on.
If all the other techniques we’ve looked at for storing data locally have been hammers and screwdrivers, SQLite is the power drill of local storage options!
Open up the same starting point: https://github.com/iOSAppDevelopmentwithSwiftinAction/Bookcase.git (Chapter11.2-. StoreDataStart).
Your iOS app comes ready to implement a relational database using a SQLite3 library. If your app has a lot of data, contains complex relationships between model objects, or will need to perform many queries (such as filtering, searching, and so on), you might want to consider using the power of SQLite to manage your data.
SQLite is fast—operations using SQLite3 can even perform better than equivalent operations on Core Data types. Rather than storing the whole database in memory or encoding the entire model using atomic storage, SQLite3 operations only make specific changes to the database such as adding, deleting, or updating rows. To get data out of your database, you’d query the database with an SQLite SELECT statement, and receive a dataset in response. This type of data store is called a transactional store (see figure 11.5).
SQLite3 syntax is standard, so if you’re already familiar with working with databases, applying this knowledge to iOS shouldn’t be too much of a learning curve, especially compared to the far more involved Core Data. I don’t intend to go into detail on SQLite3 syntax here, but if you need to brush up, https://sqlite.org contains more information.
You’re going to explore using SQLite3 to store and retrieve data for the Bookcase app. The first job to do is to create the database itself.
You have two choices to set up the database:
The latter has the advantage of being able to easily include data in the database before adding it to your project. Because supplying data to a project via the database can sometimes be an additional motivation for using databases, you’ll focus on adding a database file to your app bundle.
I use the free SQLiteBrowser (http://sqlitebrowser.org) to generate and edit databases, but if you have a preferred program, feel free to use that.
You’ll want to create a Books database containing a Book table recreating the data structure of the Book structure.
private var booksFile: URL = { let filePath = appSupportDirectory.appendingPathComponent( 1 "Books").appendingPathExtension("db") 1 if !FileManager().fileExists(atPath: filePath.path) { 2 if let bundleFilePath = 3 Bundle().resourceURL?.appendingPathComponent( 3 "Books").appendingPathExtension("db") { 3 do { 4 try FileManager().copyItem( 5 at: bundleFilePath, to: filePath) 5 } catch let error as NSError { //fingers crossed } } } return filePath }()
To preserve system resources, global variables and constants are lazy by default, without the need for the lazy modifier. Once the system determines if the books database exists in the application support directory and copies it over if not, this process won’t be repeated.
Now that you have the database set up, you can start performing operations on it using the SQLite3 framework. SQLite3 is written in the C programming language, which contains quite a laborious and un-Swift-like API. Most who use SQLite prefer to use a wrapper that simplifies the code you need to write to perform operations on your database.
You’ll include SQLite3 in your project and set up a library of code that will act as a wrapper to the SQLite3 framework, making interactions with the database more straightforward.
You’re going to use a popular SQLite wrapper library in your project called FMDB. The only problem with this library is that it’s written in Objective-C. Not to worry, it’s not too difficult to incorporate Objective-C in a Swift project.
To use Objective-C in a Swift project, you’ll need to set up what’s called a bridging header, which explicitly imports the Objective-C classes you want access to from Swift.
The quickest way to set up a bridging header is to create an Objective-C file (with extension .m), or drag a .m file into a project that doesn’t yet contain one. Xcode will automatically offer to configure a bridging header for you.
After selecting Create Bridging Header, you’ll see a bridging header file appear in your Project Navigator. Xcode also automatically adds a path to this header file in the Objective-C Bridging Header setting in the target’s build settings.
Once your bridging header is set up, import the headers for any Objective-C classes you wish to use.
Let’s go through the steps in setting up the FMDB framework.
You first need to request that Apple’s SQLite framework be included in the Bookcase project.
#import "FMDB.h"The FMDB.h file, in turn, will import headers for all the FMDB classes.
That’s it—the FMDB wrapper should be ready to use. To double-check, build your project and then, somewhere inside a method, start typing FMDB. If all is well, code completion should suggest one of the several FMDB classes you imported.
Let’s kick off by retrieving books from your Books database.
First, you’ll need to use the FMDB wrapper to get a reference to the database. Before performing any queries on a database you’ll also need to open it.
func getOpenDB() -> FMDatabase? { guard let db = FMDatabase(path: booksFile.path) else { print("unable to create database") return nil } guard db.open() else { print("Unable to open database") return nil } return db }
After opening a database and performing any necessary queries, be sure to close it again to free up any system resources.
let rs = db.executeQuery("select * from books", values: nil)Because a database query can throw an error, it must be surrounded in a do-catch statement. Queries return a special FMDB data type called a result set. Result sets contain the results of a query. In this case, it will contain the data for each row of the books table, beginning with the first row. You can iterate through a result set by calling its next method.
init?(rs: FMResultSet) { let rating = rs.double(forColumn: Key.rating) guard let title = rs.string(forColumn: Key.title), let author = rs.string(forColumn: Key.author), let isbn = rs.string(forColumn: Key.isbn), let notes = rs.string(forColumn: Key.notes) else { return nil } self.init(title: title, author: author, rating: rating, isbn: isbn, notes: notes ) }With this initializer set up, you can now retrieve book data from the database table and parse it into an array of Book objects.
// MARK: SQLite func retrieveBooks() -> [Book]? { guard let db = getOpenDB() else { return nil } 1 var books: [Book] = [] do { let rs = try db.executeQuery( 2 "select *, ROWID from books", values: nil) 2 while rs.next() { 3 if let book = Book(rs: rs) { 4 books.append(book) 5 } } } catch { print("failed: (error.localizedDescription)") } db.close() 6 return books }
func loadBooks() -> [Book] { return retrieveBooks() ?? [] }
Rather than storing the entire database when an update occurs, it makes sense to take advantage of the power of SQLite and perform the specific operations required, such as adding, updating, or removing books.
To facilitate performing operations on specific rows, SQLite stores a unique primary key on each row called ROWID. It’s a good idea to keep track of these primary keys in your model class to identify each row for updating or deleting books.
func SQLAddBook(book:inout Book) { guard let db = getOpenDB() else { return } 1 do { 2 try db.executeUpdate( 3 "insert into Books (title, author, 4 rating, isbn, notes) values (?, ?, ?, ?, ?)", 4 values: [book.title, book.author, 5 book.rating, book.isbn, book.notes] 5 ) book.id = Int(db.lastInsertRowId()) 6 } catch { print("failed: (error.localizedDescription)") } db.close() 7 }
var book = book SQLAddBook(book: &book)
The methods for deleting and updating books will be similar, except the SQLite operation will change and you won’t need to get the ROWID (a book’s ID won’t change when updated, and you no longer need a book’s ID after deleting it).
The following listing shows the contents of the executeUpdate methods for deleting and updating a book.
try db.executeUpdate( "delete from Books where ROWID = ?", values: [book.id] try db.executeUpdate( "update Books SET title = ?, author = ?, rating = ?, isbn = ?, notes = ? WHERE ROWID = ?", values: [book.title, book.author, book.rating, book.isbn, book.notes, book.id]
Fill out the SQLUpdateBook and SQLRemoveBook methods, based on the executeUpdate statements in the section “Adopting Codable protocol,” calling these methods at the appropriate times.
Run your app to test what you’ve done. You can add, delete, and remove books. If you run the app again, the data should persist.
If you’d like to compare your project with mine at this point, you can check mine out at https://github.com/iOSAppDevelopment-withSwiftinAction/Bookcase.git (Chapter11.6.StoreDataSQL).
Again, you might want to store a version of the project at this point using SQLite3 before moving on.
If SQLite is the power drill to manage your app’s local data, Core Data is the jack hammer! Using Core Data, you can create a relational diagram of your model objects visually in Xcode, and then create and update your data in an object-oriented manner, with Core Data managing the underlying database implementation behind the scenes. The way you can with SQLite, you can fetch data from Core Data performing queries using search criteria.
Core Data isn’t only about storing relational data—it also offers additional features such as these:
Core Data is fast, powerful, and feature-rich, and if you’re planning a data-intensive app and are interested in these sorts of additional features, it’s worth looking into. On the other hand, Core Data can be overkill for many apps. No point getting the jackhammer out if all you’re interested in is hammering a nail!
Core Data does have a reputation for being a challenging framework to learn, but don’t be discouraged—recent improvements have made it easier to use.
We’re going to explore using Core Data to store and retrieve data for the Bookcase app.
Though we’re going to tweak it a little, you can open at the same starting point at https://github.com/iOSAppDevelopment-withSwiftinAction/Bookcase.git (Chapter11.2.StoreDataStart).
The first thing to do is create a data model describing the entities of your app (such as database tables), the properties they contain, and any relationships between entities. Core Data will then manage these entities for you.
The only entity you have in the bookcase is the Book object.
That’s it: your data model is ready to go! Before you start using the data model for persisting data to disk, I want to cover a few setup details.
When entities have relationships with other entities, the power of Core Data becomes truly evident. To visually examine the objects in your data model and the relationships between them, select the Graph Editor Style.
When you first create a project, you have the option to use Core Data. Selecting this option automatically creates the data model file you edited and generates boilerplate code that’s necessary for using Core Data. In this section, you’ll build up this boilerplate code manually and explore exactly what’s involved.
Core Data requires objects to manage your data. These objects are called the Core Data stack, and include the objects shown in table 11.3.
Object |
Description |
---|---|
Managed object context | Responsible for managing the data model in the memory. The managed object context is the object in the Core Data stack that you will interact with most directly. |
Persistent store coordinator | Persists to and retrieves data from the persistent object store. |
Persistent object store | Maps between the objects in the persistent store and the objects defined in the managed object model of your application. |
Persistent store data file | The data file itself stored on disk. The underlying data file can be stored as different formats: SQLite (the default), XML, or binary data. |
Managed object model | Describes the data model in your application. |
See figure 11.8 for how they all fit together.
That’s a lot of objects to keep track of—what a headache! Not to worry; since iOS 10, Apple has greatly simplified creating and accessing the objects in this stack with the NSPersistentContainer class. By instantiating the persistent container and requesting it to load persistent stores, it will create the Core Data stack for you.
Because the persistent container needs to be accessed globally, it’s often added as a lazy computed property to the AppDelegate class.
// MARK: - Core Data stack lazy var persistentContainer: NSPersistentContainer = { 1 let container = NSPersistentContainer(name: "Model") 2 container.loadPersistentStores( completionHandler: { (storeDescription, error) in 3 if let error = error as NSError? { 4 fatalError("Unresolved error") 4 } 4 }) return container }()
// MARK: - Core Data Saving support func saveContext () { let context = persistentContainer.viewContext 1 if context.hasChanges { 2 do { 3 try context.save() 4 } catch { fatalError("Unresolved error") 5 } } }
self.saveContext()
let appDelegate = (UIApplication.shared.delegate as! AppDelegate)The table view controller will also need a reference to the managed object context to perform updates and fetches on the database.
lazy var context:NSManagedObjectContext = { return self.appDelegate.persistentContainer.viewContext }()
That’s all for the boilerplate setup; now, let’s do a little cleanup on your Bookcase app.
Core Data manages many operations on the data for you, making part of your existing code redundant. For those following along in Xcode, before we get into the details of using Core Data in your app’s code, you’ll need to perform a little cleanup of code that won’t be required.
It may surprise you that you won’t need the BooksManager class. Core Data will be handling the management of your books data!
static let defaultCover = UIImage(named: "book.jpg")!
Great! Tidy-up complete, you’re finally ready to start adding managed objects to Core Data for your app.
Now that you’ve set up the data model and the Core Data stack, how can you add an object to the persistent store for your app on a device?
Since iOS 10, it’s too easy! All you need to do is instantiate a new model object, passing in the managed object context, set its properties like you would any Swift object, and then call the AppDelegate’s saveContext method. Believe it or not, that’s it!
The following listing, for example, would create a new book with a title of Great Expectations.
let book = Book(context: context) 1 book.title = "Great Expectations" 2 appDelegate.saveContext() 3
If you need to consider users with earlier versions of iOS, the first line blows out to the following:
let book = NSEntityDescription.insertNewObject( forEntityName: "Book", into: context) as! Book
As you can see, the syntax prior to iOS 10 was unwieldy. For brevity and clarity, I’ll assume at least iOS 10 for the rest of this section. If you need to update the deployment target of your app to iOS 10, you can find this in the General properties for your app’s main target.
If the user is creating a new book object, the BookViewController class will need a reference to the managed object context.
var context:NSManagedObjectContext!
viewController.context = context
@IBAction func touchSave(_ sender: AnyObject) { let bookToSave: Book if let book = book { bookToSave = book 1 } else { bookToSave = Book(context: context) 2 } bookToSave.title = titleTextField.text! 3 bookToSave.author = authorTextField.text! 3 bookToSave.rating = 3 3 bookToSave.isbn = isbnTextField.text! 3 bookToSave.notes = notesTextView.text! 3 delegate?.saveBook(book: bookToSave) dismissMe() }
func saveBook(book: Book) { appDelegate.saveContext() }
You no longer want to update the table view in this method. Instead, you’ll soon be setting up the BookTableViewController class to receive notifications of updates to the data and update the interface.
Now that you know how to store managed objects in the persistent object store, you need to retrieve these objects to display to the user. Use a fetch request to define how to fetch these managed objects. You can request an NSFetchRequest object directly from your managed object with the fetchRequest method. Using generics, you can specify that your NSFetchRequest contains a fetch request of your Book managed object.
The NSFetchRequest can specify
For example, a basic NSFetchRequest with a batch size of 20 items that searches for all books and sorts by title would look like the following listing.
let fetchRequest: NSFetchRequest<Book> = Book.fetchRequest() 1 fetchRequest.fetchBatchSize = 20 2 fetchRequest.sortDescriptors = 3 [NSSortDescriptor(key: "title", ascending: true)] 3 fetchRequest.predicate = NSPredicate(value:true) 4
We’ll look at sorting with NSSortDescriptor and searching with NSPredicate in more detail shortly.
Now that you have a fetch request, you can pass it and the managed object context into a fetched results controller. A fetched results controller can perform the fetch and manage the results of the fetch request.
You’re going to use the delegate of the fetched results controller to respond to changes in the data and update your table view. You also have the option to request the fetched results controller to cache the results of the query to avoid recalculating the same fetch request.
// MARK: FetchedResultsController lazy var fetchedResultsController: NSFetchedResultsController<Book> = 1 self.getFetch() 1 func getFetch() -> NSFetchedResultsController<Book> { 2 let fetchRequest: NSFetchRequest<Book> = Book.fetchRequest() fetchRequest.fetchBatchSize = 20 fetchRequest.sortDescriptors = [NSSortDescriptor(key: "title", ascending: true)] fetchRequest.predicate = NSPredicate(value: true) fetchedResultsController = NSFetchedResultsController( 3 fetchRequest: fetchRequest, 3 managedObjectContext: self.context, 3 sectionNameKeyPath: nil, 3 cacheName: nil 4 ) fetchedResultsController.delegate = self 5 do { 6 try fetchedResultsController.performFetch() 7 return fetchedResultsController } catch { fatalError("Error (error)") } }
The table view controller now needs to adopt the protocol associated with the fetched view controller’s delegate. Methods in this delegate will be triggered when changes occur in the data. You could use the data returned in these protocol methods to perform specific operations in the table, such as insert, delete, and update. For this example, you’ll keep it simple, however, and reload the table when content changes.
extension BooksTableViewController: NSFetchedResultsControllerDelegate { func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) { self.tableView.reloadData() } }You can use your fetched results controller to display the data to the user in the table by responding to the UITableViewDelegate methods. Let’s start by defining the number of items in each section. The NSFetched-ResultsController class contains a sections property with information about each section. As you know, your results will have one section; you can get information about the first section and extract the number of objects.
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { let sectionInfo = self.fetchedResultsController.sections![section] return sectionInfo.numberOfObjects }Now, you need to extract actual book data from the fetched results controller to display in the table. It’s straightforward to get a Book managed object from the fetched results controller by calling the object method and passing in an indexPath.
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell( withIdentifier: "bookCell", for: indexPath) let book = self.fetchedResultsController.object(at: indexPath) cell.textLabel?.text = book.title cell.detailTextLabel?.text = book.author cell.imageView?.image = BooksTableViewController.defaultCover return cell }
Now that you can extract a managed object at an index of the table, you can use the same method to pass this object to the detail view controller in the prepareForSegue method, when the user selects a book in the table to update.
viewController.book = self.fetchedResultsController.object( at: selectedIndexPath)Implementing deletion of a managed object is equally straightforward. Call the managed object context’s delete method, passing in the Book object to delete from the fetched results controller. To persist the changes to the store, finish by calling the saveContext method. It’s not necessary to request the table view to update because this update will be triggered in the fetched results controller delegate.
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) { if editingStyle == .delete { context.delete(fetchedResultsController.object(at: indexPath)) appDelegate.saveContext() } }
Rather than the BooksManager sorting the data in memory, you’ll use the sortDescriptors attribute of the NSFetchRequest.
Sort descriptors describe how you’d like a sort operation of your data to be performed. A sort descriptor specifies a field to sort and the direction of the sort. The sortOrder property of NSFetchRequest is an array so that your fetch request can have multiple sort operations for multiple fields.
The sort descriptor can also specify an optional method to customize the comparison. The NSString method has a convenient method called localizedCaseInsensitiveCompare for comparisons that ignore case and localization differences.
Here’s an example sort descriptor that will sort the title field in an ascending order, ignoring case and localization:
NSSortDescriptor(key: "title", ascending: true, selector: #selector(NSString.localizedCaseInsensitiveCompare(_:)))
You need two sort descriptors in the Bookcase app—one that sorts by title and another that sorts by author.
If the user selects to sort by title, the fetch request should prioritize the title sort descriptor. Conversely, if the user wants to sort by author, the author sort descriptor should take priority.
You’ll need to get the user’s currently selected segment in the sort order segmented control. If you haven’t yet created one (you may have for the user defaults challenge at the end of section 11.1.2), connect an outlet for it in the Books-TableViewController.
@IBOutlet weak var sortSegmentedControl: UISegmentedControl!
let segmentIndex = sortSegmentedControl.selectedSegmentIndex 1 guard let sortOrder = SortOrder(rawValue: segmentIndex) 2 else {fatalError("Segment error")} let titleDescriptor = NSSortDescriptor(key: "title", 3 ascending: true, selector: #selector(NSString.localizedCaseInsensitiveCompare(_:))) let authorDescriptor = NSSortDescriptor(key: "author", 4 ascending: true, selector: #selector(NSString.localizedCaseInsensitiveCompare(_:))) if self.sortOrder == .title { 5 fetchRequest.sortDescriptors = 6 [titleDescriptor,authorDescriptor] 6 } else { fetchRequest.sortDescriptors = 7 [authorDescriptor,titleDescriptor] 7 }
Now, when the user selects a new segment of the segmented control, you should regenerate the fetch results controller before updating the table.
@IBAction func changedSegment(_ sender: UISegmentedControl) { fetchedResultsController = getFetch() self.tableView.reloadData() }
Rather than the BooksManager searching through your data in memory, you’re going to take advantage of searching via predicates as a built-in feature of NSFetch-Request.
Predicates allow you to define the criteria for filtering your data using a natural language interface. You can use all the basic operators, such as = or <, and similar to SQLite queries, you have English comparisons such as LIKE, CONTAINS, or BEGINSWITH, and logical operations such as AND or OR.
All you need to do is instantiate the NSPredicate class, passing in the filtering criteria via the format property. For example, here’s a predicate that returns books with a rating of 4:
NSPredicate(format: "rating = 4")
A predicate that returns books that contain “the” in the title looks like this:
NSPredicate(format: "title CONTAINS 'the'")
Notice that strings need to be contained in quotes.
If you want your search to ignore differences such as letter case (upper- or lowercase) or letter accents (called diacritics), you can use the CONTAINS[CD] comparison (CD stands for case diacritics).
The predicate you added to the fetchRequest earlier returned all books, but if text is in the search bar, you only want books that match this text.
guard let searchText = searchController.searchBar.text else { fatalError("No search bar") } if searchText != "" { fetchRequest.predicate = NSPredicate(format: "(title CONTAINS[CD] '(searchText)') OR (author CONTAINS[CD] '(searchText)')") }When a change is made to the text in the search bar, the fetched results controller should be regenerated before reloading the table view.
func updateSearchResults(for searchController: UISearchController) { fetchedResultsController = getFetch() tableView.reloadData() }
Well, it’s been a journey, but you’re there! Core Data should now be set up and ready to use in your table view controller. You can view, add, delete, and update managed objects, and search and sort the data.
Add a fetched results controller to the collection view controller. Make the necessary changes to display, add, edit, search, and sort book managed objects from this tab. You will find interesting challenges when setting up the collection view controller, because you’ll be displaying items in section 2, but requesting them in section 1 of the fetched results controller.
If you’d like to compare your project with mine at this point, you can check it out at https://github.com/iOSAppDevelopmentwithSwiftinAction/Bookcase.git (Chapter11.7.StoreDataCoreData).
In the next chapter, we’ll take data persistence to the next level—in iCloud!
In this chapter, you learned the following: