Room (Часть 1)

Если перед разработчиком встаёт вопрос хранения большого объёма данных, то ответ будет вполне очевиден — нужно использовать базу данных. Для этих целей в андроиде есть поддержка sqlite средствами которой, можно решить подавляющее большинство задач, однако использовать её зачастую не совсем удобно. Даже для самых простых операций вставки/получения данных, приходится писать много однотипного кода, что может привести не только к неудобствам, но и к ошибкам. И вот тут нам на помощь приходит библиотека Room — некая прослойка между API для взаимодействия с базой и вашим кодом.

Схематичное изображение архитектуры Room (источник — developer.android.com)

Если раньше приходилось использовать неудобные API (некоторые требуют по аж семь параметров), заполнять данными ContentValues, делать обход записей извлекая данные из Cursor, то теперь всё упростилось. Эти рутинные вещи сделает Room, причем еще на стадии компиляции проекта и плюс к тому, произведёт некоторые проверки позволяющие избежать ошибок. При этом, никто не запрещает программисту пользоваться всей мощью языка запросов если это вдруг станет необходимо. Для начала работы с Room необходимо добавить в файл build.gradle такие строки:

def room_version = "2.1.0-alpha04"
implementation "androidx.room:room-runtime:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"

Существуют три основных типа компонентов входящих в состав Room:
Entity — объект который необходимо хранить в базе данных. Для каждого Entity создаётся отдельная таблица в базе данных.
DAO — объект используемый для любых манипуляций с Entity. Используется для добавления, удаления, изменения и получения данных из базы.
Database — класс базы данных, в нём определены Entity и DAO которые относятся к данной базе, информация о версии, правила обновления итд.

Быстрый старт 🚀

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

@Entity()
public class Artist {

   @PrimaryKey(autoGenerate = true)
   private Long id;

   private String name;

   public Artist(Long id, String name) {
      this.name = name;
      this.id = id;
   }

   public Long getId() {
      return id;
   }

   public String getName() {
      return name;
   }

}

По-умолчанию, для каждого поля в классе, будет создаваться свой столбец в соответствующей таблице. Имя столбца будет совпадать с именем поля, а тип данных столбца с типом данных поля. Всё что нужно, это указать какое поле является ключом, при помощи аннотации @PrimaryKey. Все поля которые будут храниться в таблице, должны быть доступны для записи извне. Например можно добавить конструктор который позволит их задать или сделать сеттер на каждый из них. Ну или вовсе сделать их публичными (что с точки зрения инкапсуляции конечно не всегда приветствуется). Если будет использоваться конструктор, то имена его параметров должны соответствовать именам полей которые хранятся в таблице. Типы данных разумеется тоже должны совпадать, порядок параметров не важен, само содержимое конструктора тоже роли не играет (код скомпилируется даже если он будет пустым). Так же необходимо предоставить возможность читать поля при помощи соответствующих геттеров, без них будет ошибка компиляции. Теперь, необходимо добавить в приложение DAO при помощи которого мы будем читать/писать данные в таблицу с исполнителями. Согласно документации, DAO может быть интерфейсом или абстрактным классом, главное не забыть соответствующую аннотацию:

@Dao
public interface MyDao {

   // Четыре метода выполняющие операции вставки, удаления, обновления и чтения записей
   @Insert
   void addArtist(Artist person);

   @Delete
   void removeArtist(Artist person);

   @Update
   void updateArtist(Artist person);

   @Query("select * from Artist")
   List<Artist> getArtists();

}

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

@Database(entities = {Artist.class}, version = 1)
public abstract class MyDatabase extends RoomDatabase {
   public abstract MyDao getMyDao();
}

Класс должен быть абстрактным и наследоваться от RoomDatabase. В аннотации @Database указаны какие entities входят в состав базы данных, а так же её версию. В сам класс необходимо добавить абстрактный публичный метод возвращающий ссылку на MyDao. Теперь, когда классы созданы, можно начинать работу с базой данных. Для начала необходимо получить экземпляр класса MyDatabase а от него — MyDAO. Делается это следующим образом:

MyDatabase db = Room.databaseBuilder(getApplicationContext(), MyDatabase.class, "db_name")
              .allowMainThreadQueries().build();
// allowMainThreadQueries - разрешает использовать базу данных из main потока. 
// Так лучше не делать, но для тестовых целей подходит
MyDao dao = db.getMyDao();

Теперь добавим несколько артистов в базу данных:

//null вместо id позволит базе данных самой назначить свободный идентификатор
Artist artist = new Artist(null, "AC/DC");
dao.addArtist(artist);
artist = new Artist(null, "Aerosmith");
dao.addArtist(artist);
artist = new Artist(null, "Led Zeppelin");
dao.addArtist(artist);
artist = new Artist(null, "Metallica");
dao.addArtist(artist);
artist = new Artist(null, "Queen");
dao.addArtist(artist);

Проверяем чтение из базы

// выведет всех артистов добавленных выше
List<Artist> artists = dao.getArtists();
for (Artist artist : artists) {
   Log.d("DB-TEST", artist.getId() + " : " + artist.getName());
}

Удаление и обновление записей работают аналогичным образом:

//удаляем Metallica (имя можно не указывать, нужен только ключ)
Artist artist = new Artist(4L, null);
dao.removeArtist(artist);
// Queen переименовываем
artist = new Artist(5L, "Queen-new-name");
dao.updateArtist(artist);

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

Погружение в Entity

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

Игнорирование полей

Room пытается сохранить каждое поле Entity класса в таблицу базы данных, но часто может возникать ситуация, когда это не требуется. Для этого необходимо аннотировать такое поле при помощи @Ignore

@Ignore
private Bitmap icon;

@Ignore
private int currentState;

Но что делать, если ваш Entity класс это наследник другого класса (с кучей ненужных для сохранения полей), менять который вы не хотите или не можете ? В данном случае можно воспользоваться параметром ignoredColumns

// Поля last_name и first_name из класса Person не будут сохраняться 
@Entity(ignoredColumns = {"last_name", "first_name"})
public class User extends Person {

    @PrimaryKey
    public int id;

    @Ignore // Можно комбинировать с предыдущим способом
    public Bitmap icon;
}

Вложенные объекты

Иногда удобно хранить данные в рамках одной таблицы, а работать с ними при помощи разных классов. Допустим есть класс House, у которого есть GPS координаты и адрес. Адрес состоит из двух полей (улица и город), а координаты из широты и долготы. Создаем классы Coordinates и Address и добавляем поля такого типа в House аннотируя их при помощи @Embedded:

@Entity
public class House {

   @PrimaryKey
   public Long id;
   public String houseName;

   @Embedded
   public Coordinates coordinates;

   @Embedded
   public Address address;

   //Это не Entity, а просто обычный класс
   public static class Coordinates {
      public float latitude;
      public float longitude;
   }
   // И адрес тоже 
   public static class Address {
      public String street;
      public String city;
   }
}

В MyDao добавляем метод для вставки данных

@Insert
void addHouse(House house);

Затем заполняем данными и выполняем вставку

House house = new House();
house.address = new House.Address();
house.coordinates = new House.Coordinates();
house.houseName = "Test house";
house.address.city = "Vladimir";
house.address.street = "Severnaya";
house.coordinates.latitude = 56.147175F;
house.coordinates.longitude = 40.410866F;
dao.addHouse(house);

В итоге Room создаст одну таблицу House и все поля вложенных классов расположит внутри

Содержимое вложенных классов в той же таблице

Имена столбцов и таблиц

Room умеет самостоятельно выбирать имена для таблиц и столбцов исходя из названия класса Entity и имён его полей. Но если хочется именовать столбцы самостоятельно, то здесь поможет аннотация @ColumnInfo. Для переименовывания таблиц — параметр tableName аннотации @Entity

// таблица будет называться users вместо user
@Entity(tableName = "users")
public class User {

   @PrimaryKey(autoGenerate = true)
   public Long id;

   // столбец с именем будет называться user_name
   @ColumnInfo(name = "user_name")
   public String name;
   
}

Конвертация данных

Чисто технически, в базе можно хранить любые данные, главное сконвертировать их в простые типы которые поддерживаются sqlite. Например, можно преобразовать Bitmap в массив байт и записать его в базу, а при чтении выполнить обратное преобразование. Для упрощения этих процедур, Room поддерживает классы-конвертеры. В таком классе должны быть публичные статические методы, на вход принимающие объект который мы хотим преобразовать, и возвращающие любой тип данных поддерживаемый sqlite (строки, числа, массивы байт итд). Для выполнения обратного преобразования — всё наоборот: на вход примитивный тип, а на выходе объект который получается из этих данных. Для конвертации Bitmap в массив байт и наоборот, может быть использован такой класс:

public class BitmapConverter {

   @TypeConverter
   public static Bitmap bytesToBitmap(byte[] data) {
      if (data == null) {
         return null;
      }
      return BitmapFactory.decodeByteArray(data, 0, data.length);
   }

   @TypeConverter
   public static byte[] bitmapToBytes(Bitmap bitmap) {
      if (bitmap == null) {
         return null;
      }
      ByteArrayOutputStream stream = new ByteArrayOutputStream();
      bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream);
      byte[] byteArray = stream.toByteArray();
      bitmap.recycle();
      try {
         stream.close();
      } catch (IOException e) {
         e.printStackTrace();
      }
      return byteArray;
   }
}

