CoreData: синхронизация данных с сервером

swift
coredata

#1

Данную важную тему я решил затронуть, после того как увидел как синхронизирует данные с сервером, однин из участников форума :slight_smile:

Приложением будут простые заметки. В качестве сервера будет parse server, у parse ios sdk есть возможность локально хранить записи, но мы пойдем по другому пути.

Но прежде пару слов об MVC.

Я часто и много рассуждаю какую архитектуру использовать в своих приложениях и думаю не только я, это связанно с проблемами в Cocoa MVC, в частности что львиная доля логики находится во вью контроллере, что в свою очередь тянет за собой уйму проблем. Новомодные MVVM и ему подобные в идеале должны помочь решить это, но на деле вся iOS SDK это MVC и чем дальше вы пытаетесь прыгнуть от MVC, тем больше себе усложняете жизнь, пишете больше кода, от чего усложняется разработка и время затраченное на нее (это как ИМХО, буду рад если вы меня убедите в обратном). В примере будем отталкиваться от MVC, но с поправкой на возможности свифта, будем адаптировать TableViewController под определенный протокол и в дефолтном расширении этого протокола писать всю логику, должно быть интересно :slight_smile:

Сервер

Как развернуть parse server написано здесь, кой-какую логику мы вынесем на сервер, что бы уменьшить количество запросов. Для этого создадим cloud function, в папке parse-server/cloud отредактируем main.js:

Parse.Cloud.define("syncRemoved", function(request, response) {
    
    var check = request.params.check
    var remove = request.params.remove
    
    if (check == null || remove == null) {
        response.error("Bad Request")
        return
    }
    
    var removeQuery = new Parse.Query("Note")
    removeQuery.select(null)
    removeQuery.containedIn("objectId", remove)
    
    removeQuery.find().then(function(notes) {
        return Parse.Object.destroyAll(notes)
    }).then(function() {
        var checkQuery = new Parse.Query("Note")
        checkQuery.select(null)
        checkQuery.containedIn("objectId", check)
        return checkQuery.find()
    }).then(function (notes) {
        var ids = notes.map(function(note) { return note.id })
        var result = check.filter(function(id) { return ids.indexOf(id) < 0 })
        response.success(result)
    },
    function(error) {
        response.error(JSON.stringify(error))
    })
})

Здесь происходит следующие - мы получаем в качестве параметров два массива с id, в removeQuery мы ищем объекты по id из массива remove и удаляем их, если всё нормально, создаем запрос checkQuery где ищем объекты по id из массива check, естественно запрос возвращает только те объекты которые есть на сервере, далее с помощью функци map создаём массив с id которые есть на сервере и фильтруем наш check ища id из запроса. В result попадают id которых нет на сервере (indexOf < 0) и передаём их в качестве ответа, ну и если на каком то этапе что то пошло не так отправляем ошибку.

Что бы этот cloud code заработал нужно слегка отредактировать index.js вашего parse server, нужно в serverURL указать фактический адрес вместо loacalhost и в app.use("parse", api) исправить «parse» вместо «parse/app_name» (у Ивана это пункт 5), мой index.js выглядет так:

var express = require("express")
var ParseServer = require("parse-server").ParseServer
var path = require("path")

var api = new ParseServer({
    databaseURI: "mongodb://localhost:27017/notes",
    cloud: __dirname + "/cloud/main.js",
    appId: "appId",
    masterKey: "masterKey",
    serverURL: "http://serverURL:8080/parse"
})

var app = express()

app.use("/parse", api)
app.get("/", function(req, res) {
    res.status(200).send("I dream of being a website.  Please star the parse-server repo on GitHub!")
})

var httpServer = require("http").createServer(app)
httpServer.listen(8080)

ParseServer.createLiveQueryServer(httpServer)

И если вы используете dashboard в его config.json нужно переписать http://serverURL:8080/parse/app_name на http://serverURL:8080/parse.

После чего нужно всё перезапустить.

###Приложение

Модель

Создадим сущность Note:

  • date - Date
  • id - String
  • isRemoved - Bool (по умолчанию false)
  • text - String

