NSPersistentStore (Swift 4.0). Часть 2

coredata

#1

Продолжаем изучать NSPersistentStore, на просторах интернета не особо много статей на эту тему, видимо не так много людей юзают CoreData на всю катушку :slight_smile:

В первой части шла речь о 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 хранилище и внутри вашего хранилища (хранилище в хранилище :scream:) синхронизировать его с бэкендом, фактически сделать адаптер для бэкенда и далее в проекте работать с 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/

Посмотреть другие реализации: