...

вторник, 14 октября 2014 г.

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

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

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


Core Data rulezzz!



Итак, первое что мы должны сделать — приготовить правильный 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:
































Операции\Тип моделиМодель V1V1+индексDiff
Insert (10000 objects)1.9522.193+12%
NSFetchRequest (1 sort)0.5000.168-66%
NSFetchedResultsController (2 sorts)0.7170.657-8%
NSFetchedResultsController (2 sorts + batchSize)0.3020.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




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





































Операции\Тип моделиМодель V1V1+индексV1+составной индексDiff(V1)
Insert (10000 objects)1.9522.1932.079+7%
NSFetchRequest (1 sort)0.5000.1680.169-66%
NSFetchedResultsController (2 sorts)0.7170.6570.331-54%
NSFetchedResultsController (2 sorts + batchSize)0.3020.2560.182-40%

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

Separated entities


Проведем очередной тест и посмотрим на работу индексов для такой модели.

































Операции\Тип моделиМодель V2V2+индексDiff(V1+индекс)
Insert (10000 objects)3.2183.524+61%
NSFetchRequest (1 sort)0.2190.215+28%
NSFetchedResultsController (2 sorts)0.5510.542-18%
NSFetchedResultsController (2 sorts + batchSize)0.3870.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


Посмотрим на результаты тестирования:

































Операции\Тип моделиМодель V3Diff(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 в своих проектах. Удачи!


This entry passed through the Full-Text RSS service - if this is your content and you're reading it on someone else's site, please read the FAQ at http://ift.tt/jcXqJW.


Комментариев нет:

Отправить комментарий