Pull to refresh
VK
Building the Internet

Не нужно бояться Core Data

Reading time 11 min
Views 34K
Давно подметил, что среди многих своих коллег по цеху присутствует некоторая подозрительность и даже в некотором роде неприязнь к Core Data, причем некоторые к фреймворку даже и не притрагивались. Чего уж там, и я в начале своего пути освоения новой платформы относился к нему предвзято, пойдя на поводу у подобных комментариев. Но не стоит поддаваться предрассудкам и мифам, не потрогав продукт самостоятельно. Тем из нас, кто пошел «против системы», но еще не постиг инструмент полностью, я и посвящаю эту статью. На основе небольшого примера, основанного на реальной задаче разработки мобильного клиента нашей социальной сети Мой Мир, я хочу рассказать о некоторых «подводных» камнях и заострить внимание начинающего разработчика на важных моментах оптимизации использования Core Data. Предполагается, что читающий уже имеет представление, для чего нужны основные элементы Core Data (NSManagedObjectContext, NSPersistentStoreCoordinator и т.д.) и хотя бы поверхностно ознакомлен с API.

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

Core Data rulezzz!

Core Data Stack

Итак, первое, что мы должны сделать — приготовить правильный Core Data стэк. К счастью для нас, есть универсальное решение, я думаю известный всем Best Practice от WWDC 2013:

Core Data Stack

Стэк разделяется на два контекста, Main Context используется на главном потоке для чтения данных; Background Context — для редактирования, вставки и удаления больших объемов данных. То есть, рекомендуется изначально строить архитектуру своего приложения так, что все изменения происходят в бэкграунд контексте, а на главном контексте вы выполняете лишь read-only операции.

Хочется отметить, что по архитектуре стэков написано немало статей, описывающих всевозможные ветвления контекстов. На мой взгляд, они лишь задирают порог вхождения использования Core Data и только отпугивают начинающих разработчиков от применения фреймворка. На деле для 90% приложений будет достаточно вышеприведенной модели, еще 9% будет достаточно вообще одного Main Context и только оставшимся изваращ хардкорщикам нужно что-то более сложное.

Тонкие моменты

  • Начиная с iOS 7 sqlite хранилище в отличие от предыдущих версий работает в режиме WAL(Write Ahead Log) журналирования, который позволяет совершать одну операцию записи и множественные операции чтения параллельно. Если вдруг вы поддерживаете iOS 6, то возможно включить этот режим при создании координатора стэка в версиях iOS 4+ с помощью NSSQLitePragmasOption, однако это может быть чревато неприятностями. Также в iOS 6 в стэке с двумя координаторами при синхронизации контекстов через нотификацию могут не обновляться объекты в них. Поэтому для iOS 6 лучше использовать стек с двумя контекстами, имеющими общий координатор и не заморачиваться с режимом журналирования, процент активных устройств крайне низок.
  • WAL также хранит в себе мину замедленного действия в виде поломанной ручной миграции и возможных ошибок бэкапирования. Так как хранилище на диске организовано в виде трех файлов: dbname.sqlite, dbname.sqlite-wal и dbname.sqlite-shm, то при организации ручного бэкапа нужно не забывать сохранять их всех, иначе потом вас будет ждать очень «приятный» сюрприз. Инженеры Apple видимо сами забыли о наличии WAL файла, поэтому при использовании Migration Manager мы также можем разломать базу. Сам я не сталкивался с подобной проблемой, почитать подробнее можно здесь.
  • Типичные мануалы по Core Data и шаблон проекта в Xcode предлагают размещать стэк прямо в классе AppDelegate и инициализировать всё необходимое во время запуска приложения. Однако если в вашем приложении работа с базой имеет эпизодический или опциональный характер (например, она нужна только после регистрации пользователя в приложении и не нужна при гостевом доступе), имеет смысл вынести стек «в бок». Для этого подойдет отдельный Singleton класс, который будет инициализироваться непосредственно в тот момент, когда это действительно нужно. Это позволит сохранить существенный объем памяти и сократить время запуска приложения.

Проектирование модели

Продумывание схемы данных — самый важный момент при работе с Core Data. Исправление ошибки, допущенной на этапе проектирования архитектуры, может стоить разработчику кучу времени и нервов. Идеальный вариант, если после выхода в бой модель не изменяется. В реальности, если вам не придется прибегать к ручной миграции через Migration Manager и все изменения проглатываются Lightweight Migration — вы молодец. Уделяйте этому этапу как можно больше времени и старайтесь экспериментировать с разными вариантами моделей.

Вернемся к нашему приложению, в нем мы хотим добиться следующих целей:
синхронизировать фотографии с сервером без аффекта на UI (done! используем для этого Background Context в стэке);
— на главном экране показывать все фото, отсортированные по дате;
— на вторичном экране группировать фото, где критерии группировки — число лайков, фото внутри группы дополнительно сортируются по дате.

Подойдем для начала к решению задачи в лоб, создадим модель, в которой будет всего одна Entity — наша фотография со всей метаинформацией:

First model

Получилось очень просто, и, будь мы ленивыми разработчиками, на этом работа была бы закончена (и статью писал бы кто-то другой :)).

Для тестирования мы будем предполагать, что на главном экране мы нам потребуется простой NSFetchRequest, результаты которого мы затем покажем в UICollectionView:

NSFetchRequest

А на дополнительном экране мы воспользуемся всей мощью NSFetchedResultsController для формирования секций и сортировки в них:

NSFetchedResultsController

Определившись с нашей моделью, сделаем контрольный замер производительности на iPhone 5 для 10000 фото. Здесь и далее мы будем производить тестирование нашей модели на типичные операции, связанные с нашей моделью:
  • Вставка 10000 объектов с последующим сохранением контекста
  • Реквест всех 10000 объектов, отсортированных по одному полю (в нашем случае дата)
  • Использование NSFetchedResultsController c сортировкой по 2 полям и формированием секций (сортировка по количеству лайков и дате, формирование секций по количеству лайков)
  • Все тот же контроллер с использованием fetchBatchSize равным 30 (предполагаемое количество фотографий на экране галереи на телефоне), для оценки эффективности блочной выборки данных

Все данные в таблицах приведены в секундах, соответственно вставка 10000 наших фотографий на iPhone 5 займет чуть меньше двух секунд.
Операции\Тип модели Модель V1
Insets (10000 objects) 1.952
NSFetchRequest (1 sort) 0.500
NSFetchedResultsController (2 sorts) 0.717
NSFetchedResultsController (2 sorts + batchSize) 0.302

Индексы

Хотя время исполнения может показаться несущественным, не стоит пренебрегать возможностью оптимизации. Более того, на старых устройствах операции выполняются в разы медленнее, и забывать об этом точно не стоит. Первая оптимизация самая легкая и известна каждому — попробуем добавить индекс для полей, которые участвуют в формируемых нами запросах, а именно date и likes:
Операции\Тип модели Модель V1 V1+индекс Diff
Insert (10000 objects) 1.952 2.193 +12%
NSFetchRequest (1 sort) 0.500 0.168 -66%
NSFetchedResultsController (2 sorts) 0.717 0.657 -8%
NSFetchedResultsController (2 sorts + batchSize) 0.302 0.256 -15%

Довольно неплохой прирост производительности при минимальных затратах. Заметьте, что время добавления записей выросло, это обусловлено необходимостью построения индекса. Именно поэтому важно применять индекс только там, где это действительно нужно. Проставляя флажок Indexed на все возможные поля, думая что это ускорит ваше приложение, вы делаете себе медвежью услугу.

Все ли соки мы выжали из индекса? Можно заметить, что NSFetchedResultsController «ускорился» значительно в меньшей степени, чем простой NSFetchRequest. В чем же дело?

