Serialization and deserialization

You may notice that the to-do item you put in is gone when you restart the app. Such an app is useless for the user. The app needs to store the to-do items somehow and reload them when it is opened the next time. There are different possibilities to implement this. We could use Core Data, serialize the data using NSCoding, or use a third-party framework. In this book, we will write the date into a property list (plist). A plist has the advantage that it can be opened and altered with Xcode or any other editor.

The data model we implemented uses structs. Unfortunately, structs cannot be written to a plist. We have to convert the data into Any arrays and String:Any dictionaries. Add the following code to ToDoItemTests:

func test_HasPlistDictionaryProperty() { 
  let item = ToDoItem(title: "First") 
  let dictionary = item.plistDict 
} 

The static analyzer complains that there is no property with the name plistDict. Let's add it. Open ToDoItem and add the property:

var plistDict: String { 
  return "" 
} 

We will use a calculated property here because we don't want to initialize it during initialization, and the value should be calculated from the current values of the other properties. Add the following assertions at the end of the test:

XCTAssertNotNil(dictionary) 
XCTAssertTrue(dictionary is [String:Any]) 

As mentioned previously, to be able to write the date into a plist, it needs to be of type [String:Any]. Run the test. It fails because, right now, the calculated property is of type String. Replace the property with this code:

var plistDict: [String:Any] { 
  return [:] 
} 

Run all the tests. All the tests pass and there is nothing to refactor.

Now, we need to make sure that we can recreate an item from plistDict. Add the following code to ToDoItemTests:

func test_CanBeCreatedFromPlistDictionary() { 
  let location = Location(name: "Bar") 
  let item = ToDoItem(title: "Foo", 
                      itemDescription: "Baz", 
                      timestamp: 1.0, 
                      location: location)

   
  let dict = item.plistDict 
  let recreatedItem = ToDoItem(dict: dict) 
} 

We have to stop writing the test because the static analyzer complains The 'ToDoItem' struct does not have an initializer with a parameter named 'dict'. Open ToDoItem.swift and add the following code to the ToDoItem struct:

init?(dict: [String:Any]) { 
  return nil 
} 

This is enough to make the test compilable. Now, add the assertion to the test:

XCTAssertEqual(item, recreatedItem)

That assertion asserts that the recreated item is the same as the item used to create plistDict. Run the test. The test fails because we haven't implemented writing the data of struct to [String:Any] and the creation of a to-do item from [String:Any]. To write the complete information needed to recreate a to-do item into a dictionary, we first have to make sure that an instance of Location can be written to and recreated from [String:Any].

In TDD, it is important to always have only one failing test. So, before we can move to the tests for Location, we have to disable the last test we wrote. During test execution, the test runner searches for methods in the test cases that begin with test. Change the name of the previous test method to xtest_CanBeCreatedFromPlistDictionary(). Run the tests to make sure that all tests, except this one, are executed.

Now, open LocationTests and add the following code:

func test_CanBeSerializedAndDeserialized() { 
  let location = Location( 
    name: "Home", 
    coordinate: CLLocationCoordinate2DMake(50.0, 6.0))

   
  let dict = location.plistDict 
} 

Again, the static analyzer complains because the property is missing. We already know how to make this compilable again. Add this code to Location:

var plistDict: [String:Any] { 
  return [:] 
} 

With this change, the test compiles. Add the following code to the end of the test:

XCTAssertNotNil(dict) 
let recreatedLocation = Location(dict: dict) 

Again, this does not compile because Location does not have an initializer with one parameter called dict. Let's add it:

init?(dict: [String:Any]) { 
  return nil 
} 

The test passes again. But it is not finished yet. We need to make sure that the recreated location is the same as the one we used to create the [String:Any]. Add the assertion at the end of the test:

XCTAssertEqual(location, recreatedLocation)

Run the test. It fails. To make it pass, the plistDict property has to have all the information needed to recreate the location. Replace the calculated property with this code:

