Original address: Application Architecture Guide
I. Mobile application user experience
Consider what happens when you share photos in your favorite social networking app:
- The application triggers the camera intent. The Android operating system then launches the camera app to process the request. At this point, the user has left the social networking application, but their experience is still seamless.
- The camera application may trigger other intents (such as launching a file selector), which in turn may launch another application.
- Finally, users return to the social networking app and share photos.
During this process, the user can be interrupted by phone calls or notifications at any time. After processing, users want to be able to go back and continue sharing photos. This application jumping behavior is common on mobile devices, so your application must handle these processes correctly.
Note that mobile devices also have limited resources, so the operating system may terminate some application processes at any time to make room for new ones.
Given these environmental conditions, your application components can be started separately, out of sequence, and the operating system or user can destroy them at any time. Because these events are not under your control, you should not store any application data or state in application components, and application components should not depend on each other.
Common architectural principles
If you shouldn’t use application components to store application data and state, how should you design your application?
2.1 Separation of Concerns
The most important principle to follow is separation of concerns. A common mistake is to write all your code in one Activity or Fragment. These interface-based classes should contain only the logic that handles interface and operating system interactions. You should keep these classes as lean as possible to avoid many life-cycle related problems.
Note that you do not own the implementation of the Activity and Fragment; They are just adhesive classes that represent the relationship between the Android operating system and the application. The operating system may destroy them at any time based on user interaction or system conditions such as running out of memory. In order to provide a satisfying user experience and a more manageable application maintenance experience, it is best to minimize your reliance on them.
2.2 Through the model-driven interface
Another important principle is that you should go through a model-driven interface (preferably a persistent model). The model is the component responsible for processing the application data. They are independent of the View objects and application components in the application and are therefore not affected by the application lifecycle and related concerns.
Persistence is ideal for several reasons:
- If the Android operating system destroys the application to free up resources, the user does not lose data.
- When the network connection is unstable or unavailable, the application continues to work.
The model classes on which the application is based should clearly define data management responsibilities, which will make the application more testable and consistent.
3. Recommended application architecture
In this section, we will demonstrate how to construct an application using architectural components through an end-to-end use case.
Note: No application is written in the best way for every situation. Having said that, the recommended architecture is a good starting point for most situations and workflows. If you already have a good way of writing Android applications (following common architectural principles), you don’t need to change.
Suppose we want to build an interface for displaying user profiles. We use the private backend and REST API to get the data for a given profile.
An overview of
First, take a look at the figure below, which shows how all the modules should interact with each other after designing the application:
Note that each component depends only on the component at the level below it. For example, activities and fragments rely only on the viewmodel. The storage area is the only class that depends on multiple other classes; In this case, the storage relies on a persistent data model and a remote back-end data source.
This design creates a consistent and enjoyable user experience. Whether the user last used the application a few minutes ago or a few days ago, when they go back to the application now, they immediately see the user information that the application has locally. If this data is obsolete, the application’s storage module will start updating the data in the background.
Build the interface
The page consists of Fragment UserProfileFragment and its corresponding layout file user_profile_layout.xml.
To drive this interface, the data model needs to store the following data elements:
-
User ID: Indicates the ID of a user. It is best to pass this information to the Fragment using the Fragment parameter. If the Android operating system destroys our process, this information is kept so that the ID can be used the next time the application restarts.
-
User object: a data class used to store user details.
We will use the UserProfileViewModel (based on the ViewModel architecture component) to store this information.
ViewModel objects provide data for specific interface components, such as fragments or activities, and contain data-processing business logic to communicate with the model. For example, the ViewModel can call other components to load data, and it can forward user requests to modify data. The ViewModel has no knowledge of the interface components and is therefore unaffected by configuration changes such as recreating an Activity while rotating the device.
We now define the following files:
- User_profile.xml: specifies the screen layout definition.
- UserProfileFragment: Indicates the controller that displays data.
- UserProfileViewModel: Prepares data for use in
UserProfileFragment
To view and respond to user interaction.
The following code snippet shows the starting content of these files (the layout file has been omitted for simplicity).
UserProfileViewModel
class UserProfileViewModel : ViewModel() {
val userId : String = TODO()
val user : User = TODO()
}
Copy the code
UserProfileFragment
class UserProfileFragment : Fragment() {
// To use the viewModels() extension function, include
// "androidx.fragment:fragment-ktx:latest-version" in your app
// module's build.gradle file.
private val viewModel: UserProfileViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.main_fragment, container, false)
}
}
Copy the code
Now that we have these code modules, how do we connect them? After all, we need a way to notify the interface when we set the User field in the UserProfileViewModel class.
To get the user, our ViewModel needs to access the Fragment parameter. We can pass them through the Fragment, or better yet, using the SavedState module, we can tell the ViewModel to read the arguments directly:
Note: SavedStateHandle allows the ViewModel to access the saved state and parameters of the associated Fragment or Activity.
// UserProfileViewModel class UserProfileViewModel( savedStateHandle: SavedStateHandle ) : ViewModel() { val userId : String = savedStateHandle["uid"] ? : throw IllegalArgumentException("missing user id") val user : User = TODO() } // UserProfileFragment private val viewModel: UserProfileViewModel by viewModels( factoryProducer = { SavedStateVMFactory(this) } ... )Copy the code
After we get the user object, we need to notify the Fragment. This is where the LiveData architecture components come in.
LiveData is an observable data store. Other components in the application can use this storage to monitor changes to objects without creating clear and strict dependency paths between them. The LiveData component also follows the lifecycle state of application components such as activities, fragments, and services, and includes cleanup logic to prevent object leaks and excessive memory consumption.
Note: If you are already using libraries like RxJava, you can continue to use them instead of LiveData. However, when using such libraries and methods, be sure to handle the application lifecycle correctly. In particular, make sure that data flows are paused when the associated LifecycleOwner stops and destroyed when the associated LifecycleOwner destroys. You can also add android. Arch. Lifecycle: reactivestreams artifacts, to LiveData with other response flow libraries (such as RxJava2) used together.
To include the LiveData component in the application, we change the field type in the UserProfileViewModel to LiveData. UserProfileFragment is now notified when data is updated. In addition, because this LiveData field is lifecycle aware, they are automatically cleaned up when references are no longer needed.
UserProfileViewModel
class UserProfileViewModel( savedStateHandle: SavedStateHandle ) : ViewModel() { val userId : String = savedStateHandle["uid"] ? : throw IllegalArgumentException("missing user id") val user : LiveData<User> = TODO() }Copy the code
Now, we modify the UserProfileFragment to view the data and update the interface:
UserProfileFragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.user.observe(viewLifecycleOwner) {
// update UI
}
}
Copy the code
Each time the user profile data is updated, the system calls the onChanged() callback and refreshes the interface.
If you’re familiar with other libraries that use observable callbacks, you may have realized that we didn’t replace the Fragment’s onStop() method to stop observing data. This step is not necessary with LiveData because it is lifecycle aware. This means that the Fragment does not call the onChanged() callback unless it is active (that is, it has received onStart() but not onStop()). LiveData also automatically removes observers when the Fragment’s onDestroy() method is called.
In addition, we did not add any logic to handle configuration changes (for example, the user rotates the device’s screen). The UserProfileViewModel automatically resumes after configuration changes, so once a new Fragment is created, it will receive the same ViewModel instance and immediately call the callback with the current data. Since ViewModel objects are supposed to last longer than their updated counterparts, ViewModel implementations must not contain direct references to View objects. For a detailed view of the ViewModel lifecycle that corresponds to the interface component lifecycle, see The ViewModel Lifecycle.
To get the data
Now that we have connected the UserProfileViewModel to the UserProfileFragment using LiveData, how do we get the user profile data?
In this example, we assume that the back-end provides the REST API. We use the Retrofit library to access the back end, but feel free to use other libraries that play the same role.
Here is the definition of a Webservice that communicates with the back end:
Webservice
interface Webservice {
/**
* @GET declares an HTTP GET request
* @Path("user") annotation on the userId parameter marks it as a
* replacement for the {user} placeholder in the @GET path
*/
@GET("/users/{user}")
fun getUser(@Path("user") userId: String): Call<User>
}
Copy the code
The first idea to implement a ViewModel might be to call a Webservice directly to get data and then assign that data to a LiveData object. This design makes sense, but if adopted, it will become harder and harder to maintain as the application expands. This would place too much responsibility on the UserProfileViewModel class, which violates the separation of concerns principle. In addition, the time range of the ViewModel is associated with the Activity or Fragment lifecycle, which means that when the lifecycle of the associated interface object ends, the Webservice data is lost, thereby affecting the user experience.
The ViewModel delegates the data retrieval process to a new module, the storage area.
The storage area module handles data operations. They provide a clean API so that the rest of the application can easily retrieve the data. When the data is updated, they know where to get the data and what API calls to make. You can think of a storage area as a medium between different data sources, such as persistence models, network services, and caches.
The UserRepository class (as shown in the following code snippet) uses a WebService instance to retrieve the user’s data:
UserRepository
class UserRepository {
private val webservice: Webservice = TODO()
// ...
fun getUser(userId: String): LiveData<User> {
// This isn't an optimal implementation. We'll fix it later.
val data = MutableLiveData<User>()
webservice.getUser(userId).enqueue(object : Callback<User> {
override fun onResponse(call: Call<User>, response: Response<User>) {
data.value = response.body()
}
// Error case is left out for brevity.
override fun onFailure(call: Call<User>, t: Throwable) {
TODO()
}
})
return data
}
}
Copy the code
While the storage area module may seem unnecessary, it serves an important function: it extracts data sources from the rest of the application. Now, UserProfileViewModel doesn’t know how to fetch data, so we can provide the viewmodel with data obtained from several different data fetch implementations.
Note: For simplicity, we omit the network error case. For alternative implementations of exposing error and load state, see appendix: Exposing Network State.
Manage dependencies between components
The UserRepository class above requires a Webservice instance to retrieve the user’s data. It can create the instance directly, but to do so, it needs to know the dependencies of the Webservice class. Furthermore, UserRepository may not be the only class that requires a Webservice. In this case, we need to copy the code, because every class that needs to reference a Webservice needs to know how to construct the instance and its dependencies. If each class created a new WebService, the application would become very resource-intensive.
You can use the following design pattern to solve this problem:
-
Dependency injection (DI) : Dependency injection enables a class to define its dependencies without constructing them. At run time, another class is responsible for providing these dependencies.
-
Service locator: The service locator pattern provides a registry from which a class can obtain its dependencies without constructing them.
You can leverage these patterns to extend your code because they provide a clear dependency management pattern (without copying code or adding complexity). In addition, you can use these patterns to quickly switch between test and production data capture implementations.
We recommend adopting dependency injection mode and using the Hilt library for your Android applications. Hilt automatically constructs objects by traversing the dependency tree, providing compile-time guarantees for dependencies, and creating dependency containers for Android framework classes.
Our sample application uses Hilt to manage the dependencies of Webservice objects.
Connect the ViewModel to the storage area
Now, we modify the UserProfileViewModel to use the UserRepository object:
UserProfileViewModel
class UserProfileViewModel @ViewModelInject constructor( @Assisted savedStateHandle: SavedStateHandle, userRepository: UserRepository ) : ViewModel() { val userId : String = savedStateHandle["uid"] ? : throw IllegalArgumentException("missing user id") val user : LiveData<User> = userRepository.getUser(userId) }Copy the code
Cache data
The UserRepository implementation abstracts the call to the Webservice object, but because it relies on only one data source, it is not very flexible.
The key problem with the UserRepository implementation is that once it gets data from the back end, it does not store that data anywhere. Therefore, if the user returns to the UserProfileFragment after leaving the class, the application must retrieve the data, even if the data has not changed.
This design is not ideal for the following reasons:
- Wasting valuable network bandwidth.
- Forcing the user to wait for a new query to complete.
To remedy these shortcomings, we added a new data source to UserRepository to cache User objects in memory:
UserRepository
// @Inject tells Hilt how to create instances of this type // and the dependencies it has. class UserRepository @Inject constructor( private val webservice: Webservice, // Simple in-memory cache. Details omitted for brevity. private val userCache: UserCache ) { fun getUser(userId: String): LiveData<User> { val cached : LiveData<User> = userCache.get(userId) if (cached ! = null) { return cached } val data = MutableLiveData<User>() // The LiveData object is currently empty, but it's okay to add it to the // cache here because it will pick up the correct data once the query // completes. userCache.put(userId, data) // This implementation is still suboptimal but better than before. // A complete implementation also handles error cases. webservice.getUser(userId).enqueue(object : Callback<User> { override fun onResponse(call: Call<User>, response: Response<User>) { data.value = response.body() } // Error case is left out for brevity. override fun onFailure(call: Call<User>, t: Throwable) { TODO() } }) return data } }Copy the code
Keep the data
With our current implementation, if the user rotates the device or returns to the application immediately after leaving the application, the existing interface becomes immediately visible because the storage retrieves data from the in-memory cache.
But what happens if the user leaves the app and returns hours later, after the Android operating system has killed the process? In this case, if we rely on our current implementation, we need to get the data from the network again. This retrieval process is not only a poor user experience, but also a waste of resources because it consumes valuable mobile data.
You can solve this problem by caching network requests, but doing so raises a new and interesting question: What happens if the same user data is displayed for another type of request, such as getting a list of friends? The application will display inconsistent data, making it easier to confuse users. For example, if a user makes a friend list request and a single user request at different times, the application might display two different versions of the same user’s data. Applications will need to figure out how to combine this inconsistent data.
The right way to handle this situation is to use the persistence model. This is where the Room persistence library comes in.
Room is an object mapping library that implements local data persistence with minimal boilerplate code. At compile time, it validates each query against the data schema, so that a corrupt SQL query results in a compile-time error rather than a run-time failure. Room abstracts some of the low-level implementation details that work with raw SQL tables and queries. It also allows you to observe changes to database data, including collections and join queries, and expose such changes using LiveData objects. It even explicitly defines execution constraints to solve some common threading problems, such as access to storage on the main thread.
Note: If your application already uses another persistence solution such as SQLite Object Relational Mapping (ORM), you do not need to replace the existing solution with Room. However, if you are writing a new application or refactoring an existing one, we recommend that you use Room to hold application data. In this way, you can take advantage of the library’s abstraction and query validation capabilities.
To use Room, we need to define the local schema. First, we add the @Entity annotation to the User data model class and the @PrimaryKey annotation to the ID field of that class. These annotations mark User as a table in the database and id as the table’s primary key: User
@Entity
data class User(
@PrimaryKey private val id: String,
private val name: String,
private val lastName: String
)
Copy the code
Then, we create a database class by implementing RoomDatabase for our application:
UserDatabase
@Database(entities = [User::class], version = 1)
abstract class UserDatabase : RoomDatabase()
Copy the code
Note that UserDatabase is an abstract class. Room will automatically provide its implementation. Please refer to the Room documentation for details.
Now, we need a way to insert user data into the database. To do this, we create a data access object (DAO).
UserDao
@Dao
interface UserDao {
@Insert(onConflict = REPLACE)
fun save(user: User)
@Query("SELECT * FROM user WHERE id = :userId")
fun load(userId: String): LiveData<User>
}
Copy the code
Note that the load method returns an object of type LiveData. Room knows when the database has been modified and automatically notifying all active observers when the data has changed. This is efficient because Room uses LiveData; It updates data only if there is at least one active observer.
Note: Room will check for invalidation based on changes to the table, which means it may issue false positive notifications.
After defining the UserDao class, we reference the DAO from our database class:
UserDatabase
@Database(entities = [User::class], version = 1)
abstract class UserDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
Copy the code
Now, we can modify UserRepository to include Room data sources:
class UserRepository @Inject constructor( private val webservice: Webservice, // Simple in-memory cache. Details omitted for brevity. private val executor: Executor, private val userDao: UserDao ) { fun getUser(userId: String): LiveData<User> { refreshUser(userId) // Returns a LiveData object directly from the database. return userDao.load(userId) } private fun refreshUser(userId: String) { // Runs in a background thread. executor.execute { // Check if user data was fetched recently. val userExists = userDao.hasUser(FRESH_TIMEOUT) if (! userExists) { // Refreshes the data. val response = webservice.getUser(userId).execute() // Check for errors here. // Updates the database. The LiveData object automatically // refreshes, so we don't need to do anything else here. userDao.save(response.body()!!) } } } companion object { val FRESH_TIMEOUT = TimeUnit.DAYS.toMillis(1) } }Copy the code
Note that while we changed the source of the data in UserRepository, we did not need to change the UserProfileViewModel or UserProfileFragment. This small scale update demonstrates the flexibility of our application architecture. This is also good for testing because we can provide a fake UserRepository and test the official UserProfileViewModel at the same time.
If users wait a few days before returning to an application that uses this architecture, they are likely to see outdated information until the storage area can retrieve updated information. Depending on your use case, you may not want to display this outdated information. Instead, you can display placeholder data that displays sample values and indicates that your application is currently fetching and loading the latest information.
Single trusted source
It is common for different REST API endpoints to return the same data. For example, if our back end has other endpoints returning a list of friends, the same user object may come from two different API endpoints, and may even use different levels of granularity. If UserRepository returns the response from the Webservice request as is, without checking for consistency, the interface may display confusing information because the version and format of the data from the store will depend on the endpoint of the most recent invocation.
Therefore, our UserRepository implementation stores network service responses in the database. In this way, changes to the database trigger a callback to the LiveData object. With this model, the database acts as a single trusted source that is accessed by the rest of the application using UserRepository. Whether or not you use disk caching, we recommend that your storage area designate a data source as the single trusted source for the rest of the application.
Displays the operations being performed
In some use cases (pull down to refresh), the interface must show the user that a network operation is currently being performed. It is good practice to separate the interface operations from the actual data, as the data may be updated for various reasons. For example, if we get a list of friends, we might programmatically get the same User again, triggering a LiveData
update. From the interface’s perspective, the request in transit is just another data point, like any other data in the User object itself.
We can use one of the following policies to show consistent data update status in the interface (regardless of where the data update request comes from) :
-
Change getUser() to return an object of type LiveData. This object will contain the state of the network operation. For an example, see the NetworkBoundResource implementation in the Android Architecture Components GitHub project.
-
Provide another public function in the UserRepository class that returns the refresh status of the User. This policy works better if you only want to show the network status on the interface when the data acquisition process is due to an explicit user action (pulldown refresh).
Test each component
In the separation of Concerns section, we mentioned that one of the main benefits of following this principle is testability.
The following list shows how to test each code module in the extension example:
-
Interface and interaction: Use the Android interface for pile testing. The best way to create this test is to use the Espresso library. You can create a Fragment and provide a mock UserProfileViewModel for it. Since the Fragment communicates only with UserProfileViewModel, simulating this single class is sufficient to fully test the application’s interface.
-
ViewModel: You can use the JUnit test to test the UserProfileViewModel class. You only need to emulate one class, UserRepository.
-
UserRepository: You can also use JUnit tests to test UserRepository. You need to simulate the Webservice and UserDao. In these tests, verify the following behavior:
- Whether the storage area made the correct network service invocation.
- Whether the store saves the results to the database.
- Does the store not make unnecessary requests when the data is cached and kept up to date?
-
Since Both Webservice and UserDao are interfaces, you can simulate them or create fake implementations for more complex test cases.
-
UserDao: Test the DAO class using a pile-in test. Because these pile-in tests do not require any interface components, they run quickly. For each test, create an in-memory database to ensure that the test does not have any side effects (such as changing database files on disk).
Note: Room allows you to specify a database implementation, so you can test the DAO by providing a JUnit implementation of SupportSQLiteOpenHelper. However, you are not advised to use this approach because the version of SQLite running on the device may be different from the version on the development device.
-
Webservice: In these tests, avoid making network calls to the back end. All testing, especially web-based testing, must be independent of the outside world. Several libraries, including MockWebServer, help you create fake local servers for these tests.
-
Test artifact: The architecture component provides a Maven artifact that controls its background threads. Androidx.arch. core: Core-testing artifacts contain the following JUnit rules:
InstantTaskExecutorRule
: Use this rule to immediately perform any background operations on the calling thread.CountingTaskExecutorRule
: Use this rule to wait for background operations on architecture components. You can also associate this rule with Espresso as a free resource.
Fourth, best practices
Programming is a creative area, and building Android apps is no exception. Whether it’s passing data between multiple activities or fragments, retrieving remote data and keeping it locally for use in offline mode, or any other common situation encountered by complex applications, there are many ways to solve the problem.
While the following recommendations are not mandatory, in our experience, following them will make your code base stronger, more testable, and more maintainable in the long run:
Avoid specifying application entry points (such as activities, services, and broadcast receivers) as data sources.
Instead, you should only coordinate it with other components to retrieve the subset of data relevant to that entry point. Each application component is short-lived, depending on how the user interacts with the device and the current overall health of the system.
Establish clearly defined boundaries of responsibility between modules of the application.
For example, do not spread code that loads data from the network across multiple classes or packages in your code base. Also, do not define unrelated responsibilities (such as data caching and data binding) in the same class.
Expose as little code as possible in each module.
Do not attempt to create a “that’s the One” shortcut to render the internal implementation details of a module. In the short term, you may save time, but as your code base continues to evolve, you may repeatedly run into technical trouble.
Consider how each module can be tested independently.
For example, if you use a well-defined API to get data from the network, it will be easier to test modules that hold that data in a local database. If you mix the logic of these two modules in one place, or if you spread the network code across the entire code base, it becomes much more difficult to test, if at all.
Focus on your app’s unique core to make it stand out from the rest.
Don’t just write the same boilerplate code over and over again. Instead, focus your time and effort on what makes your application different, and let the Android architecture components and other libraries suggested handle repetitive templates.
Keep as much relevant and up-to-date data as possible.
This way, users can use the functionality of your application even if their device is in offline mode. Note that not all users enjoy a stable high-speed connection.
Specifies a data source as a single trusted source.
Whenever an application needs access to this piece of data, it should always come from this single trusted source.
V. Appendix: Open network status
In the recommended application architecture section above, we omitted network errors and load states to simplify the code snippet.
This section demonstrates how to expose network state using Resource classes that encapsulate data and its state.
The following code snippet provides an example implementation of Resource:
Resource
// A generic class that contains data and status about loading this data.
sealed class Resource<T>(
val data: T? = null,
val message: String? = null
) {
class Success<T>(data: T) : Resource<T>(data)
class Loading<T>(data: T? = null) : Resource<T>(data)
class Error<T>(message: String, data: T? = null) : Resource<T>(data, message)
}
Copy the code
It is very common to load data from the network while displaying a copy of that data on disk, so it is recommended that you create a helper class that can be reused in multiple places. In this case, we create a class called NetworkBoundResource.
The following figure shows the decision tree for NetworkBoundResource:
It first looks at the database of resources. The first time an entry is loaded from the database, NetworkBoundResource checks whether the result is good enough to dispatch or should be retrieved from the network. Note that considering that you might want to display cached data while updating data over the network, both situations can occur simultaneously.
If the network call completes successfully, it saves the response to the database and reinitializes the data flow. If the network request fails, NetworkBoundResource directly dispatches a failure message.
Note: After saving the new data to disk, we reinitialize the data flow from the database. Usually, however, we don’t need to do this because the database itself just dispatches the changes.
Note that relying on the database to dispatch changes has associated side effects, which is not good because the undefined behavior of these side effects occurs if the database ends up undispatching changes because the data has not changed.
Also, do not dispatch results from the network, as this would violate the single trusted source principle. After all, the database may contain triggers that change data values during a “save” operation. Also, do not dispatch SUCCESS without new data, because if you do, the client will receive the wrong version of the data.
The following code snippet shows the public API provided by the NetworkBoundResource class for its subclasses:
NetworkBoundResource.kt
// ResultType: Type for the Resource data. // RequestType: Type for the API response. abstract class NetworkBoundResource<ResultType, RequestType> { // Called to save the result of the API response into the database @WorkerThread protected abstract fun saveCallResult(item: RequestType) // Called with the data in the database to decide whether to fetch // potentially updated data from the network. @MainThread protected abstract fun shouldFetch(data: ResultType?) : Boolean // Called to get the cached data from the database. @MainThread protected abstract fun loadFromDb(): LiveData<ResultType> // Called to create the API call. @MainThread protected abstract fun createCall(): LiveData<ApiResponse<RequestType>> // Called when the fetch fails. The child class may want to reset components // like rate limiter. protected open fun onFetchFailed() {} // Returns a LiveData object that represents the resource that's implemented // in the base class. fun asLiveData(): LiveData<ResultType> = TODO() }Copy the code
Note the following important details about the class definition:
It defines two type parameters (ResultType and RequestType) because the data type returned from the API may not match the data type used locally. It uses a class called ApiResponse for network requests. ApiResponse is a simple encapsulated container for the retrofit2.call class that converts the response into a LiveData instance.
The full implementation of the NetworkBoundResource class appears as part of the GitHub project, an Android architecture component.
After creating NetworkBoundResource, we can use it to write User implementations of disk and network bindings in the UserRepository class:
UserRepository
class UserRepository @Inject constructor( private val webservice: Webservice, private val userDao: UserDao ) { fun getUser(userId: String): LiveData<User> { return object : NetworkBoundResource<User, User>() { override fun saveCallResult(item: User) { userDao.save(item) } override fun shouldFetch(data: User?) : Boolean { return rateLimiter.canFetch(userId) && (data == null || ! isFresh(data)) } override fun loadFromDb(): LiveData<User> { return userDao.load(userId) } override fun createCall(): LiveData<ApiResponse<User>> { return webservice.getUser(userId) } }.asLiveData() } }Copy the code