Chapter 11. Local data persistence

This chapter covers

  • Storing app state on the device
  • Storing user preferences on the device
  • Using different techniques for storing data on the device

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

  • State preservation and restoration— Your app remembers where you left it.
  • User defaults— Your app remembers your preferences.
  • Property lists— Serialize your model objects into a type of structured data often used by Apple.
  • XML— Serialize your model objects into an XML format.
  • JSON— Encode your model objects into the JSON format.
  • Archiving objects— Store model objects directly to the device by making them encodable.
  • SQLite— Use SQLite operations to store data in a local database.
  • Core data— Store data using object-oriented code built over a relational database.

Along the way, we’ll also explore

  • App delegate— Responding to app-level events in your app’s delegate.
  • Error handling— Dealing with errors that may occur during your app’s execution.
  • Using Objective-C in a Swift project— Creating a bridging header to import Objective-C classes in your Swift project.

As you can see, we have much to get through, so let’s get started!

11.1. Preserving user preferences and state

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.

11.1.1. Preserving and restoring state

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.

Checkpoint

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.

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

  • App launch
  • App state changes (for example, app enters background/foreground)
  • App receives remote or local notifications
  • Manage preserving and restoring app state

  1. Add the following methods to the app delegate to request the system to save and restore the app’s state:
    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.
  2. Open the storyboard, select the tab bar controller, and open the Identity Inspector.
  3. Enter a string in the Restoration ID property—it doesn’t really matter what the ID is, as long as it’s unique.
  4. Do the same for the two navigation controllers the tab bar controller displays in its tabs. That’s it! The app should now remember the user’s last tab preference.
  5. Run the app and switch to the books collection tab.
  6. Send the app to the background by clicking the simulator’s Home button.
  7. Now run the app again, and it should launch straight to the collection scene!

In addition to tab bar controllers and view controllers, you can also use state preservation and restoration to preserve the state of

  • Navigation controllers
  • Table views and collection views
  • Scroll views
  • Text fields and text views
  • Image views
Note

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.

11.1.2. Preserving user preferences on the device

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.

Figure 11.1. In-app settings are often 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.

  1. First, you’ll need a key for the ISBN user default. The key is a string that’s used to reference this preference when you store and retrieve its value. In the Book-ViewController.swift file (external to the BookViewController class), create a private global variable for the key.
    private let isbnKey = "ISBN"
  2. In the toggleISBN method, store the new user default using this key. Get a reference to the standard defaults with the standard singleton, and call its set method.
    UserDefaults.standard.set(isbnStackView.isHidden, forKey: isbnKey)
  3. Now, all that’s left is to retrieve this user default and use it to show or hide the field. To retrieve user defaults, you can use convenience methods that specify the data type you expect. As the isHidden property you stored was a Boolean type, retrieve it using the bool method. Add the following to the viewDidLoad method:
    isbnStackView.isHidden = UserDefaults.standard.bool(forKey: isbnKey)
    Now, test if the ISBN preference is persisting in user defaults.
  4. Run the app, select to add a book, and select the Info button to show the ISBN field.
  5. Run the app again, select to add a book again, and you should discover that your preference for seeing the ISBN field has been preserved.

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.

Challenge

Using user defaults, record the user’s choice of sort order in the segmented controls in the books table and books collection scenes.

Checkpoint

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).

11.2. Storing data locally

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.

Table 11.1. Local storage alternatives

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.

11.2.1. Storage setup

Before we get into comparing alternatives, let’s perform additional setup to the Bookcase app that will be useful for different options.

Determining storage location

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.

Table 11.2. Useful iOS directories

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.

  1. Define the appSupportDirectory private global variable in the BooksManager.swift file (outside the BooksManager class).
    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
    }()

    • 1 Gets URL to application support directory
    • 2 Checks that directory exists
    • 3 do-catch statement
    • 4 Creates directory if necessary
    • 5 Returns url
    Because the createDirectory method can throw an error, you’ll need to surround it in a do-catch statement. (See sidebar “Error handling.”)
  2. Once you have a path to the application support directory, generate a path to a directory to store the books data, using the URL object’s appendingPath-Component method.
    private let booksFile =                                         1
       appSupportDirectory.appendingPathComponent("Books")          1

    • 1 Gets URL to Books file