Xcode 8 автоматически генерирует подклассы NSManagedObject для сущностей, лежат они глубоко где то в ~/Library/Developer/Xcode/DerivedData. И по умолчанию date у нас будет NSDate, что нам не совсем подходит, по этому пишем где угодно Note().date и по date правой кнопкой Jump to Defenition:


Здесь у date нам нужно просто удалить префикс NS, должно получится так:

Это следует проделывать после каждой генерации файла (не совсем удобно, будем надеятся что в будущем поправят этот косяк), генерируется он после каких то изменениях в сущности, билде под другую архитектуру, ну и если вы скачаете мой пример вам тоже придется удалять этот чёртов префикс :slight_smile:

Далее укажем что id у нас уникальный:

В приложении у нас будет два контекста, view context будет отвечать за отображение и реагировать на действия пользователя, backgraund context будет работать с сервером:

Создадим файл CoreDataStack и пишем в него:

typealias CDContext = NSManagedObjectContext

extension CDContext {
    private class PersistentContainer: NSPersistentContainer {
        
        static let `default` = PersistentContainer(name: "Notes")
        
        lazy var backgraundContext: CDContext = {
            return self.newBackgroundContext()
        }()
        
        override init(name: String, managedObjectModel model: NSManagedObjectModel) {
            super.init(name: name, managedObjectModel: model)
            viewContext.automaticallyMergesChangesFromParent = true
            viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
            loadPersistentStores { _, error in
                guard error == nil else { fatalError(error!.localizedDescription) }
            }
        }
    }
    
    static var view: CDContext {
        return PersistentContainer.default.viewContext
    }
    
    static var backgraund: CDContext {
        return PersistentContainer.default.backgraundContext
    }
}

В iOS 10 появился NSPersistentContainer который приставляет из себя полноценный Core Data Stack и в значительной степени упрощает работу с контекстами.

Создаём синглтон PersistentContainer от которого нам надо backgraundContext (это отдельный контекст в отдельном потоке) и viewContext (который в основном потоке) в методе init настраиваем viewContext «automaticallyMergesChangesFromParent» - автоматически получает изменения из других контекстов и «mergePolicy» - перезаписывает объекты с уникальным id и оборачиваем всё это в CDContext для удобства.

Далее в этом же файле создадим более удобный FetchedResultsController:

class NoteFetchedResultsController: NSFetchedResultsController<Note>, NSFetchedResultsControllerDelegate {
    
    var willChangeContent: (() -> ())?
    var changeContent: ((IndexPath, NSFetchedResultsChangeType) -> ())?
    var didChangeContent: (() -> ())?

    
    convenience override init() {
        let fetchRequest: NSFetchRequest<Note> = Note.fetchRequest()
        fetchRequest.predicate = NSPredicate(format: "isRemoved = NO")
        fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
        self.init(fetchRequest: fetchRequest, managedObjectContext: CDContext.view, sectionNameKeyPath: nil, cacheName: nil)
        delegate = self
    }
    
    func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        willChangeContent?()
    }
    
    func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
        changeContent?(type == .insert ? newIndexPath! : indexPath!, type)
    }
    
    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        didChangeContent?()
    }
}

FetchedResultsController у нас сам себе делегат, не показывает заметки которые isRemoved, сортирует по дате и самое главное контекст у нас view.

Создам файл Note.swift

В нем пишем протокол NoteIF:

@objc protocol NoteIF {
    var id: String? { get set }
    var date: Date? { get set }
    var text: String? { get set }
    func update(with interface: NoteIF)
}

extension NoteIF {
    var isSynchronized: Bool {
        return id != nil
    }
}

В его расширение будем отталкиваться от того что если нет id то заметка не синхронизирована.

Расширяем Note с помощью NoteIF:

extension Note: NoteIF {
    
    var backgraund: Note {
        return CDContext.backgraund.object(with: objectID) as! Note
    }
    
    convenience init(with interface: NoteIF) {
        self.init(context: CDContext.backgraund)
        update(with: interface)
    }
    
    func update(with interface: NoteIF) {
        id = interface.id
        date = interface.date
        text = interface.text
    }
}

Здесь backgraund для получения того же объекта из backgraund контекста, в методе init указываем контекст backgraund (из интерфейса будем создавать заметки в фоне).

