Простий код, повторне використання та синхронізація — як працювати з Core Data. Кейс Impulse

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Привіт, спільното! Мене звати Євген Величко, і я iOS-розробник в продукті Impulse, який є частиною IT-компанії Headway, що розробляє мобільні EdTech продукти. Я займаюся розробкою застосунку Impulse, що став № 1 у категорії brain training за кількістю завантажень.

Impulse — це великий та складний сервіс для тренування різних функцій мозку: пам’яті, уваги, просторового сприйняття, швидкості реакції тощо. Наш функціонал також повноцінно працює офлайн — від збору та збереження поточного прогресу користувача до надання щотижневих звітів. Активність кожного з наших декількох десятків тисяч щоденних юзерів постійно зберігається, і це створює виклики. Раніше, коли додаток мав обмежений функціонал і не так багато контенту, ми не мали жодних проблем із даними. Та по мірі ускладнення UI та додавання нових фіч, ми стикнулися з проблемами із забезпеченням консистентності даних на логічному рівні.

Вирішити їх допоміг один простий і безпечний метод роботи з Core Data. Детальніше про нього розповім у цій статті з допомогою мого колеги з Impulse — iOS-розробника Євгена Циганенка. Сподіваємося, що цей матеріал буде корисний усім, хто використовує Core Data і прагне отримати безпечний та зручний інструмент для роботи з локальними даними.

Ми припустились хрестоматійних помилок у зберіганні даних

Працюючи з великою кількістю даних користувачів Impulse, ми постійно натикалися на одні і ті ж проблеми: у crashlytics зростала кількість рандомних крашів пов’язаних з Core Data, а застосунок іноді поводився непередбачувано. Наприклад, сьогодні зберігаєш модель і все добре, а завтра стираються деякі властивості, що не завжди відразу помітно, особливо на великих та складних моделях зі складними залежностями.

Ми намагалися виправляти логіку даних, відловлюючи race conditions, старалися з’ясувати, який саме код є проблемним. Але те, що в застосунку повсюдно використовувався Rx, не залишав нам шансів на вирішення цієї проблеми.

Далі так продовжуватись не могло — наявна реалізація підсистеми зберігання даних перестала задовольняти наші вимоги. Ми зробили аудит сервісу, який відповідав за роботу з Core Data та почали усувати проблеми, про які навіть не думали на початку розробки. Виявилося, що початковий варіант нашого сервісу був хрестоматійним прикладом того, як НЕ треба робити.

У чому саме були проблеми?

  • NSManagedObjectContext в публічному інтерфейсі;
  • постійне використання performBackgroundTask (щоразу створюючи новий context та відповідну background queue);
  • використання fetch на кожну ітерацію циклу;
  • десь використовувався viewContext, десь background, але практично ніколи не гарантувалася потокобезпека.

Три головні вимоги для сервісу зберігання даних

Проаналізувавши всі ці проблеми, ми зрозуміли, що треба ставити на паузу розробку нового функціоналу та переписувати сервіс із нуля. А розпочати ми вирішили з визначення абстрактних завдань, які мав вирішити новий функціонал.

  • Простота й продуктивність. Що простішим буде код, то простіше тестувати й дебажити, а відповідно, менше шансів облажатися.
  • Повторне використання, аби мінімізувати дублювання коду.
  • Синхронізація доступу до моделей на рівні сервісу, а не бізнес-логіки та, як наслідок, повна передбачуваність поведінки та позбавлення від умов гонки.

Далі я розповім детальніше, як ми досягали виконання поставлених завдань.

Простота й продуктивність

У старому коді використовувався класичний підхід із parent-child контекстами. Такий підхід легко гуглитися за запитом на роботу з Core Data в бекграунді. Мабуть, звідти він і був узятий, адже насправді він ускладнює код та не несе жодних переваг. Для покриття більшості кейсів, а зокрема й наших, потрібен лише один бекграундний контекст. А від viewContext ми взагалі вирішили відмовитися та стали працювати з Core Data повністю поза main queue.

Отже, прибравши непотрібні операції зі збереження parent-child контекстів, і найважливіше, прибравши виклик performBackgroundTask, що створює новий контекст та нову асоційовану з ним чергу, ми вже сильно виглали в продуктивності. Код стало простіше дебажити, а поведінка стала більш передбачуваною.

Перевикористання

Перш за все, визначимо протоколи для мапінга моделей. Модель в публічному інтерфейсі -> модель Core Data (NSMangedObject) і назад. Це необхідно зробити для уніфікації інтерфейсу — сервер може посилати одну модель, зберігати можемо іншу, але модель в публічному інтерфейсі не повинна від цього залежати. Це робить наш підхід більш масштабованим та стійким до змін. Для цього опишемо два простих протоколи:

Протокол EntityConvertable, який реалізовуватиме NSManagedObject, відрізняється від зворотного наявністю обов’язкового поля id. У ManagedObjectConvertible воно відсутнє, оскільки моделі, що реалізують даний протокол, буду ще й реалізовувати Identifiable.

Далі опишемо два розширення — для NSManagedObject та для ManagedObjectConvertible — що дозволяють проводити примітивні операції зі створення об’єктів.

Наступним кроком стане опис простого протоколу створення реквестів, пов’язаного з EntityConvertable.

