Core Data в деталях

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

    На этом все.

    Спасибо всем, кто дочитал до конца.
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 6

      +1
      Вы пишете:
      Классы, наследуемые, от NSManagedObject (таблицы), не имеют «обычного» конструктора, в отличии от остальных классов. Чтобы создать объект типа Company, нужно написать достаточно неповоротливую конструкцию с использованием NSEntityDescription.

      Начиная с iOS 10 появилась возможность создать экземпляр NSManagedObject через конструктор указывая только NSManagedObjectContext:
      let company = Company(context: persistentContainer.viewContext)
        0
        Спасибо, это очень важное замечание, использовать «конструктор» удобнее и безопаснее, так как не требуется выполнять приведение типов. Уж не знаю, как и почему я упустила эту важную деталь, внесу исправления в статью по вашему комментарию.
        0
        Спасибо за хорошую статью. Название статьи противоположно содержанию), деталей оч мало, везде совсем по чуть-чуть и нет реальных проблемы из продакшена, например кодогенерация не всегда работает очевидно. Еще было бы оч интересно почитать по утечки памяти при использовании циклических связей. Рекомендую книгу от objc.io Core Data. Там авторы не рекомендуют использовать parent сущности, так как все entities с одинаковым парентом будут хранится в одной таблице, что может привести к performance issues
          0
          У меня не было намерений описывать все детали CoreData. Это невозможно в одной статье. Здесь набор пунктов, «которые я считаю важными или недостаточно освещенными в туториалах».
          Про циклические связи я не писала. Инверсия — это не циклическая связь, она обеспечивает внутренний механизм кеширования и докачки данных в CoreData, и никак не меняет связанность таблиц. Если же вы реально используете циклические связи в базе — то это ваше право и ваши проблемы.
          Про Parent Entity я не писала, и не рекомендовала их использовать. Это полезная вещь, которая может уменьшить количество кода. Но основная проблема — это усложнение понимания структуры данных. Так что здесь нужно тщательно взвешивать свое решение. Использовать Parent Entity имеет смысл только, если у вас действительно большое количество кода в классе, и, главным образом, много совпадающих полей. Есть также несколько статей, о проблемах с performance при использовании Parent Entity. Я этот момент не проверяла лично, допускаю, что такое возможно. Но в целом, при правильном проектировании базы, выборки из одной или из двух таблиц, или фильтры не должны серьезно влиять на производительность. Даже если у вас миллионы записей в локальной базе (что очень маловероятно), вы вряд ли сможете почувствовать разницу, разбив одну таблицу на две или наоборот.
            0
            Спасибо за ответ. Циклической я назвал инверсию так как есть два объекта и каждый ссылается на другого, соответственно если Вы используется инверсию, у Вас могут происходить memory leak'и. Об этом есть здесь developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CoreData/MO_Lifecycle.html в разделе Breaking Strong References Between Objects.
            Немного не так понял раздел Вашей статьи «Наследование», почему-то подумал что это про parent entity, май бэд.
              0
              В CoreData создание связи между объектами всегда сопровождается созданием инверсии. Поэтому фраза «если вы используете инверсию» аналогична фразе «если вы используете CoreData». Если у вас есть какой-то интересный опыт с CoreData memory management, и вы знаете в каких ситуациях возникают утечки и как этого избежать — напишите отдельную статью или подробный комментарий, будет интересно.

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

        Самое читаемое