Продолжаем изучать NSPersistentStore, на просторах интернета не особо много статей на эту тему, видимо не так много людей юзают CoreData на всю катушку
В первой части шла речь о NSAtomicStore, здесь пойдёт о NSIncrementalStore.
И так, в NSIncrementalStore можно получать/сохранять/обновлять/удалять данные постепенно не все сразу, это позволяет хранить в памяти только те данные которые нужны для работы приложения (в случае с NSAtomicStore в памяти хранится всё хранилище).
Следует использовать когда:
- Данные слишком велики для загрузки в память сразу
- Получение данных дорогостоящая или трудоемкая операция
- Данные не всегда доступны
Думаю наиболее актуальны будут два сценария:
- Необходимо синхронизировать ваш бэкенд
- Не подходит SQLite (нужен другой формат, не устраивает производительность)
Пример будет простой, все данные будут храниться в массиве словарей, создадим прокаченный in-memory store.
Поехали
Создаём класс IncrementalMamoryStore подкласс NSIncrementalStore, как и в случае с NSAtomicStore оверрайдим matadata и метод loadMetadata оставляем пустым, matadata для примера не понадобится.
class IncrementalMamoryStore: NSIncrementalStore {
static var type: String {
return String(describing: self)
}
internal static let uuid = UUID().uuidString
private var _metadata: [String: Any] = [NSStoreUUIDKey: IncrementalMamoryStore.uuid, NSStoreTypeKey: IncrementalMamoryStore.type]
override var metadata: [String : Any]! {
get { return _metadata }
set { _metadata = newValue }
}
override func loadMetadata() throws {}
}
Первый метод executeRequest:withContext:error: и самый главный (в нём происходит вся магия) когда вы удаляете/обновляете/сохраняете/запрашиваете данные, координатор вызывает именно этот метод и в зависимости от запроса нужно реагировать соответствующем образом.
Запросы бывают двух видов:
- NSFetchRequest - всем знакомый
- NSSaveChangesRequest - в нем приходят inserted/updated/deleted objects (а так же lockedObjects про которые можно почитать в документации)
Начнём с insertedObjects, все объекты пришедшие в этом сете нужно вставить в кэш:
private var cache: [[String: Any]] = []
override func execute(_ request: NSPersistentStoreRequest, with context: NSManagedObjectContext?) throws -> Any {
switch request {
case let fetchRequest as NSFetchRequest<NSFetchRequestResult>:
break
case let saveRequest as NSSaveChangesRequest:
saveRequest.insertedObjects?.forEach { cache.append(dict(from: $0)) }
return []
default: break
}
return NSNull()
}
В кэш вставляются словари полученные из NSManagedObject и вернуть нужно пустой массив, тем самым обозначив успех операции.
func dict(from object: NSManagedObject) -> [String: Any] {
var properties = object.entity.attributesByName.mapValues { (object.value(forKey: $0.name) ?? NSNull()) }
let relationships = object.entity.relationshipsByName.mapValues { value -> Any in
if let object = object.value(forKey: value.name) as? NSManagedObject {
return referenceObject(for: object.objectID)
} else if let objects = object.value(forKey: value.name) as? Set<NSManagedObject> {
return Set(objects.map { referenceObject(for: $0.objectID) } as! [String])
} else {
return NSNull()
}
}
relationships.forEach { properties[$0.key] = $0.value }
properties["entityName"] = object.objectID.entity.name!
properties["referenceId"] = referenceObject(for: object.objectID)
return properties
}
Атрибуты в словарь добавляем как есть, в связях вместо NSManagedObject записываем их referenceObject (который будет UUID), добавляем в словарь имя сущности и referenceId который нужно вернуть в методе obtainPermanentIDsForObjects:error:
override func obtainPermanentIDs(for array: [NSManagedObject]) throws -> [NSManagedObjectID] {
return array.map { newObjectID(for: $0.entity, referenceObject: "ID-\(UUID().uuidString)") }
}
Этот метод вызывается перед методом executeRequest:withContext:error: и вернуть нужно массив с NSManagedObjectID в таком же порядке что и [NSManagedObject].
В документации написано использовать NSString или NSNumber в качестве referenceObject, но не всё так просто, если строка UUID начинается с цифр, то в referenceObject запишется число обрубленное по первые строковые символы, что бы обойти этот глюк допишем ID перед UUID.
Для сетов updatedObjects и deletedObjects, просто перезаписываем и удаляем словари из кэша:
override func execute(_ request: NSPersistentStoreRequest, with context: NSManagedObjectContext?) throws -> Any {
switch request {
case let fetchRequest as NSFetchRequest<NSFetchRequestResult>:
break
case let saveRequest as NSSaveChangesRequest:
saveRequest.insertedObjects?.forEach { cache.append(dict(from: $0)) }
saveRequest.updatedObjects?.forEach { object in
if let index = cache.index(where: { $0["referenceId"] as! String == referenceObject(for: object.objectID) as! String }) {
cache.remove(at: index)
cache.insert(dict(from: object), at: index)
}
}
saveRequest.deletedObjects?.forEach { object in
if let index = cache.index(where: { $0["referenceId"] as! String == referenceObject(for: object.objectID) as! String }) {
cache.remove(at: index)
}
}
return []
default: break
}
return NSNull()
}
Прежде че перейти к самому главному, запросу на выборку, нужно реализовать еще два метода.
newValuesForObjectWithID:withContext:error:
override func newValuesForObject(with objectID: NSManagedObjectID, with context: NSManagedObjectContext) throws -> NSIncrementalStoreNode {
if let index = cache.index(where: { $0["referenceId"] as! String == referenceObject(for: objectID) as! String }) {
let dict = cache[index]
var properties = objectID.entity.attributesByName.mapValues { dict[$0.name] ?? NSNull()}
let relationships = try objectID.entity.relationshipsByName.filter { !$0.value.isToMany }.mapValues {
try newValue(forRelationship: $0, forObjectWith: objectID, with: context)
}
relationships.forEach { properties[$0.key] = $0.value }
return NSIncrementalStoreNode(objectID: objectID, withValues: properties, version: 1)
} else {
fatalError()
}
}
Координатор вызывает этот метод для извлечения недостающих атрибутов и отношений к одному (To One), нужно вернуть NSIncrementalStoreNode, который является контейнером для NSManagedObjectID и значений атрибутов.
newValuesForRelationship:forObjectWithID:withContext:error:
override func newValue(forRelationship relationship: NSRelationshipDescription, forObjectWith objectID: NSManagedObjectID, with context: NSManagedObjectContext?) throws -> Any {
if let index = cache.index(where: { $0["referenceId"] as! String == referenceObject(for: objectID) as! String }) {
if let ids = cache[index][relationship.name] as? Set<String> {
return ids.map { newObjectID(for: relationship.destinationEntity!, referenceObject: $0) }
} else if let id = cache[index][relationship.name] as? String {
return newObjectID(for: relationship.destinationEntity!, referenceObject: id)
} else {
return relationship.isToMany ? [] : NSNull()
}
} else {
fatalError()
}
}
Отношения ко многим (To Many) как правило более “дорогие” и для них предусмотрен отдельный метод, в котором нужно вернуть сет с NSManagedObjectID, поскольку я вызываю этот метод в newValuesForObjectWithID:withContext:error: для отношений к одному, для него нужно вернуть просто NSManagedObjectID.
NSFetchRequest
В зависимости от от типа ответа NSFetchRequest должен возвращать:
- Массив NSManagedObject (managedObjectResultType)
- Массив NSManagedObjectID (managedObjectIDResultType) запрашиваются при запросе из бекграунд контекста
- Массив словарей (dictionaryResultType)
- Или количество объектов (countResultType)
override func execute(_ request: NSPersistentStoreRequest, with context: NSManagedObjectContext?) throws -> Any {
switch request {
case let fetchRequest as NSFetchRequest<NSFetchRequestResult>:
switch fetchRequest.resultType {
case .managedObjectResultType, .managedObjectIDResultType:
var array = cache.filter { $0["entityName"] as! String == fetchRequest.entity!.name! }
if let predicate = fetchRequest.predicate {
array = (array as NSArray).filtered(using: predicate) as! [[String: Any]]
}
if let sortDescriptors = fetchRequest.sortDescriptors {
array = (array as NSArray).sortedArray(using: sortDescriptors) as! [[String: Any]]
}
return array.map { dict -> AnyObject in
let objectId = newObjectID(for: fetchRequest.entity!, referenceObject: dict["referenceId"]!)
return fetchRequest.resultType == .managedObjectIDResultType ? objectId : context!.object(with: objectId)
}
default:
fatalError("Request Unsupported")
}
default: break
}
return NSNull()
}
Для примера достаточно обработать два типа, managedObjectResultType и managedObjectIDResultType. Сначала фильтруем по имени сущности, фильтруем по predicate, сортируем по sortDescriptors и возвращаем либо массив NSManagedObjectID, либо массив NSManagedObject запрошенных у контекста.
И всё это будет работать пока вы не засунете в предикат NSManagedObject (например для фильтрации по связям и или перед удалением контекст делает запрос вида SELF IN Set<NSManagedObject>
), которых нет в кэше.
Что бы поправить ситуацию нужно разобрать предикат и заменить в нём NSManagedObject (или NSManagedObjectID в зависимости от типа запроса) на referenceId:
func parse(predicate: NSPredicate) -> NSPredicate {
let referenceId = { (object: Any) -> Any? in
switch object {
case let object as NSManagedObject:
return self.referenceObject(for: object.objectID)
case let array as [NSManagedObject]:
return array.map { self.referenceObject(for: $0.objectID) }
case let set as Set<NSManagedObject>:
return set.map { self.referenceObject(for: $0.objectID) }
case let objectId as NSManagedObjectID:
return self.referenceObject(for: objectId)
case let array as [NSManagedObjectID]:
return array.map { self.referenceObject(for: $0) }
case let set as Set<NSManagedObjectID>:
return set.map { self.referenceObject(for: $0) }
default:
return nil
}
}
guard let _predicate = predicate as? NSComparisonPredicate, _predicate.rightExpression.expressionType == .constantValue,
let value = _predicate.rightExpression.constantValue.flatMap(referenceId) else { return predicate }
return NSComparisonPredicate(
leftExpression: _predicate.leftExpression.description == "SELF" ? NSExpression(forKeyPath: "referenceId") : _predicate.leftExpression,
rightExpression: NSExpression(format: "%@", value as! CVarArg),
modifier: _predicate.comparisonPredicateModifier,
type: _predicate.predicateOperatorType,
options: _predicate.options
)
}
И подставляем для фильтрации кэша:
array = (array as NSArray).filtered(using: parse(predicate: predicate)) as! [[String: Any]]
Пришло время опробовать на деле
Создадим сущность User с атрибутом name (String):
Создадим класс BackendStore подкласс IncrementalMamoryStore и реализуем в нём следующую логику:
class BackendStore: IncrementalMamoryStore {
override func execute(_ request: NSPersistentStoreRequest, with context: NSManagedObjectContext?) throws -> Any {
let result = try super.execute(request, with: context)
if let fetchRequest = request as? NSFetchRequest<NSFetchRequestResult>,
fetchRequest.entity!.name! == "User" && (result as? [Any])?.isEmpty ?? false {
apiRequest { array in
let context = CDContext.background
context.perform {
array.forEach { dict in
let user = User(context: context)
user.name = dict["name"]
}
try? context.save()
try? CDContext.view.save()
NotificationCenter.default.post(name: .usersDidLoad, object: nil)
}
}
}
return result
}
private func apiRequest(completion: @escaping ([[String: String]]) -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
completion([
["name": "User A"],
["name": "User B"],
["name": "User C"]
])
}
}
}
Мы перехватываем запрос и если пользователей нет в хранилище, запрашиваем их у “api”, после чего сохраняя пользователей и отправляем уведомление.
Во вьюконтроллере подписываемся на уведомление и запрашиваем пользователей:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(fetchUsers), name: .usersDidLoad, object: nil)
fetchUsers()
}
@objc func fetchUsers() {
let request: NSFetchRequest<User> = User.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
let context = CDContext.view
context.perform {
let result = try? context.fetch(request)
result?.forEach {
print("User name: \($0.name!)")
}
}
}
}
Запускаем смотрим что получилось.
Идея синхронизировать данные на уровне хранилища очень крута, можно сделать внутреннее SQLite хранилище и внутри вашего хранилища (хранилище в хранилище ) синхронизировать его с бэкендом, фактически сделать адаптер для бэкенда и далее в проекте работать с CoreData, не задумываясь откуда берутся данные.
Документация здесь.
Проект здесь.
Почитать по теме:
http://nshipster.com/nsincrementalstore/
https://andyshep.org/2015/01/2015-01-10-building-basic-nsincrementalstore/
http://chris.eidhof.nl/post/accessing-an-api-using-coredatas-nsincrementalstore/
Посмотреть другие реализации: