Upgrade to Pro — share decks privately, control downloads, hide ads and more …

BUILDING A DATA LAYER THAT FITS YOUR OVERALL AP...

BUILDING A DATA LAYER THAT FITS YOUR OVERALL APP ARCHITECTURE

This talk takes you through how to build a data layer that has the following three components:
1. Data model which shows how the application's data will be presented.
2. Data source where the CRUD functionality is implemented and
3. Repository which will expose, update and synchronize data.

Avatar for Jacquiline Gitau

Jacquiline Gitau

July 25, 2023
Tweet

More Decks by Jacquiline Gitau

Other Decks in Programming

Transcript

  1. GOOGLE IO Extended - SPEAKER TEMPLATE Table of Contents 01

    02 03 04 05 06 About the Data layer Storing data locally Creating a network data source Creating a repository Testing Updating the UI layer
  2. It’s an architectural layer that manages your application’s data. Contains

    the business logic to perform CRUD operations. It’s separation of concern makes it reusable, allowing it to be presented on multiple screens, share information between different parts of the app, and reproduce business logic outside of the UI for unit testing. The key component types that makeup the data layer are data models, data sources and repositories as shown in the diagram in the slide. Data Layer Section 01
  3. The application data is usually represented as the data models.

    These are in-memory representations of data. It is immutable. Class responsible for reading and writing data to a single source such as a database or a network service. Data Models Data Source Should manage a single data source. Repository Data Layer Components Section 01
  4. Task Class(Data Model) data class Task( val id: String val

    title: String = "", val description: String = "", val isCompleted: Boolean = false, ) { ... }
  5. 8 Relationship diagram The diagram shows the relationship between task

    repository, model, data source and database. Section 2
  6. 1. Create a data model. • Store data in a

    room database you need to create a database entity. 2. Create a data source. • Use the Data Access Object as the local data source. 3. Update the database schema. • To store data from the data model 4. Update hilt configuration. • Hilt needs to know how to create the data source so that it can be injected into the classes that use it. Steps to store data locally Section 02
  7. //Data Model LocalTask.kt @Entity( tableName = "task" ) data class

    LocalTask( @PrimaryKey val id: String, var title: String, var description: String, var isCompleted: Boolean, )
  8. @Dao (Data source) interface TaskDao { @Query("SELECT * FROM task")

    fun observeAll(): Flow<List<LocalTask>> @Upsert suspend fun upsert(task: LocalTask) @Upsert suspend fun upsertAll(tasks: List<LocalTask>) @Query("UPDATE task SET isCompleted = :completed WHERE id = :taskId") suspend fun updateCompleted(taskId: String, completed: Boolean) @Query("DELETE FROM task") suspend fun deleteAll() }
  9. ToDoDatabase.kt @Database(entities = [LocalTask::class], version = 1, exportSchema = false)

    abstract class ToDoDatabase : RoomDatabase() { abstract fun taskDao(): TaskDao }
  10. The network API is simple and performs just two operations

    which are: • Save all tasks, overwriting all the previous tasks. • Load all tasks. We will create a data source that will communicate to the network service. The network service Section 03
  11. NetworkTask.kt (Data Model) data class NetworkTask( val id: String, val

    title: String, val shortDescription: String, val priority: Int? = null, val status: TaskStatus = TaskStatus.ACTIVE ) { enum class TaskStatus { ACTIVE, COMPLETE } }
  12. TaskNetworkDataSource.kt (Data Source) class TaskNetworkDataSource @Inject constructor() { // A

    mutex is used to ensure that reads and writes are thread-safe. private val accessMutex = Mutex() private var tasks = listOf( NetworkTask( id = "PISA", title = "Build tower in Pisa", shortDescription = "Ground looks good, no foundation work required." ), NetworkTask( id = "TACOMA", title = "Finish bridge in Tacoma", shortDescription = "Found awesome girders at half the cost!" ) ) }
  13. TaskNetworkDataSource.kt (Data Source Continued) class TaskNetworkDataSource @Inject constructor() { suspend

    fun loadTasks(): List<NetworkTask> = accessMutex.withLock { delay(SERVICE_LATENCY_IN_MILLIS) return tasks } suspend fun saveTasks(newTasks: List<NetworkTask>) = accessMutex.withLock { delay(SERVICE_LATENCY_IN_MILLIS) tasks = newTasks } } private const val SERVICE_LATENCY_IN_MILLIS = 2000L
  14. LocalTask is an internal model that should not be exposed

    to other architectural layers. We need to transform LocalTask into Task by mapping the fields from LocalTask to Task. To achieve this, we will create an extension function for the class. Map Internal models to external models Section 04
  15. // Convert a LocalTask to a Task fun LocalTask.toExternal() =

    Task( id = id, title = title, description = description, isCompleted = isCompleted, ) // Convenience function which converts a list of LocalTasks to a list of Tasks fun List<LocalTask>.toExternal() = map(LocalTask::toExternal) // Equivalent to map { it.toExternal() }
  16. A TODO app isn’t that much of use if it

    can’t update and create tasks. We need to add methods that will create, update and delete tasks by implementing suspend functions. Updating Data Section 04
  17. //Method to create task ID // This method might be

    computationally expensive private fun createTaskId() : String { return UUID.randomUUID().toString() }
  18. //Create a task id using the newly created createTaskId suspend

    fun create(title: String, description: String): String { val taskId = createTaskId() }
  19. The data layer has a responsibility to ensure that long

    running tasks or complex tasks don’t block the main thread. Don’t block the main thread Section 04
  20. //Add CoroutineDispatcher dependency class DefaultTaskRepository @Inject constructor( private val localDataSource:

    TaskDao, private val networkDataSource: TaskNetworkDataSource, @DefaultDispatcher private val dispatcher: CoroutineDispatcher, )
  21. //Place createTaskId() inside withContext block suspend fun create(title: String, description:

    String): String { val taskId = withContext(dispatcher) { createTaskId() } }
  22. Now that you have a task ID, use it along

    with the supplied parameters to create a new Task. Create and store the task Section 04
  23. suspend fun create(title: String, description: String): String { val taskId

    = withContext(dispatcher) { createTaskId() } val task = Task( title = title, description = description, id = taskId, )}
  24. //Add extension function to the end of LocalTask fun Task.toLocal()

    = LocalTask( id = id, title = title, description = description, isCompleted = isCompleted, )
  25. //Add the external function to insert task in the data

    source suspend fun create(title: String, description: String): Task { ... localDataSource.upsert(task.toLocal()) return taskId }
  26. To complete the synchronization algorithm you need to create methods

    to save and refresh data from the network data source. Save and refresh network data Section 04
  27. //Create mapping function fun NetworkTask.toLocal() = LocalTask( id = id,

    title = title, description = shortDescription, isCompleted = (status == NetworkTask.TaskStatus.COMPLETE), ) fun List<NetworkTask>.toLocal() = map(NetworkTask::toLocal) fun LocalTask.toNetwork() = NetworkTask( id = id, title = title, shortDescription = description, status = if (isCompleted) { NetworkTask.TaskStatus.COMPLETE } else { NetworkTask.TaskStatus.ACTIVE } ) fun List<LocalTask>.toNetwork() = map(LocalTask::toNetwork)
  28. //Add a refresh method suspend fun refresh() { val networkTasks

    = networkDataSource.loadTasks() localDataSource.deleteAll() val localTasks = withContext(dispatcher) { networkTasks.toLocal() } localDataSource.upsertAll(networkTasks.toLocal()) }
  29. //Add saveTasksToNetwork private suspend fun saveTasksToNetwork() { val localTasks =

    localDataSource.observeAll().first() val networkTasks = withContext(dispatcher) { localTasks.toNetwork() } networkDataSource.saveTasks(networkTasks) }
  30. //Add saveTasksToNetwork suspend fun create(title: String, description: String): String {

    ... saveTasksToNetwork() return taskId } suspend fun complete(taskId: String) { localDataSource.updateCompleted(taskId, true) saveTasksToNetwork() }
  31. Use coroutine scope in order to save data to the

    network. This allows the operation to complete in the background without making the caller wait for the results. Don’t make the caller wait Section 4
  32. // Add a CoroutineScope class DefaultTaskRepository @Inject constructor( // ...other

    parameters... @ApplicationScope private val scope: CoroutineScope, )
  33. //Wrap code inside scope.launch private fun saveTasksToNetwork() { scope.launch {

    val localTasks = localDataSource.observeAll().first() val networkTasks = withContext(dispatcher) { localTasks.toNetwork() } networkDataSource.saveTasks(networkTasks) } }
  34. We will perform instrumental testing to the local data source

    to verify the code is working correctly. Testing the local data source Section 05
  35. //Create a Test Class and add a test database class

    TaskDaoTest { private lateinit var database: ToDoDatabase @Before fun initDb() { database = Room.inMemoryDatabaseBuilder( getApplicationContext(), ToDoDatabase::class.java ).allowMainThreadQueries().build() } }
  36. //Add a test(what will be the outcome?) @Test fun insertTaskAndGetTasks()

    = runTest { val task = LocalTask( title = "title", description = "description", id = "id", isCompleted = false, ) database.taskDao().upsert(task) val tasks = database.taskDao().observeAll().first() assertEquals(0, tasks.size) }
  37. @Test fun insertTaskAndGetTasks() = runTest { val task = LocalTask(

    title = "title", description = "description", id = "id", isCompleted = false, ) database.taskDao().upsert(task) val tasks = database.taskDao().observeAll().first() assertEquals(1, tasks.size) assertEquals(task, tasks[0]) }
  38. Verify that all functionality added in the repository works by

    creating unit tests. Testing the task repository Section 05
  39. FakeTaskDao.kt (Cntd next slide) class FakeTaskDao(initialTasks: List<LocalTask>) : TaskDao {

    private val _tasks = initialTasks.toMutableList() private val tasksStream = MutableStateFlow(_tasks.toList()) override fun observeAll(): Flow<List<LocalTask>> = tasksStream override suspend fun upsert(task: LocalTask) { _tasks.removeIf { it.id == task.id } _tasks.add(task) tasksStream.emit(_tasks) } }
  40. class FakeTaskDao(initialTasks: List<LocalTask>) : TaskDao { override suspend fun upsertAll(tasks:

    List<LocalTask>) { val newTaskIds = tasks.map { it.id } _tasks.removeIf { newTaskIds.contains(it.id) } _tasks.addAll(tasks) } override suspend fun updateCompleted(taskId: String, completed: Boolean) { _tasks.firstOrNull { it.id == taskId }?.let { it.isCompleted = completed } tasksStream.emit(_tasks) } override suspend fun deleteAll() { _tasks.clear() tasksStream.emit(_tasks) } }
  41. DefaultTaskRepositoryTest.kt class DefaultTaskRepositoryTest { private var testDispatcher = UnconfinedTestDispatcher() private

    var testScope = TestScope(testDispatcher) private val localTasks = listOf( LocalTask(id = "1", title = "title1", description = "description1", isCompleted = false), LocalTask(id = "2", title = "title2", description = "description2", isCompleted = true), ) }
  42. DefaultTaskRepositoryTest.kt (Continued) class DefaultTaskRepositoryTest { .... private val localDataSource =

    FakeTaskDao(localTasks) private val networkDataSource = TaskNetworkDataSource() private val taskRepository = DefaultTaskRepository( localDataSource = localDataSource, networkDataSource = networkDataSource, dispatcher = testDispatcher, scope = testScope ) }
  43. //Test exposed data DefaultTaskRepositoryTest.kt @Test fun observeAll_exposesLocalData() = runTest {

    val tasks = taskRepository.observeAll().first() assertEquals(localTasks.toExternal(), tasks) }
  44. //Test data updates on creation @Test fun onTaskCreation_localAndNetworkAreUpdated() = testScope.runTest

    { val newTaskId = taskRepository.create( localTasks[0].title, localTasks[0].description ) val localTasks = localDataSource.observeAll().first() assertEquals(true, localTasks.map { it.id }.contains(newTaskId)) val networkTasks = networkDataSource.loadTasks() assertEquals(true, networkTasks.map { it.id }.contains(newTaskId)) }
  45. //Test data updates on completion @Test fun onTaskCompletion_localAndNetworkAreUpdated() = testScope.runTest

    { taskRepository.complete("1") val localTasks = localDataSource.observeAll().first() val isLocalTaskComplete = localTasks.firstOrNull { it.id == "1" } ?.isCompleted assertEquals(true, isLocalTaskComplete) val networkTasks = networkDataSource.loadTasks() val isNetworkTaskComplete = networkTasks.firstOrNull { it.id == "1"} ?.status == NetworkTask.TaskStatus.COMPLETE assertEquals(true, isNetworkTaskComplete) }
  46. //Test data refresh @Test fun onRefresh_localIsEqualToNetwork() = runTest { val

    networkTasks = listOf( NetworkTask(id = "3", title = "title3", shortDescription = "desc3"), NetworkTask(id = "4", title = "title4", shortDescription = "desc4"), ) networkDataSource.saveTasks(networkTasks) taskRepository.refresh() assertEquals(networkTasks.toLocal(), localDataSource.observeAll().first()) }
  47. Update the view model that displays the first screen in

    the application - the list of all current active tasks. Updating the viewmodel for the task list screen Section 06
  48. @HiltViewModel class TasksViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, private

    val taskRepository: DefaultTaskRepository, ) : ViewModel () { /* ... */ //Initialize the tasksStream variable using the repository. private val tasksStream = taskRepository.observeAll() }
  49. fun complete(task: Task, completed: Boolean) { viewModelScope.launch { if (completed)

    { taskRepository.complete(task.id) showSnackbarMessage(R.string.task_marked_complete) } else { ... } } }
  50. Update the view model that updates new tasks in the

    view model. Updating the viewmodel for the Add task screen Section 06
  51. Thank You Jacqui Gitau She/Her Droidettes Co-Lead, WTM Ambassador Resources:

    https://developer.android.com/codelabs/building-a-data-layer