вторник, 28 августа 2018 г.

Core Data vs. Realm

Практический в каждом приложении требуется манипулировать какими либо данными, сохранять, загружать, фильтровать или искать. Чаще всего для этого используются фреймворки, работающие по принципу ORM, т.к. в объектно-ориентированном окружении это делать проще и удобнее. В таких системах разработчик может работать с данными, как с привычными объектами, использовать их напрямую в качестве модели данных (в паттернах типа MVC). Для разработчиков iOS приложений уже доступен встроенный фреймворк Core Data, который можно использовать для хранения и манипулирования данными. Но среди сторонних альтернатив тоже есть популярные реализации, одна из них Realm. В этой статье мы постараемся сравнить эти два решения, увидим, что у них общего, в чем различия и посмотрим на быстродействие. Возможно этот обзор поможет вам определиться с выбором.

Содержание


Синтаксис и базовые операции
Особенности реализации
Быстродействие
Выводы

Синтаксис и базовые операции


1. Инициализация

Рассмотрим минимально необходимый набор действий и код для настройки и дальнейшего использования хранилища данных.

Core Data
Для работы с CD в первую очередь необходимо создать специальный файл модели, где будут описаны типы хранимых данных. Для редактирования этого файла в Xcode есть специальный интерфейс.

После создания модели, нужно загрузить ее с помощью класса NSManagedObjectModel. Затем нужно создать объект класса NSPersistentStoreCoordinator, указав объект модели. После этого мы должны добавить хранилище, указав путь к файлу и способ хранения данных (в нашем случае мы будем использовать SQLite хранилище). И наконец мы создаем объект класса NSManagedObjectContext, с помощью которого и будем производить все манипуляции с данными. Сделаем для настройки Core Data специальный класс и будем использовать его:
#import "CoreDataStack.h"

@interface CoreDataStack()
@property (nonatomic) NSPersistentStoreCoordinator *persistentStoreCoordinator;
@property (nonatomic) NSManagedObjectModel *managedObjectModel;
@property (nonatomic, readwrite) NSManagedObjectContext *managedObjectContext;
@end

@implementation CoreDataStack

-(NSPersistentStoreCoordinator *) persistentStoreCoordinator
{
    if (_persistentStoreCoordinator != nil)
        return _persistentStoreCoordinator;
    
    NSURL *storeDirectoryURL = [[[NSFileManager defaultManager] URLsForDirectory:NSApplicationSupportDirectory inDomains:NSUserDomainMask] lastObject];
    storeDirectoryURL = [storeDirectoryURL URLByAppendingPathComponent:[[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleIdentifier"]];
    [[NSFileManager defaultManager] createDirectoryAtURL:storeDirectoryURL withIntermediateDirectories:YES attributes:nil error:nil];
    NSURL *storeURL = [storeDirectoryURL URLByAppendingPathComponent:@"data.sqlite"];

    NSError *error = nil;
    _persistentStoreCoordinator = [[NSPersistentStoreCoordinator alloc] initWithManagedObjectModel:self.managedObjectModel];
    if (![_persistentStoreCoordinator addPersistentStoreWithType:NSSQLiteStoreType configuration:nil URL:storeURL options:0 error:&error])
    {
        NSLog(@"NSPersistentStoreCoordinator persistent store initialization error %@", error);
        _persistentStoreCoordinator = nil;
    }

    return _persistentStoreCoordinator;
}

-(NSManagedObjectModel *) managedObjectModel
{
    if (_managedObjectModel != nil)
        return _managedObjectModel;
    
    NSURL *modelURL = [[NSBundle mainBundle] URLForResource:@"data" withExtension:@"momd"];
    _managedObjectModel = [[NSManagedObjectModel alloc] initWithContentsOfURL:modelURL];
    return _managedObjectModel;
}

-(NSManagedObjectContext *) managedObjectContext
{
    if (_managedObjectContext != nil)
        return _managedObjectContext;
    
    NSPersistentStoreCoordinator *coordinator = self.persistentStoreCoordinator;
    if (coordinator != nil)
    {
        _managedObjectContext = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSPrivateQueueConcurrencyType];
        [_managedObjectContext setPersistentStoreCoordinator:coordinator];
    }
    
    return _managedObjectContext;
}

@end

Справедливости ради, можно сказать, что в iOS 10 появился класс NSPersistentContainer, который немного упрощает настройку и берет на себя создание объектов модели, координатора и контекстов. Мы могли бы использовать его, вместо нашего собственного класса CoreDataStack.

Realm
Аналогом NSManagedObjectContext здесь выступает класс RLMRealm, но для начала работы с ним не требуется никаких предварительных действий, экземпляр класса можно получить, вызвав метод [RLMRealm defaultRealm] в любом месте в коде приложения.

2. Создание модели

Рассмотрим процесс создания модели данных.

Core Data
Во время инициализации стека Core Data мы рассмотрели создание файла модели и увидели интерфейс для ее редактирования. В этом интерфейсе мы создаем так называемые entity и задаем атрибуты, связи между entity. Кроме того, Xcode позволяет видеть нашу модель в виде диаграммы.
Далее для каждого entity нужно написать соответствующий класс, наследуемый от NSManagedObject, который будет использоваться в приложении в качестве элемента данных. Это можно сделать вручную, либо воспользоваться встроенным в Xcode генератором. В нашем примере мы сделаем это вручную, чтобы не нагромождать проект. У нас будет два entity, поэтому сделаем два класса.
#import <CoreData/CoreData.h>

@class CDProject;

@interface CDSwifter : NSManagedObject
@property (nonatomic) int16_t age;
@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *level;
@property (nonatomic, copy) NSString *bio;
@property (nonatomic, retain) NSSet<CDProject*> *failedProjects;
@end
#import <CoreData/CoreData.h>

@class CDSwifter;

@interface CDProject : NSManagedObject
@property (nonatomic) int16_t bugs;
@property (nonatomic, copy) NSString *name;
@property (nonatomic, retain) CDSwifter *swifter;
@end

Realm
Для описания модели достаточно создать класс, унаследованный от RLMObject и добавить в него необходимые property.
#import <Realm/Realm.h>
#import "RMProject.h"

@interface RMSwifter : RLMObject
@property NSInteger age;
@property NSString *name;
@property NSString *level;
@property NSString *bio;
@property RLMArray<RMProject *><RMProject> *failedProjects;
@end
#import <Realm/Realm.h>

@interface RMProject : RLMObject
@property NSInteger bugs;
@property NSString *name;
@end

RLM_ARRAY_TYPE(RMProject)

3. Создание и сохранение объектов

Рассмотрим как создаются и сохраняются данные в обоих фреймворках.

Core Data
Объекты создаются и изменяются в рамках NSManagedObjectContext, затем контекст сохраняется методом save: или изменения откатываются методом rollback.

Создание объекта:
NSManagedObjectContext *context = ...;

CDSwifter *swifter = [NSEntityDescription insertNewObjectForEntityForName:@"CDSwifter" inManagedObjectContext:context];
swifter.name = @"Ivan Kholod";
swifter.level = kCDSwifterLevelBaby;
swifter.age = 12;
swifter.bio = @"NSBorrowKit owner";
Создание другого объекта и связи с первым объектом:
CDProject *project = [NSEntityDescription insertNewObjectForEntityForName:@"CDProject" inManagedObjectContext:context];
project.name = @"NSBorrowKit";
project.bugs = 300;
project.swifter = swifter;
Сохранение данных контекста:
NSError* error = nil;
if (![context save:nil])
{
    NSLog(@"Failed to save NSManagedObjectContext: %@", error);
}

Realm
Пока объект не добавлен в RLMRealm, его можно использовать как любой другой объект в приложении. Добавление и последующее изменение делается только в рамках транзакции с помощью метода transactionWithBlock: либо методов beginWriteTransaction и commitWriteTransaction и их перегруженных аналогов.

Создание объекта:
RMSwifter* swifter = [RMSwifter new];
swifter.name = @"Nerzh Woodcrust";
swifter.level = kRMSwifterLevelBaby;
swifter.age = 11;
swifter.bio = @"MacBook Pro 2017 lover";
Создание другого объекта и связи с первым объектом:
RMProject* project = [RMProject new];
project.name = @"Unknown";
project.bugs = 1000;
[swifter.failedProjects addObject:project];
Сохранение данных:
RLMRealm *realm = [RLMRealm defaultRealm];
[realm transactionWithBlock:^{
    [realm addObject:swifter];
}];

4. Запросы

Теперь рассмотрим как сохраненные данные можно получить и отфильтровать.

Core Data
Получить все объекты CDSwifter:
NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"CDSwifter"];
NSArray *result = [context executeFetchRequest:fetchRequest error:nil];
Получить подмножество объектов CDSwifter с age > 10:
NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"CDSwifter"];
fetchRequest.predicate = [NSPredicate predicateWithFormat:@"age > 10"];
NSArray *result = [context executeFetchRequest:fetchRequest error:nil];
Получить подмножество объектов CDSwifter, у которых среди failedProjects есть такие, у которых bugs > 100:
NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"CDSwifter"];
fetchRequest.predicate = [NSPredicate predicateWithFormat:@"ANY failedProjects.bugs > 100"];
NSArray *result = [context executeFetchRequest:fetchRequest error:nil];
То же самое, но с сортировкой по возрасту:
NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"CDSwifter"];
fetchRequest.predicate = [NSPredicate predicateWithFormat:@"ANY failedProjects.bugs > 100"];
fetchRequest.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"age" ascending:YES]];
NSArray *result = [context executeFetchRequest:fetchRequest error:nil];

Realm
Получить все объекты RMSwifter:
RLMResults *result = [RMSwifter allObjects];
Получить подмножество объектов RMSwifter с age > 10:
RLMResults *result = [RMSwifter objectsWhere:@"age > 10"];
Получить подмножество объектов RMSwifter, у которых среди проваленных проектов есть такие, у которых больше 100 багов:
RLMResults *result = [RMSwifter objectsWhere:@"ANY failedProjects.bugs > 100"];
То же самое, но с сортировкой по возрасту:
RLMResults *result = [[RMSwifter objectsWhere:@"ANY failedProjects.bugs > 100"] sortedResultsUsingKeyPath:@"age" ascending:YES];
Кроме того, Realm позволяет делать каскадные запросы, например:
RLMResults *result = [[RMSwifter objectsWhere:@"level = %@", kRMSwifterLevelJunior] objectsWhere:@"age > 10"];

5. Отслеживание изменений

Важной частью работы с данными является отслеживание их изменений. Чаще всего это нужно для обновления интерфейса пользователя и уменьшения связности между различными частями кода приложения. Рассмотрим какие возможности дают нам оба инструмента.

Core Data
Для отслеживания изменений полей конкретного объекта можно использовать стандартный механизм KVO:
-(void) subscribe
{
    [self.swifter addObserver:self forKeyPath:@"age" options:NSKeyValueObservingOptionNew context:NULL];
}

-(void) observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
    if (self.swifter == object)
    {
        if ([keyPath isEqualToString:@"age"])
        {
            NSLog(@".age value changed: %@", object);
        }
    }
}
Для отслеживания изменений в выборке можно использовать специальный класс NSFetchedResultsController. С помощью его делегата можно получать уведомления об изменении как набора объектов запроса (удаленные, добавленные, перемещенные объекты), так и о факте изменения конкретного объекта в выборке (но без информации о том, что изменилось).
-(void) subscribe
{
    NSManagedObjectContext *context = nil;
    NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"CDSwifter"];
    fetchRequest.predicate = [NSPredicate predicateWithFormat:@"age > 10"];
    self.frc = [[NSFetchedResultsController alloc] initWithFetchRequest:fetchRequest managedObjectContext:context sectionNameKeyPath:nil cacheName:nil];
    frc.delegate = self;
    
    NSError* error = nil;
    if (![self.frc performFetch:&error])
    {
        NSLog(@"Failed to fetch with FRC: %@", error);
    }
}

#pragma mark - NSFetchedResultsControllerDelegate

-(void) controller:(NSFetchedResultsController *)controller didChangeObject:(id)anObject atIndexPath:(NSIndexPath *)indexPath forChangeType:(NSFetchedResultsChangeType)type newIndexPath:(NSIndexPath *)newIndexPath
{
    //...
}

-(void) controller:(NSFetchedResultsController *)controller didChangeSection:(id)sectionInfo atIndex:(NSUInteger)sectionIndex forChangeType:(NSFetchedResultsChangeType)type
{
    //...
}

-(void) controllerWillChangeContent:(NSFetchedResultsController *)controller
{
    //...
}

-(void) controllerDidChangeContent:(NSFetchedResultsController *)controller
{
    //...
}

Realm
Для отслеживания изменений полей объектов и выборок используется одинаковый механизм. В обоих случаях с помощью метода addNotificationBlock: можно добавить блок, который будет срабатывать при изменениях. В ответ метод возвращает специальный объект-токен, который нужно хранить в памяти. Блок будет вызываться, пока токен существует.
-(void) subscribe
{
    self.swifterUpdateToken = [self.swifter addNotificationBlock:^(BOOL deleted, NSArray<RLMPropertyChange *> *changes, NSError *error) {
        for (RLMPropertyChange* change in changes)
        {
            NSLog(@"Value of property '%@' changed from '%@' to '%@'", change.name, change.previousValue, change.value);
        }
    }];
    
    RLMResults *result = [RMProject objectsWhere:@"bugs > 100"];
    self.projectsUpdateToken = [result addNotificationBlock:^(RLMResults *results, RLMCollectionChange *change, NSError *error) {
        NSLog(@"Deletions: %@", change.deletions);
        NSLog(@"Insertions: %@", change.insertions);
        NSLog(@"Modifications: %@", change.modifications);
    }];
}
Стандартный механизм KVO для отслеживания значений property у объектов тоже доступен и можно использовать его при желании.


Особенности реализации


1. Управление объектами
Core Data: в рамках одного контекста один экземпляр entity представлен одним объектом в памяти, поэтому при соблюдении определенных условий объекты можно сравнивать на соответствие по указателю.
Realm: одной записи данных может соответствовать множество экземпляров класса RLMObject, поэтому объекты нет смысла сравнивать по указателю.

2. Запросы
Core Data: нет ленивых запросов; есть так называемые faults и relationship faults, т.е. частичная подгрузка данных entity и связанных entity в некоторых случаях, по мере необходимости; выборки статические и не меняются при изменении данных (не считая специального NSFetchedResultsController)
Realm: все запросы ленивые, реализованы собственные классы коллекций; данные подгружаются по мере необходимости; поддержка каскадных выборок; по умолчанию выборки и объекты автообновляемые и всегда соответствуют содержимому хранилища.

3. Сохранение и откат изменений
Core Data: все изменения объектов хранятся в рамках экземпляра NSManagedObjectContext; изменения передаются в родительский NSManagedObjectContext или NSPersistentStore во время операции save: или откатываются с помощью функций NSUndoManager у контекста.
Realm: все изменения объектов производятся в рамках транзакций и возможность отмены изменений существует только в рамках текущей транзакции.

4. Индексы
Core Data: есть поддержка индексов, в iOS 11 появилось более продвинутое API для работы с ними, в том числе поддержка составных индексов.
Realm: есть поддержка индексов по атрибутам, primary key, нет составных индексов.

5. Наследование
Core Data: есть поддержка наследования классов в модели
Realm: нет поддержки наследования

6. Шифрование
Core Data: нет встроенного шифрования
Realm: поддерживает встроенное прозрачное шифрование алгоритмом AES-256

7. Синхронизация
Core Data: есть синхронизация через iCloud между устройствами
Realm: есть синхронизация с сервером, авторизация

8. Многопоточность
Core Data: нельзя напрямую передавать данные между потоками; возможно создание дочерних контекстов, применение изменений дочернего контекста в родительский контекст; встроенная возможность выполнять операции с контекстом в private queue или main queue.
Realm: нельзя напрямую передавать данные между потоками; для каждого потока должен быть свой экземпляр RLMRealm; транзакции не блокируют операции чтения, но блокируют выполнение других транзакций; для корректной работы в многопоточной среде достаточно использовать метод [RLMRealm defaultRealm], который вернет собственный экземпляр RLMRealm для текущего потока.
Примечание: нужно быть осторожным при использовании Realm с private queue, т.к. код в рамках одной private queue может запускаться в контексте разных потоков, потому каждую операцию, запускаемую в private queue, нужно рассматривать, как отдельный поток, т.е. получать [RLMRealm defaultRealm] и запрашивать новые экземпляры данных.

9. Хранилище
Core Data: файл с данными можно просматривать стандартными инструментами.
Realm: для просмотра файла с данными есть свое приложение.


Быстродействие


Проведем несколько тестов на скорость выполнения основных операций, таких как сохранение новых объектов, обновление существующих и поиск. Мы не будем ставить своей целью проводить всесторонний тест с учетом всех возможных конфигураций, а просто взглянем, что мы получаем при использовании в самых обычных ситуациях. При желании, вы можете повторить или модифицировать тесты, ссылка на исходный код будет в конце статьи.
Все сравнительные замеры будут производиться на идентичных данных в равных условиях, на одном устройстве. На каждый тест будем делать по 5 запусков подряд. Перед каждым тестом
приложение перезапускается. Данных в тестах будет больше, чем обычно используют большинство реальных приложений, чтобы лучше видеть разницу измерений.
На всех диаграммах по оси Х указан порядковый номер измерения, по оси Y результаты измерений в секундах.

