Search code examples
iosswifthierarchical-datanscodingdata-persistence

What is best practice for data persistence in hierarchical classes using NSCoding in Swift?


Problem: I've implemented data persistence in my iOS app using NSKeyedArchiver and it currently functions to save hierarchical data, but only from the top level class. How can I make it possible to save the entire hierarchy from any level?

Background: I'm developing an iOS app that uses a data structure of hierarchical classes, e.g. School, Classroom, Student. Basically, the School class contains an array of Classrooms (along with other properties like district, name, phone number, etc.) the Classroom class contains an array of Students (along with other properties like teacher, room number, etc.) and the Student class has properties for each student (e.g. name, grade, courses, etc.).

The app has three view controllers, one for each level of the hierarchy that allows the data to be changed at each level: DistrictTableViewController has an array of School objects and can add/delete array elements, SchoolTableViewController has an array of Classroom objects and can add/delete elements from the array of Classroom objects, and ClassroomViewController allows the user to add/remove/edit Students.

I've implemented data persistence in all three classes using NSCoding and it currently functions to save data in the hierarchy, but I can only save the data from the top level DistrictTableVC (app entry point). DistrictTableVC has a saveSchools() method. Instead, I want to be able to save changes from any of the three ViewControllers, e.g. a change to a Student property would immediately save the Student object, as well as the array of Students in the Classroom and the array of Classrooms in the School.

The current configuration is such that the DistrictTableVC passes a single School object to the SchoolTableVC, SchoolTableVC passes a single Classroom object to ClassroomVC. I think what I should be doing instead is:

  1. create a new top level class called District which holds the array of schools and also uses NSCoding
  2. pass a District object between the three VCs instead of singular lower level objects
  3. move the saveSchools() method from DistrictTableVC to the new District class, allowing me to call it from any of the three ViewControllers.

Since I am not a professional, I am reaching out to see:

  1. am I on the right track? or
  2. perhaps someone out there knows of a better way to do this?

Thanks for reading!!

class DistrictTableViewController: UITableViewController {

    private let reuseIdentifier = "schoolCell"

    var schoolsArray = [School]()

    override func viewDidLoad() {
        super.viewDidLoad()

        self.navBarTitle.title = "Schools"

        // Load saved Schools if they exist, otherwise load sample data
        if let savedSchools = loadSchools() {
            schoolsArray += savedSchools
            print("Loading saved schools")

            // Update all School stats
            updateSchoolListStats()

        } else {
            // Load the sample data
            loadSampleSchools()
            print("Failed to load saved data. Loading sample data...")
        }
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

    //MARK: TableView datasource
    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return schoolsArray.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

        let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier, for: indexPath) as! SchoolTableViewCell

        // Configure the cell...
        let school = schoolsArray[indexPath.row]
        school.calcSchoolStats()

        return cell
    }

    // Override to support conditional editing of the table view.
    override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
        // Return false if you do not want the specified item to be editable.
        return true
    }

    // Override to support editing the table view.
    override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
        if editingStyle == .delete {

            // Delete the row from the data source
            schoolsArray.remove(at: indexPath.row)
            saveSchools()

            tableView.deleteRows(at: [indexPath], with: .fade)

        } else if editingStyle == .insert {
            // Create a new instance of the appropriate class, insert it into the array, and add a new row to the table view
        }    
    }

    // MARK: - Navigation
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {

        super.prepare(for: segue, sender: sender)

        // Deselect any selected cells
        for (_, cell) in tableView.visibleCells.enumerated() {
            cell.isSelected = false
        }

        // SchoolTableViewCell pressed: pass the selected school to SchoolsTableViewController
        if (segue.identifier ?? "") == "showSchoolDetail" {
            //guard let schoolsTableViewController = segue.destination as? SchoolsTableViewController else {
            fatalError("Unexpected destination: \(segue.destination)")
            }
            guard let selectedSchoolCell = sender as? SchoolTableViewCell else {
                fatalError("Unexpected sender: \(String(describing: sender))")
            }
            guard let indexPath = tableView.indexPath(for: selectedSchoolCell) else {
                fatalError("The selected SchoolTableViewCell is not being displayed by the table")
            }

            schoolTableViewController.school = schoolsArray[indexPath.row]
        }

        // Add button pressed: show SchoolAttributesViewController
        if addBarButtonItem == sender as? UIBarButtonItem {
            guard segue.destination is SchoolAttributesViewController else {
                fatalError("Unexpected destination: \(segue.destination)")
            }
        }
    }

    @IBAction func unwindToSessionsTableViewController(sender: UIStoryboardSegue) {

        if let sourceViewController = sender.source as? SchoolsTableViewController, let school = sourceViewController.school {

            if let selectedIndexPath = tableView.indexPathForSelectedRow {
                // Update an existing session
                schoolsArray.array[selectedIndexPath.row] = school
                tableView.reloadRows(at: [selectedIndexPath], with: .none)
            } else {
                // Add a new school to the Table View
                schoolsArray.insert(session, at: 0) // Update date source; add new school to the top of the table

                let newIndexPath = IndexPath(row: 0, section: 0)
                tableView.insertRows(at: [newIndexPath], with: .automatic)
                tableView.cellForRow(at: newIndexPath)?.isSelected = true

                tableView.cellForRow(at: newIndexPath)?.selectedBackgroundView = bgColorView
            }
            //updateSessionListStats()
            //sessionsTableView.reloadData()

            saveSchools()
        }
    }

    //MARK: Actions
    private func saveSchools() {
        let isSuccessfulSave = NSKeyedArchiver.archiveRootObject(schoolsArray, toFile: School.ArchiveURL.path)

        if isSuccessfulSave {
            os_log("Schools successfully saved", log: OSLog.default, type: .debug)
        } else {
            os_log("Failed to save schools...", log: OSLog.default, type: .error)
        }
    }
    //MARK: Private Methods
    private func updateSchoolListStats() {
        for (_, school) in schoolsArray.array.enumerated() {
            for (_, classroom) in school.classroomArray.enumerated() {
                classroom.calcStats()
            }
            school.calcSchoolStats()
        }
    }
    private func loadSchools() -> [School]? {
        return NSKeyedUnarchiver.unarchiveObject(withFile: School.ArchiveURL.path) as? [School]
    }

class School: NSObject, NSCoding {

    //MARK: Properties
    var name: String
    var district: String
    var phoneNumber: Int
    var classroomArray = [Classroom]()

    //MARK: Archiving Paths
    static let DocumentsDirectory = FileManager().urls(for: .documentDirectory, in: .userDomainMask).first!
    static let ArchiveURL = DocumentsDirectory.appendingPathComponent("schoolsArray")

    init (name: String = "Default", district: String = "", phoneNumber: Int = -1, classroomArray = [Classroom]()) {
        self.name = name
        self.district = district
        self.phoneNumber = phoneNumber
        self.classroomArray = classroomArray
    }

    func calcSchoolStats() {
    }

    //MARK: NSCoding Protocol
    func encode(with aCoder: NSCoder) {

        aCoder.encode(name, forKey: "name")
        aCoder.encode(district, forKey: "district")
        aCoder.encode(phoneNumber, forKey: "phoneNumber")
        aCoder.encode(classroomArray, forKey: "classroomArray")
    }
    required convenience init?(coder aDecoder: NSCoder) {
        // The name is required. If we cannot decode a name string, the initializer should fail.
        guard let name = aDecoder.decodeObject(forKey: "name") as? String else {
            os_log("Unable to decode the name for a School object.", log: OSLog.default, type: .debug)
            return nil
        }
        let district = aDecoder.decodeObject(forKey: "district") as! String
        let phoneNumber = aDecoder.decodeInteger(forKey: "phoneNumber")
        let classroomArray = aDecoder.decodeObject(forKey: "classroomArray") as! [Classroom]

        // Must call designated initializer.
        self.init(name: name, district: district, phoneNumber: phoneNumber, classroomArray: classroomArray)
    }
}

class Classroom: NSObject, NSCoding {

    //MARK: Properties
    var teacher: String
    var roomNumber: Int
    var studentArray = [Student]()

    //MARK: Archiving Paths
    static let DocumentsDirectory = FileManager().urls(for: .documentDirectory, in: .userDomainMask).first!
    static let ArchiveURL = DocumentsDirectory.appendingPathComponent("classroomsArray")

    init (teacher: String = "", building: Int = -1, studentArray = [Student]()) {
        self.teacher = teacher
        self.roomNumber = roomNumber
        self.studentArray = studentArray
    }

    func calcStats() {
    }

    //MARK: NSCoding Protocol
    func encode(with aCoder: NSCoder) {

        aCoder.encode(teacher, forKey: "teacher")
        aCoder.encode(roomNumber, forKey: "roomNumber")
        aCoder.encode(studentArray, forKey: "studentArray")
    }
    required convenience init?(coder aDecoder: NSCoder) {
        // The teacher is required. If we cannot decode a teacher string, the initializer should fail.
        guard let teacher = aDecoder.decodeObject(forKey: "teacher") as? String else {
            os_log("Unable to decode the teacher for a Classroom object.", log: OSLog.default, type: .debug)
            return nil
        }
        let roomNumber = aDecoder.decodeInteger(forKey: "roomNumber")
        let studentArray = aDecoder.decodeObject(forKey: "studentArray") as! [Student]

        // Must call designated initializer.
        self.init(teacher: teacher, roomNumber: roomNumber, studentArray: studentArray)
    }
}

class Student: NSObject, NSCoding {

    //MARK: Properties
    var first: String
    var last: String
    var grade: Int
    var courses: [String]

    //MARK: Archiving Paths
    static let DocumentsDirectory = FileManager().urls(for: .documentDirectory, in: .userDomainMask).first!
    static let ArchiveURL = DocumentsDirectory.appendingPathComponent("students")

    init (first: String = "", last: String = "", grade: Int = -1, courses = [String]()) {
        self.first = first
        self.last = last
        self.grade = grade
        self.courses = courses
    }

    //MARK: NSCoding Protocol
    func encode(with aCoder: NSCoder) {

        aCoder.encode(first, forKey: "first")
        aCoder.encode(last, forKey: "last")
        aCoder.encode(grade, forKey: "grade")
        aCoder.encode(courses, forKey: "courses")
    }
    required convenience init?(coder aDecoder: NSCoder) {
        // The first name is required. If we cannot decode a first name string, the initializer should fail.
        guard let first = aDecoder.decodeObject(forKey: "first") as? String else {
            os_log("Unable to decode the first name for a Student object.", log: OSLog.default, type: .debug)
            return nil
        }
        let last = aDecoder.decodeObject(forKey: "last") as! String
        let grade = aDecoder.decodeInteger(forKey: "grade")
        let courses = aDecoder.decodeObject(forKey: "courses") as! [String]

        // Must call designated initializer.
        self.init(first: first, last: last, grade: grade, courses: courses)
    }
}

Solution

  • Got it working! Now each view controller has a district object and can call district.saveDistrict() whenever the data model is modified.

    class District: NSObject, NSCoding {

    //MARK: Properties
    var array: [School]
    
    //MARK: Archiving Paths
    static let DocumentsDirectory = FileManager().urls(for: .documentDirectory, in: .userDomainMask).first!
    static let ArchiveURL = DocumentsDirectory.appendingPathComponent("District")
    
    init (array: [School] = [School]()) {
        self.array = array
    }
    
    //MARK: Actions
    func saveDistrict() {
        let isSuccessfulSave = NSKeyedArchiver.archiveRootObject(array, toFile: District.ArchiveURL.path)
    
        if isSuccessfulSave {
            os_log("Schools array successfully saved", log: OSLog.default, type: .debug)
        } else {
            os_log("Failed to save schools array...", log: OSLog.default, type: .error)
        }
    }
    func loadSavedDistrict() -> District? {        
        var savedDistrict = District()
        if let districtConst = NSKeyedUnarchiver.unarchiveObject(withFile: District.ArchiveURL.path) as? [School] {
            savedDistrict = District(array: districtConst)
        }
        return savedDistrict
    }
    
    //MARK: NSCoding Protocol
    func encode(with aCoder: NSCoder) {
        aCoder.encode(array, forKey: "array")
    }
    required convenience init?(coder aDecoder: NSCoder) {
        // The array is required. If we cannot decode the array, the initializer should fail.
        guard let array = aDecoder.decodeObject(forKey: "array") as? [School] else {
            os_log("Unable to decode the Schools array object.", log: OSLog.default, type: .debug)
            return nil
        }
    
        // Must call designated initializer.
        self.init(array: array)
    }
    

    }