В этом же файле создаем класс PFNote подкласс PFObject:

class PFNote: PFObject, PFSubclassing, NoteIF {
    
    var id: String? {
        get { return objectId }
        set { objectId = newValue }
    }
    
    var date: Date? {
        get { return updatedAt }
        set {}
    }
    
    @NSManaged var text: String?
    
    convenience init(with interface: NoteIF) {
        self.init()
        update(with: interface)
    }
    
    func update(with interface: NoteIF) {
        id = interface.id
        text = interface.text
    }
    
    static func parseClassName() -> String {
        return "Note"
    }
}

PFNote соответствует протоколу NoteIF - NoteIF будет выступать в качестве связующего звена.

То что получилось можно скачать здесь.

Контроллер

TableViewController

В файле TableViewController.swift создадим протокол TableBase которому будет соответствовать наш TableViewControoler и в расширении которого будем писать всю логику:

protocol TableBase: class {
    var fetchedResultsController: NoteFetchedResultsController { get set }
    var indexPathForSelectedItem: IndexPath? { get }
    
    func beginUpdates()
    func endUpdates()
    func selectItem(at indexPath: IndexPath)
    func insertItem(at indexPath: IndexPath)
    func reloadItem(at indexPath: IndexPath)
    func deleteItem(at indexPath: IndexPath)
    func reloadAll()
    func showError(_ message: String)
}

Реализуем методы протокола:

class TableViewController: UITableViewController, TableBase {
    
    var fetchedResultsController = NoteFetchedResultsController()
    var indexPathForSelectedItem: IndexPath?  {
        return tableView.indexPathForSelectedRow
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        didLoad()
    }
    
    func endUpdates() {
        tableView.endUpdates()
    }
    
    func beginUpdates() {
        tableView.beginUpdates()
    }
    
    func selectItem(at indexPath: IndexPath) {
        tableView.selectRow(at: indexPath, animated: true, scrollPosition: .none)
        performSegue(withIdentifier: "ShowDetail", sender: nil)
    }
    
    func insertItem(at indexPath: IndexPath) {
        tableView.insertRows(at: [indexPath], with: .none)
    }
    
    func reloadItem(at indexPath: IndexPath) {
        tableView.reloadRows(at: [indexPath], with: .none)
    }
    
    func deleteItem(at indexPath: IndexPath) {
        tableView.deleteRows(at: [indexPath], with: .none)
    }
    
    func reloadAll() {
        tableView.reloadData()
    }
    
    func showError(_ message: String) {
        let show = {
            let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert)
            alert.view.tintColor = #colorLiteral(red: 0, green: 0, blue: 0, alpha: 1)
            alert.addAction(UIAlertAction(title: "OK", style: .cancel))
            self.present(alert, animated: true, completion: nil)
        }
        if Thread.isMainThread {
            show()
        } else {
            DispatchQueue.main.async {
                show()
            }
        }
    }
}

В showError проверяем из какого потока он вызван и показываем alert из главного.

В расширении TableBase первое что нужно сделать это настроить FetchedResultsController:

private func setupFRC() {
    do {
        try fetchedResultsController.performFetch()
    } catch {
        showError(error.localizedDescription)
    }
    fetchedResultsController.willChangeContent = { [weak self] in
        self?.beginUpdates()
    }
    
    fetchedResultsController.changeContent = { [weak self] indexPath, type in
        switch type {
        case .insert:
            self?.insertItem(at: indexPath)
        case .update:
            self?.reloadItem(at: indexPath)
        case .delete:
            self?.deleteItem(at: indexPath)
        default:
            self?.reloadAll()
        }
    }
    
    fetchedResultsController.didChangeContent = { [weak self] in
        self?.endUpdates()
    }
}

Тут ничего необычного, кроме того что FetchedResultsController работает через протокол.

Добавляем заметку:

internal func addNote() {
    do {
        let context = CDContext.view
        let note = Note(context: context)
        note.date = Date()
        try context.save()
        if let indexPath = fetchedResultsController.indexPath(forObject: note) {
            selectItem(at: indexPath)
        }
    } catch {
        showError(error.localizedDescription)
    }
}

