Как подружиться с Realm

в 9:18, , рубрики: android, clean architecture, java, orm, Realm, repository, Блог компании FairBear, разработка мобильных приложений, Разработка под android

В этой статье мы хотели бы поделиться опытом использования набирающей популярность библиотеки для хранения данных — Realm. Перед любым проектом вначале разработки встает вопрос что использовать для хранения данных — что-то проверенное или попробовать инструменты из разряда для хипстеров.

image

Мы — небольшой стартап, разрабатывающий детский лаунчер. Хотя мы стартап и у нас небольшая команда, но большое внимание мы уделяем качеству кода. За два года разработки довольно сильно менялись требования, функционал и выбранные нами технологии. Вплоть до того, что мы перешли с полностью нативного приложения на гибридное, на основе Cordova. Также, одним из этих изменений стал переход с BaaS от Facebook'а Parse на Realm. В этой статье мы хотим рассказать о проблемах, с которыми мы столкнулись при переходе на Realm и стоит ли пробовать новые библиотеки, если со старыми уже "подружились".

Realm — библиотека, предназначенная для облегчения хранения данных на устройстве, аналог ORM, только со своим ядром и спецификой. В настоящее время ее разработчики дали решение и для облачного хранения этих же объектов. Все строится на идеологии "живых объектов", в этой системе не нужно думать о синхронизации между потоками, устройствами, пользователями, все делается за вас — любое изменение автоматически применяется ко всем клиентам этого объекта. К тому же разработчики этой базы данных обещают большую скорость выборок, тем самым выборки из базы данных можно делать чуть ли не на UI-потоке.

Что было до Realm

До этого мы использовали Parse. В нем объекты представлялись, как словари, которые сериализовывались в виде json'ов и, либо отправлялись в облако, либо сохранялись на устройстве. Синхронизация лежала полностью на плечах разработчика приложения, но SDK давало довольно много возможностей, к тому же было open-source, чем мы пользовались иногда и пытались себе облегчить взаимодействие между сервером и устройством, форкнув SDK.

Из проблем, с которыми мы столкнулись при использовании Parse, можно отметить:

1) Трудность отладки — анонимные классы от Bolts очень трудно тестировать и профилировать, невозможно понять, что откуда идет. К тому же именно эти классы у нас тратили в узких местах до 40% времени выполнения.
2) Довольно часто, при большом количестве данных, скорость обработки данных оставляла желать лучшего.
3) Много своего кода для синхронизации между хранилищами.

Совет: изначально думайте о синхронизации ваших хранилищ и баз данных, иначе потом будет происходить невероятное.

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

Немного кода

У нас есть две реализации интерфейса для хранения данных:

public interface Storage<ModelType, QueryType> {
    interface Transaction<T> {
        T transact() throws Exception;
    }
    QueryType initQuery(QueryType query);
    void saveInBackground(ModelType object, @Nullable SaveCallback callback);
    void saveAllInBackground(List<? extends ModelType> objects, @Nullable SaveCallback callback);
    void save(ModelType object) throws StorageException;
    void saveAll(List<? extends ModelType> objects) throws StorageException;
    void deleteInBackground(ModelType object, @Nullable DeleteCallback callback);
    void deleteAllInBackground(List<? extends ModelType> object, @Nullable DeleteCallback callback);
    void deleteAll(List<? extends ModelType> object) throws StorageException;
    void delete(ModelType object) throws StorageException;
    void discard() throws StorageException;
    void discardInBackground(@Nullable DeleteCallback cb);
    boolean isDiscarded();
}

LocalStorage implements Storage<...>
CloudStorage implements Storage<...>

Совет: всегда прячьте свои хранилища за интерфейс, даже если не видите в этом смысла. Не разбирайте Cursor в Fragment'e или View.

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

Год назад Parse анонсировал закрытие своего сервиса и мы, не долго думая, решили переходить на Realm. Весь переход мы представляли как "перепишем LocalStorage под специфику и интерфейс Realm SDK". Но все, как обычно, оказалось немного сложнее...

Немного об устройстве Realm

Сам переход занял довольно мало времени — Realm API очень удобное и требует минимальных телодвижений от разработчика. Мы сразу же отказались от асинхронных методов в нашем хранилище, решив, что все будет происходить на вызывающем потоке.

Realm и логика получения объектов из него работает следующим образом:

Есть объект Realm — это точка вхождения в базу данных, через которую идет как получение объектов, так и их сохранение. Все объекты привязаны к какому-либо инстансу Realm'а. Более того каждый Realm привязан к потоку на котором вы его получили. За счет этого достигается синхронизация всех объектов базы данных. Все, однажды полученные объекты на конкретном Realm'е, кешируются в памяти и следующие их получения происходят гораздо быстрее. Внутри объекты Realm синхронизируются друг между другом. Как говорят разработчики Realm'а эти синхронизации почти мгновенные, но на наш взгляд иногда они происходили с задержками.

