Объединение Core Data с Дженериками
Если вы столкнулись с багом в Combine, скорее всего, вы только начали использовать фреймворк для всех видов асинхронной логики в своей кодовой базе. Одной из таких областей, с которой я в последнее время много взаимодействую в этом отношении, является Core Data.
Когда Apple в iOS 13 представила Combine, выпуск включал в себя несколько удобных publisher API-шек для NSManagedObject, в основном, в виде publishers(-ов) для наблюдения за ключом и значением и соответствия протоколу ObservableObject. Однако, несмотря на это, есть еще много чего желать.
Извлечение, добавление и удаление объектов - асинхронны по своей природе, и подходили бы идеально к миру publisher в Combine. К счастью, с Deferred Futures мы можем с минимальными усилиями прикрепить функциональность publisher к нашим взаимодействиям с Core Data:
func addTodo(context: NSManagedObjectContext, title: String) -> AnyPublisher {
Deferred { [context] in
Future { promise in
context.perform {
let todo = Todo(context: context)
todo.title = title
do {
try context.save()
promise(.success(todo))
} catch {
promise(.failure(error))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
Однако в проекте, который охватывает несколько сущностей Core Data, обертывание такого рода логики может привести к большому количеству повторяющегося кода. Давайте узнаем, как мы можем использовать обобщения для прикрепления функциональности publisher к любому объекту Core Data.
Выявление общих взаимодействий
Если бы мы ознакомились с каким-либо проектом Core Data, когда речь идет о его управляемых объектах, скорее всего мы бы определили следующие общие взаимодействия:
- Получение списка объектов с возможностью фильтрации или сортировки
- Получение одного существующего объекта
- Добавление нового объекта
- Обновление существующего объекта
- Удаление существующего объекта
Вышеуказанные взаимодействия могут выглядеть очень похоже на абстрактный шаблон репозитория, который в конечном итоге станет хорошим названием для нашего общего класса:
class CoreDataRepository {
private let context: NSManagedObjectContext
init(context: NSManagedObjectContext) {
self.context = context
}
}
Выше мы определили класс, который имеет тип общего заполнителя с удобным названием Entity. Мы указываем, что наш общий заполнитель должен быть типом, который наследует NSManagedObject. Благодаря этой спецификации мы сможем вызывать соответствующие методы, связанные с NSManagedObject, такие как запросы на выборку.
Мы также требуем, чтобы наш класс был создан с помощью NSManagedObjectContext. Следуя шаблону инверсии управления (IoC) с нашим контекстом, мы предоставляем потребителям класса гибкость в определении контекста в зависимости от обстоятельства (т.е. визуального или фонового контекста).
Теперь, когда у нас есть основа для нашего класса общего репозитория, начнем реализацию взаимодействий, которые мы определили ранее в этом разделе.
Получение списка объектов
Получение объектов в Core Data обычно включает в себя создание запроса на выборку и, при необходимости, предоставление дополнительных фильтров или спецификаций сортировки в виде NSPredicates или NSSortDescriptors:
let request: NSFetchRequest = Todo.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Todo.title, ascending: true)]
do {
let todos = try context.fetch(request)
return todos
} catch {
debugPrint("an error occurred \(error.localizedDescription)")
}
Если бы мы сделали эту функциональность более многоразовой и скорректировали ее для работы с нашей новой универсальной реализацией publisher, мы могли бы определить метод следующим образом:
class CoreDataRepository {
//...
func fetch(sortDescriptors: [NSSortDescriptor] = [],
predicate: NSPredicate? = nil) -> AnyPublisher {
Deferred { [context] in
Future { promise in
context.perform {
let request = Entity.fetchRequest()
request.sortDescriptors = sortDescriptors
request.predicate = predicate
do {
let results = try context.fetch(request) as! [Entity]
promise(.success(results))
} catch {
promise(.failure(error))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
//..
}
Запрос конкретного объекта
Фреймворк Core Data предоставляет несколько методов для запроса существующего объекта. Для нашего класса репозитория мы собираемся предположить, что хотели бы получить объект, только если он существует, а в противном случае - выдать ошибку. Здесь мы можем использовать метод existsObject в классе NSManagedObjectContext:
guard let todo = try? context.existingObject(with: id) as? Todo else {
return
}
Сделав вышесказанное более многоразовым, мы можем применить его к нашему общему классу с помощью следующего:
enum RepositoryError: Error {
case objectNotFound
}
class CoreDataRepository {
// ..
func object(_ id: NSManagedObjectID) -> AnyPublisher {
Deferred { [context] in
Future { promise in
context.perform {
guard let entity = try? context.existingObject(with: id) as? Entity else {
promise(.failure(RepositoryError.objectNotFound))
return
}
promise(.success(entity))
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
// ..
}
Заметьте, выше мы выдаем пользовательскую ошибку в случае, если нам не удается найти объект. Это могло бы помочь в дальнейшем управлении ошибками, если бы мы захотели в нашем приложении заменить возникновение ошибки на резервный вариант.
Добавление нового объекта
Когда дело доходит до создания или добавления новых объектов, составление общей реализации может быть немного сложным. Это связано с тем, что каждая сущность будет иметь свои собственные соответствующие атрибуты, которые собираются до сохранения вновь добавленного объекта:
let todo = Todo(context: context)
todo.title = title
todo.createdAt = Date()
do {
try context.save()
} catch {
debugPrint("an error occurred \(error.localizedDescription)")
}
Как можно собрать функцию, которая предоставляет вызывающей стороне возможность применять изменения к объекту до его сохранения? Здесь мы можем использовать удобный in-out параметр Swift:
class CoreDataRepository {
// ..
func add(_ body: @escaping (inout Entity) -> Void) -> AnyPublisher {
Deferred { [context] in
Future { promise in
context.perform {
var entity = Entity(context: context)
body(&entity)
do {
try context.save()
promise(.success(entity))
} catch {
promise(.failure(error))
}
}
}
}
.eraseToAnyPublisher()
}
//..
}
Обновление или удаление существующего объекта
Обновление или удаление объекта немного проще, особенно если ожидается немедленное сохранение изменений:
class CoreDataRepository {
// ..
func update(_ entity: Entity) -> AnyPublisher {
Deferred { [context] in
Future { promise in
context.perform {
do {
try context.save()
promise(.success(()))
} catch {
promise(.failure(error))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
func delete(_ entity: Entity) -> AnyPublisher {
Deferred { [context] in
Future { promise in
context.perform {
do {
context.delete(entity)
try context.save()
promise(.success(()))
} catch {
promise(.failure(error))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
// ..
}
Собираем все это вместе
Теперь, когда мы определили функциональность реализации нашего общего репозитория, мы можем прикрепить функциональность CRUD (Create, Read, Update, Delete) publisher к любому объекту, который наследуется от класса NSManagedObject:
// create a repo for the relevant entity with the relevant context
let repo = CoreDataRepository(context: context)
// add an entity
repo.add { todo in
todo.title = "Hello Generics"
}
.sink { completion in
switch completion {
case .failure(let error):
debugPrint("an error occurred \(error.localizedDescription)")
case .finished:
break
}
} receiveValue: { todo in
debugPrint("todo has been added")
}
// fetch entities
repo.fetch(sortDescriptors: [NSSortDescriptor(keyPath: \Todo.title, ascending: true)])
.replaceError(with: [])
.sink { todos in
debugPrint("\(todos.count) todos fetched")
}
// get an existing object
repo.object(todoId)
.sink { completion in
switch completion {
case .failure(let error):
debugPrint("an error occurred \(error.localizedDescription)")
case .finished:
break
}
} receiveValue: { todo in
debugPrint("hello \(todo.title) object")
}
// update an entity
todo.title = "updated title"
repo.update(todo)
.sink { completion in
switch completion {
case .failure(let error):
debugPrint("an error occurred \(error.localizedDescription)")
case .finished:
break
}
} receiveValue: { _ in
debugPrint("todo updated")
}
// delete an entity
repo.delete(todo)
.sink { completion in
switch completion {
case .failure(let error):
debugPrint("an error occurred \(error.localizedDescription)")
case .finished:
break
}
} receiveValue: { _ in
debugPrint("todo deleted")
}
Вывод
Обобщения позволяют нам писать гибкие, повторно используемые функции, которые могут работать с любым типом, выступая в качестве отличного инструмента для предотвращения дублирования или повторения кода. В этой статье мы узнали, как можно задействовать их для присоединения многократно используемой функциональности publisher к любому объекту Core Data.