Pull to refresh

Core Data в современном интерьере SwiftUI. Некоторые уточнения и заблуждения. часть 1

Reading time22 min
Views6.5K
Фреймворк Core Data, разработанный Apple для постоянного хранения данных на своих платформах, эффективно работающий даже с очень большими объемами данных, используется очень давно, с версии iOS 3 и macOs 10.4, так что прошло где-то порядка 10 лет с того момента, когда Apple впервые представила фреймворк Core Data. Когда это произошло, языка программирования Swift вообще не было в проекте, так что Core Data была спроектирована с ориентацией на Objective-C и, конечно, это отразилось на её API.



Но на WWDC 2019 был впервые представлен SwiftUI, который предложил нам новую парадигму конструирования UI, он был предложен для iOS 13 и полностью опирался на Swift, его корни — это Swift, хотя он использует UIKit “под капотом” и полностью зависит от UIKit на iOS, по крайней мере на данный момент, и от AppKit на macOS. Конечно, он это скрывает, как только может, он сконструирован и реализован с прицелом на Swift. Более того, Swift сам был существенно доработан с целью поддержки SwiftUI и стал ещё более мощным и интересным.

Но дело даже не в возрастном различии фреймворков, a в том, что Core Data принципиально связана с объектно-ориентированным программированием ( классы, наследование, делегирование и все такое), a суперсовременный SwiftUI основан на декларативном функциональном программировании (структуры, протокольно-ориентированное программирование) и имеет реактивную природу, которая воплощается в использовании архитектуры MVVM.

Apple проделала огромную работу, чтобы эти две мощные технологии прекрасно работали вместе, а это означает, что Core Data интегрируется в SwiftUI так, как будто он всегда был разработан исключительно для SwiftUI.

Отчасти это удалось потому, что язык программирования Swift поддерживает как объектно-ориентированное, так и функциональное программирование в равной степени, а, отчасти потому, что Apple удалось научить Core Data превосходно „играть“ на поле „реактивности“ SwiftUI.
Уже сейчас Core Data довольно хорошо интегрирована в SwiftUI, и со временем ситуация будет только улучшаться. Никогда не работалось с Core Data так просто и комфортно, как в SwiftUI, и я надеюсь, что сумею показать вам это.

Для того, чтобы понять, за счет чего удалось так кардинально улучшить работу Core Data в SwiftUI, давайте сначала кратко рассмотрим основы функционирования Core Data, затем поработаем с простейшим шаблоном, который Apple предлагает для работы с Core Data в SwiftUI, a в заключении рассмотрим упрощенную модификацию реального приложения Enroute из стэнфордских курсов CS193P 2020, которое оперативно подкачивает данные о рейсах, аэропортах и авиакомпаниях с сервера FlightAware, записывает данные в Core Data и позволяет оперативно выбрать любую нужную информацию по различным простым и сложным критериям. Подобное приложение необходимо для того, чтобы показать, как работать с взаимосвязями различных Core Data объектов и как динамически конфигурировать запросы @FetchRequest в SwiftUI.

Мы будем работать в Xcode 14, iOS 16, SwiftUI 4.0, так что будет показана новая Navigation система в SwiftUI и новая многопоточная Swift concurrency.

Код находится на Github.

Небольшое введение в Core Data. Что такое Core Data  стек?


Core Data — это “родное” Apple решение для постоянного хранения данных. У Core Dataесть две ответственности:
— Управление Моделью Данных в виде графа связанных объектов
— Постоянное хранение



Типичную Модель данных вы видите на двух рисунках ниже (в табличной и графической формах).




На первом рисунке представлен Рейс Flight, у него есть ряд атрибутов: время прибытия actualOn, время отправления actualOff, есть идентификатор рейса ident и даже некоторые “взаимосвязи” с другими объектами: с Аэропортом Airport в качестве аэропорт назначения destination и аэропорта отправления origin, a также с авиакомпанией Airline.

На втором рисунке вы видите графическое представление “взаимосвязей” между объектами Аэропорт Airport, Рейсом Flight и Авиакомпанией Airline.

Например, вы видите, что у Рейса Flight имеются две “взаимосвязи” с Аэропортом Airport — аэропорт вылета origin и аэропорт прилета destination. Заметьте, что обе эти «взаимосвязи» действуют и в обратную сторону для объекта Airport в виде рейсов flightsFrom, вылетающих из этого аэропорта, и рейсов flightsTo, прибывающих в этот аэропорт.

“Взаимосвязи” origin и destination для объекта Flight являются просто объектами Airport, а «взаимосвязи» flightsFrom и flightsTo для объекта Airport представляют собой множества NSSet рейсов Flight. В Core Data достаточно изменить лишь одну сторону «взаимосвязи», другая сторона будет формироваться автоматически.

Но этого недостаточно. Core Data должна постоянно хранить эту связанную информацию на диске и уметь записывать и читать её в зависимости от запросов пользователя. И за это ответственна вторая часть Core Data — взаимодействие с постоянным хранилищем.

В вашем коде управление графом объектов осуществляется через контекст Managed Object Context. Внутри этого контекста “живет” множество объектов Managed Object.



Что я имею в виду под Managed Object объектами?

Если мы обратимся в упомянутому выше примеру с рейсами Flight, аэропортами Airport и авиакомпаниями AirLine, то с точки зрения контекста Managed Object Context это множество Managed Object объектов. Вам не нужно создавать структуры struct для Аэропорта, Рейса и Авиакомпании, Xcode автоматически генерирует «за кулисами» для вас ManagedObject классы class для этих объектов, которые в дальнейшем будут принадлежать хотя бы одному контексту Managed Object Context в вашем коде и ответственность за их изменение и постоянное хранение берет на себя Core Data. Вполне допустимо иметь множество контекстов ManagedObjectContext, но это редко используется и требует некоторых продвинутых навыков.

Вы будете работать с редактором Модели Данных, встроенном в Xcode, а сама Модель данных хранится в файле с расширением .xcdatamodeld. Вам только необходимо убедиться, что ваш рабочий контекст Managed Object Context знает о файле с Моделью данных.

Помимо управления графом связанных объектов, у нас есть еще слой “постоянного хранения” Persistence. В слое “постоянного хранение” Persistence присутствует хранилище, и Core Data отвечаем за хранение наших данных в этом хранилище и за восстановления их в нужном формате.

За коммуникацию между этими двумя важными элементами в Core Data отвечает координатор PersistenceStoreCoordinator:



Все вышеперечисленное — контекст Managed Object Context, “постоянное хранилище” Persistence и координатор PersistenceStoreCoordinator — называется Core Data стеком.

Со временем работа с Core Data стеком постоянно улучшалась, но в SwiftUI мы имеем возможность работать с Core Data стеком, практически не замечая его.

Какие изменения Core Data в SwiftUI?





В SwiftUI изменилось несколько вещей:

1. ManagedObject объекты Core Data теперь реализуют ObservableObject и Identifiable протоколы. Наши Core Data объекты — рейс Flight, аэропорт Airport и авиакомпания AirLine — теперь могут автоматически обновлять наши SwiftUI Views как обычные @ObservedObject или @StateObject объекты. По существу, теперь эти Core Data объекты превратились в миниатюрные ViewModel, которые мы можем напрямую использовать в наших SwiftUI Views. Кроме того, все свойства этих объектов являются @Published свойствами, а это супер удобно, так как любое их изменение также приводит к автоматическому обновлению SwiftUI Views. Объекты Core Data также стали Identifiable, что позволяет использовать их без каких-либо затруднений в таких SwiftUI конструкциях, как ForEach и List.

2. добавлена новая “Обертка Свойства” (Property Wrapper) @FetchRequest, которая позволяет очень просто выбирать данные из Core Data и очень просто отображать их на UI, это маленький „шедевр“, изобретенный Apple для работы Core Data с декларативным SwiftUI. Это НЕ одноразовая выборка данных, @FetchRequest постоянно постоянно обеспечивает наш UI актуальными данными. Таким образом, ваш UI всегда будет синхронизирован с базой данных. Это полностью отвечает реактивной “природе” SwiftUI. В iOS 15 ещё добавлена возможность динамического обновления @FetchRequest, то есть динамического обновления предиката запроса nsPredicate и дескрипторов сортировки sortDescriptor. Там также появилась поддержка секций с помощью @SectionedFetchRequest.

3. контекст managedObjectContext теперь включен в “среду” обитания вашего приложения @Environment, так что передав его в топовый View с помощью модификатора .environment(\.managedObjectContext,...), вы автоматически получаете его во всех дочерних Views.

4. появилась возможность создания “фантомной” (в памяти) базы данных Core Data для предварительных просмотров Preview в SwiftUI и для тестирования.

Так что Apple определенно много думала об интеграции Core Data и SwiftUI. Цель моей статьи — показать вам комфортную работу Core Data в современном SwiftUI приложении.

