Android development architecture has been developed from the initial Activity architecture (MVC), to the mainstream MVP, MVVM architecture. There are also good practices in the community. Today, I would like to talk about a reasonable Android architecture based on my own experience.

MVC, MVP, MVVM three hierarchical architecture

MVC, god model

It is believed that some experienced developers have experienced the era of Activity (Fragment) programming, which is the era of the so-called MVC architecture. At that time, during development, we would put our business module atmosphere in three layers. The diagram below:

It would have been a better implementation if we had developed our architecture strictly according to the figure above. But there is a very serious problem with the practice of this architecture in Android. When dividing the View layer, do we use the layout as the View layer, or do we use the Activity/Fragment and the layout file as the View layer? If it is the latter, then we should also introduce a new Control layer. In the early days (15, 14 years ago) of Android development, generally did not consider so much, the layout file directly as the View layer, Activity/Fragment as the so-called Control layer.

What’s the problem with having XML files as the View layer? The XML file has very little control, it can only change the UI during development, and once published, the XML UI is fixed. If you want to modify the UI (text updates) while the App is running, then we need to use Java code. Usually we update the UI with code in the Activity/Fragment.

Then this brings a new problem, Activity/Fragment as a C layer, hosting both C layer and V layer business. So the IMPLEMENTATION of MVC in Android generally becomes the VC model. This model, in complex business scenarios, creates a large number of Gods, also known as God Class. In general, our thousands of rows of activities are the result of this model.

MVP, everything goes back

After the painful time of maintaining the God Class, we chose a new MVP architecture, which is very similar to the MVC architecture. The MVP in Android uses the layout file and Activity/Fragment as the View layer, which only contains UI related code, not business logic. Presenter is the logical layer, which is similar to the traditional MVC architecture, but a little different from MVC. In THE MVP architecture, layer V cannot directly query layer M data (MVC does). On the other hand, the way data is exchanged between different levels is based on the asynchronous Callback pattern, so the MVP implementation will include a large number of interfaces.

The architecture of the MVP is as follows:

As the picture shows, MVP and MVC are architecturally very similar. The biggest difference is that in MVP, layer V and layer M do not interact directly. The MVP was able to split our business into three separate parts, but the downside of the MVP was obvious. A lot of template code, interface-based communication leads us to define a lot of one-time interfaces. In addition, the life cycles of P layer and V layer are inconsistent, and there may be cases where P layer still exists and V layer is recycled. This will cause the UI update to fail, which can easily Crash.

Whether it’s worth adding so much code and a one-time interface to split the logic is a question that needs to be fully considered.

MVVM, data driven model

Because there was so much MVP boilerplate code, we tried the MVVM architecture again. Google has also introduced a number of ancillary tools to help us implement the MVVM architecture in Android, such as DataBinding, LiveData, ViewModel series, and so on. The core of the MVVM architecture is data-driven, which means that the UI is automatically refreshed when data is updated.

To use a metaphor:

We compare the View to a criminal, and the act of updating the View to sending the criminal to jail. So in MVC and MVP, we, C and P, are the police, who are responsible for putting criminals in jail. In MVVM, after a criminal commits a crime (data change), it goes to the prison itself.

Using the MVVM architecture will save us a lot of code to update the UI, and it is more error resistant and robust to proactively update the UI after the data is updated. We don’t need to pay attention to the timing of data changes, but to the results of data changes. MVVM architecture diagram is as follows:

The MVVM architecture, supported by the official Jetpack, is becoming a mainstream architecture, and should be considered for new projects. New modules for old businesses can also try to migrate into the MVVM architecture.

It’s not just stratification

Through analysis, the above three architectures are all designed to solve a problem, that is, to separate the UI, logic and data layers and achieve decoupling. In fact, there are no advantages or disadvantages of these three architectures, but only the ways of implementation. The MVVM architecture is generally recommended for implementations because of the official component enablement. So is architecture just layering?

No, because in actual development, the business scenario is very complex, and in addition to layering the business, we also need to focus on: caching, flow limiting, paging, domain design (not too much in this article), and so on. The caching and stream limiting scenarios are briefly described below.

The cache

Let’s start by focusing on a common problem in Android, the caching problem. In Android development, we don’t cache data in most scenarios, and if we do, we use SharedPreferences in most scenarios. Both not caching the data and using SharedPreferences to cache the data in large quantities can cause problems.

In most cases, the data we request from the server is the same over a certain period of time. For example, if we request the personal information interface, it is likely that the data will be the same now and a minute later. So if we can cache the data from the first request, then we don’t have to repeat the request the second time. This is what the cache is for. Of course, the cache also has a timeout period, which needs to be set according to the specific service. And the cache allows us to show the user the last data he saw in case of a network exception. So a well-designed APP, it’s going to take caching into account.

The problem with SharedPreferences is that it is efficient, because it is file-based and implements data persistence and reads/writes data in full, so it is not appropriate to use SharedPreferences heavily for caching.

Current limiting

The main function of traffic limiting is to limit the client to initiate a large number of repeated requests in a short period of time, resulting in the flood peak of the traffic in the background. In general, we can limit the flow by limiting the time interval between repeated clicks. However, this approach will, one, hack into our UI implementation, and, two, have some scenarios where the request is not initiated by the UI.

