目录
  1. 1. 一、MVP 架构概述
    1. 1.1. 1.1 Android MVC 的困境
    2. 1.2. 1.2 MVP 的核心思想
    3. 1.3. 1.3 Contract 接口契约模式
  2. 2. 二、MVP 的完整实现
    1. 2.1. 2.1 Model 层实现
    2. 2.2. 2.2 Presenter 层实现
    3. 2.3. 2.3 View 层实现 —— Passive View
  3. 3. 三、MVP 的生命周期与内存管理
    1. 3.1. 3.1 为什么需要 attach/detach
    2. 3.2. 3.2 三种引用管理方案
    3. 3.3. 3.3 Process Death 处理
  4. 4. 四、MVP 的测试策略
    1. 4.1. 4.1 MVP 测试的核心优势
  5. 5. 五、MVP 完整实战 —— 用户资料页面
    1. 5.1. 5.1 功能需求
    2. 5.2. 5.2 Contract 定义
    3. 5.3. 5.3 Presenter 实现
    4. 5.4. 5.4 View 实现
  6. 6. 六、MVP vs MVC vs MVVM 深度对比
    1. 6.1. 6.1 MVC 在 Android 上的问题
    2. 6.2. 6.2 MVP 解决了什么
    3. 6.3. 6.3 MVP vs MVVM
    4. 6.4. 6.4 从 MVP 迁移到 MVVM 的路径
  7. 7. 七、MVP 的常见反模式与最佳实践
    1. 7.1. 7.1 反模式一:万能 Presenter
    2. 7.2. 7.2 反模式二:View 接口爆炸
    3. 7.3. 7.3 反模式三:View 包含业务逻辑
    4. 7.4. 7.4 反模式四:在 Presenter 中操作 View
  8. 8. 八、总结
  9. 9. 参考资源
【架构篇】MVP架构

一、MVP 架构概述

1.1 Android MVC 的困境

在 Android 开发的早期,开发者通常将 Activity/Fragment 既当作 View(处理 UI 渲染)又当作 Controller(处理业务逻辑),这种模式被称作”上帝对象”(God Object)。以下是一段典型的早期 Android 代码:

public class LoginActivity extends AppCompatActivity {
private EditText etUsername, etPassword;
private Button btnLogin;
private ProgressBar progressBar;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_login);

etUsername = findViewById(R.id.et_username);
etPassword = findViewById(R.id.et_password);
btnLogin = findViewById(R.id.btn_login);
progressBar = findViewById(R.id.progress);

btnLogin.setOnClickListener(v -> {
String username = etUsername.getText().toString();
String password = etPassword.getText().toString();
if (TextUtils.isEmpty(username)) {
etUsername.setError("用户名不能为空");
return;
}
// 验证密码...
progressBar.setVisibility(View.VISIBLE);
// 直接发起网络请求
new Thread(() -> {
try {
String result = ApiClient.login(username, password);
runOnUiThread(() -> {
progressBar.setVisibility(View.GONE);
if (result != null) {
startActivity(new Intent(this, HomeActivity.class));
} else {
Toast.makeText(this, "登录失败", Toast.LENGTH_SHORT).show();
}
});
} catch (Exception e) {
runOnUiThread(() -> {
progressBar.setVisibility(View.GONE);
Toast.makeText(this, "网络异常", Toast.LENGTH_SHORT).show();
});
}
}).start();
});
}
}

这种写法的核心问题:

  • View 和业务逻辑耦合:UI 代码和业务逻辑混在一起,修改任一都牵动全局
  • 无法单元测试:所有代码依赖 Android Framework(Activity、View),必须用 Instrumentation Test
  • 代码难以复用:同样的登录逻辑在其他页面无法重用
  • 违反单一职责原则:Activity 同时处理 UI 渲染、输入验证、网络请求、异常处理

1.2 MVP 的核心思想

MVP(Model-View-Presenter)将这三部分职责严格分离:

  • Model:数据层,负责数据的获取、存储和业务逻辑。包括网络请求、数据库操作、文件读写。Model 对 View 和 Presenter 完全无感知。
  • View:纯 UI 层(Passive View),只负责显示数据和转发用户操作给 Presenter。View 永远不直接访问 Model。
  • Presenter:中间人(Mediator),从 Model 获取数据,处理后调用 View 的接口更新 UI。Presenter 持有 View 引用和 Model 引用。
┌──────────┐          ┌──────────────┐          ┌──────────┐
│ View │ ───────> │ Presenter │ ───────> │ Model │
│(Activity)│ <─────── │ │ <─────── │(Repository)
└──────────┘ └──────────────┘ └──────────┘
用户交互 业务逻辑 数据获取
只调用 Presenter 调用 View 接口更新 UI 网络/数据库

1.3 Contract 接口契约模式

MVP 最经典的实现方式是通过 Contract 接口定义 View 和 Presenter 的通信协议:

public interface LoginContract {
interface View {
void showLoading();
void hideLoading();
void showError(String message);
void showLoginSuccess();
void navigateToHome();
void setLoginButtonEnabled(boolean enabled);
}

interface Presenter {
void attach(View view);
void detach();
void onUsernameChanged(String username);
void onPasswordChanged(String password);
void onLoginClicked();
}
}

Contract 的作用:

  • 明确通信协议:一眼看清 View 和 Presenter 之间可以互相调用哪些方法
  • 编译时安全:接口定义后,任何实现偏差都会在编译阶段暴露
  • 方便协作:团队成员根据 Contract 各自开发 View 实现和 Presenter 实现

二、MVP 的完整实现

2.1 Model 层实现

Model 层负责数据获取,不感知 UI 层的任何存在:

// model/UserRepository.kt
interface UserRepository {
suspend fun login(username: String, password: String): Result<User>
suspend fun getUserProfile(userId: String): Result<UserProfile>
suspend fun updateProfile(profile: UserProfile): Result<Unit>
}

