如何把业务代码越写越复杂? | MVP - MVVM - Clean Architecture

15,286 阅读12分钟

本文以一个真实项目的业务场景为载体,描述了经历一次次重构后,代码变得越来越复杂(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、协程、IntentService、RxJava。

  1. “细节” 增加耦合。 GodActivity 引入了大量本和它无关的类:Retrofit、Executors、ContentValues、Cursor、SQLiteDatabase、Response、OkHttpClient。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 是直接在 Activity 中 new,而构建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;
    }
}

ViewModel实例通过ViewModelStore获取:

// 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);
    }
    
    ...
}

ViewModelStoreViewModel实例存储在HashMap中。

ViewModelStore通过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;
    }
}

ViewModelStoreOwner实例又存储在哪?

// Activity 基类实现了 ViewModelStoreOwner 接口
public class ComponentActivity extends androidx.core.app.ComponentActivity implements
        LifecycleOwner,
        ViewModelStoreOwner,
        SavedStateRegistryOwner,
        OnBackPressedDispatcherOwner {
        
        // Activity 持有 ViewModelStore 实例
        private ViewModelStore mViewModelStore;
        
        public ViewModelStore getViewModelStore() {
            if (mViewModelStore == null) {
                // 获取配置无关实例
                NonConfigurationInstances nc =(NonConfigurationInstances) getLastNonConfigurationInstance();
                if (nc != null) {
                    // 从配置无关实例中恢复 ViewModel商店
                    mViewModelStore = nc.viewModelStore;
                }
                if (mViewModelStore == null) {
                    mViewModelStore = new ViewModelStore();
                }
            }
            return mViewModelStore;
        }
        
        // 静态的配置无关实例
        static final class NonConfigurationInstances {
            // 持有 ViewModel商店实例
            ViewModelStore viewModelStore;
            ...
        }
}

Activity 就是ViewModelStoreOwner实例,且持有ViewModelStore实例,该实例还会被保存在一个静态类中。

最终的持有链如下:NonConfigurationInstances 持有 ViewModelStore 持有 ViewModel。

所以 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 层接口,也不主动给界面推数据,而是界面被动地观察数据变化。

MVVM 这种更新界面的方式称为 “数据驱动”,即只需更新数据即可,因为界面会主动观察数据的变化并做出响应。

这使得 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
    }
}

Repository 提供了数据访问能力,隐藏了操纵网络和数据的实现细节。

MVVM 架构中并未明确强调 Repository 的存在,其中第一个 M 是Model,表示绘制界面的数据模型,也隐含着“数据驱动”的意思,即一个 Model 对应一种展示状态,而 Repository 描述了如何获取原始数据并传递给 ViewModel,在 ViewModel 中实现将原始数据转换成界面需要的 Model。

Demo 中 数据库和网络都返回 LiveData 形式的数据,这样合并两个数据源只需要一个MediatorLiveData。所以使用了 Room 来访问数据库。并且定义了LiveDataCallAdapterFactory用于将 Retrofit 返回结果也转化成 LiveData。

这里也存在耦合:Repository需要了解 Retrofit 和 Room 的使用细节。

当访问数据库和网络的细节越来越复杂,甚至又加入内存缓存时,再增加一层抽象,分别把访问内存、数据库、和网络的细节都隐藏起来,也是常见的做法。这样 Repository 中的逻辑就变成: “运用什么策略将内存、数据库和网络的数据进行组合并返回给业务层”。

Clean Architecture

经多次重构,代码结构不断衍化,最终引入了ViewModelRepository。层次变多了,表面上看是越来越复杂了,但其实理解成本越来越低。因为 所有复杂的细节并不是在同一层次被展开。

最后用 Clean architecture 再审视一下这套架构:

微信截图_20220415110337.png

Entities

它是业务实体对象,对应于 MVVM 中的 M,一个 Entity 应该对应界面中的一类业务逻辑。对于 Demo 来说 Entity 包含新闻News,并在还要包含新闻的加载状态loading以及errorMessage。这才构成了一个完成的 Entity,这个 Entity 可以表达新闻列表所有的 UI 状态:

data class NewsEntity(
    val news: List<News>,
    val loading: Boolean,
    val errorMessage: String
)

更有甚者,将 Entity 抽象为LCE,即 Loading, Content, Error:

sealed class Lce<out T> {
    open val data: T? = null
    abstract fun <R> map(f: (T) -> R): Lce<R>
    inline fun doOnData(f: (T) -> Unit) {
        if (this is Success) {
            f(data)
        }
    }
    // 成功的内容
    data class Success<out T>(override val data: T) : Lce<T>() {
        override fun <R> map(f: (T) -> R): Lce<R> = Success(f(data))
    }
    // 失败的错误
    data class Error(val message: String) : Lce<Nothing>() {
        constructor(t: Throwable) : this(t.message ?: "")

        override fun <R> map(f: (Nothing) -> R): Lce<R> = this
    }
    // 正在加载
    object Loading : Lce<Nothing>() {
        override fun <R> map(f: (Nothing) -> R): Lce<R> = this
    }
}

inline fun <S> lce(crossinline f: suspend () -> S): ActionsFlow<Lce<S>> {
    return actionsFlow {
        emit { Lce.Loading }
        try {
            val result = f()
            emit { Lce.Success(result) }
        } catch (e: Exception) {
            emit { Lce.Error(e) }
        }
    }
}

Use Cases

它是业务逻辑,Entities 是名词,Use Cases 就是用它造句。对于 Demo 来说 Use Cases 就是 “展示新闻列表” ,包括展示正常的新闻列表、网络请求失败的提示、以及正在加载的 loading。这才是 NewsUseCase 的全部业务逻辑。

在 Clean Architecture 中每一个业务逻辑都会被抽象成一个 UseCase 类,它被 Presenters 持有。就 Demo 演示的场景来看,Use Case 显得多余,因为只有“展示新闻”这一个业务逻辑,其实可以直接把业务逻辑写在 Presenter 中。但如果界面上还包括用户信息,得从另一个网络接口返回,还包括不一样的 loading 效果,以及默认展示效果。那就应该将其抽象为一个新的 UserInfoUseCase,它对应一个新的 UserInfoEntity。

Repository

它是业务数据访问接口,抽象地描述获取和存储 Entities。和 Demo 中的 Repository 一模一样。

但在 Clean Architecture 中,它由 UseCase 持有。

Presenters

它是业务逻辑的持有者及触发者,对应 MVP 中的 Presenter,MVVM 中的 ViewModel。

在 Clean Architecture 中因为不同类别的业务逻辑都被抽象到不同的 Use Case 中,所以 Presenter 层退化为 Use Case 的持有者。变得异常“clean”。新人接手代码时,只要一看 Presenter 中包含几个 Use Case 就可以快速了解业务框架。

DB & API

它是抽象业务数据访问接口的实现,和 Demo 中的NewsRepositoryImpl一模一样。

UI

它是构建布局的细节,就像 Demo 中的 Activity。

Device

它是和设备相关的细节,DB 和 UI 的实现细节也和设备有关,这里的 Device是指除了数据和界面之外的和设备相关的细节,比如如何在通知栏展示通知。

依赖方向

洋葱圈的内三层都是抽象,而只有最外层才包含实现细节(和 Android 平台相关的实现细节。比如访问数据库的细节、绘制界面的细节、通知栏提醒消息的细节、播放音频的细节)

洋葱圈向内的箭头意思是:外层知道相邻内层的存在,而内层不知道外层的存在。即外层依赖内层,内层不依赖外层。也就说应该尽可能把业务逻辑抽象地实现,业务逻辑只需要关心做什么,而不该关心怎么做。这样的代码对扩展友好,当实现细节变化时,业务逻辑不需要变。

参考

What is Clean Architecture in Android? - GeeksforGeeks

ArchitectureComponentsDemo/Lce.kt at master · fabioCollini/ArchitectureComponentsDemo (github.com)