Android架构设计 搞懂应用架构设计原则,不要再生搬硬套的使用MVVM、MVI

4,005 阅读16分钟

首先,谷歌官方似乎并没有把自己建议的应用架构命名为 MVVM 或 MVI, MVVM 和 MVI是开发者根据不同时期官方应用架构指南的特点,达成的一个统一称谓。

对于学习这两个种架构,我们需要自己去理解官方应用架构指南,否则只能生搬硬套的使用他人理解的 MVVM 和 MVI。

首先看看现在最新的官方应用架构指南,是如何建议我们搭建应用架构的。

一,官方应用架构指南

1,架构的原则

应用架构定义了应用的各个部分之间的界限以及每个部分应承担的职责。谷歌建议按照以下原则设计应用架构。

分离关注点

通过数据模型驱动界面

单一数据源

单向数据流

2,谷歌推荐的应用架构

每个应用应至少有两个层:

  • 界面层 - 在屏幕上显示应用数据。
  • 数据层 - 包含应用的业务逻辑并公开应用数据。

可以额外添加一个名为“网域层”的架构层,以简化和重复使用界面层与数据层之间的交互。

总结下来,我们的应用架构应该有三层:界面层、网域层、数据层。

其中网域层可选,即无论你的应用中有没有网域层,与你的应用架构是 MVVM 还是 MVI 无关。

image.png

个人的理解是:界面层、网域层、数据层应该是应用级别的,而不是页面级别的。

如果我认为分层是页面级别的,那么我在接到一个业务需求 A 时(一般一个业务会新建一个页面activity 或 fragment 来承接),我的实现思路是:

  • 新建页面 A_Activity
  • 新建状态容器 A_ViewModel
  • 新建数据层 A_Repository

这样的话,数据层(A_Repository) 和 界面层(界面元素 A_Activity + 状态容器 A_ViewModel)强相关,数据层无复用性可言。

如果我认为分层是应用级别的,那么在接到业务A 时,实现思路是:

  • 新建页面 A_Activity
  • 新建状态容器 A_ViewModel
  • 首先从应用的数据层寻找业务 A 需要使用的 业务数据 是否有对应的存储仓库 Respository,如果有,则复用(A_ViewModel 中依赖 Repository),拿到业务数据后,转成 UI 数据;如果无,则创建 这种业务数据 的存储仓库 Repository,后续其他界面层如果页使用到这种业务数据,可以直接复用这种业务数据对应的 Repository。

2.1,界面层架构设计指导

界面层在架构中的作用

界面的作用是在屏幕上显示应用数据,并充当主要的用户互动点。

从数据层获取是业务数据,有时候需要界面层将业务数据转换成 UI 数据供界面元素显示。

界面层的组成

界面层由以下两部分组成:

  • 界面元素:在屏幕上呈现数据的界面元素可以使用 View 或 Jetpack Compose 函数实现。

  • 状态容器:用于存储数据、向界面提供数据以及处理逻辑的状态容器(如 ViewModel 类)。

image.png

界面层的架构设计遵循的原则

这里以一个常见的列表页面为案例进行讲解,这个列表页面有以下交互:

  • 打开页面时,网络数据回来之前展示一个加载中 view。
  • 首次打开页面,如果没有数据或者网络请求发生错误,展示一个错误 view。
  • 具备下拉刷新能力,刷新后,如果有数据,则替换列表数据;如果无返回数据,则弹出一个 Toast。

接着我们用这个业务,按照以下原则进行分析:

1, 定义界面状态

界面元素 加上 界面状态 才是用户看到的界面。

image.png

上面说的列表页面,根据它的业务需求,需要有以下界面状态

  • 展示加载中 view 的界面状态
  • 展示加载错误 view 的界面状态
  • 列表数据 view 界面状态
  • Toast view 界面状态
  • 刷新完成 view 界面状态

无论采用 MVVM 还是 MVI,都需要这些界面状态,只是他们的实现细节不同,具体可以看下面的讲解。

2,定义状态容器

状态容器:就是存放我们定义的界面状态,并且包含执行相应任务所必需的逻辑的类。

ViewModel 类型是推荐的状态容器,用于管理屏幕级界面状态,具有数据层访问权限。但并不是只能用 ViewModel作为状态容器。

无论采用 MVVM 还是 MVI,都需要定义状态容器,来存放界面状态。

3,使用单向数据流管理状态

看看官方在界面层的架构指导图:

image.png

界面状态数据流动是单向的,只能从 状态容器 到 界面元素。

界面发生的事件 events(如刷新、加载更多等事件)流动是单向的,只能从 界面元素 到 状态容器。

无论采用 MVVM 还是 MVI,都需要使用单向数据流管理状态。

4,唯一数据源

唯一数据源针对的是:定义的界面状态 和 界面发生的事件。

界面状态唯一数据源指的是将定义的多个界面状态,封装在一个类中,如上面的列表业务,不采用唯一数据源,界面状态的声明为:

/**
 * 加载失败 UI 状态,显示失败图
 * 首屏获取的数据为空、首屏请求数据失败时展示失败图
 * 初始值:隐藏
 */
val loadingError: StateFlow<Boolean>
    get() = _loadingError
private val _loadingError = MutableStateFlow<Boolean>(false)

/**
 * 正在加载 UI 状态,显示加载中图
 * 首屏时请求网络时展示加载中图
 * 初始值:展示
 */
val isLoading: StateFlow<Boolean>
    get() = _isLoading
private val _isLoading = MutableStateFlow<Boolean>(true)

/**
 * 加载成功后回来的列表 UI 状态,将 list 数据展示到列表上
 */
val newsList: StateFlow<MutableList<News>>
    get() = _newsList
private val _newsList = MutableStateFlow<MutableList<News>>(mutableListOf())

/**
 * 加载完成 UI 状态
 */
val loadingFinish: StateFlow<Boolean>
    get() = _loadingFinish
private val _loadingFinish = MutableStateFlow<Boolean>(false)

/**
 * 界面 toast UI 状态
 */
val toastMessage: StateFlow<String>
    get() = _toastMessage
private val _toastMessage = MutableStateFlow<String>("")

采用唯一数据源声明界面状态时,代码如下:

sealed interface NewsUiState  {

    object IsLoading: NewsUiState

    object LoadingError: NewsUiState

    object LoadingFinish: NewsUiState

    data class Success(val newsList: MutableList<News>): NewsUiState

    data class ToastMessage(val message: String = ""): NewsUiState

}


val newsUiState: StateFlow<NewsUiState>
    get() = _newsUiState

private val _newsUiState: MutableStateFlow<NewsUiState> =
    MutableStateFlow(NewsUiState.IsLoading)

界面发生的事件的唯一数据源指的是将界面发生的事件封装在一个类中,然后统一处理。比如上面描述的列表业务,它的界面事件有 初始化列表事件(首屏请求网络数据)、刷新事件、加载更多事件。

不采用唯一数据源,界面事件的调用实现逻辑为:在 activity 中直接调用 viewModel 提供的 initData、freshData 和 loadMoreData 方法;

采用唯一数据源,界面事件的调用实现逻辑为,先将事件中封装在一个 Intent 中,viewModel 中提供一个统一的事件入口处理方法 dispatchIntent,在 activity 中 各个场景下都调用 viewModel#dispatchIntent,代码如下:

sealed interface NewsActivityIntent {
    data class InitDataIntent(val type: String = "init") : NewsActivityIntent

    data class RefreshDataIntent(val type: String = "refresh") : NewsActivityIntent

    data class LoadMoreDataIntent(val type: String = "loadMore") : NewsActivityIntent
}

fun dispatchIntent(intent: NewsActivityIntent) {
    when (intent) {
        is NewsActivityIntent.InitDataIntent -> {
            //初始化逻辑
            initNewsData()
        }
        is NewsActivityIntent.RefreshDataIntent -> {
            //刷新逻辑
            refreshNewsData()
        }
        is NewsActivityIntent.LoadMoreDataIntent -> {
            //加载更多逻辑
            loadMoreNewsData()
        }
    }
}

因为有了唯一数据源这一特点,才将最新的应用架构称为 MVI,MVVM 不具备这一特点。

