Недавно я начала работать над большим проектом с использованием Core Data. Обычное дело, что люди на проектах меняются, опыт теряется, а нюансы забываются. Углубить всех в изучение конкретного фреймворка невозможно — у всех полно своих рабочих проблем. Поэтому я подготовила небольшую презентацию, из тех пунктов, которые считаю важными или недостаточно освещенными в туториалах. Делюсь со всеми и надеюсь, что это поможет писать эффективный код и не делать ошибок. Предполагается, что вы уже немного в теме.

Начну с банального.

Core Data – это фреймворк, который управляет и хранит данные в приложении. Можно рассматривать Core Data, как оболочку над физическим реляционным хранилищем, представляющую данные в виде объектов, при этом сама Core Data не является базой данных.

Объекты Core Data


image

Для создания хранилища в приложении используются классы NSPersistentStoreCoordinator или NSPersistentContainer. NSPersistentStoreCoordinator создает хранилище указанного типа на основе модели, можно указать размещение и дополнительные опции. NSPersistentContainer можно использовать с IOS10, дает возможность создания с минимальным количеством кода.

Работает это следующим образом: если по указанному пути существует база данных, то координатор проверяет ее версию и, при необходимости, де��ает миграцию. Если база не существует, то она создается на основании модели NSManagedObjectModel. Чтобы все это работало правильно, перед внесением изменений в модель создавайте новую версию в Xcode через меню Editor -> Add Model Version. Если вывести путь, то можно найти и открыть базу в эмуляторе.

Пример с NSPersistentStoreCoordinator
var persistentCoordinator: NSPersistentStoreCoordinator = {
        
    let modelURL = Bundle.main.url(forResource: "Test", withExtension: "momd")
    let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL!)
    let persistentCoordinator = NSPersistentStoreCoordinator(managedObjectModel: 
                                            managedObjectModel!)
    let documentsPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, 
                                            .userDomainMask, true)[0]
    let storeURL = URL(fileURLWithPath: documentsPath.appending("/Test.sqlite"))
    print("storeUrl = \(storeURL)")
    do {
        try persistentCoordinator.addPersistentStore(ofType: NSSQLiteStoreType,
                                             configurationName: nil,
                                             at: storeURL,
                                             options: [NSSQLitePragmasOption:
                                                          ["journal_mode":"MEMORY"]])
        return persistentCoordinator
    } catch {
        abort()
    }
} ()

Пример с NSPersistentContainer
var persistentContainer: NSPersistentContainer = {
        
    let container = NSPersistentContainer(name: "CoreDataTest")
    container.loadPersistentStores(completionHandler: { (storeDescription, error) in
        print("storeDescription = \(storeDescription)")
        if let error = error as NSError? {
            fatalError("Unresolved error \(error), \(error.userInfo)")
        }
    })
    return container        
} ()


Core Data использует 4 типа хранилища:

— SQLite
— Binary
— In-Memory
— XML (только для Mac OS)

Если, например, по соображениям безопасности Вы не хотите хранить данные в файловом виде, но при этом хотите использовать кеширование в течении сессии и данные в виде объектов, вполне подойдет хранилище типа «In-Memory». Собственно, не запрещается иметь в одном приложении несколько хранилищ разного типа.

Несколько слов хочется сказать об объекте NSManagedObjectContext. Вообще, Apple дает весьма туманную формулировку для NSManagedObjectContext — среда для работы с объектами Core Data. Все это от желания отмежеваться от ассоциаций с реляционными базами, и представить Core Data, как простое в использовании средство, не требующее понимания ключей, транзакций и прочей базданской атрибутики. Но на языке реляционных баз NSManagedObjectContext можно, в некотором смысле, назвать менеджером транзакций. Вы, наверное, заметили, что он имеет методы save и rollback, хотя скорее всего вы пользуетесь только первым.

Недопонимание этого простого факта, приводит к использованию одноконтекстной модели, даже в тех ситуациях, где ее недостаточно. К примеру, вы редактируете большой документ, и при этом вам потребовалось загрузить пару справочников. В какой момент вызывать save? Если бы мы работали с реляционной базой, то тут бы не возникло вопросов, поскольку каждая операция выполнялась бы в своей транзакции. В Core Data тоже есть вполне удобный способ для решения этой проблемы — это ответвление дочернего контекста. Но к сожалению, это почему-то используется редко. Вот тут есть неплохая статья на эту тему.

Наследование


По непонятной мне причине, существует очень большое количество мануалов и примеров, где никак не используется наследование для Entity/NSManagedObject(таблиц). Между тем, это очень удобный и��струмент. Если вы не используете наследование, то присваивать значения атрибутам (полям) можно только через KVС механизм, который, не выполняет проверку имен и типов атрибутов, а это легко может привести к ошибкам времени выполнения.

Переопределение классов для NSManagedObject делается в дизайнере Core Data:

image

Наследование и кодогенерация


После указания названия класса для Entity, можно воспользоваться кодогенерацией и получить класс с готовым кодом:

image

image

Если вы хотите посмотреть автогенерируемый код, но при этом, не хотите добавлять файлы в проект, можно воспользоваться другим способом: установить у Entity опцию «Codegen». В этом случае код нужно поискать в ../DerivedData/…

image

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

Вот примерно такой код будет создан:

@objc public class Company: NSManagedObject {
    
    @NSManaged public var inn: String?
    @NSManaged public var name: String?
    @NSManaged public var uid: String?
    @NSManaged public var employee: NSSet?  
}

В swift @NSManaged имеет тот же смысл, что и dynamic в Objective C.
Core Data сама заботится о получении данных (имеет внутренние аксессоры) для атрибутов своих классов. Если у вас есть транзитные поля, то нужно добавлять функции для их расчета.

Классы, наследуемые, от NSManagedObject (таблицы), не имели до IOS10 «обычного» конструктора, в отличии от остальных классов. Чтобы создать объект типа Company, нужно было написать достаточно неповоротливую конструкцию с использованием NSEntityDescription. Сейчас появился более удобный метод инициализации через контекст (NSManagedObjectContext). Код ниже. Обратите внимание на преимущество наследования при присвоении атрибутов перед механизм KVC:

// 1 - создание записи через NSEntityDescription, присвоение значений через KVO
let company1 = NSEntityDescription.insertNewObject(forEntityName: "Company", into: moc)
company1.setValue("077456789111", forKey: "inn")
company1.setValue("Натура кура", forKey: "name")
               
// 2 - создание записи через NSEntityDescription, присвоение значений через свойства
let company2 = NSEntityDescription.insertNewObject(forEntityName: "Company", into: moc) 
                                                     as! Company
company2.inn = "077456789222"
company2.name = "Крошка макарошка"

// 3 - создание записи через инициализатор (IOS10+), присвоение значений через свойства
let company3 = Company(context: moc)
company3.inn = "077456789222"
company3.name = "Крошка макарошка"

Пространство имен для NSManagedObject


Еще одна вещь которая стоит отдельного упоминания — это пространство имен.

image

У вас не возникнет затруднений, если вы работаете на ObjectiveC или Swift. Обычно, это поле заполняется правильно по умолчанию. А вот в смешанных проектах, возможно, для вас станет сюрпризом, что для классов в swift и ObjectiveC нужно проставить разные опции. В Swift «Модуль» должен быть заполнен. Если это поле не будет заполнено, то к имени класса добавится префикс с названием проекта, что вызовет ошибку выполнения. В Objetive C «Модуль» оставляйте пустым, иначе NSManagedObject не будет найден при обращении к нему через имя класса.

Связи между объектами


В принципе тема связей неплохо освещена, но я хочу сделать акцент на способах добавления дочерних сущностей в родительскую. Поэтому сначала быстренько напомню механизм создания связей. Рассмотрим традиционный пример, компания — сотрудники, связь один ко многим:

  • Создаем связь на каждой стороне (таблице)
  • После этого становится доступным поле Inverse, его нужно заполнить в каждой таблице.

image

image

Apple очень настаивает на указании инверсных связей. При этом, инверсия не усиливает связанность, а помогает Core Data отслеживать ��зменения на обоих сторонах связи, это важно для кеширования и обновления информации.

Также важно правильно указывать правило удаления. Правило удаления – это действие, которое будет выполнятся с данным объектом при удалении родительского объекта.

  • Cascade — удаление всех дочерних объектов, при удалении родительского.
  • Deny — запрет удаления родителя, если есть дочерний объект
  • Nullify — обнуление ссылки на родителя
  • No action — действие не указано, выдаст предупреждении при компиляции

В указанном примере, при удалении компании все сотрудники будут удалены (cascade). При удалении сотрудника, ссылка на него в компании будет обнулена (пред экран)

Способы добавления дочерних сущностей в родительскую


1) Первый способ — это добавление через NSSet. Для примера добавим 2 сотрудника в компанию:

let set = NSMutableSet();
        
if let employee1 = NSEntityDescription.insertNewObject(forEntityName: "Employee", 
                                                         into: moc) as? Employee {
      employee1.firstName = "Дима"
      employee1.secondName = "Васильев"
      set.add(employee1)
}
        
 if let emploee2 = NSEntityDescription.insertNewObject(forEntityName: "Employee", 
                                                        into: moc) as? Employee {
       employee2.firstName = "Наташа"
       employee2.secondName = "Ростова"
       set.add(employee2)
 }
        
company.employee = set

Этот способ удобен при первичной инициализации объекта или при заполнении базы. Тут есть небольшой нюанс. Если в компании уже были сотрудники, а вы присвоили новый set, то у бывших сотрудников обнулится ссылка на компанию, но они не будут удалены. Как вариант можно получить список сотрудников и работать уже с этим set'ом.

let set = company.mutableSetValue(forKey: "employee") 

2) Добавление дочерних объектов через идентификатор родителя

if let employee = NSEntityDescription.insertNewObject(forEntityName: "Employee", 
                                                       into: moc) as? Employee {
     employee.firstName = "Маша"
     employee.secondName = "Богданова"
     employee.company = company
}

Второй способ удобен при добавлении или редактировании дочернего объекта в
отдельной форме.

3) Добавление дочерних объектов через автогенерируемые методы

extension Company {
    
    @objc(addEmployeeObject:)
    @NSManaged public func addEmployee(_ value: Employee)
    
    @objc(removeEmployeeObject:)
    @NSManaged public func removeFromEmployee(_ value: Employee)
    
    @objc(addEmployee:)
    @NSManaged public func addEmployee(_ values: NSSet)
    
    @objc(removeEmployee:)
    @NSManaged public func removeFromEmployee(_ values: NSSet)
}

Для полноты картины полезно знать про этот метод, но мне он как-то не пригодился, а лишний код я удаляю, чтобы не загромождать проект.

Запросы с условием по дочернему элементу


В Core Data вы не можете составить произвольный запрос между любыми данными, как мы это можем делать в SQL. Но между зависимыми объектами это следить несложно используя стандартный предикат. Ниже пример запроса, выбирающего все компании в который есть сотрудник с указанным именем:

public static func getCompanyWithEmployee(name: String) -> [Company] {

    let request = NSFetchRequest<NSFetchRequestResult>(entityName: self.className())
    request.predicate = NSPredicate(format: "ANY employee.firstName = %@", name)
    do {
        if let result = try moc.fetch(request) as? [Company] {
            return result
        }
    } catch { } 
    return [Company]()
}

Вызов метода в коде будет выглядеть так:

// выбрат�� комании, где работает Миша
let companies = Company.getCompanyWithEmployee(name: "Миша")

Не используйте в запросах транзитные поля, их значения не определены в момент выполнения запроса. Ошибки не произойдет, но результат будет неправильный.

Настройка атрибутов (полей)


Вы, наверное заметили, что атрибуты Entity имеют несколько опций.
C опциональностью все понятно из названия.

Oпция «использовать скалярный тип» появилась в swif. В Objective C не используются скалярные типы для атрибутов, так как они не могут принимать значение nil. Попытка присвоить скалярное значение атрибуту через KVC вызовет ошибку выполнения. Отсюда становится понятным, почему типы атрибутов в Core Data не имеют строгого соответствия с типами языка. В swift, и в смешанных проектах атрибуты скалярного типа можно использовать.

Транзитные атрибуты – это расчетные поля, которые не сохраняются в базе. Их можно использовать для шифрования. Эти атрибуты получают значения через переопределенный аксессор, либо через присваивание примитивов по мере надобности (например, в переопределенных willSave и awakeFromFetch).

Аксессоры атрибутов:


Если вам не нужно использовать расчетные поля, например, делать шифрование или что-то другое, то можно вообще не задумываться о том, чем являются аксессуары атрибутов. Между тем операции получения и присвоения значений атрибутам не являются «атомарными». Чтобы понять, что я имею ввиду смотрите код ниже:

// чтение
let name = company.name

// чтение
company.willAccessValue(forKey: "name")
let name = company.primitiveValue(forKey: "name")
company.didAccessValue(forKey: "name")

// присвоение
company.name = "Азбука укуса"

// присвоение
company.willChangeValue(forKey: "name")
company.setPrimitiveValue("Азбука укуса", forKey: "name")
company.didChangeValue(forKey: "name")

Используйте примитивы в event’ах NSManagedObject вместо обычного присваивания, чтобы избежать зацикливания. Пример:

override func willSave() {
      let nameP = encrypt(field: primitiveValue(forKey: "name"), password: password)
      setPrimitiveValue(nameP, forKey: "nameC")
      super.willSave()
  }
    
override func awakeFromFetch() {
      let nameP = decrypt(field: primitiveValue(forKey: "nameC"), password: password)
      setPrimitiveValue(nameP, forKey: "name")
      super.awakeFromFetch()
}

Если вдруг когда-то вам придется прикрутить в проект функцию awakeFromFetch, то вы удивитесь, что работает она весьма странно, а по факту вызывается она совсем не тогда, когда вы выполните запрос. Связано это с тем, что Core Data имеет весьма интеллектуальный механизм кеширования, и если выборка уже находится в памяти (например, по причине того, что вы только что заполнили эту таблицу), то метод вызываться не будет. Тем не менее, мои эксперименты показали, что в плане вычисляемых значений, можно смело положиться на использование awakeFromFetch, об этом же говорит документация Apple. Если же для тестирования и разработки вам нужно принудительно вызвать awakeFromFetch, добавьте перед запросом managedObjectContext.refreshAllObjects().

На этом все.

Спасибо всем, кто дочитал до конца.