FetchRequest інкапсулює в собі NSFetchRequest, пов’язаний з EntityConvertable, що робить FetchRequest зручною точкою для створення модифікацій та розширення функціоналу. Додаткова умова where Self: NSObject потрібна для зручнішої взаємодії з Rx, та легко може бути прибрана, якщо в цьому немає потреби. AnyFetchRequest є простою реалізацією механізму стирання типу для зручного створення наших запитів. Таким чином, окремий запит матиме вигляд типу:

Синхронізація

Зазвичай процес оновлення моделей Core Data відбувається у кілька етапів. Отримання моделей із контексту -> модифікація цих моделей -> збереження модифікованих даних. У результаті, ми маємо, майже зі 100% ймовірністю невизначену поведінку, адже не знаємо, хто ще зараз намагається модифікувати цю модель. Тож тестувати та налагоджувати такий код майже неможливо. А отже, спроба усунути умову гонки лише на рівні бізнес-логіки не реалізована реальному проекті.

Потрібен буде окремий спеціаліст або навіть кілька, які займатимуться тільки переглядом усіх мердж-реквестів, тримаючи в голові всю логіку виклику звернень до тієї чи іншої моделі. В умовах розвитку та ускладнення проєкту це неминуче призводитиме до тих самих багів та невизначеної поведінки, про яку згадував вище.

Рішення просте, утім, не завжди очевидне, як показує практика. Потрібно об’єднати етапи отримання даних, мапінга та модифікації в один, при цьому гарантуючи виконання одного блоку за раз — тобто обернувши на послідовну чергу. В результаті, основний метод нашого сервісу, що виконує безпечну модифікацію моделей Core Data, виглядатиме приблизно так:

У цьому коді звертаємо увагу на наступне:

Метод performWrite викликає синхронний метод NSManagedObjectContext performAndWait. Викликаючи його замість асинхронного perform, ми переносимо турботу про синхронізацію на себе. Це, по суті, є підстрахуванням, адже бекграундний контекст, створений за допомогою newBackgroundContext(), асоційований із синхронною чергою. Тому можна просто використовувати метод perform, не обертаючи виконання додатково в синхронну чергу, створену окремо. Однак, якщо раптом знадобиться використати додатковий контекст, код повністю збереже свою функціональність.

Загалом метод update має інтерфейс, що дозволяє приймати до чотирьох реквестів для одночасної модифікації. Це цілком задовольняє потреби нашого проєкту, але за необхідності, його легко можна розширити.

У результаті, виклик блоку модифікації у вашому репозиторії/use case буде виглядати приблизно так:

У прикладі використовується Rx, проте код може бути легко перенесений на Reactive або Combine через схожість функціоналу та навіть синтаксису. Водночас ті, хто взагалі не використовують реактивщину, можуть легко обійтися без неї, адже вище описаний код повністю незалежний від будь-яких сторонніх бібліотек та фреймворків. Повний код з прикладом використання, а також з реалізацією функціоналу NSFetchResultsController (безпечного і повністю в бекграунді) можна отримати тут.

Висновки

За півтора року, що ми використовуємо цей підхід в Impulse, не було жодного багу, пов’язаного з Core Data або невизначеною поведінкою моделей через умови гонки. Нагадаю, що додатком щодня користується кілька десятків тисяч юзерів. Стабільність та передбачуваність сильно зросла, що однозначно добре позначається на бізнес-показниках.

P. S.: Розглянутий у цій статті код у жодному разі не претендує на істину останньої інстанції та є лише одним із можливих рішень цієї проблеми. Також обгортка реалізує далеко не повний список можливостей Core Data (наприклад butch update), а лише надає функціонал, що вирішує безпосередньо завдання нашого проєкту. Також у репозиторії ви знайдете речі, не описану в цій статті, зокрема реалізацію thread-safe властивостей — ThreadSafeBox та лінзи LensRepresentable, які значно покращують та спрощують роботу, проте не є обов’язковими елементами та можуть бути легко видалені з коду без порушення цілісності та функціональності.

Сподобалась стаття? Натискай «Подобається» внизу. Це допоможе автору виграти подарунок у програмі #ПишуНаDOU

👍ПодобаєтьсяСподобалось6
До обраногоВ обраному2
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Дякую за статтю!
А чому взагалі зупинились на Core Data? А не online PaaS Firebase чи AWS?

let mapper: (Entity) -> Void
let collectionMapper ([Entity]) -> Void

Або я чогось не розумію, або значення функції map — конвертація одного типу до іншого:

developer.apple.com/...​swift/array/map(_:)-87c4d

Тобто, по мало би бути щось накшталт:

let mapper: (Entity) -> T
let collectionMapper ([Entity]) -> T

T — Generic constraint to NSManagedObject.

А взагалі по загалям — так, що CoreData що Realm — вимагають знання типових проблем багатопоточності — типу race condition — щоб не ловити креші на проді =)

Ну і плодити контексти + realm instances — теж погана ідея. Воно норм для малих проектів, але потім починають вилазити баги.

Дякую за статтю.

1 контекст и 1 поток. Если идёт большое обновление таблицы Сats, а UI хочет отобразить Employees, то UI придется подождать. В вашем случае это ОК для стабильности?

butch update

Мабуть малось на увазі bAtch update

Підписатись на коментарі