5,向界面公开界面状态的方式

在状态容器中定义界面状态后,下一步思考的是如何将提供的状态发送给界面。

谷歌推荐使用 LiveData 或 StateFlow 等可观察数据容器中公开界面状态。这样做的优点有:

  • 解耦界面元素(activity 或 fragment) 与 状态容器,如:activity 持有 viewModel 的引用,viewModel 不需要持有 activity 的引用。

无论采用 MVVM 还是 MVI,都需要向界面公开界面状态,公开的方式也可以是一样的。

6,使用界面状态

在界面中使用界面状态时,对于 LiveData,可以使用 observe() 方法;对于 Kotlin 数据流,您可以使用 collect() 方法或其变体。

注意:在界面中使用可观察数据容器时,需要考虑界面的生命周期。因为当未向用户显示视图时,界面不应观察界面状态。使用 LiveData 时,LifecycleOwner 会隐式处理生命周期问题。使用数据流时,最好通过适当的协程作用域和 repeatOnLifecycle API,如:

class NewsActivity : AppCompatActivity() {

    private val viewModel: NewsViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect {
                    // Update UI elements
                }
            }
        }
    }
}

无论采用 MVVM 还是 MVI,都需要使用界面状态,使用的方式都是一样的。

2.2,数据层架构设计指导

数据层在架构中的作用

数据层包含应用数据和业务逻辑。业务逻辑决定应用的价值,它由现实世界的业务规则组成,这些规则决定着应用数据的创建、存储和更改方式。

数据层的架构设计

数据层由多个仓库组成,其中每个仓库都可以包含零到多个数据源。您应该为应用中处理的每种不同类型的数据分别创建一个存储库类。例如,您可以为与电影相关的数据创建一个 MoviesRepository 类,或者为与付款相关的数据创建一个 PaymentsRepository 类。

每个数据源类应仅负责处理一个数据源,数据源可以是文件、网络来源或本地数据库。

层次结构中的其他层不能直接访问数据源;数据层的入口点始终是存储库类。

公开 API

数据层中的类通常会公开函数,以执行一次性的创建、读取、更新和删除 (CRUD) 调用,或接收关于数据随时间变化的通知。对于每种情况,数据层都应公开以下内容:

  • 一次性操作:在 Kotlin 中,数据层应公开挂起函数;对于 Java 编程语言,数据层应公开用于提供回调来通知操作结果的函数。

  • 接收关于数据随时间变化的通知:在 Kotlin 中,数据层应公开数据流,对于 Java 编程语言,数据层应公开用于发出新数据的回调。

class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

    suspend fun modifyData(example: Example) { ... }
}

多层存储库

在某些涉及更复杂业务要求的情况下,存储库可能需要依赖于其他存储库。这可能是因为所涉及的数据是来自多个数据源的数据聚合,或者是因为相应职责需要封装在其他存储库类中。