Так же можно задавать разные конфигурации для этих инстансов. Разработчики Realm советуют как можно активнее использовать разделение на конфигурации. Конфигурация это аналог базы данных. К примеру хранить в разных конфигурациях разные типы объектов, или разделять состояния объектов и зависимо от состояния перемещать между ними. Таким образом работая с одной базой данных не будет работы с лишними объектами. На практике единственное, как мы могли бы разделить на конфигурации это Realm для логирования и для всего остального.

Объекты Realm'а бывают двух типов. Тот, который описали в коде вы и который сгенерился при сборке проекта. В runtime они отличаются тем, что ваш объект не смотрит никуда и это простой POJO-объект, объект типа сгенерированного класса — это прокси объект, так называемый медиатор, set и get методы которого смотрят в базу данных. То есть любое изменение объекта мгновенно ведет к его изменению в базе данных.

Специфическая синхронизация в этот момент накладывает довольно сильное ограничение — мы можем изменять прокси-объекты только из того потока, на котором их получили, но об этом чуть ниже.

К тому же, чтобы сбросить in-memory кеш нужно вызывать метод close у Realm-объекта, с которым вы взаимодействовали. Более того, при получении инстанса срабатывает счетчик объектов на каждом потоке. То есть метод close срабатывает только тогда, когда мы его вызвали столько раз, сколько раз вызывали Realm.getInstance() на этом же потоке.

Такая логика накладывает огромные ограничения для архитектуры приложения, которая прячет базу данных за интерфейсом. В документации же показан очень простой пример использования этого метода.

Мы свели на нет их логику, с подсчетом выданных объектов, кешируя выданные инстансы в ThreadLocal список. На каждый поток мы выдаем и держим ровно один инстанс realm'а. При его закрытии мы удаляем его.

Немного кода

private final Context mContext;
private final ThreadLocal<Realm> mRealm = new ThreadLocal<>();

public void save(RealmObject object) throws StorageException {
    Realm realm = getRealm();
    realm.beginTransaction();
    try {
        realm.copyToRealmOrUpdate(object);
        realm.commitTransaction();
    } catch (Exception e) {
        realm.cancelTransaction();
        throw new StorageException(e);
    }
}
private Realm getRealm() {
    initIfNeeded(mContext);
    Realm realm = mRealm.get();
    if (realm == null) {
        Log.d(Tags.STORAGE(), "Init Realm on the thread: " + Thread.currentThread().getName());
        realm = Realm.getDefaultInstance();
        mRealm.set(realm);
    }
    return realm;
}
public void closeConnection() {
    Realm realm = mRealm.get();
    if (realm != null) {
        Log.d(Tags.STORAGE(), "Clos on finished thread: " + Thread.currentThread().getName());
        realm.close();
        mRealm.remove();
    }
}

Осталось научиться вовремя его закрывать...

Совет: контролируйте каждый поток, который запускается в приложении — группируйте их по пулам потоков. Никогда не делайте в лоб.

new Thread(new Runnable() {...}).start()

Нам это очень помогло. Все выполняющиеся операции в других потоках мы контролировали через пулы потоков. Тем самым мы можем не изменяя бизнес-логику чистить Realm-объекты после выполнения потока в пуле.

Выглядит это следующим образом:

Немного кода

class RealmCleanerThreadExecutor(val mStorageManager: StorageManager) {
  private val TAGS = List("RealmCleanerThreadExecutor")

  private val mCount: AtomicInteger = new AtomicInteger(0)
  private val CPU_COUNT = Runtime.getRuntime.availableProcessors
  private val CORE_POOL_SIZE = CPU_COUNT + 1

  val threadFactory = new ThreadFactory() {
    private val mCount: AtomicInteger = new AtomicInteger(0)

    def newThread(r: Runnable): Thread = {
      new Thread(() => {
        r.run()
        mStorageManager.getRealm.closeConnection()
      }, "RealmCleanerThreadExecutor_N" + mCount.incrementAndGet())
    }
  }
  val executor: ExecutorService = Executors.newFixedThreadPool(CORE_POOL_SIZE, threadFactory)
}

Да, у нас еще и scala.

Для тех, кто использует Cordova:

В наследнике CordovaActivity нужно переопределить метод, создающий CordovaInterfaceImpl, на вход ему можно передать нужный нам Executor.

