我是怎么把业务代码越写越复杂的 | MVP - MVVM - Clean Architecture

本文以一个真实项目的业务场景为载体,描述了经历一次次重构后,代码变得愈来愈复杂(you ya)的过程。java

本篇 Demo 的业务场景是:从服务器拉取新闻并在列表展现。android

GodActivity

刚接触 Android 时,我是这样写业务代码的(省略了和主题无关的 Adapter 和 Api 细节):git

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 知道太多细节:github

  1. 异步细节
  2. 访问数据库细节
  3. 访问网络细节
  1. 若是大量 “细节” 在同一个层次被铺开,就显得啰嗦,增长理解成本。

拿说话打个比方:web

你问 “晚饭吃了啥?”数据库

“我用勺子一口一口地吃了鸡生下的蛋和番茄再加上油一块儿炒的菜。”编程

听了这样地回答,你还会和他作朋友吗?其实你并不关心他吃的工具、吃的速度、食材的来源,以及烹饪方式。api

  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;  } } 复制代码

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实例,该实例还会被保存在一个静态类中,因此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 平台相关的实现细节。好比访问数据库的细节、绘制界面的细节、通知栏提醒消息的细节、播放音频的细节)

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

本文使用 mdnice 排版

相关文章
相关标签/搜索