- PVSM.RU - https://www.pvsm.ru -
Давно подметил, что среди многих своих коллег по цеху присутствует некоторая подозрительность и даже в некотором роде неприязнь к Core Data, причем некоторые к фреймворку даже и не притрагивались. Чего уж там, и я в начале своего пути освоения новой платформы относился к нему предвзято, пойдя на поводу у подобных комментариев. Но не стоит поддаваться предрассудкам и мифам, не потрогав продукт самостоятельно. Тем из нас, кто пошел «против системы», но еще не постиг инструмент полностью, я и посвящаю эту статью. На основе небольшого примера, основанного на реальной задаче разработки мобильного клиента нашей социальной сети Мой Мир, я хочу рассказать о некоторых «подводных» камнях и заострить внимание начинающего разработчика на важных моментах оптимизации использования Core Data. Предполагается, что читающий уже имеет представление, для чего нужны основные элементы Core Data (NSManagedObjectContext
, NSPersistentStoreCoordinator
и т.д.) и хотя бы поверхностно ознакомлен с API.
Наш кейс: необходимо разработать приложение, позволяющее хранить и структурировать большой объем фотографий с различной метаинформацией о них. Для этого нам потребуется Core Data… и все.
Итак, первое, что мы должны сделать — приготовить правильный Core Data стэк. К счастью для нас, есть универсальное решение, я думаю известный всем Best Practice от WWDC 2013:
Стэк [2] разделяется на два контекста, Main Context используется на главном потоке для чтения данных; Background Context — для редактирования, вставки и удаления больших объемов данных. То есть, рекомендуется изначально строить архитектуру своего приложения так, что все изменения происходят в бэкграунд контексте, а на главном контексте вы выполняете лишь read-only операции.
Хочется отметить, что по архитектуре стэков написано немало статей, описывающих всевозможные ветвления контекстов. На мой взгляд, они лишь задирают порог вхождения использования Core Data и только отпугивают начинающих разработчиков от применения фреймворка. На деле для 90% приложений будет достаточно вышеприведенной модели, еще 9% будет достаточно вообще одного Main Context и только оставшимся изваращ хардкорщикам нужно что-то более сложное.
NSSQLitePragmasOption
, однако это может быть чревато неприятностями. Также в iOS 6 в стэке с двумя координаторами при синхронизации контекстов через нотификацию могут не обновляться объекты в них [4]. Поэтому для iOS 6 лучше использовать стек с двумя контекстами, имеющими общий координатор и не заморачиваться с режимом журналирования, процент активных устройств крайне низок.AppDelegate
и инициализировать всё необходимое во время запуска приложения. Однако если в вашем приложении работа с базой имеет эпизодический или опциональный характер (например, она нужна только после регистрации пользователя в приложении и не нужна при гостевом доступе), имеет смысл вынести стек «в бок». Для этого подойдет отдельный Singleton
класс, который будет инициализироваться непосредственно в тот момент, когда это действительно нужно. Это позволит сохранить существенный объем памяти и сократить время запуска приложения.Продумывание схемы данных — самый важный момент при работе с Core Data. Исправление ошибки, допущенной на этапе проектирования архитектуры, может стоить разработчику кучу времени и нервов. Идеальный вариант, если после выхода в бой модель не изменяется. В реальности, если вам не придется прибегать к ручной миграции через Migration Manager и все изменения проглатываются Lightweight Migration [6] — вы молодец. Уделяйте этому этапу как можно больше времени и старайтесь экспериментировать с разными вариантами моделей.
Вернемся к нашему приложению, в нем мы хотим добиться следующих целей:
— синхронизировать фотографии с сервером без аффекта на UI (done! используем для этого Background Context в стэке);
— на главном экране показывать все фото, отсортированные по дате;
— на вторичном экране группировать фото, где критерии группировки — число лайков, фото внутри группы дополнительно сортируются по дате.
Подойдем для начала к решению задачи в лоб, создадим модель, в которой будет всего одна Entity — наша фотография со всей метаинформацией:
Получилось очень просто, и, будь мы ленивыми разработчиками, на этом работа была бы закончена (и статью писал бы кто-то другой :)).
Для тестирования мы будем предполагать, что на главном экране мы нам потребуется простой NSFetchRequest
, результаты которого мы затем покажем в UICollectionView
:
А на дополнительном экране мы воспользуемся всей мощью NSFetchedResultsController
для формирования секций и сортировки в них:
Определившись с нашей моделью, сделаем контрольный замер производительности на iPhone 5 для 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" как на рисунке:
Далее нам необходим файл 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:
Посмотрим, как теперь будет обрабатываться 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 довольно ограничено в использовании, так как накладывает дополнительные требования на синхронизацию контекстов). В нашем приложении само собой напрашивается разделение нашей записи на две: сама фотография и метаданные для нее:
Проведем очередной тест и посмотрим на работу индексов для такой модели.
ОперацииТип модели | Модель 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% |
Результаты этого теста довольно интересны. Обратите внимание, что скорость данной модели с использованием индекса идентична с учетом погрешности модели без него. Воспользовавшись уже известным нам способом заглянуть вглубь, мы можем обнаружить, что в обоих случаях индекс не задействуется, поскольку первым происходит 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:
Посмотрим на результаты тестирования:
ОперацииТип модели | Модель 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% с первоначальным вариантом без индексов.
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 является не только простым средством работы с данными, но и мощным инструментом в умелых руках. Исследуйте и экспериментируйте, а я надеюсь, что статья открыла для вас что-то новое и подтолкнула навстречу к более эффективному применению Core Data в своих проектах. Удачи!
Автор: WisDooMer
Источник [8]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/razrabotka-pod-ios/71617
Ссылки в тексте:
[1] Image: http://habrahabr.ru/company/mailru/blog/235941/
[2] Стэк: https://github.com/Wisors/CDTTest/blob/master/CoreData%20Tests/CoreData/CDTCoreDataStack.m
[3] WAL(Write Ahead Log) журналирования: http://www.sqlite.org/wal.html
[4] могут не обновляться объекты в них: http://stackoverflow.com/questions/19562119/coredata-threading-ios-6-vs-7
[5] здесь: http://pablin.org/2013/05/24/problems-with-core-data-migration-manager-and-journal-mode-wal/
[6] Lightweight Migration: https://developer.apple.com/library/ios/documentation/cocoa/conceptual/CoreDataVersioning/Articles/vmLightweightMigration.html
[7] Здесь: https://cloud.mail.ru/public/6f25710d0d0c%2FCDT.xlsx
[8] Источник: http://habrahabr.ru/post/235941/
Нажмите здесь для печати.