例如,负责处理用户身份验证数据的存储库 UserRepository 可以依赖于其他存储库(例如 LoginRepository 和 RegistrationRepository,以满足其要求。

image.png

注意:传统上,一些开发者将依赖于其他存储库类的存储库类称为 manager,例如称为 UserManager 而非 UserRepository。

数据层生命周期

如果该类的职责作用于应用至关重要,可以将该类的实例的作用域限定为 Application 类。

如果只需要在应用内的特定流程(例如注册流程或登录流程)中重复使用同一实例,则应将该实例的作用域限定为负责相应流程的生命周期的类。例如,可以将包含内存中数据的 RegistrationRepository 的作用域限定为 RegistrationActivity。

数据层定位思考

数据层不应该是页面级别的(一个页面对应一个数据层),而应该是应用级别的(数据层有多个存储仓库,每种数据类型有一个对应的存储仓库,不同的界面层可以复用存储仓库)。

比如我做的应用是运动健康app,用户的睡眠相关的数据有一个 SleepResposity,用户体重相关的数据有一个 WeightReposity,由于应用中很多界面都可能需要展示用户的睡眠数据和体重数据,所以 SleepResposity 和 WeightReposity 可以供不同界面层使用。

二,MVVM

1,MVVM 架构图

image.png

2,MVVM 实现一个具体业务

使用上面提到的列表页面业务,按照 MVVM 架构实现如下:

2.1,界面层的实现

界面层实现时,需要遵循以下几点。

1,选择实现界面的元素

界面元素可以用 view 或 compose 来实现,这里用 view 实现。

2,提供一个状态容器

这里使用 ViewModel 作为状态容器;状态容器用来存放界面状态变量;ViewModel 是官方推荐的状态容器,而不是必须使用它作为状态容器。

3,定义界面状态

这个需求中我们根据业务描述,定义出多个界面状态。

/**
* 加载失败 UI 状态,显示失败图
* 首屏获取的数据为空、首屏请求数据失败时展示失败图
* 初始值:隐藏
*/
val loadingError: StateFlow<Boolean>
   get() = _loadingError
private val _loadingError = MutableStateFlow<Boolean>(false)

/**
* 正在加载 UI 状态,显示加载中图
* 首屏时请求网络时展示加载中图
* 初始值:展示
*/
val isLoading: StateFlow<Boolean>
   get() = _isLoading
private val _isLoading = MutableStateFlow<Boolean>(true)

/**
* 加载成功后回来的列表 UI 状态,将 list 数据展示到列表上
*/
val newsList: StateFlow<MutableList<News>>
   get() = _newsList
private val _newsList = MutableStateFlow<MutableList<News>>(mutableListOf())

/**
* 加载完成 UI 状态
*/
val loadingFinish: StateFlow<Boolean>
   get() = _loadingFinish
private val _loadingFinish = MutableStateFlow<Boolean>(false)

/**
* 界面 toast UI 状态
*/
val toastMessage: StateFlow<String>
   get() = _toastMessage
private val _toastMessage = MutableStateFlow<String>("")

4,公开界面状态

这里选择数据流 StateFlow 公开界面状态。当然也可以选择 LiveData 公开界面状态。

5,使用/订阅界面状态

我这里使用的是数据流 StateFlow 公开的界面状态,所以在界面层相对应的使用 flow#collect 订阅界面状态。

6,数据模型驱动界面

结合上面几点,界面层的实现代码为:

界面元素的实现:

class NewsActivity: ComponentActivity() {

    private var mBinding: ActivityNewsBinding? = null
    private var mAdapter: NewsListAdapter? = null
    private val mViewModel = NewsViewModel()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        mBinding = ActivityNewsBinding.inflate(layoutInflater)
        setContentView(mBinding?.root)
        initView()
        initObserver()
        initData()
    }

    private fun initView() {
        mBinding?.listView?.layoutManager = LinearLayoutManager(this)
        mAdapter = NewsListAdapter()
        mBinding?.listView?.adapter = mAdapter

        mBinding?.refreshView?.setOnRefreshListener {
            mViewModel.refreshNewsData()
        }
    }

    private fun initData() {
        mViewModel.getNewsData()
    }

    private fun initObserver() {
        lifecycleScope.launch {
            lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
                launch {
                    mViewModel.isLoading.collect {
                        if (it) {
                            mBinding?.loadingView?.visibility = View.VISIBLE
                        } else {
                            mBinding?.loadingView?.visibility = View.GONE
                        }
                    }
                }
                launch {
                    mViewModel.loadingError.collect {
                        if (it) {
                            mBinding?.loadingError?.visibility = View.VISIBLE
                        } else {
                            mBinding?.loadingError?.visibility = View.GONE
                        }
                    }
                }
                launch {
                    mViewModel.loadingFinish.collect {
                        if (it) {
                            mBinding?.refreshView?.isRefreshing = false
                        }
                    }
                }
                launch {
                    mViewModel.toastMessage.collect {
                        if (it.isNotEmpty()) {
                            showToast(it)
                        }
                    }
                }
                launch {
                    mViewModel.newsList.collect {
                        if (it.isNotEmpty()) {
                            mBinding?.loadingError?.visibility = View.GONE
                            mBinding?.loadingView?.visibility = View.GONE
                            mBinding?.refreshView?.visibility = View.VISIBLE
                            mAdapter?.setData(it)
                        }
                    }
                }
            }
        }
    }

}