Error handling

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!")
}
Preparing for storing and retrieving data

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.

  1. Create stubs for a storeBooks method and a retrieveBooks method in the BooksManager, ready to fill in later with appropriate serializing and parsing methods.
    // 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.
  2. Add a call to the storeBooks method at the end of each of the addBook, removeBook, and updateBook methods.
    storeBooks()
  3. Now that you have a method set up to retrieve books, request them from the loadBooks method. If no books are stored, resort to the sample books.
    func loadBooks() -> [Book] {
        return retrieveBooks() ?? sampleBooks()
    }
    Generally, when serializing data, each property will require a name to identify it.
  4. To avoid typos, set up a private struct in the Book.swift file (but outside the Book structure) that defines keys for each property in the Book structure.
    internal struct Key {
        static let title = "title"
        static let author = "author"
        static let rating = "rating"
        static let isbn = "isbn"
        static let notes = "notes"
    }
Checkpoint

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.

11.2.2. Structured data files

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).

Figure 11.2. Data persistence with structured data files

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

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).

Figure 11.3. Info property list edited two ways

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.

  1. Add a dictionary computed property to the Book structure that returns a representation of the Book object as a dictionary of strings.
    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.
    Tip

    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.

  2. Add the following to the storeBooks method in your BooksManager class:
    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.
  3. Give the Book structure an initializer that generates a new Book based on a dictionary. Unwrap the dictionary string values (casting them to the appropriate data type where necessary, such as the rating Double property), and then instantiate the new Book by calling the designated initializer.
    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
                 )
    }

    • 1 Failable initializer
    • 2 Unwraps dictionary properties
    • 3 Casts rating to Double
    • 4 Calls designated initializer
    Note

    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.

    Next, you retrieve your books array from file in the retrieveBooks method in the BooksManager. The NSArray class can be instantiated directly from a file.
    NSArray(contentsOf: booksFile)
  4. You need to cast this NSArray as a Swift array of dictionaries of strings.
    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) } )
  5. Because the initializer is failable, it returns an array of optional Book, so you’ll need to unwrap it.
    guard let books = array.map( { Book(book: $0) } )
     as? [Book] else {return nil}
  6. Return the books property. Your finished retrieveBooks method in the BooksManager class should look like this:
    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
    }

    • 1 Retrieves property list file
    • 2 Casts as array of dictionaries
    • 3 Converts to Book objects
    • 4 Unwraps optional Books
  7. Run the app; add, delete, or update a book.
  8. Run the app again, and you should find that your changes have persisted as property lists!
Tip

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.

Checkpoint

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.

XML

In this section, we’ll explore storing and retrieving data locally as XML.

Checkpoint

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.

  1. Add an xml computed property to the Book structure that returns a representation of the Book object as an XML node.
    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.
  2. Add the following to the storeBooks method in your BooksManager class:
    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)")
       }
    }

    • 1 Creates XML root node
    • 2 For each book
    • 3 Adds XML child
    • 4 Surrounds in do-catch
    • 5 Writes XML string to file
    • 6 Use safeguards
    • 7 Specifies encoding
  3. Give the Book structure an initializer that generates a new Book object based on an XML node. Unwrap the XML node text values, and instantiate a new Book. This code is similar to the property list code in the previous section.
    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
                 )
    }

    • 1 Failable initializer
    • 2 Unwraps dictionary properties
    • 3 Casts rating to Double
    • 4 Calls designated initializer
    The BooksManager can now retrieve the XML structure from file using the XML class. The root element of the XML will contain a series of children nodes that represent book data.
  4. You can use the initializer you set up in the Book structure to parse each book node and, finally, generate an array of Book objects:
    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
    }

    • 1 Parses XML file
    • 2 Maps child nodes
    • 3 Instantiates Book with XML

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.

Checkpoint

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.

11.2.3. Archiving objects

In this section, we’ll explore storing model types in your project directly to a local file.

Checkpoint

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.

