这篇文章要解决的问题是“MVPS是什么?”
本文的写作背景:我要参加的新项目中,使用了MVPS架构模式。在此之前,我没有做过Android UI开发,也很少使用Kotlin语言,同时完全不了解MVPS是怎么回事。因此我带着这个问题,和豆包展开对话,针对豆包给出的答案中不理解的概念和原理进行追问,最终弄懂了MVPS是怎么回事。我把学习到的内容梳理成文,以便以后遗忘时可以快速回忆。如果对屏幕前的你有所帮助,那就更好了~
本文的主要内容:从问题出发,以点带面,通过一个简化的MVPS示例代码,在逐行阅读代码的过程中,学习到了MVPS的原理、架构、用法,kotlin的实现,以及其中常用的一些工具类例如state、flow、协程、suspend函数等。
now let‘s start
我们先来看一些概括性的概念,如果感到这一段有些抽象,没关系,可以先看示例代码和后面的代码解析,再回来看这里,就容易理解了。
MVPS基本概念
首先,MVPS (Model-View-Presenter-State)是 Android 开发架构模式中的一种。
MVPS 通过 单向数据流(View → Presenter → State → View)实现严格的状态管理。
我对MVPS各个模块的之间的交互关系总结如下:View监听到用户操作,调用Presenter进行处理,Presenter驱动Model拿到数据,Presenter更新state,View自动感知到State变化并更新显示。 打个比方,View是瞭望台上放哨和往外发信号的,Presenter是指挥官,Model是一线小工,State是一块黑板,Presenter往黑板上写好字View马上自动往外发。
MVPS 架构的核心组件
Model: 负责数据处理(如网络请求、数据库操作)
View: 通常是 Activity/Fragment,负责 UI 展示和用户交互(如按钮点击、输入框输入),并将用户操作传递给 Presenter 处理。它订阅 State 的变化并更新 UI。
Presenter: 作为 View 和 Model 的桥梁,处理业务逻辑。它接收 View 的用户操作,调用 Model 处理数据,并根据数据更新 State。
State: 是一个不可变的数据容器,保存 UI 的所有状态(如加载中、成功、错误等)。Presenter 通过更新 State 来通知 View 刷新(实际上是用StateFlow实现实时监控的响应式编程)。
MVPS示例代码
程序员的世界,代码比文字要更直观,我们来看一个简化的MVPS示例。
// 1. State 层:保存 UI 所有状态
data class UserState(
val isLoading: Boolean = false, // 是否正在加载数据
val user: User? = null, // 用户数据(可为空)
val error: String? = null // 错误信息(可为空)
)
// 2. Model 层:数据获取与处理
interface UserRepository {
suspend fun fetchUser(userId: String): User // 挂起函数:在协程中异步获取用户数据
}
// 3. Presenter 层:处理业务逻辑(不继承ViewModel)
class UserPresenter(
private val repository: UserRepository,
private val coroutineScope: CoroutineScope // XXX 注入协程作用域
) {
private val _state = MutableStateFlow(UserState())
val state: StateFlow<UserState> = _state
// 获取用户数据的方法
fun loadUser(userId: String) {
coroutineScope.launch(Dispatchers.IO) { // 使用注入的作用域启动协程 // 指定IO调度器
_state.update { it.copy(isLoading = true) }
try {
val user = repository.fetchUser(userId)
withContext(Dispatchers.Main) { // XXX 切换回主线程更新状态
_state.update {
it.copy(
isLoading = false,
user = user,
error = null
)
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
_state.update {
it.copy(
isLoading = false,
error = e.message ?: "获取用户数据失败" // XXX 空安全处理
)
}
}
}
}
}
}
// 4. View 层(Activity)
class UserActivity : AppCompatActivity() {
private val presenter by lazy {
UserPresenter(
repository = UserRepositoryImpl(),
coroutineScope = lifecycleScope // XXX 使用Activity的生命周期作用域
)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_user)
// 订阅 State 变化
lifecycleScope.launchWhenStarted {
presenter.state.collect { state ->
updateUI(state)
}
}
// 触发加载
presenter.loadUser("123")
}
private fun updateUI(state: UserState) {
// 根据 State 更新 UI
progressBar.visibility = if (state.isLoading) View.VISIBLE else View.GONE
if (state.user != null) {
userTextView.text = state.user.name
}
if (state.error != null) {
errorTextView.text = state.error
}
}
}
state
总结:
1、state类中所有成员定义成val
2、更新状态时,通过
data class
自带的copy()
方法创建新对象
开局就是一个State,这个对象在后续其他类中都有大量访问,我们先看看它是个啥。答:UserState是一个自定义的数据类对象,用来存放状态数据
// 1. State 定义
data class UserState(
val isLoading: Boolean = false,
val user: User? = null,
val error: String? = null
)
data class
是 Kotlin 中一种特殊的类,专门用于存储数据。
可以注意到UserState中的所有成员都是val(即不可变数据),这可不是随便写的,实际上不论是数据类的设计初衷、不可变性原则、还是状态管理的最佳实践,都要求这样做,这样做有以下好处:
- 对象状态在创建后不可变,避免因意外修改导致的数据不一致。
- 天然支持线程安全(无需额外同步机制)。
- 可追溯性:每次状态变更都生成新对象,便于追踪状态变化历史。
- 适配响应式编程:与 Flow、LiveData 等响应式框架无缝配合,确保状态变更触发 UI 刷新。
我们当前的这个场景(state/ Flow/响应式编程)尤其适合这个设计思路,总之你记得 【state类中所有成员定义成val】 就对了。
既然val成员不可变,那么更新状态的时候怎么办呢?答案如下——
当需要更新状态时,通过 data class
自带的 copy()
方法创建新对象,而非修改旧对象:
// 正确做法:通过 copy() 创建新状态
val oldState = UserState(isLoading = true)
val newState = oldState.copy(isLoading = false, user = loadedUser)
// 错误做法(若使用 var):直接修改状态可能导致逻辑混乱
oldState.isLoading = false // 隐藏的状态变更难以追踪
tips:顺便说一下,data class的copy方法得到的对象newState中的成员,如果参数设置了就用新值,没设置的会用oldState中的值~所以参数中只填写要变化的成员即可,不变的不用写。
现在你一定能看懂示例中的这段代码了吧~这就是在更新状态呀
_state.value = _state.value.copy(
isLoading = false,
user = user,
error = null
)
Flow
接下来迎面而来的代码是:
class UserPresenter(...) {
private val _state = MutableStateFlow(UserState())
val state: StateFlow<UserState> = _state
这是在干嘛?MutableStateFlow是个啥东西?它为什么要以UserState为参数?_state和state是什么关系?StateFlow又是啥?
如果你也一头雾水,说明你需要了解一下Flow的概念。Flow在MVPS架构中太关键了,如果不了解它的原理,代码肯定是看不懂的。你可以根据自己的疑问逐级查询下面的概念,目录能够帮助你导航。
Kotlin Flow
上面的例子中使用 Kotlin Flow 实现数据和状态的管理。
核心操作符:
flow { ... }
:创建 Flow。emit(value)
:发送数据项delay(time)
:暂停协程执行(非阻塞)。collect { ... }
:收集数据。
Flow 属于Kotlin协程库(kotlinx-coroutines-core)的一部分,需添加依赖:
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
使用Kotlin Flow 无需额外依赖第三方库,只要项目引入了 Kotlin 协程即可使用。是现代 Kotlin 项目(尤其是 Android 开发)处理异步数据的首选方案。
如果不用Kotlin Flow,还可以使用LiveData(Jetpack 组件)、RxJava(跨平台响应式库)、回调机制(传统方案)。但要么需要引入第三方库,要么用起来复杂。现代 Android 项目,推荐优先使用Kotlin Flow。我们这里也不多做展开,仅研究使用Flow的情况。
热流和冷流
可以比喻为水龙头的两种模式:
1、冷数据流:按需放水的水龙头
只有当 “收集者”(杯子)开始接水时,数据才会产生。就像水龙头只有在被打开时才放水,关闭时停止。例如:
- 数据库查询:只有当程序执行查询语句时,才会从数据库获取数据(冷流按需生成)。
- 文件读取:只有当程序调用 “读取文件” 方法时,才会逐行读取数据(数据生成依赖收集动作)。
2、热数据流:一直放水的水龙头。
无论是否有 “收集者”(杯子),数据都会持续产生。就像水龙头一直开着,即使没人接水,水也会流走(数据会被丢弃或保留最新值)。例如:
- 实时天气数据:不管有没有人查看,气象局都会持续更新数据(热流持续产生)。
- 直播视频流:不管有没有观众,摄像头都在持续录制(数据持续生成)。
代码示例:
Tips:使用 flow { ... }
创建的是冷流,使用MutableStateFlow
或 StateFlow
创建的是热流。还有其他创建方式这里不提了
1、冷流:调用 collect
触发数据生成
val coldFlow = flow {
println("开始生成数据")
emit(1)
delay(1000)
emit(2)
}
// 第一次收集:重新执行上游代码
coldFlow.collect { println(it) } // 输出: 开始生成数据、1、2
// 第二次收集:再次执行上游代码
coldFlow.collect { println(it) } // 再次输出: 开始生成数据、1、2
2、热流:无论是否 collect,数据都会生成,hotFlow.value的值发生变化时,各个hotFlow.collect{}中的代码就会被触发执行
val hotFlow = MutableStateFlow(0) // 初始值为0
// 收集者1
lifecycleScope.launch {
hotFlow.collect { println("收集者1: $it") }
}
// 更新数据(无需collect)
hotFlow.value = 1 // 收集者1会收到1
// 新收集者2
lifecycleScope.launch {
hotFlow.collect { println("收集者2: $it") } // 立即收到最新值1
}
tips:emit
是冷流(Flow
)专用的操作符,用于在流中发送数据项。热流(如 StateFlow
)不使用 emit
,而是通过 value
属性直接更新状态。
MutableStateFlow(UserState())
MutableStateFlow
是Kolin协程库提供的典型 热流(Hot Flow) 实现
MutableStateFlow
是一个泛型类,可以传递任意类型的数据,例如:
// 整型
val intFlow = MutableStateFlow(0)
intFlow.value = 100
// 字符串
val stringFlow = MutableStateFlow("初始值")
stringFlow.value = "新文本"
// 自定义数据类
data class User(val name: String, val age: Int)
val userFlow = MutableStateFlow(User("张三", 25))
userFlow.value = User("李四", 30)
// 集合类型
val listFlow = MutableStateFlow(listOf<String>())
listFlow.value = listOf("A", "B", "C")
好了,现在我们知道了,MVPS-UserPresenter中下面一行代码的含义就是:创建一个热流对象_state,其中管理的是UserState类型的状态数据,每当状态值更新 的时候(即Presenter调用_state.update)
// 3. Presenter 层(业务逻辑)
class UserPresenter(
...
private val _state = MutableStateFlow(UserState())
就会自动触发View中以下presenter.state.collect{}中的代码,从而实现ui显示效果随着状态变化自动更新~
// 4. View 层(Activity)
class UserActivity : AppCompatActivity() {
...
presenter.state.collect { state ->
updateUI(state)
}
collect
StateFlow.collect()
是一个 suspend 函数,必须在协程作用域中调用。它的工作原理是:
- 协程启动后,
collect
会挂起当前协程,不会阻塞线程 - 当
StateFlow
的值发生变化时,协程恢复执行,执行collect
内的代码块 - 协程取消时(如 Activity 销毁),
collect
自动停止收集
_state.update
最后再来熟悉一下状态更新的语法
_state.update { it.copy(isLoading = true) }
推荐**update + copy
组合使用**,这是一种标准组合用法,常用于 安全更新状态。update
确保原子性,copy
负责生成新对象。标准用法示例:
// 点击加载按钮时更新状态
button.setOnClickListener {
_state.update { state ->
state.copy(isLoading = true) // 仅修改 isLoading 属性
}
}
update
是 MutableStateFlow
提供的方法,update
保证状态值的修改是原子操作,避免多线程冲突。接收一个 Lambda 表达式,参数为当前状态值,返回新状态值。
copy
是 Kotlin 数据类(data class) 的内置方法,负责创建新对象。
copy
也可独立使用,以下两种写法基本是等价的,但仅用copy无法保证原子性,多线程环境下可能导致竞态条件(两次赋值覆盖)。
// 原始写法
_state.value = _state.value.copy(isLoading = true)
// 等价于 update 方法
_state.update { currentState ->
currentState.copy(isLoading = true)
}
一对StateFlow实现单向数据流
代码里能看到有两个state成员,区别是一个私有可变,一个公开不可变。这体现了单向数据流的设计原则。
class UserPresenter(...) {
private val _state = MutableStateFlow(UserState()) // 可变状态流:用于内部更新状态
val state: StateFlow<UserState> = _state // 不可变状态流:外部只能观察,不能修改
内部使用 MutableStateFlow:在 ViewModel 或业务逻辑层管理和修改状态。
外部暴露 StateFlow:在 View 层或接口中提供只读访问,确保状态的单向流动(Present→ View)
特性 | MutableStateFlow | StateFlow |
---|---|---|
可变性 | 可变(Mutable) | 只读(Read-only) |
修改方式 | 通过 value 或 update 直接修改状态 | 无法直接修改,只能观察状态变化 |
适用角色 | 作为状态的 生产者(通常在 ViewModel 内部) | 作为状态的 消费者(暴露给 View 层) |
tips:StateFlow创建的也是热流
tips:私有前缀:在 Kotlin 代码中,使用 _
作为私有属性前缀(如 _uiState
)是一种常见的命名约定,其核心目的是明确区分内部可变状态与外部只读接口,并强化单向数据流的设计原则。
协程
示例代码中出现了很多带有Scope、launch、suspend等的关键字,它们是协程相关的概念,我们来看看它们的含义以及用法。
协程是 Kotlin 中轻量级 “线程”,基于挂起函数实现非阻塞编程,比传统线程更省资源。 常用方法:
suspend
:标记挂起函数,可暂停恢复。launch
:启动协程,返回 Job。async
:启动协程并返回 Deferred 获取结果。withContext
:切换协程执行线程。- 作用域如
viewModelScope
、lifecycleScope
管理生命周期。
注入coroutineScope的原理
CoroutineScope
是 Kotlin 协程中的核心接口,用于管理协程的生命周期。
在 Kotlin 协程的最佳实践中,推荐大多数使用协程的类通过外部注入CoroutineScope
。我们的示例代码就是这样做的。
// 3. Presenter 层:处理业务逻辑(不继承ViewModel)
class UserPresenter(
private val repository: UserRepository,
private val coroutineScope: CoroutineScope // 注入协程作用域
) {
...
// 获取用户数据的方法
fun loadUser(userId: String) {
coroutineScope.launch(Dispatchers.IO) { // 使用注入的作用域启动协程 // 指定IO调度器
...// 协程逻辑
示例代码中,可以看到UserPresenter的构造函数有一个类型为 CoroutineScope
的参数coroutineScope
。
用于将协程作用域(即协程的生命周期)从外部注入到 Presenter 内部,即协程作用域由外部指定。
注入的作用域,一般与组件(如 Activity、ViewModel)的生命周期绑定。当宿主销毁时,作用域自动取消所有协程,避免内存泄漏。如果直接在类内部创建 CoroutineScope
,可能导致内存泄漏(例如 Activity 销毁后协程仍在运行)。
常见注入的作用域类型:
优先使用 Android 提供的 lifecycleScope
和 viewModelScope
(ViewModel 专属),或在自定义组件中手动管理作用域。
作用域类型 | 生命周期绑定对象 | 自动取消时机 |
---|---|---|
lifecycleScope | Activity/Fragment | 组件销毁时(onDestroy) |
viewModelScope | ViewModel | ViewModel 清除时(onCleared) |
GlobalScope | 应用进程 | 进程结束(不推荐使用) |
自定义作用域 | 手动管理 | 调用 cancel () |
View层负责注入lifecycleScope
在我们的例子中,coroutineScope是谁注入的呢?
可以看到是Activity(MVPS中的View层),由Activity在创建presenter的时候去指定,具体使用的是lifecycleScope,则Presenter 内部协程的生命周期会与外部组件(如 Activity)绑定,当外部组件销毁时,注入的作用域会自动取消协程。
// 4. View 层(Activity)
class UserActivity : AppCompatActivity() {
private val presenter by lazy {
UserPresenter(
repository = UserRepositoryImpl(),
coroutineScope = lifecycleScope //使用Activity的生命周期作用域
)
}
tips:除了注入coroutineScope,还有一种方法是让UserPresenter继承ViewModel,则viewModelScope
自动与 ViewModel 生命周期绑定,无需手动注入。这种方式更简单快速。但注入法更灵活,不依赖 Android 组件,适用于跨平台架构。这里不多深挖,用到的时候如果需要再自行查询选择。
class UserPresenter : ViewModel() { // 继承 ViewModel
Present层负责使用Scope
- Presenter 无需关心作用域的创建和销毁,由外部(Activity)负责。
- 当 Activity 销毁时,
lifecycleScope
会自动取消所有协程,避免内存泄漏。
在我们的例子中,UserPresenter内部会使用注入的作用域启动协程:coroutineScope.launch(Dispatchers.IO) {...}
这里又出现一个新的概念:Dispatchers(调度器)
Dispatchers指定协程所在线程池
示例代码中启动协程时coroutineScope.launch(Dispatchers.IO) {...}
,有参数Dispatchers.IO
Dispatchers
决定协程在哪个线程或线程池执行,避免阻塞关键线程(如 Android 主线程)。
调度器 | 作用 | 适用场景 |
---|---|---|
Dispatchers.Main | 在 Android 主线程执行,用于更新 UI。在非 Android 环境中与 Default 相同。 | 更新 TextView、启动动画等 UI 操作。 |
Dispatchers.IO | 优化 IO 密集型操作,线程池大小动态调整(默认最多 64 线程)。 | 网络请求、文件读写、数据库操作。 |
Dispatchers.Default | 优化 CPU 密集型操作,线程池大小为 CPU 核心数。 | 复杂计算(如 JSON 解析、图像处理)。 |
Dispatchers.Unconfined | 初始在当前线程执行,遇到挂起点后由被调用的挂起函数决定运行线程。 | 测试、无需特定线程执行的场景(不推荐在实际业务中使用)。 |
选择调度器的核心原则是:让合适的操作在合适的线程池执行。在 Android 开发中,牢记 IO 操作使用 Dispatchers.IO
,UI 更新使用 Dispatchers.Main
这一基本准则即可覆盖大部分场景。
建议:在实际开发中,始终显式指定调度器,确保:
- 网络请求使用
Dispatchers.IO
。 - UI 操作使用
Dispatchers.Main
。 - 计算任务使用
Dispatchers.Default
。
suspend挂起函数
在Model层中可以看到一个suspend函数
suspend fun fetchUser(userId: String): User
被Presenter 层调用,且调用位置在协程内部(见launch{})
class UserPresenter(
...
fun loadUser(userId: String) {
coroutineScope.launch(Dispatchers.IO) {
_state.update { it.copy(isLoading = true) }
try {
val user = repository.fetchUser(userId)
...
suspend 关键字标记一个函数为 “挂起函数”(会等待代码块执行完再继续),适合需要 “拿到结果再执行后续逻辑” 的场景
挂起函数只能在协程作用域或其他挂起函数内调用。我们前面讨论过的collect
就是一个 suspend 函数,这意味着你不能在普通函数中直接调用 flow.collect { ... }
,必须通过 launch
、async
或其他协程构建器启动协程。
使用建议:
我们知道model层的职责是,负责数据的管理和处理,包括数据的获取(网络请求、数据库操作等)、存储、业务逻辑计算等。
1、若 Model 层包含 网络请求、文件读写、数据库操作 等耗时操作,推荐使用 suspend
函数,并在内部通过 withContext(Dispatchers.IO)
切换线程
2、若方法仅涉及 内存数据处理、简单计算,无需声明为 suspend
。
withContext切换线程
withContext:临时切换协程的执行线程(在协程中切换调度器),并在完成后切回原线程。
特别适合在异步任务里插一嘴 “主线程更新”,然后继续后台逻辑。(如网络请求后更新 UI: “网络请求 → 数据解析 → UI 更新”)。
我们的示例就是这样的典型用法,使用withContext切换回主线程更新状态,更新完了自动切回来,过程如下:
- 暂停当前在
IO
线程的协程,把任务 “挪” 到Main
线程(Android 主线程,用于更新 UI)执行_state.update { ... }
。 - 执行完
Main
线程里的代码后,自动切回原协程调度器(也就是Dispatchers.IO
)继续后续逻辑。
class UserPresenter(
...
fun loadUser(userId: String) {
coroutineScope.launch(Dispatchers.IO) {
_state.update { it.copy(isLoading = true) }
try {
val user = repository.fetchUser(userId)
withContext(Dispatchers.Main) { // 切换回主线程更新状态
_state.update {...}
}//结束了会自动切回原线程Dispatchers.IO
为什么需要切换:
repository.fetchUser
在Dispatchers.IO
后台线程执行网络请求,避免阻塞主线程。- 获取数据后,需要在主线程更新 UI(Android 规定只能在主线程修改 View),因此通过
withContext(Dispatchers.Main)
切换回主线程。 - 这种 “切过去、切回来” 的流程,完美适配 “后台异步任务 + 主线程更新 UI” 的需求(比如网络请求完,切回主线程刷新页面),既不阻塞主线程,又能安全更新 UI,比传统回调或 Handler 简洁很多。
与 launch
的区别:
launch
用于启动新协程,withContext
不启动新协程,而是在当前协程内切换线程。
性能优化
- 避免频繁切换:过多的
withContext
会增加线程调度开销。 - 批量操作:将同一类型的操作放在同一个
withContext
块中执行。
MVPS和MVP的区别
MVPS 并不是一个被广泛认可的标准架构术语(主流架构中只有 MVP、MVVM、MVC 等),它更可能是某些团队或场景下对 MVP 的自定义扩展(比如强调 “State” 的作用),因此不同语境下的定义可能有差异。
在我的使用场景中,MVPS就是在MVP的基础上多了一个State,另外Presenter和View的交互方式上有区别(多了state层)。
我们来看下MVP中Presenter的职责:
- 作为 View 和 Model 之间的桥梁,接收 View 的用户操作,调用 Model 处理数据,再将处理结果通知 View 更新 UI。
与上面对比可以发现,MVP中Presenter可以直接访问View接口来更新UI。而MVPS中,Presenter 只更新 State,由View 通过观察 State 的变化自动更新 UI。
可以看看下面的代码示例就更直观了。
- 传统 MVP 中 Presenter 与 View 的交互
-
直接调用 View 接口:Presenter 持有 View 的接口(如
IView
),当数据处理完成后,会直接调用 View 接口的方法(如updateUI(data)
、showLoading()
)来通知 View 更新。 -
这里 Presenter 直接 “命令” View 做什么,属于 “命令式交互” 。
// Presenter 中 fun loadData() { model.fetchData(object : Callback { override fun onSuccess(data: Data) { view.updateUI(data) // 直接调用 View 接口方法 } override fun onError() { view.showError() } }) }
-
新的MVPS 中引入 “State” 作为中间层
举例直接看我们的MVPS示例代码即可
- 通过 State 间接影响 View:Presenter 不直接调用 View 方法,而是维护一个 “状态对象(State)”(如
UIState
,包含数据、加载状态、错误信息等)。当数据处理完成后,Presenter 只更新 State,View 则通过观察 State 的变化(如监听回调、数据绑定)自动更新 UI。 - 这里 Presenter 只负责 “维护状态”,View 负责 “响应状态变化”,属于 “状态驱动式交互” 。
MVPS 的优势
- 可测试性强:Presenter 和 State 可以独立测试,不依赖 UI 组件。
- 状态可控:所有 UI 状态集中在 State 中,便于调试和追踪。
- 代码分离清晰:View 只负责渲染,Presenter 只负责逻辑,职责明确。
- 响应式编程:结合 RxJava 或 Kotlin Flow 可实现高效的异步数据流处理。
MVPS和MVVM
现代 Android 开发的主流趋势是 MVVM,因其与官方框架深度整合、状态管理规范,尤其适合中大型项目。而 MVPS 更适合特定过渡场景或轻量级项目
经过学习我发现,我的场景中 “MVPS” 更接近 MVP 向 MVVM 的过渡形态,猜测应该是项目原本是用MVP做的,后来为了解决一些耦合问题,于是向MVVM方向进行重构,从而形成了MVPS这样一种状态。