Реалистичный Realm. 1 год опыта

в 10:01, , рубрики: android, android database, android development, Realm, разработка мобильных приложений, Разработка под android
Реалистичный Realm. 1 год опыта - 1

Realm давно известен в среде мобильных (и не только) разработчиков. К сожалению, в рунете почти нет статей об этой базе данных. Давайте исправим эту ситуацию.

Ровно год назад в build.gradle нашего проекта появилась строчка:

classpath "io.realm:realm-gradle-plugin:0.89.1" 

За этот год код Realm вырос до версии 3.3, обзавелся множеством фич и починил кучу багов, реализовал новый функционал и получил облачный бекенд. Давайте поподробнее поговорим о Realm в реалиях Andoroid разработки и обсудим тонкие моменты, возникающие при его использовании.

О нас

Мы разрабатываем приложение для коммуникации внутри команды, что-то среднее между telegram и slack. Android приложение написано на Kotlin, с самого начала использовался подход offline-first, т.е. когда все данные, отображенные на экране, достаются из кэша. Попробовав несколько разных баз данных, мы остановились на Realm и в течении года активно использовали его. Данная статья выросла из внутреннего документа по использованию Realm. Статья не является переводом документации и не претендует на полноту описания, это скорее сборник рецептов и разбор тонких моментов. Для полного понимания строго рекомендуем прочитать официальную документацию. Мы же расскажем о своем опыте и какие шишки набили за этот год. Весь код для статьи написан на Kotlin, найти его вы можете на Github.

Realm как стартап

Если говорить о Realm как о компании, то это датский стартап основанный в 2011 году. Ранее проект назывался tight.db. За время существования привлечено 29M$ инвестиций. Зарабатывать компания планирует на основе Realm Mobile Platform, сама же база данных бесплатная и опенсорсная. Realm под Android появился в 2014 году и с тех пор постоянно развивается. Некоторые апдейты ломают обратную совместимость, однако фиксы можно сделать достаточно легко и быстро.

Realm как база данных

Realm — это база данных для нескольких платформ. О себе они пишут:

The Realm Mobile Platform is a next-generation data layer for applications. Realm is reactive, concurrent, and lightweight, allowing you to work with live, native objects.

Если кратко, то это нативная no-sql база данных для Android (Java, Kotlin), iOS (Objective-C, Swift), Xamarin (C#) и JavaScript (React Native, Node.js).
Так же есть backend, который позволяет синхронизировать данные из всех источников.

Из ключевых особенностей стоит отметить zero copy, MVCC и ACID. Встроенного механизма устаревания и очистки данных нет.

У Realm есть очень хорошая документация и множество примеров на github.
Сотрудники Realm периодически мониторят StackOverflow, также можно завести issue на github.

Hello world

Hello world под Android выглядит следующим образом:

Добавим в build.gradle

build.gradle (Project level) 

classpath "io.realm:realm-gradle-plugin:3.3.0"

build.gradle (App level) 

apply plugin: 'realm-android'

В Application настроим Realm Configuration

Realm.init(this)
val config = RealmConfiguration.Builder()
       .build()
Realm.setDefaultConfiguration(config)

И можно начинать работать с базой данных:

val realm = Realm.getDefaultInstance()

realm.executeTransaction { realm ->
   val dataObject = realm.createObject(DataObject::class.java)
   dataObject.name = "A"
   dataObject.id = 1
}

val dataObject = realm.where(DataObject::class.java).equalTo("id", 1).findFirst()
dataObject.name // => A

realm.executeTransaction { realm ->
   val dataObjectTransaction = realm.where(DataObject::class.java).equalTo("id", 1).findFirst()
   dataObjectTransaction.name = "B"
}
dataObject.name // => B

Сравнение с другими базами данных

На хабре есть статья от 8 апреля 2016 года, где сравниваются 9 ORM под Android, в том числе Realm. Realm там в лидерах, вот графики:

Сравнение с другими ORM

image

image

На своем сайте Realm приводит следующую статистику:

Графики с сайта Realm

Реалистичный Realm. 1 год опыта - 4
Реалистичный Realm. 1 год опыта - 5

Можно выделить три главные особенности, которые необходимо учитывать:
Live Objects — Все объекты, полученные из Realm, являются, по сути, прокси к базе данных. За счет этого достигается zero copy (объекты не копируются из базы)
Transactions — Все изменения привязанных объектов данных нужно проводить внутри транзакции
OpenClose — Необходимость открытиязакрытия instance базы данных

Live Objects

Все объекты из Realm можно получить синхронно или асинхронно.

Синхронное чтение

fun getFirstObject(realm: Realm, id: Long): DataObject? {
   return realm.where(DataObject::class.java).equalTo("id", id).findFirst()
}

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

Асинхронное чтение

Весьма неочевидный кейс. Как вы думаете, что произойдет в этом коде:

val firstObject = realm.where(DataObject::class.java).findFirstAsync()
log(firstObject.id)

Правильный ответ: получим ошибку java.lang.IllegalStateException
При асинхронном чтении мы хоть и получаем объект сразу, но работать с ним не можем, пока он не загрузится. Проверять это нужно с помощью функции isLoaded() или вызвать блокирующую функцию load(). Выглядит достаточно неудобно, поэтому тут лучше использовать rx. Преобразуем в observable и получаем загруженный объект в OnNext. Асинхронные операции доступны только в потоках с Looper.

fun getObjectObservable(realm: Realm, id: Long): Observable<DataObject> {
   return realm.where(DataObject::class.java).findFirstAsync().asObservable()
}

Основные особенности Realm объектов

  • Получение объектов из базы очень быстрое, десериализации как таковой нет, чтение с диска происходит только при обращении к конкретному полю
  • Именно для этого существует требование делать все поля приватными и обращаться через геттеры
  • Метод copyFromRealm() — позволяет получать отвязанные, полностью собранные объекты, прямо как обычная ORM. Правда и все фишки Realm становятся недоступны. На вход принимается глубина десериализации, по умолчанию MAX_INT
  • В дебагере все поля будут null. Для получения какого-либо значения нам нужно обращаться через геттер
  • Все объекты Live, т.е живые. Изменения распространяются моментально в рамках одного потока. Более сложные кейсы смотрите ниже (многопоточность).
  • Фильтрация объектов осуществляется по полям, причем названия полей вы указываете руками в виде строки. Например так: .equalTo(«id», 1). Это усложняет рефакторинг и приводит к ошибкам в наименовании полей для фильтрации. К сожалению Realm не генерирует переменные с названиями полей, поэтому все выборки лучше прятать внутри функции:
    fun findFirstDataObject(id: Long, realm: Realm) : DataObject
           =  realm.where(DataObject::class.java).equalTo("id", id).findFirst() 

    или использовать генератор имен полей от cmelchior

  • Этот пункт изменился прямо во время написания статьи (яркий пример того, как развивается проект)
    Было: Невозможно использовать DiffUtil при изменении объекта, невозможно понять какие поля у объекта изменились. Т.е. если вам пришла нотификация об изменении объекта, невозможно понять что у него изменилось. Связано это с тем, что оба объекта (старый и новый) являются live объектами и ссылаются на одни и те же данные, они всегда будут равны.
    Стало: Можно использовать RealmObjectChangeListener для понимания что изменилось:

    RealmObjectChangeListener

    Person p = realm.where(Person.class).findFirst();
                p.addChangeListener(new RealmObjectChangeListener<Person>() {
                    @Override
                    public void onChange(Person person, ObjectChangeSet changeSet) {
                        if (changeSet.isDeleted()) {
                            hide(); // Object was deleted
                        } else {
                            // Use information about which fields changed to only update part of the UI
                            if (changeSet.isFieldChanged("name")) {
                                updateName(person.getName());
                            }
                        }
                    }
                });
     

  • Любой объект доступен только пока открыт instance realm-a, из которого мы его получили. Проверять можно методами isValid. При обращении к невалидному объекту получим исключение
  • Объекты доступны только в том потоке, в котором созданы. Обращаться из другого потока нельзя, получим исключение

Аналогично списки (RealmResult) объектов (результаты запроса) являются прокси к Realm, это приводит к следующему:

  • Получение списков очень быстрое, по сути мы получаем только count. Все запросы lazy, получить большой список из сложных объектов мы можем очень быстро
  • Списки доступны только для чтения, любые методы изменения приводят к исключению
  • Т.к. мы можем быстро и дешево получать все элементы, можно забыть о проблеме пагинации. Мы всегда отдаем полный список элементов, при скролле обращаемся к объектам, и они быстро получаются из базы. Если нам нужно подгрузить данные, мы запускаем загрузку, получаем данные, сохраняем их в Realm, снова получаем полный список с загруженными элементами и отображаем его
  • До недавнего времени (до версии 3.0) была проблема с перерисовкой всех элементов списка. Если мы используем список для адаптера, то при изменении одного элемента происходит полная перерисовка всего списка. Использовать DiffUtils и сравнивать какие объекты изменились, не получится, т.к. это live объекты. В Realm 3.0 появились OrderedCollectionChangeSet, который сообщает нам DeletionRanges, InsertionRange, ChangeRanges. Стало наконец возможно понять какие объекты и как изменились.
    Пример CollectionChangeListener

    
    private final OrderedRealmCollectionChangeListener<RealmResults<Person>> changeListener = new OrderedRealmCollectionChangeListener() {
        @Override
        public void onChange(RealmResults<Person> collection, OrderedCollectionChangeSet changeSet) {
            // `null`  means the async query returns the first time.
            if (changeSet == null) {
                notifyDataSetChanged();
                return;
            }
            // For deletions, the adapter has to be notified in reverse order.
            OrderedCollectionChangeSet.Range[] deletions = changeSet.getDeletionRanges();
            for (int i = deletions.length - 1; i >= 0; i--) {
                OrderedCollectionChangeSet.Range range = deletions[i];
                notifyItemRangeRemoved(range.startIndex, range.length);
            }
    
            OrderedCollectionChangeSet.Range[] insertions = changeSet.getInsertionRanges();
            for (OrderedCollectionChangeSet.Range range : insertions) {
                notifyItemRangeInserted(range.startIndex, range.length);
            }
    
            OrderedCollectionChangeSet.Range[] modifications = changeSet.getChangeRanges();
            for (OrderedCollectionChangeSet.Range range : modifications) {
                notifyItemRangeChanged(range.startIndex, range.length);
            }
        }
    };
    

Транзакции

Изменять привязанные к Realm объекты можно только внутри транзакции, при изменении вне транзакции получим ошибку. С одной стороны, не очень удобно, с другой стороны — дисциплинирует и не дает изменять объекты в любой части кода, только в определенном слое (database). Также нужно помнить, что транзакции внутри другой транзакции запрещены.

Как нельзя делать:

val user = database.getUser(1)
button.setOnClickListener { user.name = "Test" }

Как можно:

val user = database.getUser(1)
button.setOnClickListener { database.setUserName(user, "Test") }

Транзакции можно производить синхронно и асинхронно. Давайте подробнее рассмотрим каждый из вариантов:

Синхронные транзакции:

fun syncTransaction() {
   Realm.getDefaultInstance().use {
       it.executeTransaction {
           val dataObject = DataObject()
           it.insertOrUpdate(dataObject)
       }
   }
}

Также можно выполнять транзакции между beginTransaction и commitTransaction, однако рекомендуется использовать именно executeTransaction.

К сожалению, синхронные транзакции не поддерживают onError callback, так что обработка ошибок остается на вашей совести. Есть issue на добавление onError callback c июня 2016 года.

Асинхронные транзакции

Асинхронные транзакции запускаются методом asyncTransaction. На вход отдаем саму transaction и callback onSuccess и onError, на выходе получаем объект RealmAsyncTask, с помощью которого мы можем проверить статус или отменить транзакцию. Асинхронные транзакции запускаются только в тредах с Looper. Пример асинхронной транзакции:

Realm.getDefaultInstance().use {
   it.executeTransactionAsync({
       it.insertOrUpdate(DataObject(0))
   }, {
       log("OnSuccess")
   }, {
       log("onError")
       it.printStackTrace()
   })
}

Пара важных нюансов:

Вы не сможете присвоить через сеттер объект, не привязанный к Realm. Необходимо сначала положить объект в базу, а потом прикрепить привязанную копию. Пример:

val realm = Realm.getDefaultInstance()
val parent = realm.where(Parent::class.java).findFirst()
val children = Children()
// parent.setChildren(children) <-- Error
val childrenRealm = realm.copyToRealmOrUpdate(children)
parent.setChildren(childrenRealm) /// Ok

Много транзакций лучше объединять в одну. В Realm есть внутренняя очередь на транзакции (размером 100) и если вы превысите ее, упадет исключение.

Все асинхронные транзакции работают на одном executor’e

// Thread pool for all async operations (Query & transaction)
static final RealmThreadPoolExecutor asyncTaskExecutor = RealmThreadPoolExecutor.newDefaultExecutor();

Если у вас будет много асинхронных операций за короткое время, получите ошибку RejectedExecutionException. Выходом из данной ситуации будет использование отдельного потока и запуск в нем синхронных транзакций или объединение нескольких транзакций в одну.

Open/close realm

Все объекты из базы данных мы получаем, используя конкретный instance Realm-a, и можем работать с ними пока открыт этот instance. Как только мы вызовем realm.close(), любая попытка чтения объекта обернется для нас исключением. Если мы не будем вовремя закрывать Realm, то это приведет к утечкам памяти, т.к. сборщик мусора не умеет корректно работать с ресурсами, используемыми Realm.

В официальной документации рекомендуется открыватьзакрывать Realm:

  • для Activity: onCreate / onDestroy
  • для Fragment: onCreateView/onDestroyView

Однако если вы хотите вынести логику работы с Realm из ActivityFragments в презентеры, вам придется использовать (прокидывать) методы жизненного цикла.

В случае если вам нужно как-то изменить данные или добавить новые, проще всего получить новый instance, записать данные и затем закрыть его. В Kotlin для этого можно использовать .use()

Realm.getDefaultInstance().use { // it = realm instance}

Для чтение объектов с помощью Rx можно использовать “изолированные” instance и закрывать их в doOnUnsubscribe (или использовать Observable.using)

// Use doOnUnsubscribe 
val realm = Realm.getDefaultInstance()
realm.where(DataObject::class.java).findAllSorted("id").asObservable().doOnUnsubscribe { realm.close() }

// Use Observable.using
Observable.using(Realm.getDefaultInstance(), realm -> realm.where(DataObject::class.java).equalTo("id", id)
       .findFirstAsync()
       .asObservable()
       .filter(realmObject -> realmObject.isLoaded())
       .cast(DataObject::class.java), Realm::close);

Также есть особенность, связанная с закрытием Realm в onDestroyonDestroyView. Иногда после закрытия Realm происходит вызов FragmentManagerImpl.moveToState → ViewGroup.removeView →… → RecyclerViewAdapter.getItemCount() и вызывается метод list.size() от невалидной коллекции. Так что тут нужно проверять isValid() или отвязывать adapter от recyclerView

Если вы используете Kotlin Android Extensions, то работать с view (из kotlinx.android.synthetic.*) из Fragment можно только начиная с метода onViewCreated(), лучше настраивать все listeners в этом методе, чтобы не получить NPE.

После разбора трех самых важных особенностей, пробежимся по менее важным:

Notifications, RxJava

Realm поддерживает уведомления об изменении данных, причем как самого объекта, так и вложенных объектов (всех залинкованных объектов). Реализовано это с помощью RealmChangeListener (нам приходит сам объект), RealmObjectChangeListener ( приходит измененный объект и ObjectChangeSet для него, можно понять какие поля изменились) или с помощью RxJava (в onNext получаем объект, в случае асинхронного запроса необходимо проверять isLoaded(), работает только в потоках с Looper).

RxJava2 пока не завезли, issue висит с сентября 2016 года, когда реализуют — неизвестно, используйте Interop.

Аналогично можно слушать изменения коллекций или всего instance Realm. Слушать изменения внутри транзакций запрещено.

Пример Rx:

fun getObjectObservable(realm: Realm, id: Long): Observable<DataObject?> {
   return realm.where(DataObject::class.java).equalTo("id", id).findFirstAsync()
           .asObservable<DataObject?>().filter({ it?.isLoaded }).filter { it?.isValid }
}

Многопоточность и асинхронность

Realm это MVCC база данных. Википедия говорит про MVCC:

“Управление параллельным доступом с помощью многоверсионности (англ. MVCC — MultiVersion Concurrency Control) — один из механизмов обеспечения параллельного доступа к БД, заключающийся в предоставлении каждому пользователю так называемого «снимка» БД, обладающего тем свойством, что вносимые пользователем изменения в БД невидимы другим пользователям до момента фиксации транзакции. Этот способ управления позволяет добиться того, что пишущие транзакции не блокируют читающих, и читающие транзакции не блокируют пишущих.”

На практике это выглядит следующим образом: мы можем слушать изменения объекта или с помощью RxJava получать измененные объекты в onNext. В случае, если изменения происходят в потоке А, а мы работаем с объектом в потоке B, то поток B узнает об изменениях после закрытия Realm instance в потоке A. Изменения передаются посредством Looper. Если в потоке B Looper-a нет, то изменения не дойдут (можно проверить методом isAutoRefresh()). Выход из данной ситуации — использовать метод waitForChange().

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

  • Нельзя смешивать асинхронные транзакции и синхронные, если смешать, все транзакции станут синхронными
  • Нельзя использовать асинхронные вызовы в потоках без Looper
  • Для длительных транзакций нужно открыть отдельный instance realm, иначе realm может быть закрыт во время транзакции и вы получите исключение
  • Все действия в асинхронной транзакции происходят на отдельном внутреннем executor-е, как следствие вы не можете пользоваться внешними realm объектами, возможно переполнение executor-а, изменения realm object не распространяются между потоками и прочие неудобства

Тестирование

Раньше Realm.java — был final и для тестирования нужен был powerMock или другие подобные инструменты. В данный момент Realm.java перестал быть final и можно спокойно использовать обычный mockito. Примеры тестов в демо проекте или на официальном репозитории

Один Realm хорошо, а три лучше

Работая с Realm мы всегда имеем ввиду стандартный realm, однако существуют еще In-Memory Realm и Dynamic Realm.

Стандартный Realm — можно получить методами Realm.getDefaultInstance() или с помощью конкретной конфигурации Realm.getInstance(config), конфигураций может быть неограниченное количество, это по сути отдельные базы данных.

In-Memory Realm — это Realm, который все записанные данные хранит в памяти, не записывая их на диск. Как только мы закроем этот instance, все данные пропадут. Подходит для кратковременного хранения данных.

Dynamic Realm — используется в основном при миграции, позволяет работать с realm — объектами без использования сгенерированных классов RealmObject, доступ осуществляется по именам полей.

Наследование и полиморфизм

Realm не поддерживает наследование. Любой realm-объект должен или наследоваться от RealmObject или реализовывать интерфейс маркер RealmModel и быть помеченным аннотацией @RealmClass. Наследоваться от существующих Realm объектов нельзя. Рекомендуется использовать композицию вместо наследования. Весьма серьезная проблема, issue висит с января 2015 года, но воз и ныне там.

Kotlin

Realm из коробки работает c Kotlin.
Не работают data class-ы, нужно использовать обычные open class.
Также стоит отметить Kotlin-Realm-Extensions, удобные расширения для работы с RealmObject.

Realm mobile platform

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

  • Realm Mobile Database – база для хранения данных
  • Realm Object Server – сервер, отвечающий за автоматическую синхронизацию и обработку событий
  • Realm Data Integration API – для подключения и синхронизации данных с существующими БД (Oracle, MongoDB, Hadoop, SAP HANA, Postgres и Redis)

Иллюстрация работы mobile platform

Реалистичный Realm. 1 год опыта - 6

Отладка

Для отладки у нас есть несколько инструментов:

  • RealmLog — выводит лог, есть разные уровни логирования
  • Realm браузер — нужен просмотра базы данных с компьютера. Работает только под Mac. Для просмотра базы на Windows можно использовать Stetho Realm
    Также существуют несколько Android библиотек для удобного просмотра данных на девайсе.
  • WriteCopyTo() — позволяет скопировать базу в файл и отправить ее на анализ.
  • NDK Debugging — для анализа ошибок в нативном коде можно использовать Crashlytics NDK Crash Reporting

Архитектура

Realm отлично подходит для MV* архитектур, когда вся реализация прячется за интерфейсом базы данных. Все обращения и выборки происходят в модуле базы данных (repository), наверх отдаются Observable c автоматически закрываемым realm при unsubscribe. Или принимаем на вход instance realm и производим все действия с ним. При записи объектов мы открываем realm, записываем данные и закрываем его, на вход подается только объект для сохранения. Оба примера смотрите на github.
Увы, использование Realm (без copyFromRealm) накладывает серьезные ограничения на использование clean architecture. Использовать разные модели данных для разных слоев не получится, пропадает весь смысл live объектов и прокси списков. Также сложности возникнут при создании независимых слоев и открытиизакрытии Realm, тк эта операция привязана к жизненному циклу ActivityFragment. Хорошим вариантом будет изолированный слой получения данных, преобразование объектов и сохранение их в базе данных.

Realm очень удобен при построении offline-first приложений, когда все данные для отображения мы получаем из базы данных.

Полезные ссылки

Для продолжения знакомства и разбора тонких моментов, рекомендуем следующие статьи:

Три статьи статьи от @Zhuinden:
Basics of Realm: A guide to using Realm 1.2.0
How to use Realm for Android like a champ, and how to tell if you’re doing it wrong
Realm 1.2.0 + Android Data Binding

Две статьи про интеграцию Realm от @Viraj.Tank
Safe-Integration of Realm in Android production code, Part-1 with MVP
Deep integration of Realm in Android production code, Part-2, with MVP

Многопоточность, подробный разбор:
Designing a Database: Realm Threading Deep Dive
Docs — Auto-Refresh
Docs — Threading

Недавняя статья на хабре от FairBear:
Как подружиться с Realm

Заключение

Realm сложнее, чем кажется на первый взгляд. Однако все недостатки с лихвой покрываются его мощностью и удобством. Live объекты, нотфикации и Rx, удобное API и множество других вещей упрощают создание приложений. Из конкурентов можно выделить requery, ObjectBox и GreenDao. Полностью же Realm раскрывает себя при построении offline-first приложений, когда все данные мы получаем из кэша и нам необходимы сложные выборки, а также постоянное обновление данных.
Весь приведенный код вы можете найти на Github

Автор: andrey7mel

Источник


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


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