Figure 11.4. Data persistence: archiving objects

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.

Adopting Codable protocol

Let’s start by making the Book structure codable.

  1. Indicate that your model object can be encoded and decoded by adopting the Codable protocol.
    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:

    • An enumerator called CodingKeys that lists of all the properties in the object.
    • An init method that generates your model object from data using the Decoder.
    • An encode method that encodes your model object’s data using the Encoder.
    You can implement your own version of any or all these to replace the automatically generated version. In implementing your own CodingKeys enumerator, you can omit properties or modify names of properties from the encoded version.
  2. Implement your own version of the CodingKeys enumerator in the Book structure. This is identical to the CodingKeys enumerator that would have been automatically generated, but by defining it yourself, you can omit the image property.
    enum CodingKeys: String, CodingKey {
      case title
      case author
      case rating
      case isbn
      case notes
    }
Encoding and archiving data

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.

  1. Update the method in the BooksManager to archive the array of books.
    func storeBooks() {
      do {                                         1
        let encoder = PropertyListEncoder()        2
        let data = try encoder.encode(books)       3
        //Archive data here
      } catch {
        print("Save Failed")
      }
    }

    • 1 Surrounds in do-catch
    • 2 Gets encoder
    • 3 Encodes books data
    Now that you have the data encoded as a property list, you can archive it with the NSKeyedArchiver class, calling the archiveRootObject method, passing in the object and the file path. This method will return a Bool that indicates whether the data was written successfully.
  2. Add the following after encoding the books array:
    let success = NSKeyedArchiver.archiveRootObject(               1
      data, toFile: booksFile.path)                                1
    print(success ? "Successful save" : "Save Failed")             2

    • 1 Archives encoded books data
    • 2 Prints result to console
    Next, you need to unarchive an object when you want to retrieve it from disk. For unarchiving, you’ll use the NSKeyedUnarchiver class, calling the unarchiveObject method and passing in the file path. You can unwrap the value returned as a Data object.
  3. Update the method in the BooksManager to retrieve archived data.
    func retrieveBooks()->[Book]? {
      guard let data = NSKeyedUnarchiver.unarchiveObject(         1
        withFile: booksFile.path)                                 1
        as? Data else { return nil }                              2
        //Decode data here
    }

    • 1 Gets data from disk
    • 2 Unwraps as Data object
    Now that you have the data that was archived as a property list, you can decode it back to a books array using the PropertyListDecoder. Tell the decode method what type you’re expecting this data to decode to (in this case, an array of Book), and magically, your books array should reappear! Of course, the decode could fail, so again, you’ll need to surround it in a do-catch statement.
  4. Decode the unarchived data in the retrieveBooks method.
      do {                                                             1
        let decoder = PropertyListDecoder()                            2
        let books = try decoder.decode([Book].self, from: data)        3
        return books
      } catch {
        print("Retrieve Failed")
        return nil
      }

    • 1 Surrounds in do-catch
    • 2 Gets decoder
    • 3 Decodes as array of Book
  5. Run the app, make changes to your data, and run the app again. If all has gone well, the changes you made should persist!
Checkpoint

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.

11.2.4. SQLite

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!

Checkpoint

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).

Figure 11.5. Data persistence: archiving objects.

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.

Set up the SQLite3 database file

You have two choices to set up the database:

  • You could build the database in code if it doesn’t yet exist.
  • You could build the database in an SQLite3 database management program, and include the database file with your app in the app bundle directory.

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.

  1. Feel free to create the Books database yourself, or you can download a version of the database that I’ve set up here: http://mng.bz/t9IF. Once you’ve generated a database file, add it to your project.
  2. Drag the database file into your bookcase project’s Project Navigator. Be sure to check the Bookcase target. This introduces the database file into your app bundle directory. An important thing to note is that the app bundle is read-only. The first time the app runs, it will need to copy this file into the application support directory to make changes to it.
  3. Update your booksFile property to ensure the file exists before returning the path.
    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
    }()

    • 1 Gets db path in App Support
    • 2 If db doesn’t exist
    • 3 Gets db path in Bundle
    • 4 do-catch statement
    • 5 Copies db to App Support