Сначала мы сосредоточимся на том, как инженеры Apple предлагают использовать SwiftUI + Core Data, чтобы понять, что Apple считает хорошим или достаточно хорошим на данный момент для таких приложений. Мы создадим проект, чтобы лучше понять, как устанавливается контекст Managed Object Context, на котором и будут разворачиваться все действия с вашими данными. Как Apple манипулирует записями, то есть как создает (Create), читает (Read) и удаляет (Delete) записи из Core Data хранилища. Для создания полноценного CRUD приложения в шаблоне, предложенном Apple, не хватает только одной операции — обновления (Update). Мы ее добавим и поработаем с выборкой данных из Core Data, представленной “оберткой свойства” @FetchRequest. В ходе этого я немного усовершенствую код шаблона Apple в части использования расширения extention классов class, сгенерированных Xcode для объектов Core Data, с целью придания им дополнительной функциональность с помощью „синтаксического сахара“. И покажу, что архитектура MVVM для Core Data + SwiftUI проектов заключается вовсе не в создании какой-то глобальной ViewModel для всего приложения в целом, a в настройке уже имеющихся мини-ViewModels самих объектов Core Data. Недооценка этого факта сильно ухудшает и запутывает структуру Core Data + SwiftUI проектов и читаемость его кода.

Я, как и вы, понимаю, что выбранный Apple дизайн приложения Core Data + SwiftUI может быть не идеальным с точки зрения архитектуры MVVM. Вам может показаться, что с высоты идеологии SwiftUI удастся кардинально усовершенствовать подход Apple. Но если вы решились на это, то убедитесь, что четко знаете, что делаете.

Я видела, что многие люди пишут абстракции относительно Core Data после одного дня попыток, и это лучший способ получить проблемы в будущем, потому что Core Data — это довольно большой и заточенный на эффективность как по времени, так и по памяти фреймворк, не сказать, что уж очень сложный, но создавать абстракцию того, что вы не полностью понимаете, всегда сложно. Мы рассмотрим некоторые очевидные заблуждения такого рода, которыми кишит youtube.com и некоторые курсы на Udemy. Так что чем больше мы боремся с выбранным Apple способом интеграции Core Data в SwiftUI, тем более вероятно, что столкнемся с проблемами в будущем. Уберечь вас от подобного рода проблем тоже является моей задачей.

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

Добавление Core Data в SwiftUI приложение


Используем менюFile-> New -> Project и добавим новый проект, в котором отметим галочкой опцию Use Core Data:



Когда вы это сделаете, Xcode создаст в вашем проекте файл Модели данных с расширением .xcdatamodeld, в нашем случае это файл CoreDataTest.xcdatamodeld. В этом файле вы создаете свои объекты (или сущности, как их называют в Базах Данных) и их атрибуты. У Xcode есть встроенный редактор для Модели данных. Он также позволяет вам графически создавать “взаимосвязи” между разными объектами (сущностями), хотя последнее мы не увидим в нашем шаблоне, так как у нас очень простая Модель данных, состоящая всего из одной сущности Item (отметки времени), у которой всего один атрибут — собственно сама временная отметка timestamp, имеющая ТИП Date:



«Взаимосвязи» вы увидите во второй части этой статьи при работе с реальными объектами: аэропортами Airport, рейсами Flight и авиакомпаниями Airline. Более подробную информацию о работе с Моделью данных в Core Data можно получить в материалах Стэнфордского курса CS193P.

Для того, чтобы с объектами, которые вы задали в редакторе Модели данных, можно было работать в коде, Xcode “за кулисами” автоматически генерирует NSManagedObject классы class с переменными vars, соответствующими атрибутам этих объектов.

Убедиться в этом мы можем, если пойдем в ContentView, в FetchedResults command-кликнем на Item и перейдем на определение этого объекта:



Вы видите, что для отметки времени Item создан специальный класс class Item, который наследует от NSManagedObject, при чем этот файл автоматически сгенерирован Xcode, и вы не можете его редактировать в любом случае, потому что он read-only:



К счастью, эти сгенерированные классы class являются ObservableObject и Identifiable, представляя собой, по существу, миниатюрные ViewModels, и они служат прекрасным “источником истины” (source of truth) для элементов нашего UI в SwiftUI.

Мы можем использовать расширения extension этих классов class так же, как мы делаем это с любыми другими структурами данных в Swift, чтобы добавить туда наши собственные методы и вычисляемые переменные vars.

Получаем контекст Core Data в PersistenceСontroller


Для работы в коде с Core Data объектами нам нужен контекст Managed Object Context. Для этого Apple создала в нашем приложении вспомогательную структуру struct c именем PersistenceController в файле Persistence.swift:



