В этой статье мы хотели бы поделиться опытом использования набирающей популярность библиотеки для хранения данных — 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 методов + метод на каждый set\get метод вашего исходного класса.
2) Проблемы с наследованием — не поддерживается множественное наследование.
3) Небольшие трудности с хранением примитивов — приходится писать классы обертки (странно, что они еще этого не сделали).
4) Для изменения списка в прокси-объекте нужно обязательно открыть транзакцию, то есть это должно перекладываться на плечи хранилища и довольно трудно это чисто сделать в коде бизнес-логики.
5) Невозможно переопределить поведение set/get методов, так как они генерирующиеся. Нам это нужно было, чтобы понимать стал ли данный объект отличаться от объекта на сервере. Хотя и это все решается.


В заключении


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


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


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


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


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

Поделиться с друзьями
-->

Комментарии (15)


  1. fgtmenow
    11.04.2017 18:15

    Зато сколько довольно много места сжирает в apk. Если массивный продакшн проект — да, там места не жалко. если мелкая утилита — лучше поискать что-то другое


  1. metrolog_ma
    11.04.2017 18:34

    fgtmenow Точно сейчас не скажу на сколько увеличился размер апк от Realm'а, но если разбивать на разные архитектуры и тд и тп, то можно уложиться в 1 MB. И, на самом деле, насчет сгенерированного кода и DexCount — либо это заинлайнится, либо вырежется, либо мы бы это написали руками.


  1. pontifex024
    12.04.2017 13:37

    Поправьте, если ошибаюсь.
    У Вас одна конфигурация для всех объектов (кроме логов). Каждая конфигурация — это отдельный файл. Получается, что все объекты, хранящиеся в LocalStorage, размещаются в одном файле. Это не увеличит время получения выборки (особенно, если поля, по которым производится выборка, не проиндексированы), которую, как я понял, вы получаете в UI потоке?
    Также меня смущает отсутствие закрытия инстанса в UI потоке. Вроде (по крайней мере раньше) Realm даже предупреждения в логах кидает по поводу незакрытого инстанса.


    1. metrolog_ma
      12.04.2017 14:55

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


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


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


  1. BeeJay
    12.04.2017 13:37

    Добавил бы еще в минусы проблемы с костылями имплементации Parcelable интерфейса


  1. speeker510
    12.04.2017 15:43
    +1

    Если пользоваться Kotlin то есть прикольная библиотека с extentions для Realm. Проблема закрытия инстанса отпадает сама собой.


    1. metrolog_ma
      12.04.2017 16:51

      вместо Kotlin'а мы выбрали Scala)


    1. evocodes
      12.04.2017 22:19

      Эта либа на каждую кверю инстанс открывает и закрывает? В некоторых сценариях это может очень плохо сказаться на производительности.


  1. J_K
    13.04.2017 01:34

    Оффтоп. Можно узнать, почему на ваших скриншотах в гуглплее вы обрезали их снизу? Неужели только для того, чтобы вставить немного текста в пустом пространстве вверху? Не в первых раз встречаю подобное представление скриншотов и никак не могу понять, для чего это делается. Многие компании показывают скриншоты как есть, без обрезания и, на мой взгляд, они выигрывают. Тем не менее многие обрезают.


  1. andreich
    17.04.2017 13:39

    Вообще, у меня не вызывает проблем работа с реалмом уже давненько.

    1) Вся работа с реалмом у меня ведется изолированно через репозиторий. Ни каких active records у меня нет, так как я предпочитаю работать с иммутабельными объектами, с ними меньше непредсказуемых поведений. Кстати, это же и устраняет проблему с RealmList

    2) Работа с реалмом всегда ведется в фоновом потоке, на каждый запрос создается инстанс и закрывается после выполнения. Никаких проблем с производительностью я не заметил. Тут очень поможет reactive подход. Написал один раз и радуешься потом.

    3) Касательно множетственного наследования: в java его как бы вообще нет. Я понял о чем речь идет, что в цепочке наследования не может быть ничего, кроме RealmObject, но это немного другое.

    4) На счет тестов, да, тут проблема, но надо мокать не реалм, а репозиторий в принципе, потому что логики в репозитории должно быть минимум. Если тестировать его — то это уже интеграционные тесты. Последние с реалимом надо запускать сразу на дейвасе.


    1. metrolog_ma
      18.04.2017 11:24

      1) Как происходит изменение обьекта у вас? Например, есть таблица Parent и вам нужно добавить на него связь с новым Kid (внутри Parent список из Kid). И, если не трудно, расскажите, как вы делаете поиск по спискам. Например нужно достать Kid из Parent по имени.


      2) Закрывать Realm после каждой транзакции довольно расточительно. В вашем случае стоит сделать пулл потоков для работы с Realm'ом и закрывать инстанс после работы потока, а не после транзакции. Да и если делать все операции в фоновом потоке, то это либо callback hell, либо, как вы и сделали, использовать rx. Мы же специально перешли на Realm, чтобы перенести все операции на UI поток.


      3) Да, речь была про цепочку наследования. Но это мелочь.


      4) Тесты ломаются если в ваших модельках есть выборки рилмовские (например для работы со списками), то есть все это приходится уносить из моделей и, в придачу к репозиторию, мокать все классы, которые содержат логику этих выборок. Но да и это тоже решаемо. Только у вас полностью пропадает возможность протестировать вполне важную бизнес логику. В этом случае интеграционные тесты, поднимающие аппликейшен, не очень подходят.


      1. andreich
        20.04.2017 10:17

        1) когда мне надо поменять объект, я меняю отвязанный от реалма, и сохраняю через репозиторий. Поиск по спискам я делаю либо с помощью Rx, либо с помощью extention методов к колекциям в котлин. Все зависит от ситуации. В котлине это выглядело бы примерно так:

        val kid = parent.kidlist.firstOrNull { kid -> kid.name == "Piter"}
        


        2) возможно так и есть, но городить пул потоков — это преждевременная оптимизация, вот когда начнутся тормоза в текущем вариант — я задумаюсь, сейчас все работает достаточно быстро. Никакого callback-hell у меня нет, везде rxjava. Делать операции с БД в UI — сомнительное занятие, на мой взгляд, но тут дело каждого.

        4) тестирование реалма — это вообще отдельная тема, к тому же холиварная.


        1. metrolog_ma
          20.04.2017 14:43

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


          2) Да там ведь и не БД уже, там прогретый кеш. А пулл потоков это не городить, это группировка по типу операции, вы ведь все равно там на каком-то Sheduler'е их запускаете.


          1. andreich
            20.04.2017 14:56

            1) Мне как раз и не нужны живые объекты, они несут неоднозначность в себе и кучу потенциальных багов.
            Что на счет выборок — то тут не могу сказать, на мобилке не должны храниться такие объемы данных, чтобы разница производительности при выборке небольшой части и всей таблицы была огромной.

            2) Не важно, бд или кэш. Да, там шедулеры, но делать еще одну ненужную вещь, ради того, что это будет работать на 100мс быстрей как-то сомнительно в данном случае.


  1. andreich
    20.04.2017 10:16

    ну в ту ветку :(