Создание 500000 объектов 


В этом тесте мы создаем 50 тысяч объектов и для каждого создаем 10 объектов другого типа, связанных с ним.
Здесь стоит отметить, что Core Data выдержала 4 запуска теста, т.е. было создано всего 2000000 объектов, после чего приложение было завершено из-за нехватки памяти. Realm выдержал только 3 запуска, т.е. было создано 1500000 объектов.

Обновление 50000 объектов


В этом тесте мы запрашиваем все 50000 объектов, обновляем значение одного поля и сохраняем изменения

Выборка всех объектов


В этом тесте мы запрашиваем все 50000 объектов.

Выборка всех объектов и запрос значений всех полей


В этом тесте мы запрашиваем все 50000 объектов, и проходя по всем объектам из выборки, обращаемся ко всем полям с данными.

Выборка с использованием предиката (фильтр по числовому полю)


В этом тесте под выборку попадает половина имеющихся данных. Данные фильтруются с помощью предиката по полю числового типа.

Примечение: т.к. запросы в Realm "ленивые", в измерение входит обращение к полю .count выборки, чтобы заставить запрос выполниться. Кроме того, в целях оптимизации Realm не отдает сразу данные объектов, поэтому в подобных тестах добавлено еще одно измерение, включающее проход по выборке и обращение к полям объекта.

Выборка с использованием предиката (фильтр по строковому полю)


В этом тесте под выборку попадает половина имеющихся данных. Данные фильтруются с помощью предиката по полю строкового типа.

Примечение: т.к. запросы в Realm "ленивые", в измерение входит обращение к полю .count выборки, чтобы заставить запрос выполниться. Кроме того, в целях оптимизации Realm не отдает сразу данные объектов, поэтому в подобных тестах добавлено еще одно измерение, включающее проход по выборке и обращение к полям объекта.

Выборка с использованием предиката (используя данные из связанных объектов)


В этом тесте под выборку попадают почти все имеющиеся данные. Данные фильтруются с помощью предиката, используя значения из объектов другого типа, с которыми есть связь вида "один ко многим".

Примечение: т.к. запросы в Realm "ленивые", в измерение входит обращение к полю .count выборки, чтобы заставить запрос выполниться. Кроме того, в целях оптимизации Realm не отдает сразу данные объектов, поэтому в подобных тестах добавлено еще одно измерение, включающее проход по выборке и обращение к полям объекта.

Выборка с использованием текстового поиска (строковое поле)


В этом тесте под выборку попадают все имеющиеся данные. Данные фильтруются с помощью предиката CONTAINS[cd] по полю строкового типа. Значение поля - короткий строковый идентификатор.

Примечение: т.к. запросы в Realm "ленивые", в измерение входит обращение к полю .count выборки, чтобы заставить запрос выполниться. Кроме того, в целях оптимизации Realm не отдает сразу данные объектов, поэтому в подобных тестах добавлено еще одно измерение, включающее проход по выборке и обращение к полям объекта.

Выборка с использованием текстового поиска (строковое поле)


В этом тесте под выборку попадают все имеющиеся данные. Данные фильтруются с помощью предиката CONTAINS[cd] по полю строкового типа. Значение поля - фрагмент текста ~3Кб.

Примечение: т.к. запросы в Realm "ленивые", в измерение входит обращение к полю .count выборки, чтобы заставить запрос выполниться. Кроме того, в целях оптимизации Realm не отдает сразу данные объектов, поэтому в подобных тестах добавлено еще одно измерение, включающее проход по выборке и обращение к полям объекта.


Выводы


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

Ссылки


Исходный код
Core Data Programming Guide
Realm Documentation

Наш Twitter https://twitter.com/ios_fathers
Наша группа в Telegram https://t.me/joinchat/EK6aXwxr0hj7rAc4Z1NiKw или @ios_fathers





2 комментария:

  1. В Realm есть реализация наследования: https://realm.io/docs/swift/latest#model-inheritance

    ОтветитьУдалить
    Ответы
    1. Но там ведь написано обратное. Формально да, можно унаследовать один класс от другого, но ты не получишь ожидаемого результата, кроме шаринга кода методов, если таковые есть у классов модели. И далее написано, что функционал только в планах на реализацию.

      Удалить