45

Room with a View

 5 years ago
source link: https://www.tuicool.com/articles/hit/muABnqr
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
FvEnii2.jpg!web
View from the S23NYC office

Recently, our Android team started going through tech debt and spiking on how we can continue to have a best in class SNKRS experience for our users. While the app is beloved, as evident by our 4.5 rating on Play Store, we still felt that it was necessary to update our architecture to the latest and greatest that Android has to offer. In this post I’ll be going over the work we are doing to take the old persistence model, which stored flattened feed objects into Sqlite directly, and convert it to a relation schema using Google’s fantastic Room ORM. I’ll be going over the good, the bad, and the ugly of using Room.

The Good

Kotlin Support

Room works with Kotlin out of the box, you never have to write a line of java to work with Room. Your data models, DAOs, & database can all be written in Kotlin.

API Done Right

It takes all of ten minutes to be up and running with Room:

First, create Kotlin data class and annotate them as room entities

@Entity
data class Sneaker(
        @PrimaryKey val id: String,
        ...
)

Next, create a BaseDAO (ThanksFlorina for the tip!)

interface BaseDao<T> {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(vararg obj: T)

@Delete
fun delete(vararg obj: T)
}

Now, you can create abstract DAOs like the one below. You will only have to define query functions. Insert/Delete functionality will be inherited from your base DAO. Overall, find the syntax to feel familiar for those that use retrofit

@Dao
abstract class SneakerDAO : BaseDao<Sneaker> {
    @Query("Select * from Sneaker where id like :id")
    abstract fun getSneakerById(id: String): Sneaker
}

Once you have your DAOs, you create an abstract class with your DAOs and entities:

@Database(
        entities = [Sneaker::class, ...],
        version = 1)
abstract class SnkrsDB : RoomDatabase() {
    abstract fun sneakerDAO(): SneakerDAO
    ...
}

Finally, you create an instance of your DB:

@Singleton fun provideRoomDB(context: Context): SnkrsDB =
Room.databaseBuilder(context, SnkrsDB::class.java, "coredb")
.build()

Anytime you want to access your data you only need to call the function made above:

val sneaker = snkrsDB
.sneakersDAO
.getSneakerById(5)

Code Generation

Room is great because it allows you to access your DAOs which will have auto-generated implementations of all your db operations (no more cursors/content values necessary). Developers can now focus on writing beautiful features rather than binding parameters to sql statements or handwriting all the content values boilerplate that was previously required.

Native RxJava Support (It’s Reactive!)

Let’s go back to our query. The way we currently have it written we would only be able to access the data on a background thread (in a blocking fashion). Rarely would you want data on a background thread. Instead, it would be nice to have all database calls be observables similar to how retrofit allows observable call adapters.

The function can now be changed to return a Flowable<Sneakers> rather than just a Sneaker scalar value

@Query("Select * from Sneaker where id like :id")
    abstract fun getSneakerById(id: String): Flowable<Sneaker>

Now the data can be accessed with all the power of RxJava

snkrsDB
.sneakersDAO
.getSneakerById(5)
.toObservable
.observeOn(Schedulers.io())
.subscribeOn(AndroidSchedulers.mainThread())
.subscribe({updateScreen(it)},{logError{it})

The biggest advantage of exposing queries as “endless streams” is that an update to the database will trigger a new emission to the emission above.

Anytime another insert/delete/update is run that will update the Sneaker color, my getSneakerByID backed observable will emit a new value. Additionally, schedulers can be used to allow database access on a background thread and responses to be delivered on the main thread.

Paging Support

So far there has been only a single record from the Sneaker table. What if instead all Sneakers need to be shown in a feed? Room allows writing a query like the one below to return all Sneakers:

@Query("Select * from Sneaker")
    abstract fun getSneakerById(id: String): Flowable<List<Sneaker>>

This doesn’t scale very well since the table could have thousands of records in it. Luckily the architecture components team integrated the Paging Library with Room.

Now a pagedList can be built that will bind to a RecyclerView rather than returning all records:

First, return a DataSource.Factory:

@Query("Select * from Sneaker")
    abstract fun getSneakerById(id: String): DataSource.Factory<Int, Sneaker>

Then build up your PagedList, in our case we return 10 items per page:

val config = PagedList
.Config.Builder()
.setPageSize(10)
.setInitialLoadSizeHint(20)
.build()

val dataSourceFactory = snkrsDB
.sneakerDAO()
.getPage()

val pages = RxPagedListBuilder(
dataSourceFactory, config)
.buildObservable()

Testing Support(!!!!)

One of the most exciting features of room is that it provides a straightforward API for writing connected JUnit tests. While you can’t do unit testing due to differences in sqlite on android/jvm, you can write connected junit tests that work flawlessly with Room. Here’s an example:

First you create an instance of your DB as an in memory database:

val snkrsDB = Room.inMemoryDatabaseBuilder(InstrumentationRegistry.getTargetContext(), SnkrsDB::class.<em>java</em>).build()

Next, you work with your DB same as you would in your production app

val model=testSneaker(id=5,...)
snkrsDB.sneakerDAO.insert(model)
asssertThat(snkrsDB.sneakerDAO.getByID(5)).isEqualTo(model)

TDD with SQL is now a reality and dramatically sped up my development time.

Type Converters:

Want to save a date to the db as a long? You can write a type converter for how that works:

object MyConverters {

@TypeConverter
@JvmStatic
fun toDate(value: Long?): Date? {
return if (value == null) null else Date(value)
}

@TypeConverter
@JvmStatic
fun toLong(value: Date?): Long? {
return value?.time
}

}

Similar you can have type converters for saving a list of strings to a single column:

fun stringAdapter(): JsonAdapter<List<String>> {
val listOfStringsType = Types.newParameterizedType(List::class.java, String::class.java)
val adapter: JsonAdapter<List<String>> = moshi.adapter(listOfStringsType)
return adapter
}

@TypeConverter
@JvmStatic
fun toJSON(list: List<String>?): String? = stringAdapter().toJson(list)

@TypeConverter
@JvmStatic
fun fromJson(list: String): List<String>? = stringAdapter().fromJson(list)

Notice that we used Moshi to marshal/unmarshal the String

We can now add the Type Converter to our data class which will tell Room how to save/read the field from db

@Entity
@TypeConverters(value = [MyConverters::class])
data class Sneaker(
        @PrimaryKey val id: String,
        val sizes: List<String>
)

It’s really nice to be able to encode the type converter logic and never have to worry about it again.

1:1 relationships with Embedded

Let’s say our Sneaker needs has a 1:1 relationship with a barcode, we can leverage an Embedded annotation which will save the barcode to the same table as the Sneaker:

data class Sneaker(
        @PrimaryKey val id: String,
        @Embedded val barcode:Barcode
)

Anytime we insert or query a Sneaker from Room, it will properly set the Barcode field with any scalar values the Barcode contains. Think of it as flattening and unflattening the two objects. The only caveat is that Barcode & Sneaker cannot have any fields that are named the same (more on that in the Bad/Ugly part)

The Bad

No support for saving nested objects

Most annoyingly, there is no support for saving nested objects. Even if we define the relations and embedded objects above, we still will need to iterate through our network models and save each object separately. Hopefully, the arch component team can improve on this process.

Embedded fields need unique names

Back to our embedded example above. If Barcode contains a field called name and Sneakers contains a field called name you will get an error on compile. Since Embedded fields are saved to the same table you need to have unique names for each field. One workaround is to manually rename the column in one object or another like the following:

@ColumnInfo(name = "barcodeName") val name: String?,

Another way is:

@Embedded(prefix = barcode)

which will then prefix all columns pertaining to barcode to barcode *FIELDNAME*

No support for recursive relations

Even if we go through the trouble of creating the above “view” of data, we still cannot load recursive relationships. For example, if a FeedItem contains images and barcodes, Room’s compilation will fail if each barcode contains an image as well.. This lead us to have to lazy load a Barcode’s images on our own.

The Ugly

1 to many relations are pretty messy

As mentioned at the top of the post, we are migrated from a data schema where we saved each Feed Item in a single table where all nested objects are serialized to json strings. Unless we rewrote every caller of the old sqlite db, we need to support callers that expect to get “fat models” back from room. Unfortunately this is not easy. Below are steps we needed to take to return a FeedItem which contained 1:1 & 1:many relationships 4 levels deep:

Need to make views

  1. views should contain all 1:1 as embedded & all 1:many as relations
  2. all relations need to be nullable since they get selected after embeddeds
  3. entities need to have a second constructor for nullable fields
  4. relation fields need to be marked as @ignore and nullable

Below is an example of a data schema that is similar to what we have in our app. Below are the additional objects and annotations you’d need to create if you wanted to load the following FeedItem with:

  • 1:1 relationship with a Barcode
  • 1:many relationship with Other linked items
  • 1:many relationship with properties
  • Properties that have a 1:many relationship with Images
data class FeedItemFull(
        @Embedded var thread: FeedItem,
        @Embedded var barcode: BarcodeFull?
) {
    @Relation(entity = LinkedItem::class, parentColumn = "itemId", entityColumn = "itemId")
    lateinit var relations: List<LinkedItem>
    @Relation(entity = Property::class, parentColumn = "itemId", entityColumn = "itemId")
    lateinit var properties: List<PropertFull>
}

class BarcodeFull(@Embedded
                  var barcode: Barcode? = null) {

    @Relation(entity = Barcode::class, parentColumn = "ItemId", entityColumn = "itemId")
    var barcode: List<Barcode>? = null
}

data class PropertyFull(@Embedded val property: Property) {

    @Relation(parentColumn = "propertyId", entityColumn = "propertyId")
    var images: List<Image>? = null
}

We were hoping that Room would “just work” when it comes to relationships and be able to save/load nested objects without all the ceremony above. Hopefully someday we can get to a better place with this. We considered writing an annotation processor to save FeedItems which would, in turn, save all nested complex objects to their own table.

Conclusion

After using Room for the first time we can definitely say that the good far outweigh the bad. The biggest pain point is how relationships are handled, otherwise doing sql on android is dramatically easier than its ever been. We will continue to migrate more and more of our data layer to Room and hope that the always amazing Architecture Component team will continue to make improvements


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK