Pull to refresh

Core Data в деталях

Reading time9 min
Views63K
Недавно я начала работать над большим проектом с использованием 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().

На этом все.

Спасибо всем, кто дочитал до конца.
Tags:
Hubs:
Total votes 11: ↑9 and ↓2+7
Comments6

Articles