class UserRepositoryImpl(
private val apiService: ApiService,
private val userDao: UserDao,
private val cacheManager: CacheManager
) : UserRepository {

override suspend fun login(username: String, password: String): Result<User> {
return try {
val response = apiService.login(LoginRequest(username, password))
if (response.isSuccessful && response.body() != null) {
val user = response.body()!!
// 缓存 token 和用户信息
cacheManager.saveToken(user.token)
userDao.insertUser(user.toEntity())
Result.success(user)
} else {
Result.failure(HttpException(response))
}
} catch (e: IOException) {
Result.failure(e)
}
}

override suspend fun getUserProfile(userId: String): Result<UserProfile> {
return try {
// 先读缓存
val cached = cacheManager.getProfile(userId)
if (cached != null) {
return Result.success(cached)
}
// 缓存未命中,请求网络
val response = apiService.getProfile(userId)
if (response.isSuccessful && response.body() != null) {
val profile = response.body()!!
cacheManager.putProfile(userId, profile)
Result.success(profile)
} else {
Result.failure(HttpException(response))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

2.2 Presenter 层实现

Presenter 持有 View 的弱引用(避免内存泄漏),并通过 attach/detach 管理生命周期:

// presenter/LoginPresenter.kt
class LoginPresenter(
private val userRepository: UserRepository
) : LoginContract.Presenter {

// 使用弱引用,防止 Activity 被销毁后 Presenter 仍持有引用
private var viewRef: WeakReference<LoginContract.View>? = null
private var job: Job? = null

// 缓存用户输入,用于 View 重建后恢复
private var currentUsername = ""
private var currentPassword = ""

override fun attach(view: LoginContract.View) {
viewRef = WeakReference(view)
// 如果 View 是重建的(如屏幕旋转),恢复之前输入
if (currentUsername.isNotEmpty()) {
view.onUsernameChanged(currentUsername)
}
if (currentPassword.isNotEmpty()) {
view.onPasswordChanged(currentPassword)
}
}

override fun detach() {
viewRef?.clear()
viewRef = null
job?.cancel()
}

override fun onUsernameChanged(username: String) {
currentUsername = username
validateForm()
}

override fun onPasswordChanged(password: String) {
currentPassword = password
validateForm()
}

private fun validateForm() {
val isUsernameValid = currentUsername.length >= 3
val isPasswordValid = currentPassword.length >= 6
viewRef?.get()?.setLoginButtonEnabled(isUsernameValid && isPasswordValid)
}

override fun onLoginClicked() {
val view = viewRef?.get() ?: return

// 输入验证
if (currentUsername.length < 3) {
view.showError("用户名至少3个字符")
return
}
if (currentPassword.length < 6) {
view.showError("密码至少6个字符")
return
}

view.showLoading()

job = CoroutineScope(Dispatchers.Main).launch {
val result = withContext(Dispatchers.IO) {
userRepository.login(currentUsername, currentPassword)
}

result.fold(
onSuccess = { user ->
view.hideLoading()
view.showLoginSuccess()
view.navigateToHome()
},
onFailure = { error ->
view.hideLoading()
val message = when (error) {
is IOException -> "网络连接失败,请检查网络"
is HttpException -> when (error.code()) {
401 -> "用户名或密码错误"
429 -> "请求过于频繁,请稍后再试"
500 -> "服务器内部错误"
else -> "登录失败 (${error.code()})"
}
else -> "未知错误: ${error.message}"
}
view.showError(message)
}
)
}
}
}

2.3 View 层实现 —— Passive View

View 是 完全被动的(Passive View):不做任何业务判断,只负责展示和转发事件:

// view/LoginActivity.kt
class LoginActivity : AppCompatActivity(), LoginContract.View {

private lateinit var presenter: LoginContract.Presenter
private lateinit var binding: ActivityLoginBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)

// 依赖注入 —— 使用 ServiceLocator 或 Dagger
val repository = (application as MyApp).serviceLocator.userRepository
presenter = LoginPresenter(repository)

// 设置点击监听 —— View 只转发,不判断
binding.btnLogin.setOnClickListener {
presenter.onLoginClicked()
}
binding.etUsername.doAfterTextChanged { text ->
presenter.onUsernameChanged(text?.toString() ?: "")
}
binding.etPassword.doAfterTextChanged { text ->
presenter.onPasswordChanged(text?.toString() ?: "")
}
}

override fun onStart() {
super.onStart()
presenter.attach(this) // 建立关联
}

override fun onStop() {
super.onStop()
presenter.detach() // 断开关联,防止泄漏
}

// ========== View 接口实现(纯 UI 操作)==========

override fun showLoading() {
binding.progressBar.isVisible = true
binding.btnLogin.isEnabled = false
}

override fun hideLoading() {
binding.progressBar.isVisible = false
binding.btnLogin.isEnabled = true
}

override fun showError(message: String) {
binding.tvError.isVisible = true
binding.tvError.text = message
}

override fun showLoginSuccess() {
Toast.makeText(this, "登录成功", Toast.LENGTH_SHORT).show()
}

override fun navigateToHome() {
startActivity(Intent(this, HomeActivity::class.java))
finish()
}

override fun setLoginButtonEnabled(enabled: Boolean) {
binding.btnLogin.isEnabled = enabled
}
}

Passive View 的关键原则:

  • View 中没有任何 if-else 业务判断
  • View 不做数据转换、验证、排序
  • View 的每个方法只对应一个 UI 操作(show/hide/set text)
  • View 收到用户事件后,立即调用 Presenter,不做任何前置处理

三、MVP 的生命周期与内存管理

3.1 为什么需要 attach/detach

Android 的 Activity 生命周期由系统控制,可能随时被销毁重建(屏幕旋转、语言切换、内存不足)。如果 Presenter 持有 Activity 的强引用,当 Activity 被销毁后 Presenter 仍存活(例如正在执行异步任务),会导致:

  1. 内存泄漏:Activity 无法被 GC 回收
  2. 空指针崩溃:异步任务完成后尝试更新已销毁的 Activity

3.2 三种引用管理方案

方案一:弱引用(WeakReference)

class LoginPresenter(repository: UserRepository) {
private var viewRef: WeakReference<LoginContract.View>? = null

fun attach(view: LoginContract.View) {
viewRef = WeakReference(view)
}

fun detach() {
viewRef?.clear()
viewRef = null
}

private fun getView(): LoginContract.View? = viewRef?.get()
}

// 所有 View 调用都通过 getView() 判空
private fun onLoginResult(result: Result<User>) {
val view = getView() ?: return // View 已销毁,丢弃结果
view.hideLoading()
result.fold(
onSuccess = { view.navigateToHome() },
onFailure = { view.showError(it.message ?: "错误") }
)
}

方案二:取消协程

Kotlin 协程提供了更优雅的方式 —— 配合 viewModelScope 或在 detach 时取消 Job:

class LoginPresenter(repository: UserRepository) {
private var job: Job? = null
private var view: LoginContract.View? = null // 仍然挥强引用,依赖 detach 释放

fun attach(view: LoginContract.View) {
this.view = view
}

fun detach() {
this.view = null
job?.cancel() // 取消正在执行的协程
}

fun onLoginClicked() {
val v = view ?: return
v.showLoading()
job = CoroutineScope(Dispatchers.Main).launch {
val result = withContext(Dispatchers.IO) {
repository.login(v.getUsername(), v.getPassword())
}
view?.hideLoading() // 协程返回时 view 可能为 null
view?.let { handleResult(it, result) }
}
}
}

方案三:使用 Android Architecture Components —— ViewModel

将 Presenter 的逻辑放入 ViewModel,利用 ViewModel 的生命周期长于 Activity 的特性:

class LoginViewModel(
private val userRepository: UserRepository
) : ViewModel() {

private val _viewState = MutableLiveData<LoginViewState>()
val viewState: LiveData<LoginViewState> = _viewState

fun login(username: String, password: String) {
_viewState.value = LoginViewState.Loading
viewModelScope.launch {
val result = withContext(Dispatchers.IO) {
userRepository.login(username, password)
}
result.fold(
onSuccess = { _viewState.value = LoginViewState.Success },
onFailure = { _viewState.value = LoginViewState.Error(it.message) }
)
}
}
}

sealed class LoginViewState {
object Loading : LoginViewState()
object Success : LoginViewState()
data class Error(val message: String?) : LoginViewState()
}

这是从 MVP 到 MVVM 的自然过渡 —— Presenter 演变为 ViewModel,直接调用 View 的方法演变为观察 LiveData/StateFlow。

3.3 Process Death 处理

class LoginPresenter(
private val savedState: Bundle?,
private var view: LoginContract.View?
) {
private var username: String
private var password: String

init {
// 从 savedState 恢复
username = savedState?.getString("username") ?: ""
password = savedState?.getString("password") ?: ""
}

fun saveState(outState: Bundle) {
outState.putString("username", username)
outState.putString("password", password)
}

fun attach(view: LoginContract.View) {
this.view = view
// 如果之前有输入,回填到 View
if (username.isNotEmpty()) view.onUsernameChanged(username)
if (password.isNotEmpty()) view.onPasswordChanged(password)
}
}

四、MVP 的测试策略

4.1 MVP 测试的核心优势

MVP 最大的测试优势在于:Presenter 不依赖 Android Framework。测试 Presenter 不需要启动模拟器,不需要 Robolectric,是纯 JVM 单元测试:

// presenter 测试 —— 运行在 JVM 上,毫秒级速度
class LoginPresenterTest {

private lateinit var repository: UserRepository
private lateinit var view: LoginContract.View
private lateinit var presenter: LoginPresenter

@Before
fun setup() {
repository = mock() // Mockito mock
view = mock() // Mock View 接口
presenter = LoginPresenter(repository)
presenter.attach(view)
}

@Test
fun `valid input enables login button`() {
presenter.onUsernameChanged("testuser")
presenter.onPasswordChanged("password123")

verify(view).setLoginButtonEnabled(true)
}

@Test
fun `empty username disables login button`() {
presenter.onUsernameChanged("")
presenter.onPasswordChanged("password123")

verify(view).setLoginButtonEnabled(false)
}

@Test
fun `short password disables login button`() {
presenter.onUsernameChanged("testuser")
presenter.onPasswordChanged("123")

verify(view).setLoginButtonEnabled(false)
}

@Test
fun `login success navigates to home`() = runTest {
// Arrange
whenever(repository.login("testuser", "password123"))
.thenReturn(Result.success(User("1", "testuser")))

presenter.onUsernameChanged("testuser")
presenter.onPasswordChanged("password123")

// Act
presenter.onLoginClicked()

// Assert
verify(view).showLoading()
advanceUntilIdle()
verify(view).hideLoading()
verify(view).showLoginSuccess()
verify(view).navigateToHome()
}

@Test
fun `network error shows error message`() = runTest {
whenever(repository.login(any(), any()))
.thenReturn(Result.failure(IOException("Network failure")))

presenter.onUsernameChanged("test")
presenter.onPasswordChanged("password123")
presenter.onLoginClicked()

verify(view).showLoading()
advanceUntilIdle()
verify(view).hideLoading()
verify(view).showError(contains("网络连接失败"))
}

@Test
fun `login with 401 shows credential error`() = runTest {
val response = mock<Response<User>> {
on { code() } doReturn 401
on { isSuccessful } doReturn false
}

whenever(repository.login(any(), any()))
.thenReturn(Result.failure(HttpException(response)))

presenter.onUsernameChanged("bad")
presenter.onPasswordChanged("credentials")
presenter.onLoginClicked()

advanceUntilIdle()
verify(view).showError(contains("用户名或密码错误"))
}

@Test
fun `View detached during request prevents update`() = runTest {
// 模拟异步请求过程中 View 被 detach
whenever(repository.login(any(), any())).thenReturn(Result.success(User("1", "u")))

presenter.onUsernameChanged("u")
presenter.onPasswordChanged("password123")
presenter.onLoginClicked()

// 在异步任务完成前 detach
presenter.detach()

advanceUntilIdle()

// View 方法不应该被调用(View 已经是 null)
verify(view, never()).navigateToHome()
verify(view, never()).showLoginSuccess()
}
}

五、MVP 完整实战 —— 用户资料页面

5.1 功能需求

一个完整的用户资料页面,包含:

  • 从网络获取用户资料
  • 头像加载(Glide)
  • 下拉刷新
  • 本地缓存(先显示缓存,再刷新网络)
  • 错误重试
  • 编辑资料

5.2 Contract 定义

interface ProfileContract {
// View 需要展示的数据 —— 避免接口爆炸
data class ProfileViewData(
val avatarUrl: String,
val nickname: String,
val bio: String,
val followerCount: String,
val followingCount: String,
val postCount: String
)

interface View {
fun showLoading(isRefresh: Boolean = false)
fun showContent(data: ProfileViewData)
fun showError(message: String, canRetry: Boolean)
fun showNetworkErrorWithCache(cachedData: ProfileViewData)
fun showEditSuccess()
fun navigateToEditProfile()
fun enableSwipeRefresh(enable: Boolean)
}

interface Presenter {
fun attach(view: View)
fun detach()
fun loadProfile(isRefresh: Boolean = false)
fun onEditClicked()
fun onRetryClicked()
}
}

5.3 Presenter 实现

class ProfilePresenter(
private val userRepository: UserRepository,
private val analytics: AnalyticsTracker
) : ProfileContract.Presenter {

private var view: ProfileContract.View? = null
private var loadJob: Job? = null
private var cachedData: ProfileContract.ProfileViewData? = null

override fun attach(view: ProfileContract.View) {
this.view = view
loadProfile()
}

override fun detach() {
view = null
loadJob?.cancel()
}

override fun loadProfile(isRefresh: Boolean) {
val v = view ?: return

if (!isRefresh && cachedData != null) {
// 先展示缓存的数据
v.showContent(cachedData!!)
return
}

v.showLoading(isRefresh)
v.enableSwipeRefresh(true)

loadJob = CoroutineScope(Dispatchers.Main).launch {
val result = withContext(Dispatchers.IO) {
userRepository.getUserProfile("self")
}

val currentView = view ?: return@launch

result.fold(
onSuccess = { profile ->
val viewData = profile.toViewData()
cachedData = viewData
currentView.showContent(viewData)
analytics.logEvent("profile_loaded_success")
},
onFailure = { error ->
if (cachedData != null) {
currentView.showNetworkErrorWithCache(cachedData!!)
} else {
val message = mapErrorToMessage(error)
currentView.showError(message, canRetry = true)
}
analytics.logEvent("profile_load_failed", mapOf("error" to error.message))
}
)

currentView.enableSwipeRefresh(false)
}
}

override fun onEditClicked() {
view?.navigateToEditProfile()
}

override fun onRetryClicked() {
loadProfile(isRefresh = true)
}

private fun UserProfile.toViewData() = ProfileContract.ProfileViewData(
avatarUrl = this.avatar,
nickname = this.nickname,
bio = this.bio ?: "这个人很懒,什么都没写",
followerCount = formatCount(this.followerCount),
followingCount = formatCount(this.followingCount),
postCount = formatCount(this.postCount)
)

private fun formatCount(count: Long): String = when {
count >= 10000 -> "${count / 10000}万"
count >= 1000 -> "${count / 1000}k"
else -> count.toString()
}

private fun mapErrorToMessage(error: Throwable): String = when (error) {
is IOException -> "网络连接失败,请检查网络后重试"
is HttpException -> when (error.code()) {
404 -> "用户不存在"
401 -> "登录已过期,请重新登录"
in 500..599 -> "服务器繁忙,请稍后重试"
else -> "请求失败 (${error.code()})"
}
is CancellationException -> "请求已取消"
else -> "未知错误: ${error.message}"
}
}

5.4 View 实现

class ProfileFragment : Fragment(), ProfileContract.View {

private lateinit var presenter: ProfileContract.Presenter
private var binding: FragmentProfileBinding? = null

override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
binding = FragmentProfileBinding.inflate(inflater, container, false)
return binding!!.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

val repository = (requireActivity().application as MyApp)
.serviceLocator.userRepository
val analytics = (requireActivity().application as MyApp)
.serviceLocator.analytics

presenter = ProfilePresenter(repository, analytics)

binding?.apply {
swipeRefresh.setOnRefreshListener {
presenter.loadProfile(isRefresh = true)
}
btnEdit.setOnClickListener {
presenter.onEditClicked()
}
btnRetry.setOnClickListener {
presenter.onRetryClicked()
}
}
}

override fun onStart() {
super.onStart()
presenter.attach(this)
}

override fun onStop() {
super.onStop()
presenter.detach()
}

override fun showLoading(isRefresh: Boolean) {
if (!isRefresh) {
binding?.progressBar?.isVisible = true
binding?.contentGroup?.isVisible = false
}
}

override fun showContent(data: ProfileContract.ProfileViewData) {
binding?.apply {
progressBar.isVisible = false
contentGroup.isVisible = true
errorGroup.isVisible = false
swipeRefresh.isRefreshing = false

Glide.with(root)
.load(data.avatarUrl)
.circleCrop()
.into(ivAvatar)
tvNickname.text = data.nickname
tvBio.text = data.bio
tvFollowerCount.text = data.followerCount
tvFollowingCount.text = data.followingCount
tvPostCount.text = data.postCount
}
}

override fun showError(message: String, canRetry: Boolean) {
binding?.apply {
progressBar.isVisible = false
contentGroup.isVisible = false
errorGroup.isVisible = true
swipeRefresh.isRefreshing = false
tvError.text = message
btnRetry.isVisible = canRetry
}
}

override fun showNetworkErrorWithCache(cachedData: ProfileContract.ProfileViewData) {
showContent(cachedData)
// 在顶部展示一个 Snackbar 提示正在使用缓存
Snackbar.make(
requireView(),
"网络不可用,当前显示缓存数据",
Snackbar.LENGTH_LONG
).show()
}

override fun showEditSuccess() {
Toast.makeText(requireContext(), "资料已更新", Toast.LENGTH_SHORT).show()
presenter.loadProfile(isRefresh = true)
}

override fun navigateToEditProfile() {
findNavController().navigate(R.id.action_profile_to_edit)
}

override fun enableSwipeRefresh(enable: Boolean) {
binding?.swipeRefresh?.isEnabled = enable
}

override fun onDestroyView() {
super.onDestroyView()
binding = null
}
}

六、MVP vs MVC vs MVVM 深度对比

6.1 MVC 在 Android 上的问题

在经典 Web MVC 中,Controller 接收 HTTP 请求,调用 Model 获取数据,选择 View 渲染。但在 Android 中:

  • Activity 既是 Controller 又是 ViewonCreate 中既 findViewById(View 职责)又调用 API(Controller 职责)
  • 不能独立测试:所有代码耦合在 Activity 中,必须用 Instrumentation Test
  • Fragment 的引入更糟:Controller 逻辑分散在 Activity 和多个 Fragment 之间

6.2 MVP 解决了什么

问题 MVC 现状 MVP 解决方式
测试 必须 Instrumentation Presenter 是纯 JVM 测试
复用 逻辑粘贴复制 Presenter 可被不同 View 复用
职责 Activity 做所有事 View 只渲染,Presenter 只逻辑
并行开发 UI 和逻辑绑定 根据 Contract 并行开发
代码审查 难以区分 UI 和逻辑 Contract 清晰分界

6.3 MVP vs MVVM

维度 MVP MVVM
View 更新方式 Presenter 主动调用 View 接口方法 View 观察 ViewModel 的 LiveData/StateFlow
View 与 Presenter 关系 双向引用(View 持有 Presenter,Presenter 持有 View) 单向引用(View 持有 ViewModel,ViewModel 不感知 View)
数据绑定 手动 textView.setText() DataBinding 或 ViewBinding 自动绑定
模板代码 需要写 View 接口方法 DataBinding 减少模板,但 XML 变复杂
内存泄漏风险 需要手动 attach/detach 管理 ViewModel 与 LifecycleOwner 绑定,自动清理
测试 Presenter 单元测试容易 ViewModel 单元测试也容易(mock Repository)

6.4 从 MVP 迁移到 MVVM 的路径

步骤一:提取 ViewModel

MVP:  View <-> Presenter (双向引用)
MVVM: View -> ViewModel (单向引用)

将 Presenter 持有的 View 弱引用移除,改为使用 LiveData/StateFlow 暴露状态:

// MVP Presenter
class OldPresenter {
private var view: LoginContract.View? = null

fun onLoginClicked() {
view?.showLoading()
// ... 登录逻辑
view?.hideLoading()
view?.navigateToHome()
}
}

// 迁移后:MVVM ViewModel
class NewViewModel : ViewModel() {
private val _state = MutableLiveData<LoginViewState>()
val state: LiveData<LoginViewState> = _state

fun login(username: String, password: String) {
_state.value = LoginViewState.Loading
viewModelScope.launch {
// ... 登录逻辑
_state.value = LoginViewState.Success
}
}
}

步骤二:View 从主动调用变为被动观察

// MVP: View 调用 Presenter
presenter.onLoginClicked()

// MVVM: View 观察 ViewModel
viewModel.state.observe(viewLifecycleOwner) { state ->
when (state) {
is LoginViewState.Loading -> showLoading()
is LoginViewState.Success -> navigateToHome()
is LoginViewState.Error -> showError(state.message)
}
}

// View 仍然可以调用 ViewModel 的方法
viewModel.login(username, password)

步骤三:逐步替换 DataBinding

不需要一步到位,可以渐进引入 DataBinding:

<!-- 先替换简单的 setText 绑定 -->
<TextView
android:text="@{viewModel.nickname}"
... />

七、MVP 的常见反模式与最佳实践

7.1 反模式一:万能 Presenter

// 错误:一个 Presenter 承担太多职责
class GodPresenter : Presenter {
fun onLogin() {}
fun onRegister() {}
fun onForgotPassword() {}
fun onVerifyCode() {}
fun onResetPassword() {}
fun onBindPhone() {}
// 20+ 个方法...
}

修正:按功能拆分 Presenter,每个 Presenter 只负责一个屏幕或一个功能:

class LoginPresenter : LoginContract.Presenter
class RegisterPresenter : RegisterContract.Presenter
class ForgotPasswordPresenter : ForgotPasswordContract.Presenter

7.2 反模式二:View 接口爆炸

// 错误:50+ 个 View 方法
interface View {
fun showLoading()
fun hideLoading()
fun setUsername(username: String)
fun setAvatar(url: String)
fun setNickname(nickname: String)
fun setBio(bio: String)
fun setFollowerCount(count: Int)
// ... 40+ more methods
}

修正:使用数据类批量传递 ViewData:

interface View {
fun showLoading()
fun hideLoading()
fun showContent(data: ProfileViewData) // 一次传递所有数据
fun showError(message: String)
}

data class ProfileViewData(
val username: String, val avatar: String, val nickname: String,
val bio: String, val followerCount: Int, val followingCount: Int
)

7.3 反模式三:View 包含业务逻辑

// 错误:View 中判断业务
binding.btnLogin.setOnClickListener {
if (etUsername.text.length < 3) {
etUsername.error = "用户名太短"
} else if (etPassword.text.length < 6) {
etPassword.error = "密码太短"
} else {
presenter.onLoginClicked()
}
}

修正:View 不做任何判断,直接转发:

binding.btnLogin.setOnClickListener {
presenter.onLoginClicked(
etUsername.text.toString(),
etPassword.text.toString()
)
}
// 验证逻辑在 Presenter 中

7.4 反模式四:在 Presenter 中操作 View

// 错误:Presenter 直接操作 View 组件
class LeakyPresenter(private val activity: Activity) {
fun showDialog() {
AlertDialog.Builder(activity)
.setTitle("提示")
.show()
}
}

修正:通过 View 接口隔离:

interface View {
fun showAlertDialog(title: String, message: String)
}

class SafePresenter(private var view: View?) {
fun showDialog() {
view?.showAlertDialog("提示", "确认删除?")
}
}

八、总结

MVP 架构通过 Contract 接口契约Passive View 模式,将 Android 中混乱的 Activity 代码拆分为清晰的三个层次:

  1. Model:纯数据层,与 UI 完全解耦
  2. View:纯展示层,只响应 Presenter 的指令
  3. Presenter:中间人,所有业务逻辑的集中地

MVP 最大贡献不是让代码”看起来漂亮”,而是让 单元测试覆盖业务逻辑成为可能。一个完全在 JVM 上运行、秒级通过的测试套件,是 MVP 相比于 MVC 最根本的价值。

随着 Jetpack ViewModel + LiveData/StateFlow 的成熟,MVVM 在很多场景下是 MVP 的自然演进方向,但 MVP 的核心思想——职责分离、面向接口编程、被动视图——仍然是所有 Android 架构的基石。


参考资源

打赏
  • 微信
  • 支付宝

评论