Using a business scenario from a real project, this article describes the process of code becoming more and more complex after refactoring (you YA).

The business scenario for this Demo is to pull news from the server and display it in a list.

GodActivity

When I first started working on Android, I wrote business code like this (omitting Adapter and Api details irrelevant to the topic) :

class GodActivity : AppCompatActivity() {
    private var rvNews: RecyclerView? = null
    private var newsAdapter = NewsAdapter()

    // Pull the data with retrofit
    private val retrofit = Retrofit.Builder()
            .baseUrl("https://api.apiopen.top")
            .addConverterFactory(MoshiConverterFactory.create())
            .client(OkHttpClient.Builder().build())
            .build()
    private val newsApi = retrofit.create(NewsApi::class.java)
    
    // Database operation asynchronous actuator
    private var dbExecutor = Executors.newSingleThreadExecutor()

    override fun onCreate(savedInstanceState: Bundle?). {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.news_activity)
        initView()
        fetchNews()
    }

    private fun initView(a){ rvNews = findViewById(R.id.rvNews) rvNews? .layoutManager = LinearLayoutManager(this)}// The list displays the news
    private fun showNews(news : List<News>){ newsAdapter.news = news rvNews? .adapter = newsAdapter }// Get the news
    private fun fetchNews(a) {
        // 1. First read the old news from the database for quick display
        queryNews().let{ showNews(it) }
        // 2. Pull news from the network to replace the old news
        newsApi.fetchNews(
                mapOf("page" to "1"."count" to "4")
        ).enqueue(object : Callback<NewsBean> {
            override fun onFailure(call: Call<NewsBean>, t: Throwable) {
                Toast.makeText(this@GodActivity."network error", Toast.LENGTH_SHORT).show()
            }

            override fun onResponse(call: Call<NewsBean>, response: Response<NewsBean>){ response.body()? .result? .let {// 3. Show new news
                    showNews(it) 
                    // 4. Archiving the news
                    dbExecutor.submit { insertNews(it) }
                }
            }
        })
    }
    
    // Read old news from the database (pseudo-code)
    private fun queryNews(a) : List<News> {
        val dbHelper = NewsDbHelper(this,...).val db = dbHelper.getReadableDatabase()
        val cursor = db.query(...)
        var newsList = mutableListOf<News>()
        while(cursor.moveToNext()) {
            ...
            newsList.add(news)
        }
        db.close()
        return newsList
    }
    
    // Write the news to the database (pseudo code)
    private fun insertNews(news : List<News>) {
        val dbHelper = NewsDbHelper(this,...).val db = dbHelper.getWriteableDatabase()
        news.foreach {
            val cv = ContentValues().apply { ... }
            db.insert(cv)
        }
        db.close()
    }
}
Copy the code

After all, the focus was on implementation, and the first questions were “how to draw a layout,” “how to manipulate a database,” “how to request and parse network data,” and “how to populate a list with data.” After these problems were solved, there was no time to think about architecture, so the God Activity above was created. Too many activities! Activity knows too much detail:

  1. Asynchronous details
  2. Accessing database details
  3. Access network details
  1. If a lot of “detail” is spread out at the same level, it becomes verbose and increases the cost of understanding.

For example:

You ask “What’s for dinner?”

“I used a spoon to eat chicken eggs and tomatoes stir-fried with oil.”

Will you still be friends with him? You don’t really care what he eats, how fast he eats, where his ingredients come from, or how he cooks them.

  1. The opposite of “detail” is “abstract.” In programming, “detail” is fickle, while “abstract” is relatively stable.

For example, there are several ways to implement asynchrony in Android: thread pools, handlerThreads, coroutines, IntentServices, and RxJava.

  1. “Details” increase coupling.

GodActivity introduces a number of classes that have nothing to do with it: Retrofit, Executors, ContentValues, Cursor, SQLiteDatabase, Response, and OkHttpClient. Activities should only be about interface presentation.

Separate the interface presentation from the data retrieval

Since the Activity knows too much, let the Presenter do the work for it:

// To construct a Presenter, pass the NewsView interface to the View layer
class NewsPresenter(var newsView: NewsView): NewsBusiness {
    private val retrofit = Retrofit.Builder()
            .baseUrl("https://api.apiopen.top")
            .addConverterFactory(MoshiConverterFactory.create())
            .client(OkHttpClient.Builder().build())
            .build()

    private val newsApi = retrofit.create(NewsApi::class.java)

    private var executor = Executors.newSingleThreadExecutor()

    override fun fetchNews(a) {
        // Notifies the Activity of the database news via the View layer interface
        queryNews().let{ newsView.showNews(it) }
        newsApi.fetchNews(
                mapOf("page" to "1"."count" to "4")
        ).enqueue(object : Callback<NewsBean> {
            override fun onFailure(call: Call<NewsBean>, t: Throwable) {
                newsView.showNews(null)}override fun onResponse(call: Call<NewsBean>, response: Response<NewsBean>){ response.body()? .result? .let {// Notifies the Activity of the network news via the View layer interface
                    newsView.showNews(it) 
                    dbExecutor.submit { insertNews(it) }
                }
            }
        })
    }
    
    // Read old news from the database (pseudo-code)
    private fun queryNews(a) : List<News> {
        // Construct the dbHelper by obtaining the context from the View layer interface
        val dbHelper = NewsDbHelper(newsView.newsContext, ...)
        val db = dbHelper.getReadableDatabase()
        val cursor = db.query(...)
        var newsList = mutableListOf<News>()
        while(cursor.moveToNext()) {
            ...
            newsList.add(news)
        }
        db.close()
        return newsList
    }
    
    // Write the news to the database (pseudo code)
    private fun insertNews(news : List<News>) {
        val dbHelper = NewsDbHelper(newsView.newsContext, ...)
        val db = dbHelper.getWriteableDatabase()
        news.foreach {
            val cv = ContentValues().apply { ... }
            db.insert(cv)
        }
        db.close()
    }
}
Copy the code

Just copy and paste GodActivity’s “asynchronous”, “access database”, and “Access network” into a new Presenter class. This makes the Activity simple:

class RetrofitActivity : AppCompatActivity(), NewsView {
    // Construct the business interface instance directly in the interface
    private val newsBusiness = NewsPresenter(this)

    private var rvNews: RecyclerView? = null
    private var newsAdapter = NewsAdapter()

    override fun onCreate(savedInstanceState: Bundle?). {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.news_activity)
        initView()
        // Trigger the business logic
        newsBusiness.fetchNews()
    }

    private fun initView(a){ rvNews = findViewById(R.id.rvNews) rvNews? .layoutManager = LinearLayoutManager(this)}// Implement the View layer interface to update the interface
    override fun showNews(news: List<News>?{ newsAdapter.news = news rvNews? .adapter = newsAdapter }override val newsContext: Context
        get() = this
}
Copy the code

The introduction of presenters also increases communication costs:

interface NewsBusiness {
    fun fetchNews(a)
}
Copy the code

This is the business interface in the MVP model and describes the business action. It is implemented by a Presenter and held by the interface class to trigger the business logic.

interface NewsView {
    // Pass the news to the interface
    fun showNews(news:List<News>?
    // Get the interface context
    abstract val newsContext:Context
}
Copy the code

In the MVP model, this is called the View layer interface. The Presenter holds it to trigger interface updates, and the interface class implements it to draw the interface.

The introduction of these two interfaces is significant:

Interfaces separate what to do (abstraction) from how to do it (detail). This feature makes possible a separation of concerns: the interface holder cares only about what to do, and how to do it is left to the interface implemancer.

The Activity holds the business interface, which makes it unnecessary to care about the implementation details of the business logic. The Activity implements the View-layer interface, and the details of the interface presentation are all clustered in the Activity class, making it the V in MVP.

Presenter holds the View layer interface, which makes it unnecessary to care about the presentation details. The Presenter implements the business interface, and the implementation details of the business logic are all clustered within the Presenter class, making it the P in MVP.

The biggest benefit of doing this is to reduce the cost of code understanding, because the different details are no longer spread out at the same level, but layered. When reading code, a “scratch” or “scratch” approach is a huge improvement in efficiency.

This also reduces the cost of change; when business requirements change, only the Presenter class needs to change. When adjusting the interface, only layer V needs to be changed. By the same token, the scope of the problem has been narrowed.

This also facilitates self-testing, if you want to test the performance of various critical data generated interface, you can implement a PresenterForTest. If you want to cover the various conditional branches of your business logic, you can easily write unit tests to a Presenter (which, when isolated from the interface, is pure Kotlin and does not contain any Android code).

But NewsPresenter is not pure! In addition to the business logic, it also contains the details of accessing the data. In the same way, it should abstract an interface to access the data and let the Presenter hold it. This is the M in MVP. See Repository in the next section for an implementation.

Data view binding + long life cycle data

Even if you strip the details of accessing data out of a Presenter, it’s still not pure. Since it holds the View layer interface, it requires the Presenter to know which data to pass to which interface method. This is called data binding, which is determined when the View is built (without waiting for the data to return), so this detail can be stripped from the business layer and merged into the View layer.

The instance of the Presenter is held by the Activity, so its lifecycle is synchronized with that of the Activiy, i.e., the business data and the interface. This is a drawback in some scenarios, such as landscape and portrait switching. At this point, if the life cycle of the data is not dependent on the interface, the cost of reacquiring the data can be eliminated. This certainly requires a longer-lived object (ViewModel) to hold the data.

Longer life cycle ViewModel

In the example in the previous section, the Presenter was built directly in the Activity new, whereas the ViewModel was built using viewModelProvider.get ():

public class ViewModelProvider {
    // The ViewModel instance store
    private final ViewModelStore mViewModelStore;
    
    public <T extends ViewModel> T get(@NonNull String key, @NonNull Class<T> modelClass) {
        // Get the ViewModel instance from the store
        ViewModel viewModel = mViewModelStore.get(key);

        if (modelClass.isInstance(viewModel)) {
            return (T) viewModel;
        } else{... }// If the store does not have a ViewModel instance, build it from the Factory
        if (mFactory instanceof KeyedFactory) {
            viewModel = ((KeyedFactory) (mFactory)).create(key, modelClass);
        } else {
            viewModel = (mFactory).create(modelClass);
        }
        // Store the ViewModel instance in the store
        mViewModelStore.put(key, viewModel);
        return(T) viewModel; }}Copy the code

ViewModel instance is obtained from ViewModelStore:

// The ViewModel instance store
public class ViewModelStore {
    // Store the Map of the ViewModel instance
    private final HashMap<String, ViewModel> mMap = new HashMap<>();

    / / save
    final void put(String key, ViewModel viewModel) {
        ViewModel oldViewModel = mMap.put(key, viewModel);
        if(oldViewModel ! =null) { oldViewModel.onCleared(); }}/ /
    final ViewModel get(String key) {
        returnmMap.get(key); }... }Copy the code

The ViewModelStore stores ViewModel instances in a HashMap.

ViewModelStore is obtained by ViewModelStoreOwner:

public class ViewModelProvider {
    // The ViewModel instance store
    private final ViewModelStore mViewModelStore;
    
    // To construct the ViewModelProvider, pass in the ViewModelStoreOwner instance
    public ViewModelProvider(@NonNull ViewModelStoreOwner owner, @NonNull Factory factory) {
        // Obtain ViewModelStore from ViewModelStoreOwner
        this(owner.getViewModelStore(), factory);
    }

