При создании приложения на основе фреймворка Core Data мы проектируем модель данных, в которую потом снова и снова вносим изменения. Неужели при этом каждый раз нужно удалять все данные и загружать их заново, а заодно перегружать сервер и батареи пользователей? Сначала кажется, что это единственный вариант решения, но я выяснил — всё можно сделать проще.

В статье рассказываю, как свести к минимуму последствия изменений структуры данных и их негативное влияние, а также удивляюсь, почему Core Data ещё сам не предложил такое решение.

Почему Core Data?

Существует много фреймворков для персистентного хранения данных при разработке приложений под iOS и MacOS. Один из них — Core Data — фреймворк, разработанный компанией Apple. Вот почему его можно выбрать:

  1. Core Data практически не влияет на размер скомпилированного приложения (в IPA хранится только модель).

  2. Этот фреймворк поддерживается Apple и с большой вероятностью переживёт сторонние библиотеки. Во многих приложениях, над которыми я работал, использовался как раз Core Data. 

Сценарий использования чаще всего был простой: сервер отдаёт нам какие-то данные, мы показываем их в UI и складываем в локальную БД, чтобы:

  • переиспользовать их между экранами;

  • показывать кеш, когда нет сети (например, кеш сообщений в чате или истории покупок).

При быстром внедрении новых фич модель данных (.xcdatamodel) может быстро устаревать: добавляются новые атрибуты, удаляются ненужные сущности и т. д. Заставлять разработчиков писать миграции на каждое изменение, создавать новые версии модели и хранить все версии в бандле приложения казалось плохой идеей — неизбежны конфликты мержа, ошибки, увеличение размера IPA и плохое настроение.

Хоть Core Data и способен сам сделать легковесные миграции, иногда это невозможно из-за несовместимой разницы в данных. Нам подходил вариант, при котором мы просто удаляем данные, если новая модель с ними несовместима, то есть кеш становится пустым. Но у него есть большой недостаток, который мы ощутили на недельных релизных циклах. Практически каждую неделю немного менялась то модель сообщения в чате, то модель покупки в списке. Это приводило к тому, что БД удалялась, а вся история сообщений и покупок скачивалась заново и создавала нагрузку на сервер, а также на сеть и батареи пользователей.

Хотелось использовать компромиссное решение: удалять БД не при любом изменении модели, а только если Core Data не может выполнить легковесную миграцию. Проблемы с легковесной миграцией у Core Data происходят на порядок реже, поэтому такое решение избавило бы нас от нежелательных случаев повторной загрузки уже имеющихся данных.

Какие методы использовали: 

  1. NSManagedObjectModel.isConfiguration(withName:compatibleWithStoreMetadata:) — позволяет проверить, изменилась ли модель в принципе. Этот метод сверяет хеши сущностей между моделью и данными в БД. Вернёт false, даже если возможна автоматическая легковесная миграция.

  2. NSMappingModel.inferredMappingModel(forSourceModel:destinationModel:) — позволяет проверить, возможна ли легковесная миграция от старой модели к новой. Новая модель всегда под рукой, с ней мы создаём NSPersistentContainer, но где взять старую? Это вопрос, ответ на который мы найдём дальше.

В поисках старой модели

Symbolic Breakpoint на inferredMappingModel позволил понять, что Core Data умеет восстанавливать модель данных, с которой наш NSPersistentStore был открыт в последний раз. Метод вызывается всякий раз, когда мы производим хоть какое-то изменение в модели данных между запусками приложения.

Стек вызовов до проверки .inferredMappingModel во время загрузки NSPersistentStore
Стек вызовов до проверки .inferredMappingModel во время загрузки NSPersistentStore

Где же найти старую модель? Предположений было два:

  1. Она лежит где-то в Container приложения в памяти устройства.

  2. Модель хранится прямо в самом SQLite-файле базы данных каким-то хитрым способом.

Мы проверили оба. Хорошо, что Xcode позволяет полностью вытянуть Container приложения через Windows → Devices → select device → Installed Apps → my app name → Download Container. Я исследовал все папки и файлы «пустого» приложения, в котором был только стек Core Data, и ничего подозрительного не обнаружил.

Двигаемся дальше. Чтобы понять, что Core Data делает с SQLite-файлом при инициализации, можно включить трейсинг всех SQL-запросов. Для этого редактируем схему запуска приложения и передаём аргумент запуска -com.apple.CoreData.SQLDebug 1.

Включение SQL дебаг логов в Core Data
Включение SQL дебаг логов в Core Data

До того, как проверка возможности легковесной миграции остановится на Symbolic Breakpoint, видим следующие логи:

Дебаг логи Core Data во время загрузки NSPersistentContainer
Дебаг логи Core Data во время загрузки NSPersistentContainer

Видно, что Core Data в момент вызова loadStore общается с тремя таблицами: SQLITE_MASTER, Z_METADATA и Z_MODELCACHE. SQLITE_MASTER — системная таблица СУБД SQLite. Похоже, Core Data использует её, только чтобы проверять:

  • существуют ли в БД Z_METADATA и Z_MODELCACHE; 

  • первое ли это открытие БД в принципе или нет.

Нужно было проверить, что лежит в Z_METADATA и Z_MODELCACHE. Я использовал бесплатный open-source инструмент DB Browser for SQLite

Содержимое таблицы Z_METADATA
Содержимое таблицы Z_METADATA
Содержимое таблицы Z_MODELCACHE
Содержимое таблицы Z_MODELCACHE

Здравый смысл подсказывал, что Z_MODELCACHE очень похожа на хранилище модели, но формат, в котором она там хранится, был неизвестен. Были также мысли, что, возможно, Z_METADATA в Z_PLIST хранит по каждой сущности достаточное количество информации, чтобы воссоздать модель целиком. Посмотрел, что там за plist:

Содержимое столбца Z_PLIST в таблице Z_METADATA
Содержимое столбца Z_PLIST в таблице Z_METADATA

Эта информация показалась похожей на ту, что нужна для работы метода NSManagedObjectModel.isConfiguration(withName:compatibleWithStoreMetadata:). Также это подтверждала очерёдность SQL-запросов в логах. Сначала фреймворк проверяет, изменился ли хеш отдельной сущности, и если да — тогда уже идёт в Z_MODELCACHE проверить возможность легковесной миграции.

Итак, казалось, что решение уже на поверхности, нужно лишь понять формат, в котором в Z_MODELCACHE.Z_CONTENT хранится модель. За несколько дней я попробовал много изощрённых способов, как восстановить модель из этих сырых данных: NSKeyedUnarchiever, разные утилиты, которые могут подсказывать расширение файла по его содержимому, искал разные байтовые маркеры типа файла (многие форматы в первые байты файла пишут какую-то метаинформацию) и т. д. К сожалению, всё это было безрезультатно.

Разбираем файлы на мелкие детали

Я понял, что без бутылки Hopper здесь не разобраться и нужно буквально подсмотреть, что же Core Data делает с этими данными, чтобы поднять старую модель в память. Для таких небольших исследований обычно достаточно демоверсии:

Ограничения бесплатной версии Hopper
Ограничения бесплатной версии Hopper

На содержимое вызываемых функций Core Data можно смотреть прямо в библиотеках, используемых в симуляторе. Обычно они лежат где-то здесь: 

/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/

Core Data я там не обнаружил, однако в режиме debug-сессии можно воспользоваться командой lldb image list и увидеть все загруженные динамические библиотеки. Путь нашёлся достаточно близко:

Путь к фреймворку CoreData
Путь к фреймворку CoreData

Перетаскиваем бинарный файл в Hopper и начинаем. Из стека вызовов остановки на Symbolic Breakpoint NSMappingModel.inferredMappingModel(forSourceModel:destinationModel:) видно, что он вызывается из метода -[NSStoreMigrationPolicy _gatherDataAndPerformMigration:]. Не забываем включить режим псевдокода для понятности и смотрим, что внутри:

Псевдокод функции -[NSStoreMigrationPolicy _gatherDataAndPerformMigration:] #1
Псевдокод функции -[NSStoreMigrationPolicy _gatherDataAndPerformMigration:] #1

Почти в самом начале видно, что посылаются несколько сообщений для получения sourceModel и собственной managedObjectModel.

Псевдокод функции -[NSStoreMigrationPolicy _gatherDataAndPerformMigration:] #2
Псевдокод функции -[NSStoreMigrationPolicy _gatherDataAndPerformMigration:] #2

После путешествий по содержимому функций удалось дойти до говорящей за себя -[NSSQLiteConnection fetchCachedModel]. На скриншоте я поставил ещё один Symbolic Breakpoint, чтобы показать, как глубоко она спрятана во фреймворке.

Стек вызовов до функции -[NSSQLiteConnection fetchCachedModel]
Стек вызовов до функции -[NSSQLiteConnection fetchCachedModel]

И скрин из Hopper:

Псевдокод функции -[NSSQLiteConnection fetchCachedModel] #1
Псевдокод функции -[NSSQLiteConnection fetchCachedModel] #1

Вуаля! 

Псевдокод функции -[NSSQLiteConnection fetchCachedModel] #2
Псевдокод функции -[NSSQLiteConnection fetchCachedModel] #2

Данные из БД обрабатываются compression_stream, а затем уже вызывается unarchivedObjectOfClass. Понять параметры, с которыми вызывается функция, — та ещё задачка, но документация говорит, что возможны четыре алгоритма сжатия: COMPRESSION_LZ4, COMPRESSION_ZLIB, COMPRESSION_LZMA и COMPRESSION_LZFSE. У NSData есть удобная обёртка decompressed, она позволяет не работать со стримом вручную. Брутфорсом подобрался ZLIB, и моделька успешно восстановилась в нашем коде.

И в заключение — готовое решение

Алгоритм стал ясен, решение было оформлено в класс с понятным интерфейсом. Что это нам дало? Мы добавили немного логов, и теперь по каждой БД в приложении понимаем:

  1. Как часто меняется модель.

  2. Как часто она меняется так, что кеш более не валиден, и какие поля это вызывают.

  3. Какие сущности меняются чаще всего.

Теперь мы можем подумать над тем, как разделить кеш, чтобы удалять не всё при любом изменении модели, а только то, что меняется очень часто и сильно. Благодаря этому инвалидация затрагивает меньшее количество данных.

Мне не очень понятно, почему функциональность получения текущей модели из файла не сделали публичной. Я заполнил feature request (FB10972098) и всех неравнодушных прошу последовать моему примеру :)

А пока можно воспользоваться оформленным на GitHub решением. Функциональность покрыта тестами, которые проверяют, не изменила ли Apple принцип хранения последней модели (но с iOS 11 по iOS 16 всё оставалось неизменным).

Комментарии (0)