При инициализации init структуры PersistenceController используется другая вспомогательная структура — контейнер let container: NSPersistentContainer, который согласно документации упрощает создание Core Data стека и позволяет легко извлечь из него контекст viewContext для работы с Core Data объектами. Именно через контейнер container попадает в наш PersistenceController Модель данных, в которой мы настраиваем сущности и их атрибуты и которая существует в виде файла с расширением .xcdatamodeld. Вот так все просто.



Структура PersistenceController предоставляет в наше распоряжение две базы данных Core Data, два static свойства: let shared и var preview.



Экземпляр shared — это полноценная Core Data с хранением на диске, a preview — это некоторая “фантомная” Core Data, которая хранится в специальном /dev/null файле, и годится исключительно для предварительного просмотра Preview и тестирования.

Примечание. /dev/null — специальный файл в системах класса UNIX, представляющий собой так называемое «пустое устройство» или “черную дыру”. Запись в него происходит успешно, независимо от объёма «записанной» информации.

Поэтому инициализатор init PersistenceController принимает Bool аргумент inMemory для указания того, где находится хранилище Core Data, “в памяти” или на диске.

Безусловно, структура struct PersistentController создана с одной единственной целью — облегчить получение контекста viewContext для базы данных Core Data: реальной shared или “фантомной” preview.

Надо отметить, что база данных preview в шаблоне заполняется данными, то есть туда добавляются экземпляры сущности Item, которой, вероятно, не будет в вашем проекте:



Поэтому обязательно замените это на код, который создает фиктивные версии вашей собственной сущности, иначе проект не будет компилироваться, a лучше разделите общее preview на несколько специализированных preview для отдельных View, но об этом позже.

Core Data в SwiftUI Views


Теперь нужно сделать так, чтобы наши SwiftUI Views узнали о полученном контексте базы данных Core Data, и это осуществляется через “среду” обитания SwiftUI приложения @Environment и её переменную \.managedObjectContext.

В главном файле приложения CoreDataTestApp.swift мы передаем с помощью модификатора .environment на самый верхний уровень иерархии Views наш полученный из контейнера container контекст persistenceController.container.viewContext “окружающей среде” @Environment(\.managedObjectContext). Когда вы передаете “среду обитания” @Environment некоторому View, то все Views, которые находятся в его body, получают ту же самую “среду” @Environment. Так что вам не нужно передавать её дальше по иерархии Views.



Исключение составляют модальные View наподобие .sheet или .popover, когда вы покидаете ваше базовое body и оказываетесь в новом базовомbody. В этом случае вам необходимо отдельно передавать “среду” @Environment с помощью модификатора .environment.

Итак, посмотрим на топовое ContentView в нашем шаблоне.



В топовом View мы читаем контекст managedObjectContext из “среды” обитания @Environment(\.managedObjectContext) и сохраняем его как private переменную var с именем viewContext.

Мы также получаем совершенно замечательную “Обертку Свойства” (Property Wrapper) @FetchRequest, которая имеет несколько инициализаторов init, и нам представлен один из них, наименее мною любимый, в который мы передаем только дескрипторы сортировки sortDescriptors, чтобы указать, как выбранные данные должны быть отсортированы. Вы также можете передать @FetchRequest полноценный запрос fetchRequest с предикатом nsPredicate и дескрипторами сортировки sortDescriptors, в котором отразите все требования к выборке нужных данных Core Data, которые будут находиться в вашей переменной var items.

@FetchRequest, по существу, является “живым” запросом, то есть переменная var items ВСЕГДА отражает актуальное состояние базы данных. И это действительно одна из лучших интеграций SwiftUI и Core Data, которая состоит в способности @FetchedResult переменных vars всегда поддерживать себя в актуальном состоянии.

Так что использование массива items в ForEach будет обновлять наш UI каждый раз, когда будут добавляться новые или удаляться существующие отметки времени items, и это будет происходить автоматически.

С помощью кнопок на навигационной панели …



… мы можем добавлять …



… и удалять отметки времени Item



… и везде нам нужен только контекст viewContext, нам уже не нужно вспоминать о PersistentController и о том, как мы получили контекст viewContext.

Далее у нас есть предварительный просмотр Preview для нашего ContentView, и здесь используется уже не реальная база данных PersistentController.shared, a “фантомная” PersistentController.preview, уже наполненная некоторыми отметками времени:



Поэтому на Preview мы видим те 12 временных отметок, которые мы разместили в PersistentController.preview:



Если мы запустим наше приложение на симуляторе, то оно будет работать в реальной базе данных PersistentController.shared, и мы не увидим ни одной временной отметки, так как их там никто не размещал:



Мы можем кликнуть несколько раз на кнопке “+”, чтобы добавить в нашу базу данных несколько временных отметок, можем кликнуть на кнопке “Edit” и удалить не нужную нам отметку времени. В результате мы получим две оставшиеся временные отметки и сможем. кликнув на стрелочке справа от каждой временной отметки, перейти на экран с детальным представлением временной отметки, что в нашем случае не актуально, так как у сущности Item всего один атрибут:



Вот такое простое приложение с Core Data нам предлагает Apple в качестве шаблона.

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

Для iOS 16 нам предлагается старая навигационная системы SwiftUI c NavigationView, а вместо модификатора заголовка .navigationTitle ("Select an items"), в коде шаблона присутствует Text("Select an items"), что неправильно:



Если мы заменим старый NavigationView на новый NavigationStack, Text(“...”) на модификатор .navigationTitle("Select an items"), а для NavigationLink используем новую конструкцию с navigationDestination



… то получим заголовок на навигационной панели:



“Столкновение” Core Data Optional со Swift Optional


Теперь давайте обратим внимание ещё на одну вещь — на то, как используется единственный атрибут timestamp нашего Core Data объекта Item в нашем UI.

В ContentView атрибут timestamp используется как “принудительно развернутое Optional” с восклицательным ! знаком:


.............


Это говорит о том, что атрибут timestamp является Optional атрибутом, и так оно и есть. В Модели данных Core Data есть нечто, что называется Optional значением, но оно не имеет никакого отношения к Swift Optional. Core Data понимает это по-своему и так, что при записи в базу данных объекта Item этот атрибут может вообще не иметь значения:



Когда “за кулисами” Xcode генерирует для наших Core Data объектов некоторый Swift код в виде NSManagedObject классов class c @NSManaged атрибутами …



… то в нашем случае появляется переменная @NSManaged var timestamp: Date?, которая являются Optional, но это уже полноценный Swift Optional:



Вообще это самое противоречивое Swift предложение.

Атрибут @NSManaged — это не “Обертка Свойства”, он появился задолго до SwiftUI и означает, что Core Data обращается с этим свойством так, как она считает нужным, достает его значение откуда-то на этапе runtime по требованию, она знать не знает, что такое Optional в Swift. Когда вы видите @NSManaged в коде, это красный флаг, что правила Swift здесь могут не применяться. Свойство @NSManaged не существует, когда код компилируется. Вместо этого обещано, что Core Data будет динамически добавлять это свойство при запуске приложения. В буквальном смысле Core Data изменит определение класса в памяти, чтобы добавить свойства, соответствующие Модели данных, а вы обещаете, что свойства будет того же типа. Такого рода магия встроена в Objective-C, a это, вероятно, означает, что Core Data никогда не будет полностью Swiftified без изменений кода.

С другой стороны, Swift также знать не знает о Модели данных и о том, как там представлен атрибут timestampOptional или нет, он знает только, что это значение может быть равно nil. Так что мы имеем разговор глухого со слепым.

Поэтому Xcode всегда использует Swift Optional значения в сгенерированном коде, так как это самый безопасный способ справиться с разницей Optional в Swift и Core Data. Если свойство является Optional для Swift, то он может безопасно обрабатывать более слабые ограничения Core Data. Здесь имеет место то, что называется “столкновением” Optionals. Я не хочу вдаваться в подробные причины этого столкновения, об этом можно почитать в Столкновение Optionals и здесь.

На самом деле проблема не в Optional атрибутах, a как раз в НЕ-Optional атрибутах Core Data объектов, когда вы точно знаете, что ваш атрибут НЕ является Optional и должны как-то гарантировать, что при записи в Core Data он всегда должен иметь значение.

Типичная ошибка: разработчик работает над моделью Core Data. В редакторе моделей есть флажок «Optional», который может быть включен или выключен. Ах, думает разработчик, это поле никогда не должно быть nil (неопределенным), поэтому я отключу установку «Optional».



Но затем Xcode “за кулисами” генерирует для него некоторый код, и отмеченный вами как НЕ-Optional атрибут выглядит в Swift коде как Optional, чтобы вы не поставили в Модели данных:

@NSManaged var timestamp: Date?

Модель данных и сгенерированный Swift код являются совершенно отдельными вещами, но они должны объявлять один и тот же тип данных. В противном случае ваше приложение вылетит с ошибкой, говорящей что-то вроде «Unacceptable type of value for attribute» («Недопустимый тип значения для атрибута»).

Можно ли убрать Swift Optional?

@NSManaged var timestamp: Date

Можно. Но вы только что нарушили правила инициализации переменных в Swift! В частности, вы инициализировали объект, но не предоставили значения для всех его НЕ Optional свойств. Вас никто не остановит, потому что @NSManaged говорит, что правила Swift на её территории не применяются. Таким образом, вы только что создали бомбу замедленного действия.

Мы поступим по-другому. Если атрибут Core Data объекта в действительности НЕ является Optional, то мы не будем трогать ни Модель данных, ни сгенерированный Xcode код, пусть Core Data работает в безопасном для себя режиме, a будем решать эту проблему с помощью “синтаксического сахара”, правда не того, который добавляется компилятором, а который мы добавим сами. Это было предложено Полом Хегарти в стэнфордском курсе CS193P 2020. Я собираюсь сделать переменную timestamp вычисляемой переменной var в расширении extension класса class Item:



Как я могу делать переменную timestamp вычисляемой переменной?

Ведь у моего объекта Item в Модели данных уже есть эта переменная var.

Поэтому я переименую переменную var в Модели данных и добавляю в конец имени переменной символ “_” “подчеркивания”:



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

В нашем случае это timestamp_ с символом “_” “подчеркивания” в конце имени. Это свойство является Core Data версией Optional. И get { } для моей вычисляемой переменной timestamp будет возвращать timestamp_, a если она не установлена, то текущую дату Date().

Установку set { } вычисляемой переменной timestamp также легко выполнить:



Надеюсь вы поняли, что я здесь делаю. Я предоставляю Core Data те комфортные условия работы, к которым она привыкла, a сама формирую вычисляемую НЕ Optional переменную var timestamp для работы c UI.

В результате теперь на нашем UI мы имеем дело с НЕ Optional переменной code>var timestamp и можем убрать восклицательный ! знак в Text (...) в ContentView:




Выборка данных @FetchRequest


Но есть еще одно место, где использовался атрибут Item.timestamp — это в ContentView в @FetchRequest как дескриптор сортировки. И вот здесь нам придется поступить наоборот, исправить Item.timestamp на Item.timestamp_:



В дескрипторах сортировки мы должны использовать настоящие атрибуты Core Data объектов, a не их вычисляемые аналоги. И получается, что Модель данных Core Data в чистом виде присутствует в нашем SwiftUI View.

Лучше использовать при инициализации @FetchRequest более общее понятие — запрос NSFetchRequest



… в котором можно указать и различные дескрипторы сортировки, и какой угодно сложный предикат. В нашем случае удобно разместить запрос в расширении extension класса Item для Core Data объекта:



Правило такое: все действия, сортировки, предикаты, с реальными атрибутами объекта Core Data, которые имеют символ _ (подчеркивания), лучше держать в расширении extension класса class, сгенерированного Xcode “за кулисами”:
A в SwiftUI Views лучше использовать НЕ Optional аналоги этих переменных без символа _ (подчеркивания).

Операция обновления в UpdateView и сохранение изменений


Итак, в нашем приложении мы можем создавать (Create), читать (Read) и удалять (Delete) из хранилища объекты Core Data. Для создания полноценного CRUD приложения нам не хватает операции обновления (Update).

Давайте создадим UpdateView, который позволит редактировать нашу временную отметку item.



Вместе с передаваемой в UpdateView временной отметкой item, которая является объектом Core Data, мы передаем контекст item.managedObjectContext, который позволит нам сохранить изменения этой отметки в нашей базе данных. В то же время временная отметка item является @ObservedObject переменной, и, следовательно, любые изменения её свойств будут обновлять наш UI, следовательно, мы можем рассматривать временную отметку item как своего рода миниатюрную ViewModel для нашего UpdateView:



Мы будем редактировать $item.timestamp в DatePicker и при каждом её изменении будем сохранять измененную отметку времени item в модификаторе onChange:



Здесь мы сохраняем наши изменения Core Data объекта item также, …



…как это сделала Apple в ContentView в функциях addItem



… и deleteItems:



Понятно, что мы должны где-то разместить этот повторяющийся код. Многие даже опытные разработчики размещают его почему-то в PersistenceСontroller, но мы ведь хотим уметь сохранять контекст, независимо от того, откуда он взялся, так что мы размещаем этот метод saveContext () в расширении extension класса ManagedObjectContext а в отдельном файле с именем CoreDataExtensions.swift:



Это упрощает наш код как в методе UpdateView ():



… так и в методах addItem



… и deleteItems:



Если вы не хотите обрабатывать ошибки при сохранении контекста ManagedObjectContext, то для UpdateView можно воспользоваться одной строкой кода без всяких расширений:



Для addItem и deleteItems соответственно такой строкой кода:



“Фантомные” Core Data данные в предварительном просмотре Preview


Что еще примечательного в UpdateView?

Конечно, его предварительный просмотр Preview, в котором в качестве тестовых данных используется “фантомная” база данных, инициированная как PersistenceController(inMemory: true) и заполненная единственной временной отметкой newItem с текущим временем:



Теперь в Preview мы можем полноценно тестировать наш UpdateView: выбирать дату, время и запоминать в item:



То же самое можно сделать с Preview для ContentView. Не будем брать уже готовые данные из static свойства var preview в PersistenceController, a опять воспользуемся новой “фантомной” базой данных и наполним её нужными нам данными:



В этом случае мы можем работать с Preview напрямую:



На самом деле не имеет смысл оставлять в PersistenceController “фантомную” базу данных static var preview, так как в каждом отдельном случае мы будем создавать её заново исключительно под нужды того или иного View:



Наконец-то наша структура PersistenceController приобретает компактный вид без единой лишней строчки кода. В PersistenceController мы будем оперировать только тремя понятиями: реальной базой данных shared, контекстом viewContext для неё и возможностью инициализировать ”фантомную” ( в памяти) базу данных с Моделью данных “CoreDataTest“.

Это немного улучшает читабельность CoreDataTestApp:



“Намерения” Intents в расширениях extension объектов Core Data и другое


Вернемся в ContentView и заменим Text (...) в .navigationDestination на UpdateView:



Попутно мы использовали новый в iOS 15 метод formatted для форматирования даты Date и это позволило нам избавиться от itemFormatter, предоставленного в шаблоне Apple:



Давайте добавим в расширении extension класса class Item, который является миниатюрной ViewModel для нашего UI, “Намерение” (Intent), связанное с с удалением ряда объектов Item:



Это упростит наш код в ContentView, и в результате мы получили полноценное CRUD приложение:



… вместе с компактной структурой PersistenceController



… и с расширением extension для Core Data объектов в качестве миниатюрной ViewModel:



Добавим последний штрих в приложение CoreDataTest и заставим его принудительно запоминать данные в Core Data в случае выхода из приложения или в случае перехода его, например, в фоновый режим:



В целом мне нравится то, что сделало Apple с установкой Core Data в приложении SwiftUI, за исключением того, что следует создавать “фантомную” базу данных для предварительного просмотра Preview каждого отдельного SwiftUI View и использовать классы class Core Data объектов как ViewModel, в которых помимо прочего можно «спрятать» с помощью “синтаксического сахара” все особенности работы Core Data в Swift коде.

Заблуждения


Вполне возможно, что внедряя контекст ManagedObjectContext в наш UI, Apple нарушает архитектуру MVVM, провозглашенную для SwiftUI, позволяя SwiftUI Views напрямую получать информацию о Модели, позволяя им знать, откуда берутся данные.

И нашлось немало желающих восстановить справедливость архитектуры MVVM и создать некую глобальную ViewModel, чтобы только она имела бы дело с Core Data, поставляя уже готовые данные в виде нужных массивов в Views. В youtube.com можно найти много видео с броским заголовком CoreData + SwiftUI + MVVM. Но если вам будут рассказывать о глобальной ViewModel, способной отделить Core Data от Views, то — не верьте, и я покажу, вам почему.

Эту идею пытались реализовать несколькими способами. Сразу скажу — все они плохие или очень плохие.

Сначала предлагалась обычная ViewModel c @Published свойством, которая представляет собой массив объектов Core Data, в нашем случае [FruitEntity]:



С помощью функции func getAllFruits() мы выбираем все фрукты [FruitEntity] из Core Data и размещаем в @Published var fruits, a та в свою очередь уведомляет об этом наш ContentView и он обновляет список фруктов vm.fruits на нашем UI:



................... .



То есть реактивность от ViewModel к View присутствует, a вот в обратном направлении — нет. Такая ViewModel не отрабатывает автоматически наши “Намерения” (Intents). Если мы добавляем фрукты или удаляем их из Core Data, то наш @Published массив var fruits не меняется автоматически, нам приходится вручную заново выбирать из Core Data фрукты с помощью функции func getAllFruits() и вручную обновлять:



Получается парадоксальная картина. До тех пор, пока мы не выполним какую-то CRUD операцию с нашими данными, наш список фруктов не обновится. Это выглядит странно в многозадачном режиме на iPad, когда на экране у нас будут присутствовать два приложения, работающие с одной и той же Core Data, но которые не будут чувствовать изменений Core Data, производимые в каждом из этих приложений (они не видят изменений друг друга до тех пор, пока не выполнят хоть какую-нибудь операцию):



Совсем иная картина с нашим шаблонным приложением CoreDataTest, основанным на предложенной Apple технологии с “Оберткой Свойства” @FetchRequest. В этом случае оба приложения отображают Core Data данные на экране абсолютно синхронно благодаря тому, что @FetchRequest автоматически отслеживает все изменения происходящие в Core Data, даже если они произведены другим приложением:



Конечно, можно было бы попробовать дальше работать над глобальной ViewModel, которая является ObservableObject, и сделать её “Оберткой Свойства” (Property Wrapper) вокруг NSFetchedResultsController, объекта, который мы использовали в прошлом для выборки данных из Core Data и для отслеживания изменений в Core Data, и заставить её выполнять повторную выборку и обновлять UI:



Это идеально подходило в UIKit для динамического отображения данных в таблицах TableView и коллекциях CollectionView, и NSFetchedResultsController уведомляет с помощью Notifications об изменении Core Data через своего делегата NSFetchedResultsControllerDelegate. Эта пара — NSFetchedResultsController и NSFetchedResultsControllerDelegate — в основном, делали бы то, что @FetchRequest уже делает для вас, так что я вообще не уверена, что это лучшая идея, которую вы могли бы предложить, и это выглядело бы так:



Одно из сомнений состоит в том, что из-за @Published массива var items я читаю все объекты из моего NSFetchedResultsController за один раз, a это означает, что Core Data загружает все мои объекты “в память”, хотя в нормальном состоянии в UIKit она этого не делала бы, пытаясь отложить фактическую загрузку объекта в память как можно дольше.

Поэтому, делая так, мы выбрасываете в “мусорное ведро ” большую часть оптимизации NSFetchedResultsController.

Это может быть нормально для очень маленьких наборов данных, но для больших наборов данных это может быть проблемой, и, что более важно, такой код не поддерживает секции Sections, что также может быть проблемой.

Так что эта абстракция Core Data тоже не из лучших.

Если вам абсолютно нужна Core Data, то лучший способ абстракция Core Data, который я видела до сих пор, предложен в посте Дэйва Делонге (Dave Delong) “Core Data and SwiftUI”, он работает в Apple и знает массу разных вещей о Core Data, у него есть несколько очень крутых статей на тему работы Core Data в SwiftUI, так что это действительно очень круто.

Вывод. Даже если вы не являетесь фанатом @FetchRequest, я думаю, что @FetchRequest может прекрасно на вас поработать, поэтому следует использовать его для выборки данных из Core Data и просто позволить ему делать то, что он умеет делать лучше нас, a не придумывать заменяющие ему абстракции, если вы не хотите иметь приключений и трений в вашем приложении. Используйте то, как задумано Apple, и просто внедрите контекст ManagedObjectContext в ваше View, это сделает вашу жизнь проще.

Великолепно тема Core Data + SwiftUI изложена Полом Хегарти в стэнфордских курсах CS193P 2020 на Лекциях 11 и 12, если вам нужен русскоязычный конспект этих Лекций, то он также в открытом доступе. Там очень много чего рассказывается о Core Data в SwiftUI и показываются различные динамические варианты выборки данных, в том числе и на карте Map.

Достаточно хороший материал по тема Core Data + SwiftUI в 100 days SwiftUI у Пола Хадсона.

Неплохой курс SwiftUI & Core Data у Karin Pretter.

Абсолютно НЕ рекомендую курс Core Data in iOS на Udemy.

На примере шаблонного демонстрационного примера, предложенного Apple, я показала, что давая объектам Core Data дополнительную функциональность с помощью „синтаксического сахара“ в расширении extension их классов class, сгенерированных Xcode, можно добиться комфортной работы с Core Data в SwiftUI.

Код находится на Github.

Однако до сих пор мы рассматривали тривиальный шаблонный демонстрационный пример, в котором всего лишь одна сущность с одним атрибутом.

В следующем разделе я хочу показать вам такую же комфортную работу Core Data и SwiftUI с реальными объектами — полетами Flights, аэропортами Airport и авиакомпаниями AirLine, которые мы получим на бесплатном сервисе FlightAware и разместим в Core Data. Это сильно упрощенная модификация реального приложения Enroute из стэнфордских курсов CS193P 2020, которое оперативно подкачивает данные с сервера FlightAware . Такое приложение необходимо для того, чтобы показать работу с взаимосвязями объектов типа one-to-many и динамической настройкой @FetchRequest в SwiftUI.
Tags:
Hubs:
+3
Comments2

Articles