Previous
A production-level architecture for Android apps (part 1)
Agustín Tomas Larghi ・ Feb 15 '20
Index
Context
We are going to go through all the different layers of the architecture, explaining what’s the protocol and the logic of each layer. The protocol is what the layer exposes to the above layer. The logic of the layer is what the layer is supposed to do internally.
Layers
“This architecture looks like an overkill, you can achieve the same thing using less layers, and it will scale properly.”
It may, it may not. I’m used to working on large-scale projects, where the requirements may change from one day to the next, where you may have to tweak features all the time. That’s why I try to go with the most flexible architecture possible. This is what I’ve been using and it has worked fine for me. I prefer to waste 5 minutes creating some extra classes today, that spend 1 day trying to wire something up, so it just works.
Datasources
Protocol
The protocol of the data sources is pretty straightforward, fetch data from somewhere.
Logic
There are only two places from where we can fetch information, from the network (through API endpoints or some other service) or from the cache (either through something like the SharedPreferences
, a database, or some other local source)
Network Logic
Fetching information from a backend API through RESTful endpoints. For this example, I’m using Retrofit. There are some other tools out there like Volley, but discussing the pros and cons of each tool goes beyond the scope of this article.
Code example
With Retrofit, you usually just declare an API Interface that represents the available endpoints.
/**
* Retrofit's interface that we use to communicate with a mock API hosted on apiary.io
*/
interface ExampleApi {
/**
* Fetches a paginated response containing the news.
*/
@GET("news")
fun fetchNews(
@Query("page") page: Int
): Single<Response<ApiNewFeedListResponse>>
/**
* Fetches the most recently viewed news.
*/
@GET("news/recent")
fun fetchRecentlyViewedNews(): Single<Response<ApiNewFeedListResponse>>
/**
* Fetches the user profile information
*/
@GET("profile")
fun fetchProfileInfo( ): Single<Response<ApiProfileResponse>>
/**
* Fetches the user business skills
*/
@GET("business")
fun fetchBusinessSkills(): Single<Response<ApiBusinessSkilssResponse>>
/**
* Fetches a list of cooking recipes
*/
@GET("recipes")
fun fetchAllRecipes(): Single<Response<List<ApiRecipeResponse>>>
}
ExampleAPI
interface to fetch information from a mock API hosted on ApiAry.
/**
* Retrofit's interface API that we use to communicate with the Imgur API.
*/
interface ImgurApi {
/**
* Fetches the comments associated with one particular post.
* @param id The id of the post.
* @param sort "best" if we want to fetch the comments sorted by the most up voted. "new" if
* we want to fetch the comments sorted by date.
*/
@GET("gallery/{id}/comments/{sort}")
fun fetchComments(
@Path("id") id: String,
@Path("sort") sort: String // best, new
) : Single<Response<ApiCommentsResponse>>
/**
* Fetches the posts using the specified search criteria.
* @param sort "top" If we want to fetch the latest posts. "viral" If we want to fetch the
* most trendy posts.
* @param window If sort equals "viral" this parameter gets ignored. If sort equals "top" we
* can specify if we want to fetch the most important posts from this "month", "day", or "week"
* @param query The search query.
* @param page The page number, starts from 1.
*/
@GET("gallery/search/{sort}/{window}/{page}")
fun searchGallery(
@Path("sort") sort: String = "top", // viral, top
@Path("window") window: String = "all", // if sort == top { day, week, month }
@Path("page") page: Int = 1,
@Query("q") query: String = ""
) : Single<Response<ApiImgurGalleryResponse>>
/**
* Fetches the posts using a tag.
* @param sort "top" If we want to fetch the latest posts. "viral" If we want to fetch the
* most trendy posts.
* @param perPage Max amount of results per page.
* @param tag The tag query.
* @param page The page number, starts from 1.
*/
@GET("gallery/t/{tag}/{sort}/{page}")
fun searchTags(
@Path("sort") sort: String = "top", // viral, top
@Path("page") page: Int = 0,
@Path("tag") tag: String = "",
@Query("perPage") perPage: Int = 20
) : Single<Response<ApiImgurTagResponse>>
}
ImgurAPI
interface that we use to fetch image posts from through the Imgur API.
Notice that on each API call we return a rx.Single
, that’s because when we hit an API endpoint we send a request and get a single response.
The second step is to expose the Retrofit dependency to all our other dependencies, we do this through the network modules (ExampleNetworkModule and ImgurNetworkModule) and the RetrofitModule.
On the network modules we build the REST client and we configure all the things that we need.
@Module
class ExampleNetworkModule {
@Provides
@Singleton
fun provideGson(): Gson {
return GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
// Gson deserializer to deserialize dates from the Imgur API
.registerTypeAdapter(Date::class.java, DateDeserializer())
.create()
}
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
val builder = OkHttpClient.Builder()
return builder.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient, gson: Gson): Retrofit {
return Retrofit.Builder()
.baseUrl("http://private-cc89a2-examples21.apiary-mock.com/")
.addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.client(okHttpClient)
.build()
}
}
ExampleNetworkModule
where we provide all the dependencies that we need to fetch data from the ApiAry backend.
@Module
class ImgurNetworkModule {
@Provides
@Named("authInterceptor")
fun providesAuthInterceptor(): Interceptor {
return Interceptor { chain ->
var request = chain.request()
request = request.newBuilder()
.header("Authorization", "Client-ID 6ea78556ea84b48")
.build()
chain.proceed(request)
}
}
@Provides
@Singleton
@Named("imgurOkHttp")
fun provideOkHttpClient(
@Named("authInterceptor") authInterceptor: Interceptor
): OkHttpClient {
val builder = OkHttpClient.Builder()
builder.addInterceptor(authInterceptor)
return builder.build()
}
@Provides
@Singleton
@Named("imgurRetrofit")
fun provideRetrofit(
@Named("imgurOkHttp") imgurOkHttp: OkHttpClient,
gson: Gson
): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.imgur.com/3/")
.addConverterFactory(GsonConverterFactory.create(gson))
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.client(imgurOkHttp)
.build()
}
}
We use Gson to deserialize the endpoint’s responses and turn them into backend model classes (naming convention ApiSomethingResponse). We provide the OkHttp client that the Retrofit instance is going to use as their HTTP client. And finally, we provide the actual Retrofit instance that we are going to use to instantiate the API interface.
Notice that in the ImgurNetworkModule we need to provide an OkHttp instance that authenticates against the Imgur API. We need to provide different Retrofit instances on both modules, since one points to the Imgur base host and the other to the ApiAry host. Also, notice that we actually reuse the same Gson instance for both modules.
Then we have the RetrofitModule where we provide the API Interface instance for both the Imgur API and the ApiAry API.
@Module
class RetrofitModule {
@Provides
@Singleton
fun provideExampleApi(
retrofit: Retrofit
): ExampleApi {
return retrofit.create(ExampleApi::class.java)
}
@Provides
@Singleton
fun provideImgurApi(
@Named("imgurRetrofit") imgurRetrofit: Retrofit
): ImgurApi {
return imgurRetrofit.create(ImgurApi::class.java)
}
}
Cache Logic
Fetching information locally, either through the SharedPreferences
, a DB or some other source. For this example, I’m using Room. There are some other tools that you can use like Realm or SQLDelight.
Code example
There are a few classes that we need to implement when setting up Room. The first is the RoomDatabase class, that basically handles the DB creation:
@Database(
entities = [DbRecipeDto::class],
version = 1,
exportSchema = false
)
@TypeConverters(Converters::class)
abstract class ExampleRoomDatabase : RoomDatabase() {
companion object {
fun create(context: Context, useInMemory: Boolean): ExampleRoomDatabase {
val databaseBuilder = if (useInMemory) {
Room.inMemoryDatabaseBuilder(context, ExampleRoomDatabase::class.java)
} else {
Room.databaseBuilder(context, ExampleRoomDatabase::class.java, "example.db")
}
return databaseBuilder
.allowMainThreadQueries()
.fallbackToDestructiveMigration()
.build()
}
}
abstract fun recipesDao(): RecipesDao
}
Then we need to create the classes to map the database responses (naming convention DbSomethingDto) into objects.
@Entity(
tableName = "db_recipe_dto",
indices = [Index(
value = ["id"],
unique = true
)]
)
data class DbRecipeDto(
@PrimaryKey
val id: Int,
val name: String,
val ingredients: List<String>,
val isBookmarked: Boolean = false
)
In this example, we are mapping a table that stores information about cooking recipes.
Next, we have to create the DAO to query the information from the database (naming convention SomethingDao).
@Dao
abstract class RecipesDao {
@Query("SELECT * FROM db_recipe_dto")
abstract fun getAllRecipes(): Flowable<List<DbRecipeDto>>
@Query("SELECT * FROM db_recipe_dto where id = :id")
abstract fun getRecipeById(id: Int): Flowable<DbRecipeDto>
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract fun storeRecipes(
dbRecipeDtos: List<DbRecipeDto>
): Single<LongArray>
@Update
abstract fun updateRecipes(dbRecipeDto: DbRecipeDto): Single<Int>
}
Notice that if we want to keep track of the database changes we use Flowables
to stream the query’s results. If we don’t care or if we want to listen for the results just once, we can use rx.Single
.
Repositories
Protocol
The repository protocol is to expose data from the data sources through a unified reactive interface (rx.Obsevable
). This data may come from the network layer, because we hit some endpoint, or may come from the cache layer because we ran some query on a database or fetched something from the database.
Logic
It fetches the data from the data source and wraps the response with a DataResponse
¹ class. This DataResponse class is used to carry around any information that we may need about the origin of this data (if it’s a cached response, if it came from the backend through a 304, or if it came from the backend through a 200, etc.)
Code example
Let’s say that in our example app we need a repository to access all the information related to the news.
Keeping in mind that we have these two endpoints:
/**
* Fetches a paginated response containing the news.
*/
@GET("news")
fun fetchNews(
@Query("page") page: Int
): Single<Response<ApiNewFeedListResponse>>
/**
* Fetches the most recently viewed news.
*/
@GET("news/recent")
fun fetchRecentlyViewedNews(): Single<Response<ApiNewFeedListResponse>>
Then the interface (protocol) of our repository would look like:
interface NewsRepository {
fun fetchRecentlyViewedNews() : Observable<DataResponse<ApiNewFeedListResponse>>
fun fetchNews(page: Int) : Observable<DataResponse<ApiNewFeedListResponse>>
}
Notice that the only thing that these repositories are supposed to expose are rx.Observables
, doing that we are able to plug different repositories that fetch information from different sources through RxJava.
The actual implementation (logic) of our repository would look like:
class NewsNetworkRepository (
private val retrofitApi: ExampleApi,
private val uiScheduler: Scheduler,
private val backgroundScheduler: Scheduler
) : NewsRepository {
override fun fetchRecentlyViewedNews(): Observable<DataResponse<ApiNewFeedListResponse>> {
return retrofitApi.fetchRecentlyViewedNews()
.toDataResponse()
.observeOn(uiScheduler)
.subscribeOn(backgroundScheduler)
}
override fun fetchNews(page: Int): Observable<DataResponse<ApiNewFeedListResponse>> {
return retrofitApi.fetchNews(page)
.toDataResponse()
.observeOn(uiScheduler)
.subscribeOn(backgroundScheduler)
}
}
The toDataResponse()
function, is just an extended function from rx.Observable
that wraps the stream on a DataResponse
.
Why making the Repositories return rx.Observables?
You need your repositories to have a sort of plug-and-play behavior. That means, using a reactive API (zip
, flatMap
, merge
, concat
, etc.) so you are able to combine different repository calls within your Interactors.
For example, if you need to create an Interactor that’s going to return both, the recently viewed news and all the news, you can zip the fetchRecentNews()
and the fetchNews(page)
calls together.
Another example, if you need to hit one endpoint and based on the response from that endpoint, hit another endpoint, you can just flat map the calls from both repositories and that’s it.
That’s wrong, if you have to zip two API calls together, you should get the backend to return both responses together through one single endpoint.
Again, these are real-world scenarios, and unless you’re working for a company that solely relies on mobile as its source of income (Uber, Instagram, WhatsApp, etc.) mobile is probably going to be a second-class citizen. Meaning that you may not get all the endpoints that you need. That’s neither good nor bad, it is just how it works. But if you have to juggle API calls together, better to keep it tidy.
Why using a response wrapper?
I’ve found that using a wrapper class to keep track of the origin of the data is very useful. That DataResponse
wrapper that we have there is just a simple class that keeps track of where the data is coming from.
For example, let’s say that you want to do one thing if a specific endpoint returns a 401 (Not Authorized) and another if the endpoint returns a 500 (Internal Server Error). You can do that just by looking at the DataResponse
wrapper.
Another example, let’s say that you’re storing the information on a cache layer if the endpoint replies with a 304 (Not Modified) you want to skip that from being stored on cache (because it’s the exact same information that you already have, so it’s going to be a waste of time storing that information gain). So using a wrapper class to keep track of the source of the information may come handy when trying to handle some specific scenarios.
Why do you separate your repositories into interface and implementation?
That's something that I got used to from working with clean architectures. I can't say that it's super useful all the time, but I do like the idea of having a contract between a component's protocol and its implementation. Even if requires some extra work.
Interactors
Protocol
The interactor protocol is to expose data to the ViewModels through a callback interface. The data that we expose from the interactor to the ViewModel goes as model classes. While on the repository layer we may return backend model classes (ApiSomethingResponse) from the network API or database responses (DbSomethingDTO) from the database, the interactor returns plain model classes. By doing this we completely decouple the backend responses, the database responses and the actual model classes that we use across the app.
That looks like an insane amount of work, keeping three different model classes for the backend responses, database responses and for using on the app. Why not just use the same class to map the backend responses (through Gson) and keeping the database tables?
Like many other topics that we talked about in this article, most of the layers are totally customizable. If you think that it is better to use one single model class for mapping the backend responses, the database tables, and for use across the app, you can do it. My personal experience tells me otherwise. Things may change on the backend, new fields may get exposed, some fields may get removed, you may need to track specific fields within the database, etc. I prefer to code a bit more of boilerplate code and be ready for changes that stick with a short solution and risk reliability.
Logic
An interactor uses one or more repositories to perform an action, this may be something like calling two different endpoints, wait for them to reply, and then return that information through the callback interface.
Why using a callback interface here? Shouldn’t we expose a rx.Observables?
My personal experience is that it is better to hide everything related to RxJava beneath the interactor layer. If you are working on a large-scale project you may have some junior developers on the team, and working with technologies such as RxJava it’s a bit tricky for inexperienced developers. I find it more comfortable to keep all this hidden beneath the interactor layer, that way the most senior developers can word developing the interactors, and then the developers with less experience can just use them on the ViewModels.
Also, it saves you a bit of boilerplate code. Let’s say that you need to zip two repositories together to fetch data and show it on an Activity. If have to do the same thing again on some other Activity you would have to duplicate the code for zipping those repositories.
And finally, I think it looks tidier to have some sort of contract (callback interface) between the use cases and the ViewModels.
Code example
This is how the interface (protocol) of the interactor looks like:
/**
* Fetches the recently viewed news and the paginated news altogether.
*/
interface GetNewsInteractor {
interface Callback {
/**
* Triggered when we successfully fetch the paginated news
* @param news The list of news for this page.
* @param currentPage The current page.
* @param totalPages The total amount of pages available.
*/
fun onNewsSuccessfullyFetched(
news: List<NewFeed>,
currentPage: Int,
totalPages: Int
)
/**
* Triggered when we successfully fetch the recently viewed news.
* @param news The list of recently viewed news.
*/
fun onRecentlyViewedNewsFetched(
news: List<NewFeed>
)
/**
* Triggered when something goes wrong either fetching the recently viewed news or
* the paginated news.
*/
fun onErrorFetchingNews()
/**
* Triggered when we try to hit the endpoints without internet connection.
*/
fun onNoInternetConnection()
}
/**
* Execute function to trigger this Interactor.
* @param callback A GetNewsInteractor.Callback usually implemented on the ViewModel.
* @param page The current page that we are trying to fetch.
*/
fun execute(callback: Callback, page: Int)
}
In this example, we see the GetNewsInteractor
, an interactor that fetches a list of recently viewed news and paginated news through two different endpoints.
Here I'm just handling two error scenarios for when the API call fails and for when we try to hit the API without an internet connection. If you need to handle more in-detail errors, for example, a 401/403 error response from the API, you could just add a new onNotAuthorizedError()
callback function here and implement the handling of that exception in the actual implementation.
The implementation (logic) looks like this:
class GetNewsInteractorImpl(
private val newsRepository: NewsRepository
) : GetNewsInteractor {
override fun execute(callback: GetNewsInteractor.Callback, page: Int) {
// If we are fetching the first page either because we did a PTR or because this is the
// first time opening this screen we need to fetch both
if (page < 1) {
fetchRecentlyViewedNewsAndPagedNews(page).subscribe(
{
val pagedNews = it.first
val recentlyViewedNews = it.second
callback.onNewsSuccessfullyFetched(
news = pagedNews.list,
currentPage = pagedNews.current,
totalPages = pagedNews.total
)
callback.onRecentlyViewedNewsFetched(
news = recentlyViewedNews.list
)
},
{
if (it is ConnectException || it is UnknownHostException) {
callback.onNoInternetConnection()
} else {
callback.onErrorFetchingNews()
}
}
)
} else {
fetchPagedNews(page).subscribe(
{
callback.onNewsSuccessfullyFetched(
news = it.list,
currentPage = it.current,
totalPages = it.total
)
},
{
if (it is ConnectException || it is UnknownHostException) {
callback.onNoInternetConnection()
} else {
callback.onErrorFetchingNews()
}
}
)
}
}
/**
* Fetches both, the recently viewed news from the /news/recent and the paginated news from the
* /news?page={page}
*/
private fun fetchRecentlyViewedNewsAndPagedNews(page: Int): Observable<Pair<NewFeedMetadata, NewFeedMetadata>> {
return Observables.zip(fetchPagedNews(page), fetchRecentlyViewedNews()) {
pagedNews: NewFeedMetadata, recentlyViewedNews: NewFeedMetadata ->
Pair(pagedNews, recentlyViewedNews)
}
}
/**
* Fetches the recently viewed news from the /news/recent endpoint.
*/
private fun fetchRecentlyViewedNews(): Observable<NewFeedMetadata> {
return newsRepository.fetchRecentlyViewedNews()
.map {
it.response.toNewsFeedMetadata()
}
}
/**
* Fetches the paginated news through the /news?page={page}
*/
private fun fetchPagedNews(page: Int): Observable<NewFeedMetadata> {
return newsRepository.fetchNews(page)
.map {
it.response.toNewsFeedMetadata()
}
}
}
You can see that we call the fetchRecentlyViewedNewsAndPagedNews(Int)
function which zips two calls from the NewsRepostitory
and returns them wrapped into a Pair
object.
Through RxJava you can manage to handle the responses however you want. For example, let’s say that, specifically, if the call to fetch the recently viewed orders fail you want to display a Snackbar
on the UI, you can hook from the doOnError()
and use the callback to communicate the ViewModel that something went wrong while trying to fetch the recently viewed news.
Also, you can pass any other dependencies that you might need. Let’s say that you want to track these errors through Crashlitycs, you can pass the Crashlitycs instance into the interactor and report the exceptions hooking from either the doOnError()
or in the onError()
from the Subscriber
.
The same thing goes if the behavior of your interactor depends on some SharedPreferences
configurations. You just pass the SharedPreferences
instance through the interactor’s constructor and check whatever value you need and call one function from the repositories or the other.
What about memory leaks? Don’t you dispose the Observable once you’re done with it.
Yes, disposing of the Observables
as soon as you are done with them is a good practice. You can hook from the onCleared()
function on the ViewModel and dispose of all the Observables
through the interactor. For the sake of keeping things simple, I didn’t include an example of that on every interactor. But you can see an example of that in the GetImgurPostsInteractor
and how we call the dispose()
function from within the ExampleImgurViewModel
.
ViewModel
Protocol
The ViewModel protocol is to expose data to the view layer through LiveData properties. These LiveData properties are usually MutableLiveData properties of sealed classes that represent the state of the ViewModel.
Logic
There are a few little rules from the ADT about what we are supposed to do on a ViewModel and what not. One of them is:
- Do not use or handle any view-related classes on the ViewModel. The ViewModel is not supposed to know anything about the view layer.
Then there are a few rules that I came out with by myself:
You can extend from the
AndroidViewModel
class and use the Application context, in case that for example, you need to fetch a String resource. I highly discourage using theAndroidViewModel
class, because I think that anything related to fetching resources and things like this should be done on the view layer. Also, if you need to use an Android SDK class that isn't view-related, for example as the SharedPreferences, you should use them inside your interactors. There's no need to have this logic inside the ViewModel.Try to keep as few
LiveData
properties as possible. This makes the ViewModel easier to handle and easier to scale. Ideally, you should have a singleMutableLiveData
property that represents the state of the ViewModel and that’s it. By state I mean, “showing the information”, “loading”, “no internet connection”.Sometimes you may need to have more than one
LiveData
property or even use aMediatorLiveData
property to handle changes within the other properties. For example, let’s say that you have an Activity where you can search for different things using a search bar on the Toolbar. Usually, you will have aMutableLiveData
property of a String to represent the query of what you are searching for, you can hook this into aMediatorLiveData
and listen for the changes on the queryLiveData
property, then perform the search accordingly.Another example is trying to handle different error situations. Let’s say that you have an endpoint that is prone to return a 500 response. If the user opens the app from scratch and gets a 500 when fetching the data you want to show a full display error message (a nice ImageView with a nice title explaining what just happened). On the other hand, if the user opens the app, fetches information successfully, then do a Pull-To-Refresh, and gets a 500, you may want to show just a
Snackbar
instead of the full error display. In a situation like this, you want to keep two different sealed classes to represent the state of the app. One that represents the state of the ViewModel, and the other that represents all the possible error messages. You can see an example of this in the "LiveData Example", there we display a list of posts through a search feature, this search feature also goes with a few filtering options, all this gets coordinated throughLiveData
.
Code Example
Taking a look to the "LiveData Example" we are going to see that we have two sealed classes to represent the state of the ViewModel, State
and Message
. And another sealed class to represent the state of the current search, SearchParams
.
The State
class which can be either IsLoading
(to reflect whether we are currently loading the search results or not), Success
(to reflect the success state once we have fetched the search results), NoInternetError
(to reflect the state when there's no internet connection) and UnknownError
(to reflect the state when something goes wrong while trying to fetch results)
class ExampleImgurViewModel @Inject constructor(
private val getImgurPostsInteractor: GetImgurPostsInteractor
) : ViewModel(), GetImgurPostsInteractor.Callback {
// region Sealed classes declaration
sealed class State {
data class IsLoading(
val isLoading: Boolean
) : State()
data class Success(
val query: String,
val list: List<ImgurGallery>
) : State()
object NoInternetError : State()
object UnknownError : State()
}
// ...
The Message
class represents the error messages. As I mentioned before, if we got search results through a search and then we try to search for something else and something unexpected happen (a 500 response from the endpoint, connectivity lost, etc.) we don't want to remove the information that the user is currently seeing and show the full error state, we just want to display a nice friendly Snackbar
.
// ...
sealed class Message {
object NoInternetError : Message()
object UnknownError : Message()
}
// endregion
You can see that on the GetImgurPostsInteractor.Callback
implementation within the ExampleImgurViewModel
we handle two error callbacks, one for generic errors and the other for connectivity errors.
// ...
override fun onErrorFecthingImgurPosts() {
state.value = State.IsLoading(false)
if (_imgurGalleryPosts.isEmpty()) {
state.value = State.UnknownError
} else {
message.value = Message.UnknownError
}
}
override fun onNoInternetConnection() {
state.value = State.IsLoading(false)
if (_imgurGalleryPosts.isEmpty()) {
state.value = State.NoInternetError
} else {
message.value = Message.NoInternetError
}
}
// ...
The first thing that we do is to turn off the loading state through the State.IsLoading(false)
. Then we check against the _imgurGalleryPosts
property which is a mutable collection of the Imgur posts that we have fetched so far. If the collection is empty, then we know that the user isn't seeing any post-related information, so we go with the full error state. If the collection isn't empty, then we know that the user is seeing posts, and we don't want to hide them, so we go with the message.
class ExampleImgurActivity : AppCompatActivity(), SimpleSearchView.OnQueryTextListener,
Toolbar.OnMenuItemClickListener, PopupMenu.OnMenuItemClickListener {
// ...
// region Lifecycle functions declaration
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ...
observeStateChanges()
observeMessageChanges()
// ...
}
// ...
private fun observeMessageChanges() {
viewModel.message.observe(this, Observer {
when (it) {
is ExampleImgurViewModel.Message.NoInternetError -> {
onShowNoInternetConnectionSnackbar()
}
is ExampleImgurViewModel.Message.UnknownError -> {
onShowUnknownErrorSnackbar()
}
}
})
}
// ...
private fun onShowUnknownErrorSnackbar() {
Snackbar.make(
activityExampleImgurContainer,
getString(R.string.error_unknown_error),
Snackbar.LENGTH_LONG
).show()
}
private fun onShowNoInternetConnectionSnackbar() {
Snackbar.make(
activityExampleImgurContainer,
getString(R.string.error_no_internet_connection),
Snackbar.LENGTH_LONG
).show()
}
// ...
private fun observeStateChanges() {
viewModel.state.observe(this, Observer {
when (it) {
is ExampleImgurViewModel.State.IsLoading -> {
onLoadingStateChanged(it.isLoading)
}
is ExampleImgurViewModel.State.NoInternetError -> {
onShowNoInternetConnectionError()
}
is ExampleImgurViewModel.State.UnknownError -> {
onShownUnknownErrorStateView()
}
is ExampleImgurViewModel.State.Success -> {
if (it.list.isEmpty()) {
onShowNoResultsStateView(it.query)
} else {
onShowImgurPosts(it.list)
}
}
}
})
}
// ...
private fun onShownUnknownErrorStateView() {
activityExampleImgurStateDisplay.show {
image(R.drawable.ic_sentiment_very_dissatisfied_black_24dp)
title(R.string.error_unknown_error)
}
}
private fun onShowNoResultsStateView(query: String) {
activityExampleImgurStateDisplay.show {
image(R.drawable.ic_youtube_searched_for_black_24dp)
title(getString(R.string.error_no_search_result_found, query))
}
}
private fun onShowNoInternetConnectionError() {
activityExampleImgurStateDisplay.show {
image(R.drawable.ic_signal_wifi_off_black_24dp)
title(R.string.error_no_internet_connection)
}
}
// ...
Here you can see how we observe these changes from the view layer and react accordingly. Keep in mind that in the source code we have a custom view called SimpleStateDisplay
, which is basically just a FrameLayout
with an ImageView
and a TextView
that I use to display the full error states.
To perform the actual search we have a MediatorLiveData
property of SearchParams.Query
called searchParams
.
class ExampleImgurViewModel @Inject constructor(
private val getImgurPostsInteractor: GetImgurPostsInteractor
) : ViewModel(), GetImgurPostsInteractor.Callback {
...
sealed class SearchParams {
enum class Sort(val value: String) {
TOP("top"),
VIRAL("viral")
}
enum class Window(val value: String) {
DAY("day"),
WEEK("week"),
MONTH("month")
}
data class Query(
val query: String,
val window: Window,
val sort: Sort,
val page: Int
)
}
...
val sort = MutableLiveData<SearchParams.Sort>().apply {
SearchParams.Sort.TOP // Default sort
}
val window = MutableLiveData<SearchParams.Window>().apply {
SearchParams.Window.MONTH // Default window
}
val query = MutableLiveData<String>().apply {
"" // Default query
}
private val page = MutableLiveData<Int>().apply {
1 // Default page
}
...
// LiveData property that reflects the state of what we are currently searching.
val searchParams = MediatorLiveData<SearchParams.Query>().apply {
addSource(sort) { newSort ->
// Remove all the current posts since the query has changed
_imgurGalleryPosts.clear()
value = _searchParams.copy(
sort = newSort
)
}
addSource(window) { newWindow ->
// Remove all the current posts since the query has changed
_imgurGalleryPosts.clear()
value = _searchParams.copy(
window = newWindow
)
}
addSource(query) { newQuery ->
// Remove all the current posts since the query has changed
_imgurGalleryPosts.clear()
value = _searchParams.copy(
query = newQuery
)
}
addSource(page) { newPage ->
value = _searchParams.copy(
page = newPage
)
}
}
...
The idea here is that from the View layer we set all the different properties to configure our search criteria. We set the query, the page, the sorting order, the window, etc. The searchParams
reacts to these changes and updates its state. The _searchParams
property is a SearchParam.Query
property that helps us to keep track of the current search criteria, internally, in the ViewModel.
class ExampleImgurActivity : AppCompatActivity(), SimpleSearchView.OnQueryTextListener,
Toolbar.OnMenuItemClickListener, PopupMenu.OnMenuItemClickListener {
// ...
private fun observeQueryChanges() {
viewModel.searchParams.observe(this, Observer {
viewModel.searchPosts(it)
})
}
// ...
We observe the searchParams
changes from the View layer and if we detect that anything has changed on the search criteria, we call the ViewModel and perform a new search against the Imgur API through the GetImgurPostsInteractor
.
View
Protocol
The view protocol is to expose data to the user through a UI.
Logic
The logic on the view layer consist of interacting with the ViewModel, observe the changes on the ViewModel through the LiveData properties and update the UI accordingly.
Code Example
Here you just do the usual set up of the views. For example, setting the listener for a Button
, preparing the adapters for a RecyclerView
, or setting up any other view. Other than that the implementation of the view layer should be pretty straightforward. In the onCreate(Bundle?)
hook we want to inject the dependencies, instantiate the ViewModel
, set up the views, and listen for the ViewModel
changes.
class ExampleImgurActivity : AppCompatActivity(), SimpleSearchView.OnQueryTextListener,
Toolbar.OnMenuItemClickListener, PopupMenu.OnMenuItemClickListener {
...
// region Lifecycle functions declaration
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.example_imgur_activity)
// Instantiate the ViewModel
viewInjector.inject(this)
viewModel = ViewModelProviders.of(this, viewModelProvider)
.get(ExampleImgurViewModel::class.java)
// Setup the Views' listeners
setupSwipeRefreshLayout()
setupRecyclerView()
setupToolbar()
// Observe the changes from the ViewModel
observeStateChanges()
observeMessageChanges()
observeQueryChanges()
observeQuerySortChanges()
observeQueryWindowChanges()
observeSearchQueryChanges()
}
...
Another thing that you need to care for is the state of the Fragment/Activity. You can save and restore the state of the view through the onSavedInstanceState(Bundle)
and onRestoreInstanceState(Bundle)
hooks.
...
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
viewModel.onSaveIntanceState(outState)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
viewModel.onRestoreInstanceState(savedInstanceState)
}
...
What about using Jetpack's SavedStateHandle?
At the time of writing this articles implementing this solution using SavedStateHandle
requires way more boilerplate code than just hooking the ViewModel from the onSavedInstanceState(Bundle)
and onRestoreInstanceState(Bundle)
hooks. I'll probably update the article as soon as I find a more adequate solution.
Next
My next article will be about how to integrate Unit Testing (through Robolectric + Square's MockWebServer) and Instrumentation Testing (through Espresso) into this architecture.
Notes
[1] The DataResponse "pattern" is also used by Google on the 2019 IOSched app. It is really useful when you need to do something based on the origin of the data.
Top comments (5)
Hey!
This can actually be moved into
Transformations.switchMap(searchParams) { searchPosts(it) }
inside the ViewModel. That way you don't need to observe the changes here, you can just observe the posts themselves.Thanks for the feedback! Totally, there are a few things that I could improve on this architecture, I need to update a few things here though, for example, I've changed a bit the API of the Interactors so I can actually dispose them when the ViewModel gets destroyed.
Please let me know if you see anything else that you think could be improved. 🤓💪
I'm trying to learn from this post while i'm developing an app and i have a situation that i don't know how to solve:
I have two entities (let's say User and Address) and a user has a foreign key to an Address. When i want to save a user i need to create an Address (using AddressRepository) and save a User referencing that address id (using UserRepository). The thing is that i don't know where to put this logic because i need access to two repositories in the same place. As i understand a repository should not have a reference to other repository, but i guess it would neither be correct to do this management from UseCase/Interactor because that would expose how is database implemented. What do you think about it?
I don't see why it would be a bad idea having a
InsertUserWithAddressUseCase
which internally uses two different DB repositories.I'm going to guess that the technology that you're using for the database layer is Room. This is a very brute, raw example of how that would look like, let me know if it fits your needs, or if you're looking for something else:
gist.github.com/4gus71n/2c0b0fe131...
A note though:
Anyone, please feel to drop feedback/questions. I'm always trying to improve in terms of architecture design. 🤓🏗️💪