When we solve the above problems, it can be said that there are a thousand Hamlets in the eyes of a thousand people, and everyone’s possibilities are different. And some implementations are arguably worse. So can we develop a more uniform mechanism to solve the above problems? The answer is yes. After reading an open source project of Google, I found that the official provided a very good implementation mechanism long ago. Let’s share this methodology, which is based on the MVVM architecture.

Implement a more reasonable MVVM architecture on Android

RepoRepository

From the above analysis, we know that Repository is our data warehouse, that is, data provider. We can put the caching and stream-limiting logic in here. Do we need to cache most of the data? I think we can cache most of the requested data because it makes our APP experience better. We don’t need to request data that has already been requested without updating it. And we can let the customer see the last successful data first when they are offline or when they are requesting large amounts of data.

There is no way for the client to know if the data has been updated, but you can set the cache validity period by limiting the stream. Limiting traffic here works much like the cache-Control field in our Http protocol. Data that is still within the validity period can be cached directly. The time threshold can be set according to the specific business.

We don’t want to cache all the requested data, we want to avoid caching large amounts of duplicate data, we want to define reasonable database tables to achieve data management. We can update the data by using the id returned in the background as the ID of our local data.

Let’s first look at a flowchart for a Repository that satisfies these functions:

As you can see, a Repository that meets our requirements is very complex, and it is not realistic to implement such logic in every Repository. But this general logic we can encapsulate, so let’s see what Rpository looks like when it’s encapsulated.

class RepoRepository constructor(
        private val db: GithubDb,
        private val githubService: GithubService) {

    val repoRateLimiter = RateLimiter<String>(15, TimeUnit.SECONDS)

    fun search(query: String): LiveData<Resource<List<Repo>>> {
        return object : NetworkBoundResource<List<Repo>, RepoSearchResponse>() {

            override fun saveCallResult(item: RepoSearchResponse) {
                val repoIds = item.items.map { it.id }
                valrepoSearchResult = RepoSearchResult( query = query, repoIds = repoIds, totalCount = item.total, next = item.nextPage ) db.runInTransaction { db.repoDao().insertRepos(item.items) db.repoDao().insert(repoSearchResult) }}override fun shouldFetch(data: List<Repo>? =
                    data= =null || repoRateLimiter.shouldFetch(query)

            override fun loadFromDb(a): LiveData<List<Repo>> {
                return Transformations.switchMap(db.repoDao().search(query)) { searchData ->
                    if (searchData == null) {
                        AbsentLiveData.create()
                    } else {
                        db.repoDao().loadOrdered(searchData.repoIds)
                    }
                }
            }

            override fun createCall(a) = githubService.searchRepos(query)

        }.asLiveData()
    }
}
Copy the code

The Room framework is used to manage the database, and Retrofit2 is used to invoke Github’s interface.

We put the specific scheduling logic in the NetworkBoundResource class, so that all businesses can consume the logic. Careful readers may notice that Repository does only a fraction of the work. Web requests are implemented through Retrofit2, data caching is implemented through Room, and Repository does only resource scheduling. This is an excellent design, provides more complete decoupling, and naturally supports unit testing.

In Repository it is implemented by the RateLimiter stream-limiting class, which is simply created to receive a unit of time. What it does is what the request interval is for the same query parameter, which in this case is 15 seconds.

NetworkBoundResource

NetworkBoundResource is a class that encapsulates the data scheduling content of The Repository flowchart. When we implement new businesses, we do not need to repeat the implementation of scheduling logic, but only need to pay attention to the business itself. Attach the source code:


abstract class NetworkBoundResource<ResultType, RequestType>
@MainThread constructor() {

    protected val result = MediatorLiveData<Resource<ResultType>>()

    init {
        result.value = Resource.loading(null)
        @Suppress("LeakingThis")
        val dbSource = loadFromDb()

        result.addSource(dbSource) { data ->
            result.removeSource(dbSource)
            if (shouldFetch(data)) {
                fetchFromNetwork(dbSource)
            } else {
                result.addSource(dbSource) { newData ->
                    setValue(Resource.success(newData))
                }
            }
        }
    }

    @MainThread
    protected fun setValue(newValue: Resource<ResultType>) {
        if(result.value ! = newValue) { result.value = newValue } }private fun fetchFromNetwork(dbSource: LiveData<ResultType>) = runBlocking{
        val apiResponse = createCall()
        // we re-attach dbSource as a new source, it will dispatch its latest value quickly
        result.addSource(dbSource) { newData ->
            setValue(Resource.loading(newData))
        }
        result.addSource(apiResponse) { response ->
            result.removeSource(apiResponse)
            result.removeSource(dbSource)
            when (response) {
                is ApiSuccessResponse -> {
                    val ioResult = com.recurve.coroutines.io { saveCallResult(processResponse(response)) }
                    ioResult{
                        result.addSource(loadFromDb()){
                            setValue(Resource.success(it))
                        }
                    }

                }
                is ApiEmptyResponse -> {
                    result.addSource(loadFromDb()) { newData
                        -> setValue(Resource.success(newData))
                    }
                }
                is ApiErrorResponse -> {
                    onFetchFailed()
                    result.addSource(dbSource) { newData ->
                        setValue(Resource.error(response.errorMessage, newData))
                    }
                }
            }
        }
    }

    protected open fun onFetchFailed(a) {}

    fun asLiveData(a) = result

    @WorkerThread
    protected open fun processResponse(response: ApiSuccessResponse<RequestType>) = response.body

    @WorkerThread
    protected abstract fun saveCallResult(item: RequestType)

    @MainThread
    protected abstract fun shouldFetch(data: ResultType?).: Boolean

    @MainThread
    protected abstract fun loadFromDb(a): LiveData<ResultType>

    @MainThread
    protected abstract fun createCall(a): LiveData<ApiResponse<RequestType>>
}

Copy the code

SearchRepoViewModel

class SearchRepoViewModel : ViewModel() {var repoRepository: RepoRepository? = null

    private val _query = MutableLiveData<String>()
    val query : LiveData<String> = _query

    val results = Transformations
            .switchMap<String, Resource<List<Repo>>>(_query) { search ->
                if (search.isNullOrBlank()) {
                    AbsentLiveData.create()
                } else{ repoRepository? .search(search) } }fun setQuery(originalInput: String) {
        val input = originalInput.toLowerCase(Locale.getDefault()).trim()
        if (input == _query.value) {
            return
        }
        _query.value = input
    }
}
Copy the code

As you can see, the logic of the ViewModel is very simple. It contains a method, the setQuery method, which takes query parameters, and results, which is the LiveData object returned by Repository (the use of LiveData is not covered here). If we need to process callback data, we can just use the transform function of LiveData.

View

The View layer, which we won’t go into much, can be manually updated by watching LiveData change, or automatically updated by DataBinding. According to their own habits to achieve. Let’s look at the key code in this example:

 private lateinit var binding: FragmentSearchRepoBinding
    private val searchViewModel by lazy { ViewModelProviders.of(this).get(SearchRepoViewModel::class.java)}
    private lateinit var creator: SearchRepoCreator

    override fun onCreateBinding(inflater: LayoutInflater, container: ViewGroup? , savedInstanceState:Bundle?).: ViewDataBinding {
        binding = DataBindingUtil.inflate(
                inflater, R.layout.fragment_search_repo, container, false)

        initViewRecyclerView(binding.repoList)
        creator = SearchRepoCreator()
        addItemCreator(creator)
        return binding
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?). {
        super.onViewCreated(view, savedInstanceState)
        binding.lifecycleOwner = thisbinding.query = searchViewModel.query initSearchInputListener() initRepository() binding.searchResult = searchViewModel.results searchViewModel.results.observe(viewLifecycleOwner, Observer { result -> result? .data? .let { creator.setDataList(it) } }) } }Copy the code

