我是怎么把业务代码越写越复杂的 | 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 平台相关的实现细节。比如访问数据库的细节、绘制界面的细节、通知栏提醒消息的细节、播放音频的细节)

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

猜你喜欢

转载自blog.csdn.net/Coo123_/article/details/106473690
今日推荐