Cómo hacer referencia a datos complejos con Room

Room proporciona funciones para realizar conversiones entre tipos primitivos y encuadrados, pero no admite referencias de objetos entre entidades. En este documento, se explica el uso de convertidores de tipos y por qué Room no admite referencias de objetos.

Cómo usar convertidores de tipo

A veces, necesitas que la app almacene un tipo de datos personalizados en una sola columna de base de datos. Para admitir tipos personalizados, debes proporcionar convertidores de tipo, que son métodos que indican a Room cómo convertir tipos personalizados en tipos conocidos y a partir de ellos que Room puede conservar. Para identificar los conversores de tipo, puedes usar la anotación @TypeConverter.

Supongamos que necesitas conservar instancias de Date en tu base de datos de Room. Room no sabe cómo conservar objetos Date, por lo que debes definir los conversores de tipo:

Kotlin

class Converters {
  @TypeConverter
  fun fromTimestamp(value: Long?): Date? {
    return value?.let { Date(it) }
  }

  @TypeConverter
  fun dateToTimestamp(date: Date?): Long? {
    return date?.time?.toLong()
  }
}

Java

public class Converters {
  @TypeConverter
  public static Date fromTimestamp(Long value) {
    return value == null ? null : new Date(value);
  }

  @TypeConverter
  public static Long dateToTimestamp(Date date) {
    return date == null ? null : date.getTime();
  }
}

En este ejemplo, se definen dos métodos de conversión de tipos: uno que convierte un objeto Date en un objeto Long, y otro que realiza la conversión inversa de Long a Date. Debido a que Room sabe cómo conservar objetos Long, puede usar estos conversores para conservar objetos Date.

Luego, agrega la anotación @TypeConverters a la clase AppDatabase para que Room esté al tanto de la clase de conversor que definiste:

Kotlin

@Database(entities = [User::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
  abstract fun userDao(): UserDao
}

Java

@Database(entities = {User.class}, version = 1)
@TypeConverters({Converters.class})
public abstract class AppDatabase extends RoomDatabase {
  public abstract UserDao userDao();
}

Con estos conversores de tipo definidos, puedes usar tu tipo personalizado en tus entidades y DAOs del mismo modo que usarías tipos primitivos:

Kotlin

@Entity
data class User(private val birthday: Date?)

@Dao
interface UserDao {
  @Query("SELECT * FROM user WHERE birthday = :targetDate")
  fun findUsersBornOnDate(targetDate: Date): List<User>
}

Java

@Entity
public class User {
  private Date birthday;
}

@Dao
public interface UserDao {
  @Query("SELECT * FROM user WHERE birthday = :targetDate")
  List<User> findUsersBornOnDate(Date targetDate);
}

En este ejemplo, Room puede usar el conversor de tipos definido en todas partes porque agregaste la anotación @TypeConverters a AppDatabase. Sin embargo, también puedes determinar el alcance de los conversores de tipos en entidades o DAOs específicos mediante la anotación de tus clases @Entity o @Dao con @TypeConverters.

Cómo inicializar el conversor de tipo de control

Por lo general, Room se ocupa de la creación de instancias de conversores de tipo. Sin embargo, es posible que, a veces, necesites transmitir dependencias adicionales a las clases de conversores de tipo, lo que significa que necesitas que la app controle directamente la inicialización de los conversores de tipo. En ese caso, agrega la anotación @ProvidedTypeConverter a tu clase de conversor:

Kotlin

@ProvidedTypeConverter
class ExampleConverter {
  @TypeConverter
  fun StringToExample(string: String?): ExampleType? {
    ...
  }

  @TypeConverter
  fun ExampleToString(example: ExampleType?): String? {
    ...
  }
}

Java

@ProvidedTypeConverter
public class ExampleConverter {
  @TypeConverter
  public Example StringToExample(String string) {
    ...
  }

  @TypeConverter
  public String ExampleToString(Example example) {
    ...
  }
}

Luego, además de declarar tu clase de conversor en @TypeConverters, usa el método RoomDatabase.Builder.addTypeConverter() para pasar una instancia de tu clase de conversor al compilador RoomDatabase:

Kotlin

val db = Room.databaseBuilder(...)
  .addTypeConverter(exampleConverterInstance)
  .build()

Java

AppDatabase db = Room.databaseBuilder(...)
  .addTypeConverter(exampleConverterInstance)
  .build();

Por qué Room no permite referencias a objetos

Conclusión clave: Room no permite referencias de objetos entre clases de entidades. En su lugar, debes solicitar explícitamente los datos que necesita tu app.

La asignación de relaciones de una base de datos al modelo de objetos correspondiente es una práctica común y funciona muy bien en el servidor. Incluso cuando el programa carga los campos a medida que se accede a ellos, el servidor sigue funcionando correctamente.

Sin embargo, en el cliente, este tipo de carga diferida no es factible porque, generalmente, ocurre en el subproceso de IU y la búsqueda de información en el subproceso de IU del disco crea problemas de rendimiento significativos. El subproceso de IU suele tener alrededor de 16 ms para calcular y dibujar el diseño actualizado de una actividad, por lo que, incluso si una búsqueda solo lleva 5 ms, es probable que tu app se quede sin tiempo para dibujar el marco de trabajo, lo que provocaría errores visuales notables. La búsqueda podría tardar más tiempo en completarse si hay una transacción independiente ejecutándose en paralelo o si el dispositivo ejecuta otras tareas que requieren mucha memoria. Sin embargo, si no usas la carga diferida, tu app obtiene más datos de los que necesita, lo que crea problemas de consumo de memoria.

Las asignaciones relacionales de objetos generalmente dejan esta decisión a los desarrolladores con el objetivo de que puedan hacer lo que sea mejor para los casos prácticos de sus apps. Por lo general, los desarrolladores deciden compartir el modelo entre su app y la IU. Sin embargo, esta solución no se ajusta bien porque, a medida que cambia la IU con el paso del tiempo, el modelo compartido crea problemas que son difíciles de anticipar y depurar para los desarrolladores.

Por ejemplo, considera una IU que carga una lista de objetos Book, cada uno con un objeto Author. Inicialmente, puedes diseñar tus búsquedas de modo que utilicen la carga diferida para que las instancias de Book obtenga el autor. La primera obtención del campo author realiza una búsqueda en la base de datos. Un tiempo después, notas que también debes mostrar el nombre del autor en la IU de tu app. Puedes acceder a este nombre con facilidad, como se muestra en el siguiente fragmento de código:

Kotlin

authorNameTextView.text = book.author.name

Java

authorNameTextView.setText(book.getAuthor().getName());

Sin embargo, este cambio aparentemente insignificante hace que se busque la tabla Author en el subproceso principal.

Si buscas la información del autor con anticipación, se hace difícil cambiar el modo en que se cargan esos datos si ya no los necesitas. Por ejemplo, si la IU de tu app ya no necesita mostrar la información de Author, tu app carga datos de forma efectiva que ya no se muestran, lo que desperdicia espacio valioso en la memoria. La eficiencia de tu app se reduce aún más si la clase Author hace referencia a otra tabla, como Books.

Para hacer referencia simultáneamente a varias entidades con Room, debes crear un POJO que contenga cada entidad y, luego, escribir una búsqueda que una las tablas correspondientes. Este modelo bien estructurado, combinado con las sólidas capacidades de validación de búsquedas de Room, permite que tu app consuma menos recursos durante la carga de datos, mejorando el rendimiento de tu app y la experiencia del usuario.