Quick start

Having said all that, the above is just a simple function in an architecture, and it is not realistic to implement it again in your own project. So I have actually encapsulated this framework, called MVVMRecurve. Everything about the infrastructure is encapsulated, you just need to rely on the project.

buildscript {
  ext.recurve_version = '1.0.1'
}

//modules build.gradle
implementation "com.recurve:recurve.core:$recurve_version"
implementation "com.recurve:recurve-retrofit2-support:$recurve_version"
implementation "com.recurve:recurve-module-adapter:$recurve_version"
implementation "com.recurve:coroutines-ktx:$recurve_version"

// If you want to support more features, you can rely on the following libraries
implementation "com.recurve:recurve-apollo-support:$recurve_version"
implementation "com.recurve:recurve-dagger2-support:$recurve_version"
implementation "com.recurve:recurve-module-paging-support:$recurve_version"
implementation "com.recurve:recurve-glide-support:$recurve_version"
implementation "com.recurve:viewpager2-navigation-ktx:$recurve_version"
implementation "com.recurve:bottom-navigation-ktx:$recurve_version"
implementation "com.recurve:viewpager2-tablayout-ktx:$recurve_version"
implementation "com.recurve:navigation-dialog:$recurve_version"
Copy the code

summary

MVVMRecurve is committed to building a reasonably usable Android development framework, and will actively follow up on official new technologies. If you want to understand how a development architecture is designed, you can read the source code and communicate with others. I am also trying to use this framework to develop a highly available project: GitHubRecurve.

For MVVMRecurve, this article is missing a few of them directly, and I will continue to introduce some other technical modules and design ideas later, welcome to pay attention. If you think this article is good, you can click star.

If you want to know more

  • If you want to learn about the screen adaptation, click calces-gradle-plugin

  • If you want to see How Jetpack works and demo some of the new technologies, you can check out Android-Advanced-Blueprint

Finally, your STAR is the motivation for me to stick to it. If you think the above projects are good, you can click the star. If you feel good about MVVMRecurve, you can participate in this open source project in addition to clicking star. If you want to develop a full project, you can participate in GitHubRecurve. I will continue to maintain these projects and update some technical articles on a regular basis. Thanks to everyone who read and wrote here.