Room
The Repository
A Repository is an optional abstraction at the top of the Data Layer. It's often used to
- Expose data from multiple data sources
- Cache data from a remote data source (such as a web service) in a local data source (such as a database or file)
- Convert data into Data Transfer Objects that expose a more restricted view of the data
In this class, we'll be using it to create Data Transfer Objects. If you're interested in other uses of a Repository, search online for terms such as "Android Repository Cache".
But first, where does the Repository go? It's part of the Data Layer, but by smart use of Modules in your application, you can take advantage of dependencies between modules to prevent other layers from accidentally accessing data directly.
flowchart LR
subgraph User Interface Layer
ui[User Interface Module\nView Model\nUser interface]
end
ui --> repo
subgraph Data Layer
repo[Repository Module\nRepository\nData Transfer Objects]
ds[Data Source Module\nEntities\nDAO]
repo --> ds
end
The separate Repository Module prevents the User Interface Module from directly accessing anything in the Data Source Module (assuming you set up module dependencies in your build to restrict that). It's a great way to hide details as well as make the data we use in the user interface immutable, which is great for the Jetpack Compose UI that we'll be creating.
We'll see how these modules are set up when we're walking through the example code.
Repository Code
So what does the code in the Repository module look like?
First, let's define a Data Transfer Object for our Person:
data class PersonDto(
val id: String,
val name: String,
val age: Int,
val ssn: String,
)
Note that all of the properties here are val
properties; we can only read them; we cannot change them. We can also define a couple of helper extension functions
internal fun Person.toDto() =
PersonDto(
id = id,
name = name,
age = age,
ssn = ssn
)
internal fun PersonDto.toEntity() =
Person(
id = id,
name = name,
age = age,
ssn = ssn
)
Kotlin extension functions make it look like we're defining new functions on existing types. Here we add a toDto
function to our Person
entity, and a toEntity
function to our PersonDto
.
Note that both functions are marked internal
. This restricts their use to inside of the module defining them. We're only doing the transformation inside the Repository module. Marking them internal
allows us to use them anywhere within the module, no matter which package.
Next, we define an interface for the Repository:
interface PersonRepository {
val peopleFlow: Flow<List<PersonDto>>
suspend fun insert(vararg people: PersonDto)
suspend fun update(vararg people: PersonDto)
suspend fun delete(vararg people: PersonDto)
}
Note that this interface only uses the PersonDto
, not the Person
entity. We're only exposing the DTO.
We can (and will) have multiple implementations of this interface. We'll start with one that talks to a Room database, and later in the course we'll implement it by talking to a web service.
class PersonDatabaseRepository(
context: Context
): PersonRepository {
private val dao =
Room.databaseBuilder(
context,
PersonDatabase::class.java,
"PEOPLE"
)
.build()
.dao
...
}
To create a Room database instance, we need an Android Context
. The Context
(typically an Application
or Activity
instance) gives us access to details about the application, such as where our database files are stored.
We pass that to the PersonDatabaseRepository
, create a database instance and grab the DAO from it.
Next, we expose a Flow<List<PersonDto>>
. To do this, we'll take advantage of the map
operator on Flow
. map
creates a new Flow
by collecting objects from a Flow
and transforming each item into something else.
class PersonDatabaseRepository(
context: Context
): PersonRepository {
private val dao = ...
override val peopleFlow =
dao.getPeople()
.map { people -> // AAA
people.map { it.toDto() } // BBB
}
...
}
The first map
call creates that new Flow
. Whenever we get a new value, the List<Person>
, we pass it to the lambda starting on line AAA
. That list is represented by lambda parameter people
.
Using a map
operator against that list (line BBB
), we create a new list by converting each Person
into a PersonDto
.
Anyone collecting peopleFlow
will get a List<PersonDto>
that contains a read-only copy of the Person
entity data.
The one-shot functions are all implemented similarly, so we'll just talk through the insert
function.
class PersonDatabaseRepository(
context: Context
): PersonRepository {
private val dao = ...
...
override suspend fun insert(vararg people: PersonDto) =
dao.insert(
*people // AAA
.map { it.toEntity() } // BBB
.toTypedArray() // CCC
)
override suspend fun update(vararg people: PersonDto) = ...
// similar to insert
override suspend fun delete(vararg people: PersonDto) = ...
// similar to insert
}
The caller will pass in a varying-length argument list of PersonDto
instances. This means they can pass any number of PersonDtos
into insert
, separarted by commas. For example
repository.insert(person1)
repository.insert(person1, person2, person3)
are valid calls to insert
.
Inside insert
, the people
parameter is an Array<PersonDto>
. We need to convert each PersonDto
to a Person
entity.
Line BBB
converts people
into a List<Person>
.
But now we need to pass those Person
instances into a varying-length, comma-separated argument list. Java would allow you to just pass an array, but that caused some ambiguity problems. Kotlin removed the ambiguity, but it had to make things a little more complex in the process.
We need to "spread" the values out as a comma-separated list. That's what the *
does on line AAA
. *
is called the "spread operator" in this context. But it only works against an Array
of values, so we need to call toTypedArray
on line CCC
to convert our new List<Person>
into an Array<Person>
.
Once spread, we pass the values into the DAO's insert
function.
Implement the update
and delete
functions similarly, and we now have a repository that isolates the Room data from the User Interface Layer.