Создаем Note во вью контексте, что бы она сразу появилась на экране, дату выставляем с целю что бы заметка появилась в начале списка и после сохранения контекста FetchedResultsController выводит её на экран, дальше у FetchedResultsController мы берем IndexPath заметки и по нему переходим в DetailViewController (как в обычных заметках в iOS).

internal func setNote(for detail: DetailBase) {
    guard let indexPath = indexPathForSelectedItem else { return }
    detail.note = item(at: indexPath)
    detail.delegate = self
}

DetailBase это протокол которому будет соответствовать DetailViewController, у него note это NoreIF и delegate TableBase.

internal func delete(note: NoteIF) {
    let viewContext = CDContext.view
    let note = (note as! Note)
    
    if note.isSynchronized {
        note.isRemoved = true
        let backgraundContext = CDContext.backgraund
        backgraundContext.perform {
            do {
                try PFNote(with: note).delete()
                backgraundContext.delete(note.backgraund)
                try backgraundContext.save()
            } catch {
                self.showError(error.localizedDescription)
            }
        }
    } else {
        viewContext.delete(note)
    }
    viewContext.save() { self.showError($0.localizedDescription) }
}

В методе delete мы проверяем синхронизирована заметка или нет, если синхронизирована мы убираем её с экрана (isRemoved = true) и в бекграунде удаляем её на сервере, если на сервере удалилась удаляем на устройстве, если на сервере возникли проблемы, заметка всё равно останется isRemoved и позже при синхронизации мы сможем её удалим. Ну и если заметка не синхронизирована просто удаляем её.

func completed(note: NoteIF) {
    guard !note.text!.isEmpty else { delete(note: note); return }
    
    CDContext.view.save() { self.showError($0.localizedDescription) }
    
    let context = CDContext.backgraund
    context.perform {
        do {
            let pfnote = PFNote(with: note)
            try pfnote.save()
            (note as! Note).backgraund.update(with: pfnote)
            try context.save()
        } catch {
            self.showError(error.localizedDescription)
        }
    }
}

Этот метод будет вызывать DetailViewComtroller после завершения редактирования. Проверяем если текст пустой то удаляем заметку, если не пустой сохраняем во вью контексте и в бекграунде отправляем на сервер, после сохранения на сервере у PFNote появляется objectId и дата обновления, их нужно обновить в заметке на устройстве.

DetailViewController

protocol DetailBase: class {
    var note: NoteIF! { get set }
    weak var delegate: TableBase? { get set }
}

class DetailViewController: UIViewController, DetailBase {
    
    @IBOutlet weak var textView: UITextView! {
        didSet { textView.text = note.text }
    }
    
    var note: NoteIF!
    weak var delegate: TableBase?
    private var observer: NSObjectProtocol!

    override func viewDidLoad() {
        super.viewDidLoad()
        observer = NotificationCenter.default.addObserver(forName: .UIApplicationDidEnterBackground, object: nil, queue: nil) { [weak self] _ in
            guard !(self?.textView.text ?? "").isEmpty else { return }
            self?.completed()
        }
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        completed()
    }
    
    func completed() {
        guard textView.text != note.text else { return }
        note.text = textView.text
        note.date = Date()
        delegate?.completed(note: note)
    }
    
    deinit {
        NotificationCenter.default.removeObserver(observer)
    }
    
}

Тут нас интересует метод completed - проверяем если текст изменился то присваиваем его заметке, обновляем дату и передаём делегату. Вызывается этот метод при переходе назад к TableViewController, или при уходе приложения в бекграунд, при уходе в бекграунд проверяем что бы текст был не пустой, иначе заметке удалится.

Я описал основные моменты, полный код можете посмотреть здесь.

Не забудьте в AppDelegate поправить ParseClientConfiguration.

Самое время перейти к синхронизации! Её синхронизацию, будем делить на три этапа.

Начнем с удаления:

private func syncRemoved() throws {
    let context = CDContext.backgraund
    let fetchRequest: NSFetchRequest<Note> = Note.fetchRequest()
    fetchRequest.predicate = NSPredicate(format: "id != nil")
    let allNotes = try context.fetch(fetchRequest)
    
    let notes = allNotes.filter { !$0.isRemoved }
    var removed = allNotes.filter { $0.isRemoved }
    
    let result = try PFCloud.callFunction("syncRemoved", withParameters: ["check": notes.map { $0.id! }, "remove": removed.map { $0.id! }]) as! [String]
    
    removed += notes.filter { result.contains($0.id!) }
    removed.forEach { context.delete($0) }
    
    try context.save()
}

Получаем из CoreData заметки у которых есть id, сортируем по isRemoved и передаем их id в нашу cloud function. Фильтруем те которых на сервере нет (из ответа сервера) и удаляем вместе с теми которые были помечены как isRemoved.

В реальной жизни, удалять заметки на сервере не самая лучшая идея, если будете что то подобное реализовывать, подумайте над «корзиной» в которой эти заметки будут хранится какое-то время.

Запрос на сервер:

private func findNotes(with notes: [NoteIF]) throws -> (new: [NoteIF], after: [NoteIF], before: [NoteIF]) {
    var subqueries = [PFQuery]()
    
    let subquery = PFNote.query()!
    subquery.whereKey("objectId", notContainedIn: notes.map { $0.id! })
    subqueries.append(subquery)
    
    notes.forEach {
        let subquery = PFNote.query()!
        subquery.whereKey("objectId", equalTo: $0.id!)
        subquery.whereKey("updateAt", notEqualTo: $0.date!)
        subqueries.append(subquery)
    }
    
    let result = try PFQuery.orQuery(withSubqueries: subqueries).findObjects() as [PFNote]
    
    var new = [NoteIF]()
    var after = [NoteIF]()
    var before = [NoteIF]()
    
    for pfnote in result {
        if let index = notes.index(where: { $0.id! == pfnote.id }) {
            let note = notes[index]
            if pfnote.date! > note.date! {
                after.append(pfnote)
            } else if pfnote.date! < note.date! {
                before.append(pfnote)
            }
        } else {
            new.append(pfnote)
        }
    }
    
    return (new, after, before)
}

findNotes формирует хитрый запрос на сервер, в нем нас интересуют заметки которых нет на устройстве и те у которых не соответствует дата. Полученный результат мы сортируем в три массива нет на устройстве \ если дата на сервере больше \ если дата на сервере меньше.

internal func allSync() {
    let context = CDContext.backgraund
    context.perform {
        do {
            // 1
            
            try self.syncRemoved()
            
            // 2
            
            let fetchRequest: NSFetchRequest<Note> = Note.fetchRequest()
            let allNotes = try context.fetch(fetchRequest)
            let synchronized = allNotes.filter { $0.isSynchronized }
            
            let notes = try self.findNotes(with: synchronized)
            
            for note in notes.after {
                if let index = synchronized.index(where: { $0.id! == note.id! }) {
                    synchronized[index].update(with: note)
                }
            }
            
            notes.new.forEach { _ = Note(with: $0) }
            
            try context.save()
            
            // 3
            
            let unsynchronized = allNotes.filter { !$0.isSynchronized }
            let pfnotes = unsynchronized.map { PFNote(with: $0) }
            
            
            for pfnote in notes.before {
                if let index = synchronized.index(where: { $0.id! == pfnote.id }) {
                    pfnote.update(with: synchronized[index])
                }
            }
            
            try PFNote.saveAll(pfnotes + (notes.before as! [PFNote]))
            
            for (index, note) in unsynchronized.enumerated() {
                note.update(with: pfnotes[index])
            }
            
            try context.save()
            
        } catch {
            self.showError(error.localizedDescription)
        }
    }
}

Запускаем всё асинхронно в бекграунд контексте.

  1. Удаляем всё что можно удалить.
  2. Получаем все заметки которые есть на устройстве (точнее остались после удаления), делаем запрос на сервер, из ответа из тех которые на сервере новее (after) обновляем заметки на устройстве, из new создаем новые и сохраняем контекст.
  3. Фильтруем заметки которые не синхронизированы, создаем из них массив PFNote и обновляем те которые устарели (before) после чего отправляем всё на сервер. Ну и остается только обновить только что созданные на устройстве (их id и date) и сохранить контекст.

Скачать можно здесь.


#2

Ого, круто автор расписал, молодец. +1 в карму от меня за работу!!! :grinning: :grinning: :grinning: