Android架构设计 MVVM、MVI 应该是界面级别还是应用级别?

一,官方应用架构指南

1,架构原则

应用架构定义了应用的各个部分之间的界限以及每个部分应承担的职责。为了满足上述需求,您应该按照某些特定原则设计应用架构。

1,分离关注点

2,通过数据模型驱动界面

3,单一数据源

4,单向数据流

2,推荐的应用架构

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

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

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

界面层

界面层在架构中的作用

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

从数据层获取的应用数据的格式通常不同于您需要显示的信息的格式。例如,您可能只需要在界面中显示部分数据,或者可能需要合并两个不同的数据源,以便提供切合用户需求的信息。无论您应用的是什么逻辑,都需要向界面传递完全呈现界面所需的所有信息。界面层是一个流水线,负责将应用数据变化转换为界面可以呈现的形式,然后将其显示出来。

界面层的组成

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

  • 在屏幕上呈现数据的界面元素。您可以使用 View 或 Jetpack Compose 函数构建这些元素。
  • 用于存储数据、向界面提供数据以及处理逻辑的状态容器(如 ViewModel 类)。

界面层的架构设计

界面层架构设计以一个新闻页面的案例进行讲解:

定义界面状态

将完全呈现界面所需的信息封装在如下定义的 NewsUiState 数据类中:

kotlin 复制代码
data class NewsUiState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    val newsItems: List<NewsItemUiState> = listOf(),
    val userMessages: List<Message> = listOf()
)

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    ...
)

以上示例中的界面状态需要定义是不可变的。这样的主要好处是,不可变对象可保证即时提供应用的状态。这样一来,界面便可专注于发挥单一作用:读取状态并相应地更新其界面元素。因此,切勿直接在界面中修改界面状态,除非界面本身是其数据的唯一来源。违反这个原则会导致同一条信息有多个可信来源,从而导致数据不一致和轻微的 bug。

使用单向数据流管理状态

1,单向数据流

状态向下流动、事件向上流动的这种模式称为单向数据流 (UDF)。这种模式对应用架构的影响如下:

  • ViewModel 会存储并公开界面要使用的状态。界面状态是经过 ViewModel 转换的应用数据。
  • 界面会向 ViewModel 发送用户事件通知。
  • ViewModel 会处理用户操作并更新状态。
  • 更新后的状态将反馈给界面以进行呈现。

2,状态容器

符合以下条件的类称为状态容器:负责提供界面状态,并且包含执行相应任务所必需的逻辑。

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

公开界面状态

定义界面状态并确定如何管理相应状态的提供后,下一步是将提供的状态发送给界面。

由于您使用 UDF 管理状态的提供,因此您可以将提供的状态视为数据流,换句话说,随着时间的推移,将提供状态的多个版本。因此,您应在 LiveData 或 StateFlow 等可观察数据容器中公开界面状态。这样做是为了使界面可以对状态的任何变化做出反应,而无需直接从 ViewModel 手动拉取数据。这些类型还有一个好处是,始终缓存界面状态的最新版本。

arduino 复制代码
class NewsViewModel(...) : ViewModel() {

    val uiState: StateFlow<NewsUiState> = ...
}

创建 UiState 流的一种常用方法是,将后备可变数据流作为来自 ViewModel 的不可变数据流进行公开,例如将 MutableStateFlow 作为 StateFlow 进行公开。

scss 复制代码
class NewsViewModel(...) : ViewModel() {

    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    ...

}

这样一来,ViewModel 便可以公开在内部更改状态的方法,以便发布供界面使用的更新。以需要执行异步操作的情况为例,可以使用 viewModelScope 启动协程,并且可以在操作完成时更新可变状态。

kotlin 复制代码
class NewsViewModel(
    private val repository: NewsRepository,
    ...
) : ViewModel() {

    private val _uiState = MutableStateFlow(NewsUiState())
    val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()

    private var fetchJob: Job? = null

    fun fetchArticles(category: String) {
        fetchJob?.cancel()
        fetchJob = viewModelScope.launch {
            try {
                val newsItems = repository.newsItemsForCategory(category)
                _uiState.update {
                    it.copy(newsItems = newsItems)
                }
            } catch (ioe: IOException) {
                // Handle the error and notify the UI when appropriate.
                _uiState.update {
                    val messages = getMessagesFromThrowable(ioe)
                    it.copy(userMessages = messages)
                 }
            }
        }
    }
}