Составные индексы

Давайте заглянем под капот CoreData. В первую очередь для этого нам нужно включить лог для Core Data запросов, добавив в Run схему нашего проекта параметр " -com.apple.CoreData.SQLDebug 1" как на рисунке:

Debug

Далее нам необходим файл sqlite хранилища в его наполненном состоянии. Если вы работаете с симулятором, то Хcode 6 хранит файловую систему симуляторов в директории "~/Library/Developer/CoreSimulator/Devices/". Название директории симулятора соответствует значению Identifier, который можно посмотреть в списке устройств (открывается по Shitft+CMD+2). Далее ищем директорию своего приложения и узнаем полный путь до .sqlite файла, который обычно размещают в директории Documents приложения. Если же вы хотите получить доступ к хранилищу на устройстве, то самый простой способ воспользоваться приложением iExplorer, используя его как файл-менеджер для просмотра директорий приложений на вашем девайсе. Оттуда вы можете скопировать файлы хранилища (не забывайте про .sqlite-wal и .sqlite-shm файлы) в любую удобную вам директорию. Все, что осталось сделать — это подключиться к нашему хранилищу из консоли, выполнив команду:

sqlite3 PATH/TO/SQLITE/FILE

Теперь, запустив наш проект и скормив SQL директиве "EXPLAIN QUERY PLAN" запрос из логов Core Data, мы можем узнать некоторые подробности происходящих в sqlite процессов. Посмотрим, что же происходит на самом деле при выполнение NSFetchRequest:

sqlite> EXPLAIN QUERY PLAN SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZASSETURL, t0.ZCOUNTRY, t0.ZDATE, t0.ZHEIGHT, t0.ZLATITUDE, t0.ZLIKES, t0.ZLOCATIONDESC, t0.ZLONGITUDE, t0.ZSIZE, t0.ZWIDTH FROM ZCDTMOPHOTOV1INDEX t0 ORDER BY t0.ZDATE;
0|0|0|SCAN TABLE ZCDTMOPHOTOV1INDEX AS t0 USING INDEX ZCDTMOPHOTOV1INDEX_ZDATE_INDEX

Как и ожидалось SQL-запрос использует индекс, что и привело к существенному ускорению. А что же происходит в NSFetchedResultsController:

sqlite> EXPLAIN QUERY PLAN SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZASSETURL, t0.ZCOUNTRY, t0.ZDATE, t0.ZHEIGHT, t0.ZLATITUDE, t0.ZLIKES, t0.ZLOCATIONDESC, t0.ZLONGITUDE, t0.ZSIZE, t0.ZWIDTH FROM ZCDTMOPHOTOV1INDEX t0 ORDER BY t0.ZLIKES DESC, t0.ZDATE DESC;
0|0|0|SCAN TABLE ZCDTMOPHOTOV1INDEX AS t0 USING INDEX ZCDTMOPHOTOV1INDEX_ZLIKES_INDEX
0|0|0|USE TEMP B-TREE FOR RIGHT PART OF ORDER BY

Тут дела обстоят несколько хуже, индекс сработал только для likes, а для сортировки по дате создается временное бинарное дерево. Оптимизировать такое поведение легко, создав составной индекс (compound index) для обоих полей участвующих в запросе (CAUTION: если в вашем запросе появится дополнительное условие, например WHERE, с каким-то третьим полем, то его также необходимо добавить к составному индексу, иначе он не будет использоваться при запросе). Делается это очень легко в Data Model Inspector, указав через запятую все поля, включаемые в составной индекс, в списке Indexes нашей фото Entity:

Data Model Inspector

Посмотрим, как теперь будет обрабатываться SQL-запрос:

sqlite> EXPLAIN QUERY PLAN SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZASSETURL, t0.ZCOUNTRY, t0.ZDATE, t0.ZHEIGHT, t0.ZLATITUDE, t0.ZLIKES, t0.ZLOCATIONDESC, t0.ZLONGITUDE, t0.ZSIZE, t0.ZWIDTH FROM ZCDTMOPHOTOV1COMPOUNDINDEX t0 ORDER BY t0.ZLIKES DESC, t0.ZDATE DESC;
0|0|0|SCAN TABLE ZCDTMOPHOTOV1COMPOUNDINDEX AS t0 USING INDEX ZCDTMOPHOTOV1COMPOUNDINDEX_ZLIKES_ZDATE

Можно убедиться, что вместо бинарного дерева используется составной индекс, и это не может не сказаться на производительности:
Операции\Тип модели Модель V1 V1+индекс V1+составной индекс Diff(V1)
Insert (10000 objects) 1.952 2.193 2.079 +7%
NSFetchRequest (1 sort) 0.500 0.168 0.169 -66%
NSFetchedResultsController (2 sorts) 0.717 0.657 0.331 -54%
NSFetchedResultsController (2 sorts + batchSize) 0.302 0.256 0.182 -40%

Разбиение сущностей

Еще одна возможность для оптимизации — создание сущностей, которые содержат только нужную нам в конкретном запросе информацию. Мы видим, что наша структура содержит множество второстепенных полей, никак не участвующих в формировании выдачи первоначального результата в наших контроллерах. Более того, Core Data при работе с объектом полностью вытягивает их в память, то есть чем больше структура, тем больше потребляемой памяти (прим. в iOS 8 появилось API, позволяющее изменять объекты прямо в хранилище; API довольно ограничено в использовании, так как накладывает дополнительные требования на синхронизацию контекстов). В нашем приложении само собой напрашивается разделение нашей записи на две: сама фотография и метаданные для нее:

Separated entities

Проведем очередной тест и посмотрим на работу индексов для такой модели.
Операции\Тип модели Модель V2 V2+индекс Diff(V1+индекс)
Insert (10000 objects) 3.218 3.524 +61%
NSFetchRequest (1 sort) 0.219 0.215 +28%
NSFetchedResultsController (2 sorts) 0.551 0.542 -18%
NSFetchedResultsController (2 sorts + batchSize) 0.387 0.390 +52%

Why is your index now?

Результаты этого теста довольно интересны. Обратите внимание, что скорость данной модели с использованием индекса идентична с учетом погрешности модели без него. Воспользовавшись уже известным нам способом заглянуть вглубь, мы можем обнаружить, что в обоих случаях индекс не задействуется, поскольку первым происходит JOIN метаданных, и только потом производятся сортировки в объединенной таблице:

sqlite> EXPLAIN QUERY PLAN SELECT 0, t0.Z_PK, t0.Z_OPT, t0.ZASSETURL, t0.ZMETA FROM ZCDTMOPHOTOV2INDEX t0 LEFT OUTER JOIN ZCDTMOPHOTOMETAINDEX t1 ON t0.ZMETA = t1.Z_PK ORDER BY t1.ZLIKES DESC, t1.ZDATE DESC;
0|0|0|SCAN TABLE ZCDTMOPHOTOV2INDEX AS t0
0|1|1|SEARCH TABLE ZCDTMOPHOTOMETAINDEX AS t1 USING INTEGER PRIMARY KEY (rowid=?)
0|0|0|USE TEMP B-TREE FOR ORDER BY

Итог: эта модель нам не подходит.

Нормализация

Продолжаем наши эксперименты. Мы убедились, что строгая нормализация данных не всегда есть хорошо для Core Data. Результаты прошлой модели оказались далеки от ожидаемых. Попробуем это исправить. Для этого достаточно продублировать наши поля date и likes в сущности фотографии (не забыв добавить составной индекс и отдельный для date), тем самым избежать необходимости LEFT OUTER JOIN в наших запросах. Решение оставлять или удалять эти поля в сущности метаданных нужно принять в зависимости от ситуации. Например, если дополтительно вы захотите сделать запрос с рейтингом стран по сумме лайков фотографий, сделанных в них, то при удалении этих полей мы опять столкнемся с необходимость делать JOIN, но уже в другую сторону связи. В нашем тесте свойства сущностей дублируются, и это совершенно нормальное являение для Core Data:

Third model

Посмотрим на результаты тестирования:
Операции\Тип модели Модель V3 Diff(V1+составной индекс) Diff(V1)
Insert (10000 objects) 3.861 +86% +98%
NSFetchRequest (1 sort) 0.115 -32% -77%
NSFetchedResultsController (2 sorts) 0.283 -15% -61%
NSFetchedResultsController (2 sorts + batchSize) 0.181 -1% -40%

Эксперимент удался, мы добились ускорения операций чтения, которые являются основными в приложении до 40% в сравнении с самой быстрой плоской моделью и до 80% с первоначальным вариантом без индексов.

Итоги и тонкие моменты

  • Применяйте индексы и используйте их только для актуальных в ваших запросах полей. Не забывайте о существование составных индексов
  • Экспериментируйте с разными схемами, тестируйте их производительность. Это очень просто, ведь в Xcode 6 появилась встроенная поддержка perfomance тестов.
  • Не забывайте проверять как CoreData фреймворк генерирует SQL-запросы, используя логи. С помощью "EXPLAIN QUERY PLAN" изучайте как sqlite переваривает ваш SQL запрос.
  • При обращении к результатам NSFetchedResultsController используйте только метод доступа, предоставляемый самим контроллером:

    NSManagedObject *object = [controller objectAtIndexPath:indexPath];
    

    Не стоит обращаться к массиву fetchedObjects или по протоколу NSFetchedResultsSectionInfo к массиву объектов секции:

    NSManagedObject *object = [[controller fetchedObjects] objectAtIndex:index];
    // или
    NSArray *objects = [[[controller sections] objectAtIndex:sectionIndex] objects];
    NSManagedObject *object = [objects objectAtIndex:index];
    

    Почему, спросите вы? Если вы используете fetchBatchSize размером N, то после выполнения запроса контроллером в память будет загружено только первые N объектов (либо первая секция, если размер блока больше размера секции!). Как только вы запросите первый fault-объект за пределами загруженного блока или объект из другой секции, то контроллер произведет полный проход по результатам вашего запроса, то есть выполнит N=количествоОбъектов / fetchBatchSize запросов к хранилищу. Это операция приблизительно в 3-4 раза медленнее, чем простой запрос на все элементы. При использовании доступа через objectAtIndexPath такое поведение не наблюдается. Буду очень рад, если среди читающих найдется кто-то, кто может пролить свет на такое странное поведение, не описанное в документации.
  • Нормализация — не всегда лучшее решение для Core Data
  • Если со сцены в Купертино вам говорят о том, что новый iPhone в 2 раза быстрее предыдущего… нужно этому верить, на Core Data операциях эти утверждения подтверждаются практически полностью. Я подготовил сводный файл с результатами, где вы также найдете тесты iPhone 5S. Практически по всем результатам он быстрее своего предшественника в 2 раза. Соответвенно, на еще актуальном iPhone 4S эти результаты будут примерно в 2 раза медленнее, не говоря уже о еще более старых устройствах. Здесь вы найдете сводную таблицу результатов, в которой также есть результаты нового iPhone 6.


Как вы можете заменить, Core Data является не только простым средством работы с данными, но и мощным инструментом в умелых руках. Исследуйте и экспериментируйте, а я надеюсь, что статья открыла для вас что-то новое и подтолкнула навстречу к более эффективному применению Core Data в своих проектах. Удачи!
Tags:
Hubs:
+27
Comments 27
Comments Comments 27

Articles

Information

Website
vk.com
Registered
Founded
Employees
5,001–10,000 employees
Location
Россия
Representative
Миша Берггрен