private let nameKey = "nameKey" 
private let latitudeKey = "latitudeKey" 
private let longitudeKey = "longitudeKey"

 
var plistDict: [String:Any] { 
  var dict = [String:Any]()

   
  dict[nameKey] = name

   
  if let coordinate = coordinate { 
    dict[latitudeKey] = coordinate.latitude 
    dict[longitudeKey] = coordinate.longitude 
  } 
  return dict 
} 

The code explains itself. It just puts all the information of a location into an instance of [String:Any]. Now, replace the initializer with the dict argument with the following:

init?(dict: [String:Any]) { 
  guard let name = dict[nameKey] as? String else 
  { return nil }

   
  let coordinate: CLLocationCoordinate2D? 
  if let latitude = dict[latitudeKey] as? Double, 
    let longitude = dict[longitudeKey] as? Double { 
    coordinate = CLLocationCoordinate2DMake(latitude, longitude) 
  } else { 
    coordinate = nil 
  }

   
  self.name = name 
  self.coordinate = coordinate 
} 

Run the tests. All the tests pass again.

As the location can be written to [String:Any], we can use it for the serialization of ToDoItem. Open ToDoItemTests again, and remove the x at the beginning of the method name of xtest_CanBeCreatedFromPlistDictionary(). Run the tests to make sure that this test fails.

Now, replace the implementation of the calculated plistDict property in ToDoItem with this code:

private let titleKey = "titleKey" 
private let itemDescriptionKey = "itemDescriptionKey" 
private let timestampKey = "timestampKey" 
private let locationKey = "locationKey"
 
var plistDict: [String:Any] { 
  var dict = [String:Any]() 
  dict[titleKey] = title 
  if let itemDescription = itemDescription { 
    dict[itemDescriptionKey] = itemDescription 
  } 
  if let timestamp = timestamp { 
    dict[timestampKey] = timestamp 
  } 
  if let location = location { 
    let locationDict = location.plistDict 
    dict[locationKey] = locationDict 
  } 
  return dict 
} 

Again, this is straightforward. We will put all the values stored in the properties into a dictionary and return it. To recreate a to-do item from a plist dictionary, replace init?(dict:) with this:

init?(dict: [String:Any]) { 
  guard let title = dict[titleKey] as? String else 
  { return nil }

   
  self.title = title

   
  self.itemDescription = dict[itemDescriptionKey] as? String 
  self.timestamp = dict[timestampKey] as? Double 
  if let locationDict = dict[locationKey] as? [String:Any] { 
    self.location = Location(dict: locationDict) 
  } else { 
    self.location = nil 
  } 
} 

In this init method, we fill the properties of ToDoItem with the values from the dictionary. Run the tests. All the tests pass and there is nothing to refactor.

The next step is to write the list of checked and unchecked to-do items to the disk and restore them when the app is started again. To drive the implementation, we will write a test that creates two to-do items and adds them to an item manager, sets the item manager to nil, and then, creates a new one. The created item manager should then have the same items as the one that got destroyed. Open ItemManagerTests and add the following test in it:

func test_ToDoItemsGetSerialized() { 
  var itemManager: ItemManager? = ItemManager()

   
  let firstItem = ToDoItem(title: "First") 
  itemManager!.add(firstItem)

   
  let secondItem = ToDoItem(title: "Second") 
  itemManager!.add(secondItem)

   
  NotificationCenter.default.post( 
    name: .UIApplicationWillResignActive, 
    object: nil)

   
  itemManager = nil

   
  XCTAssertNil(itemManager)

   
  itemManager = ItemManager() 
  XCTAssertEqual(itemManager?.toDoCount, 2) 
  XCTAssertEqual(itemManager?.item(at: 0), firstItem) 
  XCTAssertEqual(itemManager?.item(at: 1), secondItem) 
} 

In this test, we first create an item manager, add two to-do items, and send UIApplicationWillResignActive to signal to the app that it should write the data to disk. Next, we set the item manager to nil to destroy it. Then, we create a new item manager and assert that it has the same items.