Note

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.

Set up SQLite wrapper

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.

Using 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.

  1. Open the General tab for the Bookcase target.
  2. Select the plus (+) symbol under Linked Frameworks and Libraries.
  3. Select libsqlite3.tbd and tap the Add button. The SQLite framework is ready to go, and you’re ready to install the FMDB wrapper!
  4. In the Project Navigator for your Bookcase app, create an fmdb group in the Bookcase project, ready to contain the wrapper.
  5. Download the FMDB framework from here: https://github.com/ccgus/fmdb.git.
  6. Locate the fmdb group inside the Source group. Notice that it contains .m and .h files; these are Objective-C implementation files and header files, respectively. Select the .m and .h files and drag them into the fmdb group in the Project Navigator. As FMDB is written in Objective-C, you’ll need to create a bridging header.
  7. Xcode will offer to configure the bridging header for you. Select Create Bridging Header.
  8. Import the header for FMDB in the Bookcase-Bridging-Header.h file that was automatically generated by Xcode when you dragged in the FMDB classes.
    #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.

Retrieving books from the database

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.

  1. Set up a method in the BooksManager class that performs these frequent tasks.
    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
    }
    Note

    After opening a database and performing any necessary queries, be sure to close it again to free up any system resources.

    Once you have a reference to an open database, you can perform a SELECT query on it to extract data from a database. For example, the following will query all data in the books table:
    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.
  2. Set up an initializer in the Book structure to instantiate a new book based on a row in a result set.
    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.
  3. Set this up in the retrieveBooks method in the BooksManager.
    // 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
    }

    • 1 Gets open database
    • 2 Queries database for all books
    • 3 Iterates through result set
    • 4 Instantiates book from result set
    • 5 Adds to books array
    • 6 Closes database
  4. The way you did in the structured data files and archiving sections, you’ll want to call this retrieveBooks method in the loadBooks method. Because you could supply sample books in the database itself if you want, remove the sampleBooks method. If you have any problems retrieving books, revert to a blank array.
    func loadBooks() -> [Book] {
        return retrieveBooks() ?? []
    }
Adding, updating, and removing books

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.

  1. Add an id Int property to the Book structure.
  2. Update the initializers to update the property also. Give the id a temporary value of -1. Don’t worry, as soon as the user adds a new book, the database will return its ID ready to update this value. To perform an add/update/remove operation, you need to call the database’s executeUpdate method, using question marks to bind values. As this method can throw errors, you need to surround it in a do-catch statement. After adding a book to the database, the lastInsertRowId method of your database will contain the new ROWID of the book you added. Use this to provide your Book object with an ID.
  3. Create a SQLAddBook method in your BookManager class that receives a book object and updates this in the database. Because you’re updating the book object’s ID, you’ll need to tag the parameter as inout.
    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
    }

    • 1 Gets open database
    • 2 do-catch statement
    • 3 Updates database
    • 4 SQLite operation
    • 5 Values to bind
    • 6 Gets ROWID
    • 7 Closes database
  4. You can now call this new method at the beginning of the addBook method in your BookManager class. As the book object is updated with the ID, you’ll need to mark the argument with an ampersand. To make this parameter mutable, you’ll need to reassign it as a variable.
    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.

Listing 11.1. Delete and update 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]
Challenge

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.

Checkpoint

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.

11.2.5. Core Data

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:

  • Tracking changes, and implementing undo or redo
  • Caching or lazy loading of your objects
  • Minimizing the number of objects in memory
  • Validation of property values

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.

Checkpoint

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).

