我是怎麼把業務代碼越寫越複雜的 | MVP - MVVM - Clean Architecture

一名優秀的Android開發,需要一份完備的 知識體系,在這裏,讓我們一起成長爲自己所想的那樣~。

本文以一個真實項目的業務場景爲載體,描述了經歷一次次重構後,代碼變得越來越複雜(you ya)的過程。

本篇 Demo 的業務場景是:從服務器拉取新聞並在列表展示。

GodActivity

剛接觸 Android 時,我是這樣寫業務代碼的(省略了和主題無關的 Adapter 和 Api 細節):

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

    // 用 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)
    
    // 數據庫操作異步執行器
    private var dbExecutor = Executors.newSingleThreadExecutor()

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

    private fun initView() {
        rvNews = findViewById(R.id.rvNews)
        rvNews?.layoutManager = LinearLayoutManager(this)
    }
    
    // 列表展示新聞
    private fun showNews(news : List<News>) {
        newsAdapter.news = news
        rvNews?.adapter = newsAdapter
    }

    // 獲取新聞
    private fun fetchNews() {
        // 1. 先從數據庫讀老新聞以快速展示
        queryNews().let{ showNews(it) }
        // 2. 再從網絡拉新聞替換老新聞
        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. 展示新新聞
                    showNews(it) 
                    // 4. 將新聞入庫
                    dbExecutor.submit { insertNews(it) }
                }
            }
        })
    }
    
    // 從數據庫讀老新聞(僞代碼)
    private fun queryNews() : 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
    }
    
    // 將新聞寫入數據庫(僞代碼)
    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()
    }
}

畢竟當時的關注點是實現功能,首要解決的問題是“如何繪製佈局”、“如何操縱數據庫”、“如何請求並解析網絡數據”、“如何將數據填充在列表中”。待這些問題解決後,也沒時間思考架構,所以就產生了上面的God Activity。Activity 管的太多了!Activity 知道太多細節:

  1. 異步細節
  2. 訪問數據庫細節
  3. 訪問網絡細節
  1. 如果大量 “細節” 在同一個層次被鋪開,就顯得囉嗦,增加理解成本。

拿說話打個比方:

你問 “晚飯吃了啥?”

“我用勺子一口一口地吃了雞生下的蛋和番茄再加上油一起炒的菜。”

聽了這樣地回答,你還會和他做朋友嗎?其實你並不關心他喫的工具、喫的速度、食材的來源,以及烹飪方式。

  1. “細節” 相對的是 “抽象”,在編程中 “細節” 易變,而 “抽象” 相對穩定。

比如 “異步” 在 Android 中就有好幾種實現方式:線程池、HandlerThread、協程、IntentServiceRxJava

  1. “細節” 增加耦合。

GodActivity 引入了大量本和它無關的類:RetrofitExecutorsContentValuesCursorSQLiteDatabaseResponseOkHttpClient。Activity 本應該只和界面展示有關。

將界面展示和獲取數據分離

既然 Activity 知道太多,那就讓Presenter來爲它分擔:

// 構造 Presenter 時傳入 view 層接口 NewsView
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() {
        // 將數據庫新聞通過 view 層接口通知 Activity
        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 { 
                    // 將網絡新聞通過 view 層接口通知 Activity
                    newsView.showNews(it) 
                    dbExecutor.submit { insertNews(it) }
                }
            }
        })
    }
    
    // 從數據庫讀老新聞(僞代碼)
    private fun queryNews() : List<News> {
        // 通過 view 層接口獲取 context 構造 dbHelper
        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
    }
    
    // 將新聞寫入數據庫(僞代碼)
    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()
    }
}

無非就是複製 + 粘貼,把 GodActivity 中的“異步”、“訪問數據庫”、“訪問網絡”、放到了一個新的Presenter類中。這樣 Activity 就變簡單了:

class RetrofitActivity : AppCompatActivity(), NewsView {
    // 在界面中直接構造業務接口實例
    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()
        // 觸發業務邏輯
        newsBusiness.fetchNews()
    }

    private fun initView() {
        rvNews = findViewById(R.id.rvNews)
        rvNews?.layoutManager = LinearLayoutManager(this)
    }

    // 實現 View 層接口以更新界面
    override fun showNews(news: List<News>?) {
        newsAdapter.news = news
        rvNews?.adapter = newsAdapter
    }

    override val newsContext: Context
        get() = this
}

Presenter的引入還增加了通信成本:

interface NewsBusiness {
    fun fetchNews()
}

這是MVP模型中的業務接口,描述的是業務動作。它由Presenter實現,而界面類持有它以觸發業務邏輯。

interface NewsView {
    // 將新聞傳遞給界面
    fun showNews(news:List<News>?)
    // 獲取界面上下文
    abstract val newsContext:Context
}

MVP模型中,這稱爲View 層接口Presenter持有它以觸發界面更新,而界面類實現它以繪製界面。

這兩個接口的引入,意義非凡:

接口把 做什麼(抽象) 和 怎麼做(細節) 分離。這個特性使得 關注點分離 成爲可能:接口持有者只關心 做什麼,而 怎麼做 留給接口實現者關心。

Activity 持有業務接口,這使得它不需要關心業務邏輯的實現細節。Activity 實現View 層接口,界面展示細節都內聚在 Activity 類中,使其成爲MVP中的V

Presenter 持有View 層接口,這使得它不需要關心界面展示細節。Presenter 實現業務接口,業務邏輯的實現細節都內聚在 Presenter 類中,使其成爲MVP中的P

這樣做最大的好處是降低代碼理解成本,因爲不同細節不再是在同一層次被鋪開,而是被分層了。閱讀代碼時,“淺嘗輒止”或“不求甚解”的閱讀方式極大的提高了效率。

這樣做還能縮小變更成本,業務需求發生變更時,只有Presenter類需要改動。界面調整時,只有V層需要改動。同理,排查問題的範圍也被縮小。

這樣還方便了自測,如果想測試各種臨界數據產生時界面的表現,則可以實現一個PresenterForTest。如果想覆蓋業務邏輯的各種條件分支,則可以方便地給Presenter寫單元測試(和界面隔離後,Presenter 是純 Kotlin 的,不含有任何 Android 代碼)。

NewsPresenter也不單純!它除了包含業務邏輯,還包含了訪問數據的細節,應該用同樣的思路,抽象出一個訪問數據的接口,讓Presenter持有,這就是MVP中的M。它的實現方式可以參考下一節的Repository

數據視圖互綁 + 長生命週期數據

即使將訪問數據的細節剝離出Presenter,它依然不單純。因爲它持有View 層接口,這就要求Presenter需瞭解 該把哪個數據傳遞給哪個接口方法,這就是 數據綁定,它在構建視圖時就已經確定(無需等到數據返回),所以這個細節可以從業務層剝離,歸併到視圖層。

Presenter的實例被 Activity 持有,所以它的生命週期和 Activiy 同步,即業務數據和界面同生命週期。在某些場景下,這是一個缺點,比如橫豎屏切換。此時,如果數據的生命週期不依賴界面,就可以免去重新獲取數據的成本。這勢必 需要一個生命週期更長的對象(ViewModel)持有數據。

生命週期更長的 ViewModel

上一節的例子中,構建Presenter是直接在Activitynew,而構建ViewModel是通過ViewModelProvider.get():

public class ViewModelProvider {
    // ViewModel 實例商店
    private final ViewModelStore mViewModelStore;
    
    public <T extends ViewModel> T get(@NonNull String key, @NonNull Class<T> modelClass) {
        // 從商店獲取 ViewModel實例
        ViewModel viewModel = mViewModelStore.get(key);

        if (modelClass.isInstance(viewModel)) {
            return (T) viewModel;
        } else {
            ...
        }
        // 若商店無 ViewModel 實例 則通過 Factory 構建
        if (mFactory instanceof KeyedFactory) {
            viewModel = ((KeyedFactory) (mFactory)).create(key, modelClass);
        } else {
            viewModel = (mFactory).create(modelClass);
        }
        // 將 ViewModel 實例存入商店
        mViewModelStore.put(key, viewModel);
        return (T) viewModel;
    }
}

ViewModelStoreViewModel實例存儲在HashMap中。

ViewModelStore通過ViewModelStoreOwner獲取:

// ViewModel 實例商店
public class ViewModelStore {
    // 存儲 ViewModel 實例的 Map
    private final HashMap<String, ViewModel> mMap = new HashMap<>();

    // 存
    final void put(String key, ViewModel viewModel) {
        ViewModel oldViewModel = mMap.put(key, viewModel);
        if (oldViewModel != null) {
            oldViewModel.onCleared();
        }
    }

    // 取
    final ViewModel get(String key) {
        return mMap.get(key);
    }
    
    ...
}

ViewModelStoreOwner實例又存儲在哪?

public class ViewModelProvider {
    // ViewModel 實例商店
    private final ViewModelStore mViewModelStore;
    
    // 構造 ViewModelProvider 時需傳入 ViewModelStoreOwner 實例
    public ViewModelProvider(@NonNull ViewModelStoreOwner owner, @NonNull Factory factory) {
        // 通過 ViewModelStoreOwner 獲取 ViewModelStore 
        this(owner.getViewModelStore(), factory);
    }

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

Activity就是ViewModelStoreOwner實例,且持有ViewModelStore實例,該實例還會被保存在一個靜態類中,所以ViewModel生命週期比Activity更長。這樣 ViewModel 中存放的業務數據就可以在Activity銷燬重建時被複用。

數據綁定

`MVVM`中Activity 屬於`V`層,佈局構建以及數據綁定都在這層完成:

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

    // 構建佈局
    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
            }
        }
    }

    // 構建 ViewModel 實例
    private val newsViewModel by lazy { 
        // 構造 ViewModelProvider 實例, 通過其 get() 獲得 ViewModel 實例
        ViewModelProvider(this, NewsFactory(applicationContext)).get(NewsViewModel::class.java) }

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

    // 將數據綁定到視圖
    private fun bindData() {
        newsViewModel.newsLiveData.observe(this, Observer {
            newsAdapter.news = it
            rvNews?.adapter = newsAdapter
        })
    }

    private fun initView() {
        rvNews?.layoutManager = LinearLayoutManager(this)
    }
}

其中構建佈局 DSL 的詳細介紹可以點擊這裏。它省去了原先V層( Activity + xml )中的xml

代碼中的數據綁定是通過觀察ViewModel中的LiveData實現的。這不是數據綁定的完全體,所以還需手動地觀察observe數據變化(只有當引入data-binding包後,才能把視圖和控件的綁定都靜態化到 xml 中)。但至少它讓ViewModel無需主動推數據了:

在 MVP 模式中,Presenter持有View 層接口並主動向界面數據。

MVVM模式中,ViewModel不再持有View 層接口,也不主動給界面數據,而是界面被動地觀察數據變化。

這使得ViewModel只需持有數據並根據業務邏輯更新之即可:

// 數據訪問接口在構造函數中注入
class NewsViewModel(var newsRepository: NewsRepository) : ViewModel() {
    // 持有業務數據
    val newsLiveData by lazy { newsRepository.fetchNewsLiveData() }
}

// 定義構造 ViewModel 方法
class NewsFactory(context: Context) : ViewModelProvider.Factory {
    // 構造 數據訪問接口實例
    private val newsRepository = NewsRepositoryImpl(context)
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        // 將數據接口訪問實例注入 ViewModel 
        return NewsViewModel(newsRepository) as T
    }
}

// 然後就可以在 Activity 中這樣構造 ViewModel 了
class MvvmActivity : AppCompatActivity() {
    // 構建 ViewModel 實例
    private val newsViewModel by lazy { 
        ViewModelProvider(this, NewsFactory(applicationContext)).get(NewsViewModel::class.java) }
}

ViewModel只關心業務邏輯和數據,不關心獲取數據的細節,所以它們都被數據訪問接口隱藏了。

Demo 業務場景中,ViewModel 只有一行代碼,那它還有存在的價值嗎?

有!即使在業務邏輯如此簡單的場景下還是有!因爲ViewModel生命週期比 Activity 長,其持有的數據可以在 Activity 銷燬重建時複用。

真實項目中的業務邏輯複雜度遠高於 Demo,應該將業務邏輯的細節隱藏在ViewModel中,讓界面類無感知。比如 “將服務器返回的時間戳轉化成年月日” 就應該寫在ViewModel中。

業務數據訪問接口

// 業務數據訪問接口
interface NewsRepository {
    // 拉取新聞並以 LiveData 方式返回
    fun fetchNewsLiveData():LiveData<List<News>?>
}

// 實現訪問網絡和數據庫的細節
class NewsRepositoryImpl(context: Context) : NewsRepository {
    // 使用 Retrofit 構建請求訪問網絡
    private val retrofit = Retrofit.Builder()
            .baseUrl("https://api.apiopen.top")
            .addConverterFactory(MoshiConverterFactory.create())
            // 將返回數據組織成 LiveData
            .addCallAdapterFactory(LiveDataCallAdapterFactory())
            .client(OkHttpClient.Builder().build())
            .build()

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

    private var executor = Executors.newSingleThreadExecutor()
    // 使用 room 訪問數據庫
    private var newsDatabase = NewsDatabase.getInstance(context)
    private var newsDao = newsDatabase.newsDao()

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

    override fun fetchNewsLiveData(): LiveData<List<News>?> {
        // 從數據庫獲取新聞
        val localNews = newsDao.queryNews()
        // 從網絡獲取新聞
        val remoteNews = newsApi.fetchNewsLiveData(
                mapOf("page" to "1", "count" to "4")
        ).let {
            Transformations.map(it) { response: ApiResponse<NewsBean>? ->
                when (response) {
                    is ApiSuccessResponse -> {
                        val news = response.body.result
                        news?.let {
                            // 將網絡新聞入庫
                            executor.submit { newsDao.insertAll(it) }
                        }
                        news
                    }
                    else -> null
                }
            }
        }
        // 將數據庫和網絡響應的 LiveData 合併
        newsLiveData.addSource(localNews) {
            newsLiveData.value = it
        }

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

        return newsLiveData
    }
}

這就是MVVM中的M,它定義了如何獲取數據的細節

Demo 中 數據庫和網絡都返回 LiveData 形式的數據,這樣合併兩個數據源只需要一個MediatorLiveData。所以使用了 Room 來訪問數據庫。並且定義了LiveDataCallAdapterFactory用於將 Retrofit 返回結果也轉化成 LiveData。(其源碼可以在這裏找到)

這裏也存在耦合:Repository需要了解 Retrofit 和 Room 的使用細節。

當訪問數據庫和網絡的細節越來越複雜,甚至又加入內存緩存時,再增加一層抽象,分別把訪問內存、數據庫、和網絡的細節都隱藏起來,也是常見的做法。這樣Repository中的邏輯就變成: “運用什麼策略將內存、數據庫和網絡的數據進行組合並返回給業務層”。

Clean Architecture

經多次重構,代碼結構不斷衍化,最終引入了ViewModelRepository。層次變多了,表面上看是越來越複雜了,但其實理解成本越來越低。因爲 所有複雜的細節並不是在同一層次被展開。

最後用 Clean architecture 再審視一下這套架構:

Entities

它是業務實體對象,對於 Demo 來說 Entities 就是新聞實體類News

Use Cases

它是業務邏輯,Entities 是名詞,Use Cases 就是用它造句。對於 Demo 來說 Use Cases 就是 “展示新聞列表” 在 Clean Architecture 中每一個業務邏輯都會被抽象成一個 UseCase 類,它被Presenters持有,詳情可以去這裏瞭解

Repository

它是業務數據訪問接口,抽象地描述獲取和存儲 Entities。和 Demo 中的 Repository 一模一樣,但在 Clean Architecture 中,它由 UseCase 持有。

Presenters

它和MVP模型中 Presenter 幾乎一樣,由它觸發業務邏輯,並把數據傳遞給界面。唯一的不同是,它持有 UseCase。

DB & API

它是抽象業務數據訪問接口的實現,和 Demo 中的NewsRepositoryImpl一模一樣。

UI

它是構建佈局的細節,就像 Demo 中的 Activity。

Device

它是和設備相關的細節,DB 和 UI 的實現細節也和設備有關,這裏的 Device是指除了數據和界面之外的和設備相關的細節,比如如何在通知欄展示通知。

依賴方向

洋蔥圈的內三層都是抽象,而只有最外層才包含實現細節(和 Android 平臺相關的實現細節。比如訪問數據庫的細節、繪製界面的細節、通知欄提醒消息的細節、播放音頻的細節)

洋蔥圈向內的箭頭意思是:外層知道相鄰內層的存在,而內層不知道外層的存在。即外層依賴內層,內層不依賴外層。也就說應該儘可能把業務邏輯抽象地實現,業務邏輯只需要關心做什麼,而不該關心怎麼做。這樣的代碼對擴展友好,當實現細節變化時,業務邏輯不需要變。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章