Run the test. The test crashes because we try to access a to-do item in the item manager but there is no item yet.

Before we write the code that writes the to-do items to disk, add the following code to tearDown(), right before super.tearDown():

sut.removeAllItems() 
sut = nil 

This is needed because, otherwise, all the tests would end up writing their to-do items to disk, and the tests would not start from a clean state.

As mentioned previously, the item manager should register as an observer for UIApplicationWillResignActive and write the data to disk when the notification is sent. Add the following init method to ItemManager:

override init() { 
  super.init()

   
  NotificationCenter.default.addObserver( 
    self, 
    selector: #selector(save), 
    name: .UIApplicationWillResignActive, 
    object: nil) 
}

The enum with the value UIApplicationWillResignActive is defined in UIKit, so replace import Foundation with import UIKit. Next, add the following calculated property to create a path URL for the plist:

var toDoPathURL: URL { 
  let fileURLs = FileManager.default.urls( 
    for: .documentDirectory, in: .userDomainMask)

   
  guard let documentURL = fileURLs.first else { 
    print("Something went wrong. Documents url could not be found") 
    fatalError() 
  }

   
  return documentURL.appendingPathComponent("toDoItems.plist") 
} 

This code gets the document directory of the app and appends the toDoItems.plist path component. Now, we can write the save method:

@objc func save() { 
  let nsToDoItems = toDoItems.map { $0.plistDict }

   
  guard nsToDoItems.count > 0 else { 
    try? FileManager.default.removeItem(at: toDoPathURL) 
    return 
  } 
  do { 
    let plistData = try PropertyListSerialization.data( 
      fromPropertyList: nsToDoItems, 
      format: PropertyListSerialization.PropertyListFormat.xml, 
      options: PropertyListSerialization.WriteOptions(0) 
    ) 
    try plistData.write(to: toDoPathURL, 
                        options: Data.WritingOptions.atomic) 
  } catch { 
    print(error) 
  } 
} 

First, we create an Any array with the dictionaries of the to-do items. If the array has at least one item, we write it to the disk using the PropertyListSerialization class. Otherwise, we remove whatever is stored at the location of the file path.

When a new item manager is created, we have to read the data from the plist and fill the toDoItems array. The perfect place to read the data is in the init method. Add the following code at the end of init():

if let nsToDoItems = NSArray(contentsOf: toDoPathURL) {

  for dict in nsToDoItems { 
    if let toDoItem = ToDoItem(dict: dict as! [String:Any]) { 
      toDoItems.append(toDoItem) 
    } 
  } 
} 

Before we can run the tests, we need to do some housekeeping. We have added the item manager as an observer to NotificationCenter.default. Like good citizens, we have to remove it when we aren't interested in notifications anymore. Add the following deinit method to ItemManager:

deinit { 
  NotificationCenter.default.removeObserver(self) 
  save() 
} 

In addition to removing the observer, we call save() to trigger the save operation.

There are many lines of code needed to make one test pass. We could have broken these down into smaller steps. In fact, you should experiment with the test and the implementation and see what happens when you comment out parts of it.

Run all tests. Uh!? A lot of unrelated tests fail.

If you do not see failing tests, the timing of your tests might be different to mine. Do the following changes anyway because, otherwise, you might see failing tests later.

We haven't changed the code the other tests are testing, but we changed the way ItemManager works. If you have a look at ItemListDataProviderTests, DetailViewControllerTests, and InputViewControllerTests, we added items to an item manager instance in there. This means that we need to clean up after the tests have been executed. Open ItemListDataProviderTests and add the following code to tearDown(), right before super.tearDown():

sut.itemManager?.removeAll()

Add the same code to tearDown() in InputViewControllerTests.

Now, add the following to tearDown() in DetailViewControllerTests:

sut.itemInfo?.0.removeAll()

Run the tests again. All the tests pass. We will move to the next section, but you should implement the tests and code for the serialization and deserialization of the done items in ItemManager.

..................Content has been hidden....................

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