    public ViewModelProvider(@NonNull ViewModelStore store, @NonNull Factory factory) { mFactory = factory; mViewModelStore = store; }}Copy the code

Where is the ViewModelStoreOwner instance stored?

// The Activity base class implements the ViewModelStoreOwner interface
public class ComponentActivity extends androidx.core.app.ComponentActivity implements
        LifecycleOwner.ViewModelStoreOwner.SavedStateRegistryOwner.OnBackPressedDispatcherOwner {
        
        // The Activity holds the ViewModelStore instance
        private ViewModelStore mViewModelStore;
        
        public ViewModelStore getViewModelStore(a) {
            if (mViewModelStore == null) {
                // Get a configuration independent instance
                NonConfigurationInstances nc =(NonConfigurationInstances) getLastNonConfigurationInstance();
                if(nc ! =null) {
                    // Restore the ViewModel store from a configuration independent instance
                    mViewModelStore = nc.viewModelStore;
                }
                if (mViewModelStore == null) {
                    mViewModelStore = newViewModelStore(); }}return mViewModelStore;
        }
        
        // Static configuration independent instance
        static final class NonConfigurationInstances {
            // Hold the ViewModel store instanceViewModelStore viewModelStore; . }}Copy the code

An Activity is a ViewModelStoreOwner instance and holds a ViewModelStore instance, which is also stored in a static class, so the ViewModel lifecycle is longer than the Activity. In this way, the business data stored in the ViewModel can be reused when the Activity is destroyed and rebuilt.

Data binding

Activities in MVVM belong to the V layer, where layout construction and data binding are done:

class MvvmActivity : AppCompatActivity() {
    private var rvNews: RecyclerView? = null
    private var newsAdapter = NewsAdapter()

    // Build the layout
    private val rootView by lazy {
        ConstraintLayout {
            TextView {
                layout_id = "tvTitle"
                layout_width = wrap_content
                layout_height = wrap_content
                textSize = 25f
                padding_start = 20
                padding_end = 20
                center_horizontal = true
                text = "News"
                top_toTopOf = parent_id
            }

            rvNews = RecyclerView {
                layout_id = "rvNews"
                layout_width = match_parent
                layout_height = wrap_content
                top_toBottomOf = "tvTitle"
                margin_top = 10
                center_horizontal = true}}}// Build the ViewModel instance
    private val newsViewModel by lazy { 
        // Construct the ViewModelProvider instance and get the ViewModel instance from its get()
        ViewModelProvider(this, NewsFactory(applicationContext)).get(NewsViewModel::class.java) }

    override fun onCreate(savedInstanceState: Bundle?). {
        super.onCreate(savedInstanceState)
        setContentView(rootView)
        initView()
        bindData()
    }

    // Bind data to the view
    private fun bindData(a) {
        newsViewModel.newsLiveData.observe(this, Observer { newsAdapter.news = it rvNews? .adapter = newsAdapter }) }private fun initView(a){ rvNews? .layoutManager = LinearLayoutManager(this)}}Copy the code

A detailed description of building a layout DSL can be found here. It eliminates the XML in the original V layer (Activity + XML).

Data binding in the code is implemented by looking at LiveData in the ViewModel. This is not the end of the data binding, so you need to manually observe the observe data change (only when the data-binding package is introduced can you statically bind both views and controls into XML). But at least it eliminates the need for the ViewModel to actively push data:

In MVP mode, a Presenter holds the View-layer interface and actively pushes data to the interface.

In MVVM mode, the ViewModel no longer holds the View layer interface, nor does it actively push data to the interface, but the interface passively observes the data changes.

This allows the ViewModel to simply hold the data and update it according to the business logic:

// The data access interface is injected in the constructor
class NewsViewModel(var newsRepository: NewsRepository) : ViewModel() {
    // Hold business data
    val newsLiveData by lazy { newsRepository.fetchNewsLiveData() }
}

// Define the constructor ViewModel method
class NewsFactory(context: Context) : ViewModelProvider.Factory {
    // Construct the data access interface instance
    private val newsRepository = NewsRepositoryImpl(context)
    override fun 
        create(modelClass: Class<T>): T {
        // Inject the data interface access instance into the ViewModel
        return NewsViewModel(newsRepository) as T
    }
}

// Then you can construct the ViewModel in your Activity like this
class MvvmActivity : AppCompatActivity() {
    // Build the ViewModel instance
    private val newsViewModel by lazy { 
        ViewModelProvider(this, NewsFactory(applicationContext)).get(NewsViewModel::class.java) }
}
Copy the code

The ViewModel only cares about the business logic and data, not the details of getting the data, so they are hidden by the data access interface.

In the Demo business scenario, the ViewModel is a single line of code, so does it have any value?

There are! Even in a scenario where the business logic is so simple! Because the ViewModel lives longer than the Activity, the data it holds can be reused when the Activity is destroyed and rebuilt.

The complexity of business logic in real projects is much higher than that in Demo. The details of business logic should be hidden in ViewModel, so that the interface class is unaware. For example, “convert the time stamp returned by the server to date, month, and year” should be written in the ViewModel.

Service data access interface

// Service data access interface
interface NewsRepository {
    // Pull the news and return it as LiveData
    fun fetchNewsLiveData(a):LiveData<List<News>?>
}

// Implement the details of accessing the network and database
class NewsRepositoryImpl(context: Context) : NewsRepository {
    // Use Retrofit to build the request access network
    private val retrofit = Retrofit.Builder()
            .baseUrl("https://api.apiopen.top")
            .addConverterFactory(MoshiConverterFactory.create())
            // Organize the returned data into LiveData
            .addCallAdapterFactory(LiveDataCallAdapterFactory())
            .client(OkHttpClient.Builder().build())
            .build()

    private val newsApi = retrofit.create(NewsApi::class.java)

    private var executor = Executors.newSingleThreadExecutor()
    // Use room to access the database
    private var newsDatabase = NewsDatabase.getInstance(context)
    private var newsDao = newsDatabase.newsDao()

    private var newsLiveData = MediatorLiveData<List<News>>()

    override fun fetchNewsLiveData(a): LiveData<List<News>? > {// Get news from the database
        val localNews = newsDao.queryNews()
        // Get news from the Internet
        val remoteNews = newsApi.fetchNewsLiveData(
                mapOf("page" to "1"."count" to "4")
        ).let {
            Transformations.map(it) { response: ApiResponse<NewsBean>? ->
                when (response) {
                    is ApiSuccessResponse -> {
                        valnews = response.body.result news? .let {// Put the network news into the database
                            executor.submit { newsDao.insertAll(it) }
                        }
                        news
                    }
                    else -> null}}}// Merge the LiveData of the database and network response
        newsLiveData.addSource(localNews) {
            newsLiveData.value = it
        }

        newsLiveData.addSource(remoteNews) {
            newsLiveData.value = it
        }

        return newsLiveData
    }
}
Copy the code

This is the M in MVVM, which defines the details of how to get the data.

Both the database and the network in Demo return data in the form of LiveData, so only one MediatorLiveData is needed to merge the two data sources. So I use Room to access the database. And defines the LiveDataCallAdapterFactory used to Retrofit return results into LiveData. (The source code can be found here)

There is also a coupling here: Repository needs to know the details of how Retrofit and Room are used.

As the details of accessing the database and network become more complex, it is also common to add a layer of abstraction that hides the details of accessing the memory, database, and network, respectively, as memory caches are added. The logic in Repository then becomes: “What strategy is used to combine memory, database, and network data and return it to the business layer”.

Clean Architecture

After many refactoring, the code structure is constantly evolving, and finally ViewModel and Repository are introduced. There’s more layers, more complexity on the surface, but it’s cheaper and cheaper to understand. Because all the complex details are not being developed at the same level.

Finally, take another look at the architecture with Clean Architecture:

Entities

It’s a business entity object, and for Demo, Entities is the News entity class News.

Use Cases

It is business logic, Entities are nouns and Use Cases are sentences. In Clean Architecture, every business logic is abstracted into a UseCase class, which is held by Presenters. More details can be found here

Repository

It is a business data access interface that abstractly describes retrieving and storing Entities. It is exactly the same as Repository in the Demo, but in Clean Architecture, it is held by UseCase.

Presenters

It is much like a Presenter in the MVP model, triggering business logic and passing data to the interface. The only difference is that it holds UseCase.

DB & API

It is an implementation of the abstract business data access interface, exactly like The NewsRepositoryImpl in the Demo.

UI

It’s the details of building a layout, just like the Activity in the Demo.

Device

It’s device-specific details. DB and UI implementation details are also device-specific. By Device, we mean device-specific details other than data and interface, such as how notifications are displayed in the notification bar.

Depend on the direction

The inner three layers of the onion ring are abstract, and only the outermost layer contains implementation details (implementation details related to the Android platform). Details about accessing databases, details about drawing interfaces, details about notification bar alerts, details about playing audio)

The inward arrow in the onion ring means that the outer layer is aware of the existence of the neighboring inner layer, while the inner layer is unaware of the existence of the outer layer. The outer layer depends on the inner layer, and the inner layer does not depend on the outer layer. This means that business logic should be implemented as abstractly as possible. Business logic should only be concerned with what to do, not how to do it. Such code is extension friendly and the business logic does not need to change when implementation details change.