Android Room Tutorial Part 4 – Supporting Custom Data Types

Room is a wrapper around SQLite3 database. Consequently, out of the box, it supports the same types of data as SQLite3, namely: NULL, INTEGER, REAL, TEXT, BLOB. This translates to null, Int, Long, Float, Double, String and ByteArray types in Kotlin. That’s a decent list of primitive types, but, sometimes, you’ll need to store and retrieve additional data types using Room. That’s where Room’s type converters come into the picture.

In this article, which is a fourth in my series about Room (part 1, part 2, part 3), I’ll explain what type converters do and how you can implement them in your Android apps.

Storing and Retrieving Non-Supported Data Type with Room

Let’s continue with the example Room entity that we’ve been using in the previous articles in this series:

@Entity(tableName = "appMemoryInfo")
data class AppMemoryInfo(
    @field:ColumnInfo(name = "id") @PrimaryKey(autoGenerate = true) val id: Long,
    @field:ColumnInfo(name = "consumedMemory") val consumedMemory: Float,
    @field:ColumnInfo(name = "timestamp") val timestamp: Float,
)

Since Room supports the types of all the properties in this entity out-of-the-box, it can serialize and deserialize this entity without any additional effort.

Now imagine that instead of storing just the timestamp, we need the full datetime information, including the timezone offset. In that case, we’ll need to change this entity to:

@Entity(tableName = "appMemoryInfo")
data class AppMemoryInfo(
    @field:ColumnInfo(name = "id") @PrimaryKey(autoGenerate = true) val id: Long,
    @field:ColumnInfo(name = "consumedMemory") val consumedMemory: Float,
    @field:ColumnInfo(name = "datetime") val zonedDateTime: ZonedDateTime,
)

If I build my application now, I’ll get this error:

Cannot figure out how to save this field into database. You can consider adding a type converter for it.

Room basically tells me that it doesn’t support ZonedDateTime type and suggests that I should add a type converter for it. That’s exactly what I’m going to do.

That’s the type converter:

class ZonedDateTimeConverter {

    private val formatter = DateTimeFormatter.ISO_DATE_TIME

    @TypeConverter
    fun toString(entity: ZonedDateTime): String {
        return formatter.format(entity)
    }

    @TypeConverter
    fun fromString(serialized: String): ZonedDateTime {
        return formatter.parse(serialized) as ZonedDateTime
    }
}

Note the following aspects:

  • ZonedDateTimeConverter is a simple class which doesn’t extend any other class.
  • Both methods bear @TypeConverter annotation. This instructs Room to consider these methods when it deals with unsupported data types.
  • The first method takes ZonedDateTime as an argument and returns String. Room will call this method to serialize the respective data type before insertion into the database.
  • The second method takes String as an argument and returns ZonedDateTime. Room will call this method to deserialize the respective data type before returning the result retrieved from the database.

To instruct Room to use our custom type converter, we need to register it with MyRoomDatabase:

@Database(
    entities = [AppMemoryInfo::class],
    version = 1
)
@TypeConverters(
    value = [ZonedDateTimeConverter::class]
)
abstract class MyRoomDatabase : RoomDatabase() {
    abstract val appMemoryInfoDao: AppMemoryInfoDao
}

That’s it. Now I can build and run my application without problems because Room will be able to handle ZonedDateTime instances.

The idea behind type converters is rather straightforward: Room will inspect the annotations and the argument/return types, and “remember” this information. Then, at runtime, when the unsupported type appears, Room will delegate its handling to the respective registered type converter (or throw an error if no converter for that type exists).

Serialization of Complex Data Structures

In the previous example it was quite simple to serialize ZonedDateTime into String. That’s not always the case. In some situations, you might need to serialize collections, complex nested data structures, etc. There is no one-size-fits-all approach to that, but I usually use Json serialization/deserialization library like Gson or Moshi to tackle this use case.

Imagine that our Room entity evolves into this:

@Entity(tableName = "appMemoryInfo")
data class AppMemoryInfo(
    @field:ColumnInfo(name = "id") @PrimaryKey(autoGenerate = true) val id: Long,
    @field:ColumnInfo(name = "consumedMemory") val consumedMemory: Float,
    @field:ColumnInfo(name = "datetime") val zonedDateTime: ZonedDateTime,
    @field:ColumnInfo(name = "payload") val payload: MyComplexDataStructure,
)

MyComplexDataStructure is a custom entity that has a deep nested structure and, therefore, serializing it manually would be too time-consuming and error-prone. Type converter that uses Gson to the rescue:

class MyComplexDataStructureConverter {

    private val gson = Gson()

    @TypeConverter
    fun fromString(serialized: String): MyComplexDataStructure {
        return gson.fromJson(serialized, MyComplexDataStructure::class.java)
    }

    @TypeConverter
    fun toString(entity: MyComplexDataStructure): String {
        return gson.toJson(entity)
    }
}

Register this type converter with MyRoomDatabase and I’m done! Gson will handle all the heavy lifting for me.

Type Converters and Dependency Injection

In the above implementation of MyComplexDataStructureCoverter I instantiate Gson right inside that class. That’s probably fine for this simple example, but, in real production applications, type converters can be more complex and require additional dependencies. I wouldn’t want to instantiate all of them inside type converters, especially if they have a deep tree of their own dependencies which I’ll need to instantiated as well. Furthermore, it’s a good practice to use a single instance of Gson in the app for performance reasons.

That’s where Dependency Injection architectural pattern comes in very handy. It allows us to delegate the instantiation of objects to an external entity called Composition Root. However, if I’ll just add Gson as a constructor argument of MyComplexDataStructureCoverter like this:

class MyComplexDataStructureConverter(private val gson: Gson) {

    @TypeConverter
    fun fromString(serialized: String): MyComplexDataStructure {
        return gson.fromJson(serialized, MyComplexDataStructure::class.java)
    }

    @TypeConverter
    fun toString(entity: MyComplexDataStructure): String {
        return gson.toJson(entity)
    }
}

Then I’ll see this build error:

Classes that are used as TypeConverters must have no-argument public constructors. Use a ProvidedTypeConverter annotation if you need to take control over creating an instance of a TypeConverter.

Room once again gives us a very clear explanation of the problem and suggests a solution that we can use. Let’s implement that solution.

First, I’ll annotate MyComplexDataStructureCoverter with @ProvidedTypeConverter annotation:

@ProvidedTypeConverter
class MyComplexDataStructureConverter(private val gson: Gson) {

    @TypeConverter
    fun fromString(serialized: String): MyComplexDataStructure {
        return gson.fromJson(serialized, MyComplexDataStructure::class.java)
    }

    @TypeConverter
    fun toString(entity: MyComplexDataStructure): String {
        return gson.toJson(entity)
    }
}

If I rebuild the app now, it will succeed. However, I’ll get a runtime crash stating that a type converter for MyComplexDataStructure is missing. That’s because once I use the above annotation, Room won’t instantiate the respective type converter for me anymore. Instead, I’ll need to handle that myself.

So, let’s bind this type converter to MyRoomDatabase manually (if you wonder what’s the role of MyDatabase wrapper, you can read part 2 of this series):

class MyDatabase(context: Context, gson: Gson) {
    private val myRoomDatabase: MyRoomDatabase

    init {
        myRoomDatabase = Room.databaseBuilder(
            context,
            MyRoomDatabase::class.java,
            DatabaseConstants.DATABASE_NAME
        ).apply {
            addTypeConverter(MyComplexDataStructureCoverter(gson))
        }.build()
    }
    
    val appMemoryInfoDao get() = myRoomDatabase.appMemoryInfoDao
}

After this step, my application will both build and execute without problems.

Serialization vs Relationship with Another Table

Please note that when you serialize a complex data structure, like I did above, it’s a trade-off.

On the one hand, this approach is very simple.

On the other hand, you’ll pay this price for the simplicity:

  1. You won’t be able to use the contents of the serialized data structure in WHERE clauses of queries.
  2. You won’t be able to retrieve just a subset of the information contained in MyComplexDataStructure.
  3. You won’t be able to use migrations to evolve MyComplexDataStructure‘s schema.

Therefore, before you write type converters for complex data structures, pause for a moment and consider the long-term implications. In some cases, this trade-off makes sense. In other cases, you might need to store MyComplexDataStructure in another database table and define a relation to it using foreign keys.

Conclusion

Type converters are yet another great Room’s feature. They enable you to work with custom data types in your database in a very simple and elegant way. I believe that most real-world Android applications that use Room will need to handle custom data types at some point, so now you know how to do that using Room’s type converters.

Thanks for reading and don’t forget to subscribe to my mailing list if you want to be notified about new posts.

Check out my premium

Android Development Courses

Leave a Comment