@Override
protected CordovaInterfaceImpl makeCordovaInterface() {
    return new CordovaInterfaceImpl(this, mRealmCleanThreadExecutor.executor()) {
        @Override
        public Object onMessage(String id, Object data) {
            return MainCordovaActivity.this.onMessage(id, data);
        }
    };
}

Для тех, кто использует JobManager:

Context appContext = context.getApplicationContext();
Configuration.Builder builder = new Configuration.Builder(appContext)
    .threadFactory(realmCleanerThreadExecutor.threadFactory());
mJobManager = new JobManager(builder.build());

Realm на UI потоке мы вообще не закрываем, за счет этого прогретый инстанс Realm'а мы используем прямо на вызывающем потоке.

Таким образом, мы везде, после завершения работы потока, можем сбросить кеш объектов Realm'а, не трогая при этом клиентский код.

Совет: операции, которые являются довольно частыми, но не имеют срока окончания, мы проводим на HandlerThread. Это позволяет уменьшить количество создаваемых потоков.

Как пример, пуш-нотификации мы обрабатываем на нем и Realm привязанный к нему мы тоже не очищаем.

Одной из фичей SDK это фильтрация вложенных коллекций средствами этого самого SDK. Выглядит это примерно так

@Nullable
public SessionState getSessionState(String groupUuid) {
    RealmList<SessionState> sessionsState = getSessionsState();
    return sessionsState.where().contains(SessionState.GROUP_UUID,groupUuid).findFirst();
}

Уменьшает количество нашего кода в разы. Мы сразу же воспользовались этой возможностью и забыли о проходах по циклу и фильтрации списков руками. А потом мы увидели в их коде следующее:

public RealmQuery<E> where() {
    if (managedMode) {
        checkValidView();
        return RealmQuery.createQueryFromList(this);
    } else {
        throw new UnsupportedOperationException(ONLY_IN_MANAGED_MODE_MESSAGE);
    }
}

Если мы создаем RealmList сами, то managedMode = false и любое использование специфичных методов этих списков ведет к исключению This method is only available in managed mode. В итоге мы не можем полноценно использовать объекты Realm'а в других потоках и, также, не можем, если он детачен от Realm'а. На наш взгляд есть две стратегии:

1) Не передавать объекты между потоками, работать везде с прокси-объектами и быть уверенными, что везде можно применять фичи от SDK.

2) Передавать детаченные объекты между потоками, но контролировать код, понимая, что здесь может быть как "живой объект Realm'а", так и обычный java-объект не привязанный к базе данных.

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

В тоже время передача детаченных объектов усложняет понимание кода.

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

Насчет тестирования: JUnit и Robolectric не дружат с Realm, он должен быть полностью замокан в тестах. Тикет на это скорее всего будет вечен.

Совет: если вы хотите легко тестировать и подменять поведение объектов, то в идеале выносить все создание новых объектов во внешние зависимости. В нашем примере нам пришлось вынести в фабрику методы, создающие объекты выборок для Realm-объектов.

На самом деле эта библиотека достойна внимания, она активна развивается, баги, о которых они узнают, закрываются уже в следующие пару дней. Да и в большом промышленном проекте не будет идеальной технологии.

О чем бы еще хотелось упомянуть:

1) Генерированные Realm-объекты довольно больно бьют по DexCount, кому это важно. Каждый объект добавляет около 20 методов + метод на каждый setget метод вашего исходного класса.
2) Проблемы с наследованием — не поддерживается множественное наследование.
3) Небольшие трудности с хранением примитивов — приходится писать классы обертки (странно, что они еще этого не сделали).
4) Для изменения списка в прокси-объекте нужно обязательно открыть транзакцию, то есть это должно перекладываться на плечи хранилища и довольно трудно это чисто сделать в коде бизнес-логики.
5) Невозможно переопределить поведение set/get методов, так как они генерирующиеся. Нам это нужно было, чтобы понимать стал ли данный объект отличаться от объекта на сервере. Хотя и это все решается.

В заключении

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

Из плюсов по сравнению с остальными решениями можно выделить:

1) Удобство SDK — миграции из коробки, синхронизация, выборки, сортировки, удобная архитектура связей между объектами.
2) Скорость работы. Точных вычислений мы не делали, но многие операции мы проводим на UI-потоке, учитывая, что в приложении множество анимаций и прочего, пользовательский интерфейс кадры не теряет.
3) Возможность подписки на изменение объектов, как определенных, так и любых списков/выборок. Особенно удобно, если нужно обновлять UI при изменении набора данных.
4) Очень быстрая помощь от разработчиков Realm'а и качественная документация.

Очень надеемся на множество комментариев, о том, что мы все сделали не так и можно было сделать проще.

Можно почитать

Автор: FairBear

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js