公开界面状态时还要考虑以下事项:

  • 界面状态对象应处理彼此相关的状态。这样可以减少不一致的情况,并让代码更易于理解。如果您在两个不同的数据流中分别公开新闻报道列表和书签数量,可能会发现其中一个已更新,但另一个没有更新。当您使用单个数据流时,这两个元素都会保持最新状态。此外,某些业务逻辑可能需要组合使用数据源。例如,可能只有在用户已登录并且是付费新闻服务订阅者时,您才需要显示书签按钮。**您可以按如下方式定义界面状态类:
kotlin 复制代码
data class NewsUiState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    val newsItems: List<NewsItemUiState> = listOf()
)

val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium

在此声明中,书签按钮的可见性是两个其他属性的派生属性。随着业务逻辑变得越来越复杂,拥有单个 UiState 类,并且其中的所有属性都是立即可用的,变得越来越重要。

  • 界面状态:单个数据流还是多个数据流? 是选择在单个数据流中还是在多个数据流中公开界面状态,关键指导原则是前面提到的要点:发出的内容之间的关系。在单个数据流中进行公开的最大优势是便捷性和数据一致性:状态的使用方随时都能立即获取最新信息。不过,在有些情况下,可能适合使用来自 ViewModel 的单独的状态流:

    • 不相关的数据类型:呈现界面所需的某些状态可能是完全相互独立的。在此类情况下,将这些不同的状态捆绑在一起的代价可能会超过其优势,尤其是当其中某个状态的更新频率高于其他状态的更新频率时。
    • UiState diffing:UiState 对象中的字段越多,数据流就越有可能因为其中一个字段被更新而发出。由于视图没有 diffing 机制来了解连续发出的数据流是否相同,因此每次发出都会导致视图更新。这意味着,可能必须要对 LiveData 使用 Flow API 或 distinctUntilChanged() 等方法来缓解这个问题。
使用界面状态

如需在界面中使用 UiState 对象流,您可以对所使用的可观察数据类型使用终端运算符。例如,对于 LiveData,您可以使用 observe() 方法;对于 Kotlin 数据流,您可以使用 collect() 方法或其变体。

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

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

数据层

数据层在架构中的作用

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

数据层的架构设计

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

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

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

公开 API

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

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

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

kotlin 复制代码
class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

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

多层存储库

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

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

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

数据层生命周期

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

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

数据层定位思考

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

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

二,MVVM

MVVM 架构图

MVVM 实现一个具体业务

举一个列表页面的例子,假设业务场景是这样的:

  • 首次进入列表时页面中先展示一个加载中 view。
  • 首次进入列表,触发请求网络数据,如果网络错误或者没有请求到数据,页面展示一个加载错误 view。
  • 列表具有刷新功能,如果刷新成功,直接替换全部列表数据;如果刷新失败,弹出一个 toast view。

按照 MVVM 架构实现如下:

界面层的实现

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

1,选择实现界面的元素

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

2,提供一个状态容器

这里使用 ViewModel 作为状态容器。ViewModel 是官方推荐的状态容器,而不是必须使用它作为状态容器。

3,定义界面状态

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

swift 复制代码
/**
* 加载失败 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,数据模型驱动界面

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

界面元素的实现:

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

}

状态容器的实现:

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

数据层的实现

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

kotlin 复制代码
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 的关系是这样:

kotlin 复制代码
class NewsViewModel : ViewModel() {

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

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

2,数据层应该是"不变的"

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

三,MVI

MVI 实现一个具体业务

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

界面层的实现

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

1,选择实现界面的元素

2,提供一个状态容器

3,定义界面状态

4,公开界面状态

5,使用/订阅界面状态

6,数据模型驱动界面

MVI 还需要遵循原则:

1,单一数据源

2,单项数据流

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

//todo

2,数据层的实现

参考上面 MVVM 的数据层介绍。

相关推荐
雨白5 小时前
Jetpack系列(二):Lifecycle与LiveData结合,打造响应式UI
android·android jetpack
kk爱闹7 小时前
【挑战14天学完python和pytorch】- day01
android·pytorch·python
每次的天空9 小时前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭9 小时前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日10 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安10 小时前
Android Library Maven 发布完整流程指南
android
岁月玲珑10 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio
还鮟14 小时前
CTF Web的数组巧用
android
小蜜蜂嗡嗡16 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi0016 小时前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体