Аннотации @TypeConverter дают понять, что эти методы необходимо использовать для преобразования данных. Сам класс базы данных необходимо аннотировать при помощи @TypeConverters который показывает какие конвертеры используются базой:

@Database(entities = {Artist.class}, version = 1)
@TypeConverters(BitmapConverter.class)
public abstract class MyDatabase extends RoomDatabase {
   public abstract MyDao getMyDao();
}

Составной ключ

В примерах выше, первичный ключ состоял всего лишь из одного поля (аннотированного при помощи @PrimaryKey, но иногда этого бывает недостаточно и требуется составной ключ. Реализовать его возможно при помощи параметра primaryKeys аннотации @Entity:

@Entity(primaryKeys = {"firstName", "lastName"})
public class User {
    public String firstName;
    public String lastName;
}

Индексы таблицы

Не вдаваясь в подробности работы sqlite, использование индексов позволяет ускорить доступ к данным хранящимся в вашей таблице. Создать просто индекс можно при помощи параметра index, аннотации @ColumnInfo:

   @ColumnInfo(index = true)
   private String name;

Если требуется индекс состоящий из нескольких полей, то здесь всё делает по аналогии с составным ключом — используем параметр indices аннотации @Entity:

@Entity(indices = @Index(value = {"last_name","first_name"}, unique = true))
// Так же можно запретить вставку не уникальных данных при помощи unique 
public class Artist {

   @PrimaryKey(autoGenerate = true)
   public Long id;
   public String last_name;
   public String first_name;

}

Внешние ключи

Часто в базе данных существует несколько связанных между собой таблиц. Предположим, что в базе есть таблица «Исполнители» и таблица «Альбомы» где у каждого исполнителя может быть несколько альбомов. Очевидно, что нужно стараться избегать ситуаций, когда альбом в таблице есть, а исполнителя этого альбома не существует. Эти проблемы можно избежать добавив внешние ключ и база будет контролировать целостность данных в связанных таблицах. Определить внешний ключ поможет аннотация @ForeignKey и параметр foreignKeys аннотации @Entity:

@Entity(foreignKeys = {
        @ForeignKey(entity = Artist.class, childColumns = "artistId", parentColumns = "id")}
        )
public class Album {

   @PrimaryKey(autoGenerate = true)
   public Long id;

   public String title;

   //room настоятельно рекомендует сделать это поле еще и индексом
   @ColumnInfo(index = true)
   public Long artistId;
}

В классе Artist ничего необычного:

@Entity()
public class Artist {

   @PrimaryKey(autoGenerate = true)
   public Long id;
   public String artistName;

}

Теперь при попытке добавить альбом несуществующего артиста, будет происходить ошибка. Аннотация @ForeignKey так же позволяет задавать логику для записей у которых не стало родителя (например, удалили артиста а альбомы остались). Это задаётся при помощи параметров onDelete и onUpdate.

Полнотекстовый поиск

Если в базе данных хранится какой-то текст и есть необходимость выполнять сложные поисковые запросы, то обычно используют полнотекстовый поиск. Он позволяет использовать операторы AND, OR, NOT в поисковых запросах и существенно ускоряет поиск. Чтобы начать его использовать, нужно всего лишь аннотировать класс при помощи @Fts4:

@Entity
@Fts4
public class Message {
   //Ключ должен обязательно называться rowid и быть Integer
   @PrimaryKey(autoGenerate = true)
   @ColumnInfo(name = "rowid")
   @Ignore
   public int id;

   public String text;

   public Message(String text) {
      this.text = text;
   }
}

Сам поиск осуществляется очень простым запросом, для примера создадим несколько сообщений и попробуем поискать по ним. Но сначала добавим в MyDao новый метод который позволит передать поисковую строку и метод позволяющий вставлять сообщения:

@Query("select * from Message where text match :query ")
List<Message> findMessages(String query);

@Insert
void addMessage(Message message);

Теперь можно вставить данные и пробовать искать:

//Вставляем данные
dao.addMessage(new Message("first test message"));
dao.addMessage(new Message("second message"));
dao.addMessage(new Message("one more message for test"));
dao.addMessage(new Message("text message for test"));

//Вернёт три сообщения где есть оба слова
List<Message> result = dao.findMessages("test message");
//Вернёт два сообщения где есть любое из двух слов
result = dao.findMessages("one OR text");

В заключении стоит сказать, что если у разработчика ранее был опыт работы с sqlite, то использование всех этих аннотаций никакой сложности не вызовут. Если опыта не было, то прочитать книгу а-ля «sqlite за 24 часа» всё же необходимо. Это позволит избежать массы сюрпризов и неприятностей в будущем. В следующей статье на тему Room речь пойдёт о более детальном рассмотрении DAO и самого класса базы данных.