状态容器的实现:

class NewsViewModel : ViewModel() {

    private val repository = NewsRepository()

    /**
     * 加载失败 UI 状态,显示失败图
     * 首屏获取的数据为空、首屏请求数据失败时展示失败图
     * 初始值:隐藏
     */
    val loadingError: StateFlow<Boolean>
        get() = _loadingError
    private val _loadingError = MutableStateFlow<Boolean>(false)

    /**
     * 正在加载 UI 状态,显示加载中图
     * 首屏时请求网络时展示加载中图
     * 初始值:展示
     */
    val isLoading: StateFlow<Boolean>
        get() = _isLoading
    private val _isLoading = MutableStateFlow<Boolean>(true)

    /**
     * 加载成功后回来的列表 UI 状态,将 list 数据展示到列表上
     */
    val newsList: StateFlow<MutableList<News>>
        get() = _newsList
    private val _newsList = MutableStateFlow<MutableList<News>>(mutableListOf())

    /**
     * 加载完成 UI 状态
     */
    val loadingFinish: StateFlow<Boolean>
        get() = _loadingFinish
    private val _loadingFinish = MutableStateFlow<Boolean>(false)

    /**
     * 界面 toast UI 状态
     */
    val toastMessage: StateFlow<String>
        get() = _toastMessage
    private val _toastMessage = MutableStateFlow<String>("")

    fun getNewsData() {
        viewModelScope.launch(Dispatchers.IO) {
            val list = repository.getNewsList()
            if (list.isNullOrEmpty()) {
                _loadingError.emit(true)
            } else {
                _newsList.emit(list)
            }
        }
    }

    fun refreshNewsData() {
        viewModelScope.launch(Dispatchers.IO) {
            val list = repository.getNewsList()
            _loadingFinish.emit(true)
            if (list.isNullOrEmpty()) {
                _toastMessage.emit("暂时没有更新数据")
            } else {
                _newsList.emit(list)
            }
        }
    }
}

2.2,数据层的实现

这里的数据层只有一个新闻列表数据结构的存储仓库 NewsRepository,另外获取新闻信息属于一次性操作,根据数据层架构设计,直接使用 suspend 就好。

class NewsRepository {

    suspend fun getNewsList(): MutableList<News>? {
        delay(2000)

        val list = mutableListOf<News>()
        val news = News("标题", "描述信息")
        list.add(news)
        list.add(news)
        list.add(news)
        list.add(news)
        return list
    }
}

个人的一些理解:

1, 数据层不应该是界面级别的,而应该是应用级别的

数据层不应该是界面级别的,即一个页面对应一个 Repository;数据层应该是应用级别的,即一个应用有一个或多个数据层,每个数据层中有多个存储仓库 Respository,存储仓库可以在不同的界面层复用。

之前我一直认为,一个页面对应一个数据层,一个页面对应一个 Repository。但后来发现这种理解不太对。上面的例子中 NewsViewModel 只用到 NewsRepository,是因为这个新闻列表业务中只用到新闻列表数据这种数据,假如列表中还可以点赞 那我们就需要新建一种点赞存储仓库 LikeRepository,来处理点赞数据,这时 NewsViewModel 与 Repository 的关系是这样:

class NewsViewModel : ViewModel() {

    private val newsRepository = NewsRepository()
    private val likeRepository = LikeRepository()
}
    

数据层提供的 新闻列表数据处理能力 NewsRepository 和 点赞数据处理能力 LikeRepository,应该是应用界别的,可以供不同的界面复用。

2,数据层应该是“不变的”

这里的不变不是说数据层的业务逻辑不变,而是指无论是 MVP、MVVM 还是 MVI,他们应该可以共用数据层。

2.3,网域层的实现

网域层是可选的,是否具备网域层,跟架构是否为 MVVM 无关,这个案例中不适用网域层。

三,MVI

1,MVI 架构图

image.png

2,MVI 实现一个具体业务

同样使用上面 MVVM 实现的新闻业务。按照 MVI 架构实现如下:

2.1,界面层的实现

除了和 MVVM 遵循以下几点相同原则之外:

1,选择实现界面的元素

2,提供一个状态容器

3,定义界面状态

4,公开界面状态

5,使用/订阅界面状态

6,单向数据流

MVI 还需要遵循原则:

1,单一数据源

所以 MVI 需要:1,把界面状态聚合起来;2,把界面事件聚合起来。

综合上面的原则,采用 MVI 实现界面的实现如下:

界面元素、聚合界面状态、聚合界面事件 代码:

sealed interface NewsUiState  {

    object IsLoading: NewsUiState

    object LoadingError: NewsUiState

    object LoadingFinish: NewsUiState

    data class Success(val newsList: MutableList<News>): NewsUiState

    data class ToastMessage(val message: String = ""): NewsUiState

}


sealed interface NewsActivityIntent {
    data class InitDataIntent(val type: String = "init") : NewsActivityIntent

    data class RefreshDataIntent(val type: String = "refresh") : NewsActivityIntent

    data class LoadMoreDataIntent(val type: String = "loadMore") : NewsActivityIntent
}

class NewsActivity: ComponentActivity() {

    private var mBinding: ActivityNewsBinding? = null
    private var mAdapter: NewsListAdapter? = null
    private val mViewModel = NewsViewModel()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        mBinding = ActivityNewsBinding.inflate(layoutInflater)
        setContentView(mBinding?.root)
        initView()
        initObserver()
        initData()
    }

    private fun initView() {
        mBinding?.listView?.layoutManager = LinearLayoutManager(this)
        mAdapter = NewsListAdapter()
        mBinding?.listView?.adapter = mAdapter

        mBinding?.refreshView?.setOnRefreshListener {
            mViewModel.dispatchIntent(NewsActivityIntent.RefreshDataIntent())
        }
    }

    private fun initData() {
        mViewModel.dispatchIntent(NewsActivityIntent.InitDataIntent())
    }

    private fun loadMoreData() {
        mViewModel.dispatchIntent(NewsActivityIntent.LoadMoreDataIntent())
    }

    private fun initObserver() {
        lifecycleScope.launch {
            lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
                launch {
                    mViewModel.newsUiState.collect {
                        //更新UI
                    }
                }
            }
        }
    }

}

状态容器代码:

class NewsViewModel : ViewModel() {

    private val repository = NewsRepository()

    val newsUiState: StateFlow<NewsUiState>
        get() = _newsUiState

    private val _newsUiState: MutableStateFlow<NewsUiState> =
        MutableStateFlow(NewsUiState.IsLoading)

    fun dispatchIntent(intent: NewsActivityIntent) {
        when (intent) {
            is NewsActivityIntent.InitDataIntent -> {
                //初始化逻辑
                initNewsData()
            }
            is NewsActivityIntent.RefreshDataIntent -> {
                //刷新逻辑
                refreshNewsData()
            }
            is NewsActivityIntent.LoadMoreDataIntent -> {
                //加载更多逻辑
                loadMoreNewsData()
            }
        }
    }

    /**
     * 初始化
     */
    private fun initNewsData() {
        viewModelScope.launch(Dispatchers.IO) {
            val list = repository.getNewsList()
            if (list.isNullOrEmpty()) {
                _newsUiState.emit(NewsUiState.LoadingError)
            } else {
                _newsUiState.emit(NewsUiState.Success(list))
            }
        }
    }

    /**
     * 刷新
     */
    private fun refreshNewsData() {
       viewModelScope.launch(Dispatchers.IO) {
           val list = repository.getNewsList()
           _newsUiState.emit(NewsUiState.LoadingFinish)
           if (list.isNullOrEmpty()) {
               _newsUiState.emit(NewsUiState.ToastMessage("暂时没有新数据"))
           } else {
               _newsUiState.emit(NewsUiState.Success(list))
           }
       }
    }

    /**
     * 没有实现
     */
    private fun loadMoreNewsData() {

    }
    
}

2.2,数据层与网域层的实现

界面层:参考上面 MVVM 的数据层介绍,无论 MVP、MVVM、MVI,不同应用架构的数据层应该是不变的,即通用。

网域层:应用架构是否具备网域层不影响它是什么类型的架构,这里的列表业务没有网域层。