При создании приложения на основе фреймворка Core Data мы проектируем модель данных, в которую потом снова и снова вносим изменения. Неужели при этом каждый раз нужно удалять все данные и загружать их заново, а заодно перегружать сервер и батареи пользователей? Сначала кажется, что это единственный вариант решения, но я выяснил — всё можно сделать проще.
В статье рассказываю, как свести к минимуму последствия изменений структуры данных и их негативное влияние, а также удивляюсь, почему Core Data ещё сам не предложил такое решение.
Почему Core Data?
Существует много фреймворков для персистентного хранения данных при разработке приложений под iOS и MacOS. Один из них — Core Data — фреймворк, разработанный компанией Apple. Вот почему его можно выбрать:
Core Data практически не влияет на размер скомпилированного приложения (в IPA хранится только модель).
Этот фреймворк поддерживается Apple и с большой вероятностью переживёт сторонние библиотеки. Во многих приложениях, над которыми я работал, использовался как раз Core Data.
Сценарий использования чаще всего был простой: сервер отдаёт нам какие-то данные, мы показываем их в UI и складываем в локальную БД, чтобы:
переиспользовать их между экранами;
показывать кеш, когда нет сети (например, кеш сообщений в чате или истории покупок).
При быстром внедрении новых фич модель данных (.xcdatamodel) может быстро устаревать: добавляются новые атрибуты, удаляются ненужные сущности и т. д. Заставлять разработчиков писать миграции на каждое изменение, создавать новые версии модели и хранить все версии в бандле приложения казалось плохой идеей — неизбежны конфликты мержа, ошибки, увеличение размера IPA и плохое настроение.
Хоть Core Data и способен сам сделать легковесные миграции, иногда это невозможно из-за несовместимой разницы в данных. Нам подходил вариант, при котором мы просто удаляем данные, если новая модель с ними несовместима, то есть кеш становится пустым. Но у него есть большой недостаток, который мы ощутили на недельных релизных циклах. Практически каждую неделю немного менялась то модель сообщения в чате, то модель покупки в списке. Это приводило к тому, что БД удалялась, а вся история сообщений и покупок скачивалась заново и создавала нагрузку на сервер, а также на сеть и батареи пользователей.
Хотелось использовать компромиссное решение: удалять БД не при любом изменении модели, а только если Core Data не может выполнить легковесную миграцию. Проблемы с легковесной миграцией у Core Data происходят на порядок реже, поэтому такое решение избавило бы нас от нежелательных случаев повторной загрузки уже имеющихся данных.
Какие методы использовали:
NSManagedObjectModel.isConfiguration(withName:compatibleWithStoreMetadata:) — позволяет проверить, изменилась ли модель в принципе. Этот метод сверяет хеши сущностей между моделью и данными в БД. Вернёт false, даже если возможна автоматическая легковесная миграция.
NSMappingModel.inferredMappingModel(forSourceModel:destinationModel:) — позволяет проверить, возможна ли легковесная миграция от старой модели к новой. Новая модель всегда под рукой, с ней мы создаём NSPersistentContainer, но где взять старую? Это вопрос, ответ на который мы найдём дальше.
В поисках старой модели
Symbolic Breakpoint на inferredMappingModel позволил понять, что Core Data умеет восстанавливать модель данных, с которой наш NSPersistentStore был открыт в последний раз. Метод вызывается всякий раз, когда мы производим хоть какое-то изменение в модели данных между запусками приложения.
Где же найти старую модель? Предположений было два:
Она лежит где-то в Container приложения в памяти устройства.
Модель хранится прямо в самом 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.
До того, как проверка возможности легковесной миграции остановится на Symbolic Breakpoint, видим следующие логи:
Видно, что 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_MODELCACHE очень похожа на хранилище модели, но формат, в котором она там хранится, был неизвестен. Были также мысли, что, возможно, Z_METADATA в Z_PLIST хранит по каждой сущности достаточное количество информации, чтобы воссоздать модель целиком. Посмотрел, что там за plist:
Эта информация показалась похожей на ту, что нужна для работы метода NSManagedObjectModel.isConfiguration(withName:compatibleWithStoreMetadata:). Также это подтверждала очерёдность SQL-запросов в логах. Сначала фреймворк проверяет, изменился ли хеш отдельной сущности, и если да — тогда уже идёт в Z_MODELCACHE проверить возможность легковесной миграции.
Итак, казалось, что решение уже на поверхности, нужно лишь понять формат, в котором в Z_MODELCACHE.Z_CONTENT хранится модель. За несколько дней я попробовал много изощрённых способов, как восстановить модель из этих сырых данных: NSKeyedUnarchiever, разные утилиты, которые могут подсказывать расширение файла по его содержимому, искал разные байтовые маркеры типа файла (многие форматы в первые байты файла пишут какую-то метаинформацию) и т. д. К сожалению, всё это было безрезультатно.
Разбираем файлы на мелкие детали
Я понял, что без бутылки Hopper здесь не разобраться и нужно буквально подсмотреть, что же Core Data делает с этими данными, чтобы поднять старую модель в память. Для таких небольших исследований обычно достаточно демоверсии:
На содержимое вызываемых функций 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 и увидеть все загруженные динамические библиотеки. Путь нашёлся достаточно близко:
Перетаскиваем бинарный файл в Hopper и начинаем. Из стека вызовов остановки на Symbolic Breakpoint NSMappingModel.inferredMappingModel(forSourceModel:destinationModel:) видно, что он вызывается из метода -[NSStoreMigrationPolicy _gatherDataAndPerformMigration:]. Не забываем включить режим псевдокода для понятности и смотрим, что внутри:
Почти в самом начале видно, что посылаются несколько сообщений для получения sourceModel и собственной managedObjectModel.
После путешествий по содержимому функций удалось дойти до говорящей за себя -[NSSQLiteConnection fetchCachedModel]. На скриншоте я поставил ещё один Symbolic Breakpoint, чтобы показать, как глубоко она спрятана во фреймворке.
И скрин из Hopper:
Вуаля!
Данные из БД обрабатываются compression_stream, а затем уже вызывается unarchivedObjectOfClass. Понять параметры, с которыми вызывается функция, — та ещё задачка, но документация говорит, что возможны четыре алгоритма сжатия: COMPRESSION_LZ4, COMPRESSION_ZLIB, COMPRESSION_LZMA и COMPRESSION_LZFSE. У NSData есть удобная обёртка decompressed, она позволяет не работать со стримом вручную. Брутфорсом подобрался ZLIB, и моделька успешно восстановилась в нашем коде.
И в заключение — готовое решение
Алгоритм стал ясен, решение было оформлено в класс с понятным интерфейсом. Что это нам дало? Мы добавили немного логов, и теперь по каждой БД в приложении понимаем:
Как часто меняется модель.
Как часто она меняется так, что кеш более не валиден, и какие поля это вызывают.
Какие сущности меняются чаще всего.
Теперь мы можем подумать над тем, как разделить кеш, чтобы удалять не всё при любом изменении модели, а только то, что меняется очень часто и сильно. Благодаря этому инвалидация затрагивает меньшее количество данных.
Мне не очень понятно, почему функциональность получения текущей модели из файла не сделали публичной. Я заполнил feature request (FB10972098) и всех неравнодушных прошу последовать моему примеру :)
А пока можно воспользоваться оформленным на GitHub решением. Функциональность покрыта тестами, которые проверяют, не изменила ли Apple принцип хранения последней модели (но с iOS 11 по iOS 16 всё оставалось неизменным).