Creating a data model

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.

  1. Delete the Book.swift file; Core Data will generate the Book structure for you.
  2. Create a data model file. Select File > New > File > Data Model (in the Core Data section) > Next. The default name “Model” will be fine. Change the group to Model to neatly store the data model in an appropriate group (see figure 11.6).
    Figure 11.6. Create data model file

    Now it’s time to edit your data model.
  3. Find the Model file you created in the Project Navigator, and select it. The data model editor will appear.
  4. Add your first entity with the Add Entity button, and call it “Book” (see figure 11.7). Notice that you can add three types of things to your new entity:

    • Attributes— Similar to object properties.
    • Relationships— Connections with other entities. Relationships can be to-one or to-many.
    • Fetched properties— Similar to lazy computed properties.
    Figure 11.7. Data model editor

  5. Add attributes to the Book entity with the Add Attribute button, and assign types to each attribute:

    • title—String
    • author—String
    • rating—Double
    • isbn—String
    • notes—String
  6. Select one of the attributes you’ve added. Notice that you have a new inspector in the Inspectors panel called the Data Model Inspector. Here, you can change the attribute type, add validation specifications, give the attribute a default value, or make the property optional—or not! Uncheck Optional for all the attributes in the Book entity so you won’t have to unwrap your book properties. Each entity will be represented in code by an NSManagedObject class, but Xcode can generate a neat subclass of NSManagedObject for each entity you create in the data model that contains the attributes you specified. By default, you need to manually request this subclass to be generated, but you can request for this to be done for you automatically.
  7. Turn on automatic subclass generation: select the Book entity, and open the Data Model Inspector. Find the Codegen attribute, and instead of Manual/None, select Class Definition.

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.

Editor style

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.

Initial setup

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.

Table 11.3. Core Data stack

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.

Figure 11.8. Core Data stack

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.

  1. Add a persistent container to your AppDelegate class now. Instantiate it with the name of your data model, and then load up any persistent stores. Basic error handling has been included for now, but you should include more-relevant error handling when shipping your app.
    // 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
    }()

    • 1 Lazy computed property
    • 2 Instantiates with data model
    • 3 Completion handler
    • 4 Improve error handling here
    Changes to data are performed in memory (via the managed object context) and aren’t automatically saved to disk. To persist changes, you need to ask the managed object context to save the changes to the persistent store.
  2. Add a method to the AppDelegate for committing unsaved changes.
    // 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
           }
       }
    }

    • 1 Gets managed object context
    • 2 Only saves if necessary
    • 3 do-catch statement
    • 4 Saves changes to store
    • 5 Improve error handling here
    This method will come in handy every time you save data in the app. You can also ensure that unsaved data is saved to disk before the app terminates.
  3. Add a call to the saveContext method in the AppDelegate’s applicationWillTerminate method.
    self.saveContext()
  4. Add a reference to the application delegate in the BooksTableView-Controller, so that it can easily access the saveContext method you created. The UIApplication class has a singleton, shared, that refers to the application instance. Use the delegate property to access the app’s AppDelegate.
    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.
  5. Use the reference to the AppDelegate to keep a reference to the managed object context via the persistent container.
    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.

Cleanup

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!

  1. Delete the BooksManager class, leaving the BooksManager.swift file with just the SortOrder enum. This will generate several errors elsewhere—not to worry, we’ll attend to these in time. Rename the file SortOrder.swift.
  2. Because you no longer have a BooksManager class, you won’t need to inject it into the table view controller. Because injecting the BooksManager class was the whole point of the TabBarController, you can remove this file.
  3. In the storyboard, remove the TabBarController from the custom class for the tab bar controller in the Identity Inspector.
  4. Remove the inject method from the BooksTableViewController.
  5. Comment out the whole BooksCollectionViewController class for now, so you can focus on the BooksTableViewController class without being concerned about errors elsewhere. Temporarily remove this class from the identification of this view controller in the storyboard.
  6. Because you removed the original Book structure, you also removed a reference to the defaultCover. For simplicity, let’s add this back in the Books-TableViewController class.
    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.

Adding managed objects

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.

Listing 11.2. Create managed object
let book = Book(context: context)             1
book.title = "Great Expectations"             2
appDelegate.saveContext()                     3

  • 1 Creates Book
  • 2 Sets attribute
  • 3 Saves to persistent store

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.

  1. Add an implicitly unwrapped optional for the managed object context in the BookViewController class.
    var context:NSManagedObjectContext!
  2. Pass this context into the BookViewController in the prepareForSegue method in the BooksTableViewController class:
    viewController.context = context
  3. Now you can update the touchSave method in BookViewController to save a book. Each Book object is uniquely identifiable internally for Core Data. When the user taps the Save button in the detail view controller, you want to first check if the book already exists. If it doesn’t, you want to create a new Book managed object. If it does, you want to update the existing Book object.
    @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()
    }

    • 1 Gets book to update
    • 2 Creates book
    • 3 Updates book attributes
  4. Back in the saveBook method in the table view controller class extension, you can now call the saveContext method on the AppDelegate, to commit unsaved changes to the persistent store.
    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.

Fetching managed objects

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

  • Batch size— The number of managed objects to return.
  • Search criteria— Use the NSPredicate class to define search criteria.
  • Sort order— Use an array of instances of the NSSortDescriptor class to define the sort order.

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.

Listing 11.3. Simple fetch request
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

  • 1 Gets fetch request for Book
  • 2 Batches size
  • 3 Specifies sort by title
  • 4 Searches all

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.

  1. Store a fetched results controller of your fetch request in a lazy computed property in your BooksTableViewController. Set self as the delegate and perform the fetch.
    // 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)")
        }
    }

    • 1 Creates lazy var
    • 2 Returns fetched results controller
    • 3 Creates fetched results controller
    • 4 Specifies cache name
    • 5 Specifies delegate
    • 6 do-catch statement
    • 7 Performs fetch
    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.
  2. Add an extension to the BooksTableViewController that adopts the NSFetchedResultsControllerDelegate protocol and implement the controllerDidChangeContent method, reloading the table.
    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.
  3. Return the number of objects from the fetched results controller in the numberOfRowsInSection table view delegate method.
    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.
  4. Update the cellForRowAt method of the table view delegate protocol. Don’t forget to update the location of the image because you’re no longer storing this constant in the Book structure.
    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
    }
Updating and deleting managed objects

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.

  1. Pass in the book to update to the BookTableViewController, in the prepareForSegue method of BooksTableViewController.
    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.
  2. Update the delete portion of code in your BooksTableViewController class to delete a Book managed object:
    override func tableView(_ tableView: UITableView,
       commit editingStyle: UITableViewCellEditingStyle,
       forRowAt indexPath: IndexPath) {
     if editingStyle == .delete {
       context.delete(fetchedResultsController.object(at: indexPath))
       appDelegate.saveContext()
     }
    }
Sorting fetch requests

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!

  1. Replace the simple sort descriptor in the creation of the fetch request with one that takes the user’s preferred sort order into consideration.
    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
    }

    • 1 Gets segmented control index
    • 2 Gets preferred Sort Order
    • 3 Title sort descriptor
    • 4 Author sort descriptor
    • 5 User wants sort by title
    • 6 Prioritizes title
    • 7 Prioritizes author
    Now, when the user selects a new segment of the segmented control, you should regenerate the fetch results controller before updating the table.
  2. Update the changedSegment method.
    @IBAction func changedSegment(_ sender: UISegmentedControl) {
        fetchedResultsController = getFetch()
       self.tableView.reloadData()
    }
Searching fetch requests

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.

Note

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.

  1. Replace the fetch request predicate with a predicate that fetches books whose title or author fields contain the text in the search bar.
    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.
  2. Update the updateSearchResults method in the extension.
    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.

Challenge

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.

Checkpoint

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!

11.3. Summary

In this chapter, you learned the following:

  • Preserve app state to restore the app the way the user left it.
  • User defaults can be used to store small, discrete pieces of information, such as username, high score, or user preferences such as sound on or off.
  • Respond to app-level events in the app delegate.
  • To include Objective-C classes in a Swift project, add a bridging header and import their Objective-C header files.
  • Store smaller amounts of data in their entirety locally (also known as an atomic store) using technologies such as XML, property lists, or archiving objects.
  • For apps with greater data requirements, such as creating relationships between objects and sophisticated queries, consider storing data using a transactional store such as SQLite.
  • If your app has large data requirements with relationships and sophisticated queries, you might also want to consider Core Data. Core Data also provides additional features such as creating relationships between objects, tracking changes, caching, and validation.
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset