Swift + CoreData + Немного напильника

в 13:21, , рубрики: development, iOS, swift, xcode, напильник, разработка под iOS, метки: , , , ,

image
Зачесались у меня тут руки узнать, что это за зверь такой Swift и с чем его собственно едят. Как и ожидалось проблем и подводных камней пока оказалось много, ну либо я совсем не умею этот Swift готовить. Самая большая проблема ожидала меня при попытке подружить этот самый Swift с CoreData — штука принципиально отказывалась работать. Обильное гугление не приводило к хоть каким-либо хорошим результатам — информация была либо крайне обрывочной, либо попахивала костылями. Посему в первый вечер терзаний я капитулировал и решил использовать самое тупое решение в работе с CoreData по-старинке — хранить весь код в старом добром Objective-C и уже к нему обращаться из Swift (например в интерфейсах). Однако, перфекционизм в душе не давал покоя и требовалось реализовать чистое одноязычное решение, что я собственно и смог сделать, хотя признаться и не без костылей тоже. Кому интересен процесс прошу под кат. Также попутно предлагаю собирать баги и не самые на мой взгляд удобные вещи, которые пришли вместе с новым языком. Возможно, что-то я сделал криво — буду благодарен комментариям и поправкам, а также обсуждению лучших практик.

Кому дорого время

Можно без чтения статьи сразу просто качнуть пример отсюда https://github.com/KoNEW/CoreDataTest.git и все раскурить самому.

Синтетический пример

С чем будем ковыряться

Здесь и далее для разбора всех проблем и примеров будем использовать синтетический проект — будем делать приложение по просмотру и управлению классическими сущностями «Департамент» и «Сотрудник».
При этом департамент мы будем характеризовать такими полями как:

  • название (строка — обязательное)
  • внутренний номер (число — обязательное)
  • номер телефона (строка — опциональная, никаких проверок корректности номера делать не будем)

А сотрудника соответственно:

  • Имя (строка — обязательно)
  • Фамилия (строка — обязательно)
  • Пол (число — обязательно)
  • Возраст (число — опционально)
Менеджер управления данными

Собственно первым шагом открываем Xcode и создаем тривиальный проект с использованием CoreData и выставленным языком Swift. Единственная правка, которую мы сделаем на этом этапе — вырезаем всю работу с CoreData из делегата приложения и переносим ее в отдельный класс, который будет у нас работать в виде синглетона. Я просто привык так делать, когда раньше занимался кодом и здесь повторюсь — заодно можно глянуть как сделать синглетон на Swift. Префикс для всех наших классов будем использовать здесь и далее CS (CoreData+Swift).

Косяк №1

Не знаю уж бага это в Xcode 6 Beta или фича, но префиксы для классов собственных, чтобы не писать их всякий раз, теперь надо выставлять вручную. Сделать это можно во вкладке File Inspector если выбрать файл проекта.

Итак, что делаем:

  • Вырезаем всю работу с CoreData из AppDelegate
  • Создаем класс CSDataManager
  • В нем создаем свойства для работы с моделью, контекстом и непосредственным хранилищем данных
  • Создаем метод для работы в виде синглетона

По итогам файл AppDelegate.swift у нас выглядит следующим образом:

import UIKit
import CoreData

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
                            
    var window: UIWindow?

    func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: NSDictionary?) -> Bool {
        // Override point for customization after application launch.
        return true
    }
}

А файл CSDataManager.swft — следующим:

import UIKit
import Foundation
import CoreData

let kCSErrorDomain = "ru.novilab-mobile.cstest"
let kCSErrorLocalStorageCode = -1000

@objc(CSDataManager)
class CSDataManager:NSObject {
    //Managed Model
    var _managedModel: NSManagedObjectModel?
    var managedModel: NSManagedObjectModel{
        if !_managedModel{
            _managedModel = NSManagedObjectModel.mergedModelFromBundles(nil)
        }
        return _managedModel!
    }
    
    //Store coordinator
    var _storeCoordinator: NSPersistentStoreCoordinator?
    var storeCoordinator: NSPersistentStoreCoordinator{
        if !_storeCoordinator{
            let _storeURL = self.applicationDocumentsDirectory.URLByAppendingPathComponent("CSDataStorage.sqlite")
            _storeCoordinator = NSPersistentStoreCoordinator(managedObjectModel: self.managedModel)
            
            func addStore() -> NSError?{
                var result: NSError? = nil
                if _storeCoordinator!.addPersistentStoreWithType(NSSQLiteStoreType, configuration: nil, URL: _storeURL, options: nil, error: &result) == nil{
                    println("Create persistent store error occurred: (result?.userInfo)")
                }
                return result
            }
            
            var error = addStore()
            if  error != nil{
                println("Store scheme error. Will remove store and try again. TODO: add scheme migration.")
                NSFileManager.defaultManager().removeItemAtURL(_storeURL, error: nil)
                error = addStore()
                
                if error{
                    println("Unresolved critical error with persistent store: (error?.userInfo)")
                    abort()
                }
            }
        }
        return _storeCoordinator!
    }

    //Managed Context
    var _managedContext: NSManagedObjectContext? = nil
    var managedContext: NSManagedObjectContext {
        if !_managedContext {
            let coordinator = self.storeCoordinator
            if coordinator != nil {
                _managedContext = NSManagedObjectContext()
                _managedContext!.persistentStoreCoordinator = coordinator
            }
        }
        return _managedContext!
    }
    
    //Init
    init() {
        super.init()
        NSNotificationCenter.defaultCenter().addObserver(self, selector: "appDidEnterBackground", name: UIApplicationDidEnterBackgroundNotification, object: nil)
    }
    
    @objc(appDidEnterBackground)
    func appDidEnterBackground(){
        var (result:Bool, error:NSError?) = self.saveContext()
        if error != nil{
            println("Application did not save data with reason: (error?.userInfo)")
        }
    }

    // Returns the URL to the application's Documents directory.
    var applicationDocumentsDirectory: NSURL {
        let urls = NSFileManager.defaultManager().URLsForDirectory(.DocumentDirectory, inDomains: .UserDomainMask)
        return urls[urls.endIndex-1] as NSURL
    }

    //Save context
    func saveContext() -> (Bool, NSError?){
        println("Will save")
        var error: NSError? = nil
        var result: Bool = false
        let context = self.managedContext
        if context != nil{
            if context.hasChanges && !context.save(&error){
                println("Save context error occurred: (error?.userInfo)")
            }else{
                result = true
            }
        }else{
            let errorCode = kCSErrorLocalStorageCode
            let userInfo = [NSLocalizedDescriptionKey : "Managed context is nil"]
            error = NSError.errorWithDomain(kCSErrorDomain, code: errorCode, userInfo: userInfo)
        }
        return (result, error)
    }
    
    //Singleton Instance
    class func sharedInstance() -> CSDataManager{
        struct wrapper{
            static var shared_instance: CSDataManager? = nil
            static var token: dispatch_once_t = 0
        }
        
        dispatch_once(&wrapper.token, {wrapper.shared_instance = CSDataManager()})
        return wrapper.shared_instance!
    }
}

За основу брался автоматически код, который генерит XCode — то есть его можно считать в какой-то мере эталонным. Из интересного в плане обучения языку, в этом файле я бы для себя выделил:

  • работа с глобальными константами
  • работа со свойствами класса
  • статические методы класса (старые добрые class object methods)
  • хранимые процедуры (допилил напильником свойство storeCoordantor, так как постоянно менял в будущем модель данных и это обеспечивало автоматическое затирание файла БД по необходимости)
  • работа с Tuples на примере модифицированного метода сохранения контекста
  • работа с NSNotificationCenter

Косяк №2

Работа со свойствами — очень мне не нравится. Пример основан на том, что предлагает сам Xcode по умолчанию — соответственно я делаю вывод, что это лучшее из существующих решений. Конкретно не нравится — необходимость напрямую объявлять внутреннюю переменную для хранения (раньше это работало под капотом). При этом сами переменные не смотря на ведущее подчеркивание впереди остаются видимыми извне — и у нас по факту получается, что в инспекторе файла видны по два свойства для каждой из наших задач. Итого суммарно мне не нравится:

  • Необходимость явно дублировать свойства для решения подобных задач
  • Невозможность создания просто внутренних переменных
  • Невозможность создания внутренних свойств — раньше решалось будем определения property внутри файла реализации, а не заготовочного файла

image

Косяк №3
Паттерн синглетона реализован через жесткий костыль с использованием структуры внутренней. По идее это должно решаться простым образом через использованием переменных класса (class var), которые заявлены в спецификации языка — но де-факто компилятором еще не поддерживаются. Грусть, печаль — ждем исправлений. Также в текущей версии языка по-прежнему (в сравнении с Objective-C) нельзя обозначить инициализатор класса как private метод, в результате чего сделать чистый устойчивый к идиоту синглетон по-прежнему невозможно.

Косяк №4 или фича, не знаю

Стоит также обратить внимание на то, как работает обращение к NSNotificationCenter. Здесь есть один простой момент. Apple пишет, что все системные библиотеки (UIKit, Foundation, CoreData и т.д.) уже успешно в полной мере дружат с Swift. Однако на деле оказывается это не совсем так, или так, но не совсем. А именно — под капотом NSNotificationCenter работает на чистом Objective-C, скорее всего для совместимости со всем остальным вашим кодом. По этой причине в его применении есть ряд нюансов и ограничений:

Момент один

Для того, чтобы наш код нормально работал с Objective-C вызовами, нам надо его сделать совместимым — здесь в целом все по инструкции. Добавляем к названию класса и нужным нам методам волшебные атрибуты @objc() — например вот это часть:

@objc(CSDataManager)
class CSDataManager:NSObject {
...
@objc(appDidEnterBackground)
func appDidEnterBackground(){
...
Момент два

Логичным было бы подвязать вызов от центра уведомлений на сам метод saveContext — но поскольку он у нас возвращает Tuple, то сделать мы этого не можем, подобные конструкции не определены в Objective-C. Из-за этого мы используем костыль с вызовом простого void метода. В принципе здесь все по дзену — нет, так нет. Но в голове такие штуки при проектировании своего продукта, стоит иметь ввиду.

Создаем модель данных

Здесь все тривиально стандартными средствами XCode создаем нашу модель данных — в итоге получаем что-то такое.
image

Собственно проблема

А в чем собственно заключается проблема. Она простая — в XCode 6-Beta сломана кодогенерация для классов наследников от NSManagedObject. Точнее генерирутся код на Objective-C, а не на Swift, но это как-то не комильфо вобщем.
Итак если кратко, какие тут есть решения еще раз:

  • Вариант A. Используем нормальную кодогенерацию, получаем на выходе привычные рабочие файлы Objective-C и через Bridging файл используем их в нашем Swift-коде основном. Как использовать Objective-C классы кастомные в Swift можно прочитать здесь — Swift and Objective-C in the Same Project
  • Вариант B. Пытаемся сделать наоборот — будем писать Swift класс, который сможет вести себя как Objective-C класс и при этом будет валютно работать с CoreData. Для этого берем в правую руку напильник, а в левую крайне краткий гайд от Apple — Writing Swift Classes with Objective-C Behavior

Следуем инструкции

Рассмотрим сначала работу только с одной сущностью «Департамента», к отношениям вернемся чуть позже. Итак, следуя инструкции от Apple буква за буквой мы приходим вот к такому файлику для описания класса CSDepartment:

import Foundation
import CoreData
import UIKit

class CSDepartment : NSManagedObject{
    @NSManaged var title: NSString
    @NSManaged var internalID: NSNumber
    @NSManaged var phone: NSString?
}

А проверять всю работу будем вот таким кодом, который я оставил у себя в AppDelegate для простоты (потом он кстати у нас изменится на боле корректную версию).

func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: NSDictionary?) -> Bool {
    //Get manager
    let manager = CSDataManager.sharedInstance()
    
    //Create new department
    let newDepartment : AnyObject! = NSEntityDescription.insertNewObjectForEntityForName("CSDepartment", inManagedObjectContext: manager.managedContext)
    
    //Save context
    manager.saveContext()
    
    //Get and print all departments
    let request = NSFetchRequest(entityName: "CSDepartment")
    let departments = manager.managedContext.executeFetchRequest(request, error: nil)
    println("Departments: (departments)")
    
    return true
}

Запускаем, смотрим логи и печалимся. Важные моменты:

  • В первую очередь мы получаем ошибку сохранения данных в CoreData по причине того, что ряд полей у нас выставлены как обязательные (title и internalID), а мы их не указали. Здесь типовое решение выставить некоторые значения по умолчанию в файлике модели или использовать метод awakeFromInsert. Первое решение рабочее, но не спортивное, второе нерабочее в таком виде — метод просто не вызывается, что навело меня в уныние.
  • Второй важный момент, в логах мы видим что-то примерно такое Departments:
    [<NSManagedObject: 0xb264030> (entity: CSDepartment; id: 0xb264090 <x-coredata:///CSDepartment/t3403D9E7-F910-4E2D-989E-95D9C984C1762> ; data: {employees =     (); internalID = nil;phone = nil;title = nil;})] 
    

    Из этого лога следует важный вывод — наша переменная инстанцировалась как экземпляр NSManagedObject, а не как CSDepartment. В результате чего указать значения полей мы также не можем — так как жесткое приведение типов в духе

    let newDepartment : CSDepartment = NSEntityDescription.insertNewObjectForEntityForName("CSDepartment", inManagedObjectContext: manager.managedContext) as CSDepartment
    

    не сработает, приложение просто вылетает.

Пускаем в ход напильник

Побродив по сети, я нашел ряд телодвижений, которые приводят к тому, чтобы код стал рабочим. Итак, что надо сделать:

Шаг 1. Совместимость с Objective-C.

Где-то под капотом у нас снова работает чистый Objective-C вызовы, так что нам надо сделать наш новый класс совместимым с Objective-C вызовами. Делаем это уже привычным образом за счет директивы @objc()

Шаг 2. Шлифуем файл модели.

Это неочевидный шаг — нам надо снова выбрать файлик нашей модели и врукопашную прописать в конфигурации модели какой класс использовать для отображения сущности. image

Шаг 3. Косметический.

После двух предыдущих шагов, все должно заработать, но я еще таки добавил метод awakeFromInsert, который теперь также заработал. И также добавил метод description, чтобы в лог выводилась более красивая и понятная строка данных.
В результате код нашего класса стал выглядеть таким образом:

import Foundation
import CoreData
import UIKit

@objc(CSDepartment)
class CSDepartment : NSManagedObject{
    @NSManaged var title: NSString
    @NSManaged var internalID: NSNumber
    @NSManaged var phone: NSString?
    
    override func awakeFromInsert() {
        self.title = "New department"
        self.internalID = 0
    }
    
    func description() -> NSString{
        return "Department: className=(self.dynamicType.description()), title=(self.title), id=[(self.internalID)] and phone=(self.phone)"
    }
}

Снова прогоняем наши тесты — все работает, можно радоваться.

Работаем с отношениями

Итак с тривиальными сущностями разобрались. По аналогии можно сделать и описание сущности CSEmployee. Осталось сделать только одну вещь — заставить нашу систему работать корректно с сущностями — уметь добавлять и удалять связи. Связь между департаментом и сотрудниками у нас вида один-ко-многим. Здесь новый язык и XCode повели себя двояко.
Для реализации связи от сотрудника к департаменту все оказалось тривиально — просто добавляем в список его свойств еще одно, которое указывает на сам департамент. Итого класс сотрудника у нас начал выглядеть вот таким образом (от себя еще добавил разданную генерацию имени и фамилии из глобальных массивов):

import Foundation
import CoreData

let st_fNames = ["John", "David", "Michael", "Bob"]
let st_lNames = ["Lim", "Jobs", "Kyler"]

@objc(CSEmployee)
class CSEmployee:NSManagedObject{
    @NSManaged var firstName: NSString
    @NSManaged var lastName: NSString
    @NSManaged var age: NSNumber?
    @NSManaged var department: CSDepartment
    
    override func awakeFromInsert() {
        super.awakeFromInsert()
        self.firstName = st_fNames[Int(arc4random_uniform(UInt32(st_fNames.count)))]
        self.lastName = st_lNames[Int(arc4random_uniform(UInt32(st_lNames.count)))]
    }
    
    func description() -> NSString{
        return "Employee: name= (self.firstName) (self.lastName), age=(self.age) years" 
    }
}

А вот для реализации поддержки механизма на стороне департамента пришлось взять напильник в руку покрепче — так как опять таки ввиду сломанной кодогенерации волшебные методы для добавления дочерних сущностей не были созданы. Итого делаем следующую вещь:

  • Добавляем свойство для хранения сотрудников с классом NSSet
  • Добавляем самописные методы для добавления и удаления сотрудников — делалось на основе сниппетов XCode для версии на Objective-C

В итоге наш класс стал выглядеть следующим образом:

import Foundation
import CoreData

@objc(CSDepartment)
class CSDepartment : NSManagedObject{
    @NSManaged var title: NSString
    @NSManaged var internalID: NSNumber
    @NSManaged var phone: NSString?
    @NSManaged var employees: NSSet
    
    override func awakeFromInsert() {
        self.title = "New department"
        self.internalID = 0
    }
    
    func description() -> NSString{
        let employeesDescription = self.employees.allObjects.map({employee in employee.description()})
        return "Department: title=(self.title), id=[(self.internalID)], phone=(self.phone) and employees = (employeesDescription)"
    }

    //Working with Employees
    func addEmployeesObject(employee: CSEmployee?){
        let set:NSSet = NSSet(object: employee)
        self.addEmployees(set)
    }
    
    func removeEmployeesObject(employee: CSEmployee?){
        let set:NSSet = NSSet(object: employee)
        self.removeEmployees(set)
    }
    
    func addEmployees(employees: NSSet?){
        self.willChangeValueForKey("employees", withSetMutation: NSKeyValueSetMutationKind.UnionSetMutation, usingObjects: employees)
        self.primitiveValueForKey("employees").unionSet(employees)
        self.didChangeValueForKey("employees", withSetMutation: NSKeyValueSetMutationKind.UnionSetMutation, usingObjects: employees)
    }
    
    func removeEmployees(employees: NSSet?){
        self.willChangeValueForKey("employess", withSetMutation: NSKeyValueSetMutationKind.MinusSetMutation, usingObjects: employees)
        self.primitiveValueForKey("employees").minusSet(employees)
        self.didChangeValueForKey("employees", withSetMutation: NSKeyValueSetMutationKind.MinusSetMutation, usingObjects: employees)
    }
}

Итоговый код проверки работы и оставшиеся проблемы

В конечном счете были внесены такие коррективы:
В класс CSDataManager внесены два метода для получения полного списка департаментов и сотрудников
Изменен код примера — посмотрели фичи добавления, удаления объектов и каскадного удаления
Итак итоговый код AppDelegate:

import UIKit
import CoreData

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
                            
    var window: UIWindow?

    func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: NSDictionary?) -> Bool {
        //Get manager
        let manager = CSDataManager.sharedInstance()
        
        //Testing insert new objects
        let newDepartment : CSDepartment = NSEntityDescription.insertNewObjectForEntityForName("CSDepartment", inManagedObjectContext: manager.managedContext) as CSDepartment
        let newEmployee: CSEmployee = NSEntityDescription.insertNewObjectForEntityForName("CSEmployee", inManagedObjectContext: manager.managedContext) as CSEmployee
        let newEmployee2: CSEmployee = NSEntityDescription.insertNewObjectForEntityForName("CSEmployee", inManagedObjectContext: manager.managedContext) as CSEmployee
        newEmployee.department = newDepartment
        newDepartment.addEmployeesObject(newEmployee2)
        manager.saveContext()
        
        //Get and print all departments
        println("Have add oen department and two employees")
        println("Departments: (manager.departments())")
        println("Employees: (manager.employees())")
        
        //Testing remove child object
        newDepartment.removeEmployeesObject(newEmployee2)
        manager.saveContext()
        println("Have delete one employee")
        println("Departments: (manager.departments())")
        
        //Testing cascade remove
        manager.managedContext.deleteObject(newDepartment)
        manager.saveContext()
        println("nHave delete department")
        println("Departments: (manager.departments())")
        println("Employees: (manager.employees())")
        
        //Uncomment to remove all records
//        let departments = manager.departments()
//        for i in 0..departments.count{
//            let dep = departments[i] as CSDepartment
//            manager.managedContext.deleteObject(dep)
//        }
//        let employees = manager.employees()
//        for i in 0..employees.count{
//            let emp = employees[i] as CSEmployee
//            manager.managedContext.deleteObject(emp)
//        }
//        manager.saveContext()
//        println("nHave delete all data")
//        println("Departments: (manager.departments())")
//        println("Employees: (manager.employees())")
        
        return true
    }
}

Найдена существенная бага — каскадное удаление объектов происходит на ура, а вот при удалении сотрудников из департамента с помощью методов removeEmployeesObject и removeEmployees на дочерних объектах не происходит сброса указателей на департамент и соответственно объекты по-прежнему валидно крадутся системой в хранилище.

Заключение

В общем работать можно, но пока не без боли в душе. Напильник всегда придется держать под рукой. Буду рад комментариям, поправкам и свободной дискуссии в поисках истинного пути самурая.

Автор: KoNEV

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js