跳至主要內容

Android 架构模式一览

JI,XIAOYONG...大约 13 分钟android

Android 的架构依次有:

  • MVC
  • MVP
  • MVVM
  • MVI
  • Clean Architecture

上述架构循序渐进,当前主流设计模式是 MVVM,MVI 和 Clean Architecture,这三者的着重点各有不同,可以根据项目规模大小递进选择。

https://github.com/skydoves/android-developer-roadmap/blob/main/README_CN.md
https://github.com/skydoves/android-developer-roadmap/blob/main/README_CN.mdopen in new window

MVC

MVCModel-View-Controller,是 Android 最原始的开发方式,通过 Controller(一般为 Activity,Fragment 等)来根据用户输入处理 Model(数据/业务逻辑,比如网络请求/数据读写),并将结果更新到 View(用户界面,包括 XML,Activity,Fragment 等)上面。

在 Android 开发中,MVC 通常被用于实现应用程序的 UI 层。例如,在一个简单的应用程序中,View 可以是 Activity 或 Fragment,Controller 可以是 Activity 或 Fragment 的内部类,Model 可以是 POJO 类(类似 data class)。

但是实际开发时,View 通常会持有 Controller 和 Model 的引用产生耦合,并且几乎所有的逻辑都写在 Controller(比如 Activity/Fragment),随着项目的进行,经常在 Activity 中即负责 View 的展示,又负责业务逻辑及数据更新,导致其臃肿难以维护。

MVP

因而,MVP应运而生,Model-View-Presenter:

  • Presenter 持有 View 的抽象接口和 Model,负责二者之间的通信,处理用户的输入,将 Model 的数据处理为 View 可使用的状态;
  • View 只需持有 Presenter 并实现抽象接口对应的方法即可;
  • Model 则负责存储数据,暴露访问数据的接口,请求数据等。

这样将 View 和 Model 隔离,V 和 P 一一对应,复杂的视图可以组合复用多个 P。并且此时的 Presenter 不再和 Android API 关联,更容易单元测试和维护。

二者的区别如下图,MVP 相对于 MVC,将 V 和 M 解耦,使得 V 可以比较专注于 View 的逻辑:

https://medium.com/cr8resume/make-you-hand-dirty-with-mvp-model-view-presenter-eab5b5c16e42
https://medium.com/cr8resume/make-you-hand-dirty-with-mvp-model-view-presenter-eab5b5c16e42open in new window

关于 MVP 的代码实现,可以参考这个项目open in new window。下面是一个简单的示例代码,参考了这个文章open in new window

Model,这里是请求后台提供登录相关数据:

public class LoginInteractor {

    public void login(final String username, final String password, final OnLoginFinishedListener listener) {
        // Mock login. I'm creating a handler to delay the answer a couple of seconds
        new Handler().postDelayed(() -> {...}, 2000);
    }
}

View,这一层一般使用 Activity/Fragment/View 实现,会持有 Presenter 的引用来操作数据,同时为了让 Presenter 可以不直接和 View 关联,需要有一个抽象接口将二者隔离:

interface LoginView {
    fun showProgress()
    fun hideProgress()
    fun setUsernameError()
    fun setPasswordError()
    fun navigateToHome()
}

然后在 View 中除了正常的逻辑之外,还需要实现这个LoginView接口以便 Presenter 调用:

class LoginActivity : AppCompatActivity(), LoginView {
    ...
		// 实现 LoginView 接口方法,这里可能会用到 View 中的 UI Element
}

并且,View 中还会初始化并持有 Presenter,以通过他来响应用户的交互:

class LoginActivity : AppCompatActivity(), LoginView {

    private val presenter = LoginPresenter(this, LoginInteractor())

	// 在其他的某个方法里面响用户操作,比如调用 presenter.login()
}

Presenter,负责 View 和 Model 的交互,将从 Model 中检索到的数据转化为 View 使用的状态

// 注意这里传入了 View 的抽象类 LoginView,以及 Model:LoginInteractor
class LoginPresenter(var loginView: LoginView?, val loginInteractor: LoginInteractor) :
    LoginInteractor.OnLoginFinishedListener {

		// View 会调用这个方法,而 Presenter 则在这里与 Model/View 交互
        fun login(username: String, password: String) {
            loginView?.showProgress()
            loginInteractor.login(username, password, this)
         }
    ...

		// 在 View 被销毁时调用此方法,避免在 Activity Destroy 之后更新 UI 导致 APP 崩溃
		fun onDestroy() {
	        loginView = null
		}
}

但是 MVP 对于简单的项目来说,增加了没必要的复杂性(View 抽象类,Presenter 单独出来等),代码量增加,Presenter 持有 View 在执行耗时任务时可能导致内存泄露,Activity destroy 之后如果 Presenter 访问 View 可能会导致崩溃,并且 View 和 Presenter 存在一定的耦合(当 View 中出现诸如 view 类变化等时需要同步修改 Presenter)。

MVVM

由此,进化出 MVVM,Model-View-ViewModel,使用 ViewModel 将 View 和 Model 关联起来,同时ViewModel 不持有 View 的引用,而是 View 通过 ViewModel 操作 Model,View 监听 ViewModel 暴露出来的数据并更新 UI。

https://medium.com/@husayn.hakeem/android-by-example-mvvm-data-binding-introduction-part-1-6a7a5f388bf7d
https://medium.com/@husayn.hakeem/android-by-example-mvvm-data-binding-introduction-part-1-6a7a5f388bf7dopen in new window

在 MVVM 中ViewModel 只负责处理和提供数据,不再关心 View,便于测试,避免内存泄露;

页面的更新和用户事件处理都由 View 自己处理(比如用户点击页面event之后,View 调用 ViewModel 请求通过Model请求网络/数据库,得到并更新数据state后,View 监听到数据并刷新页面ui),二者的耦合很低。而且 Jetpack 官方提供了 LiveData/StateFlow 等可以监听 Activity 等的生命周期,降低了内存泄露风险(参考:探究 Android MVC、MVP、MVVM 的区别以及优缺点open in new window)。

ViewModel 提供数据的读写方法之后,通过使用 DataBinding,可以实现 View 和 ViewModel双向数据绑定open in new window(具体的代码实现可以参考TicTacToe-MVVMopen in new window)。

<CheckBox
        android:id="@+id/myCheckBox"
        android:checked="@={viewmodel.isChecked}" // 数据双向绑定
		android:onClick="@{() -> viewmodel.onClicked(1,2)}" // 绑定事件
    />

MVI

而为了进一步降低 MVVM 中 ViewModel 和 View 的耦合问题(MVVM 中 View 要调用 ViewModel 方法触发数据处理,数据可以双向绑定),简化数据流的管理,进一步演化出了 MVI(Model-View-Intent):

将用户操作通过 Intent 传递给 ViewModel,ViewModel 据此更新数据,并将数据传递给 View 展示,在这个过程中事件和数据是单向的。

⚠️ 注意,这里的 Intent 表示意图、事件,并非 Android 中 Activity 之间传递的Intent

https://proandroiddev.com/best-architecture-for-android-mvi-livedata-viewmodel-71a3a5ac7ee3
https://proandroiddev.com/best-architecture-for-android-mvi-livedata-viewmodel-71a3a5ac7ee3open in new window

MVI 和 MVVM 的最大区别在于,UI 层不再直接调用 VM 的各个方法执行业务逻辑(比如下载数据)而是通过V 给 VM 发送 Intent(比如 viewModel.setEvent),由 VM 内部根据不同的 Intent 执行不同的逻辑,从而使得V 和 VM 的耦合降低,此外MVI 强调单一数据源,数据是单向流动的

代码实现如下(参考自:MVI-JetpackCompose-Githubopen in new window):

UI 给 VM 发送 Intent

@Composable
fun ReposScreenDestination(UserId: String, navController: NavController) {
    val viewModel = getViewModel<ReposViewModel> { parametersOf(UserId) }
    ReposScreen(
        state = viewModel.viewState.value,
        effectFlow = viewModel.effect,
		// View 中的事件都通过发送 Intent 给 ViewModel 处理
        onEventSent = { event -> viewModel.setEvent(event) },
        onNavigationRequested = { navigationEffect ->
            if (navigationEffect is ReposContract.Effect.Navigation.Back) {
                navController.popBackStack()
            }
        },
    )
}

VM 内部根据不同的 Intent 处理逻辑,并暴露单一数据 State 给 View:

class ReposViewModel(
    private val userId: String,
    private val githubRepository: GithubRepository // 这里实际处理各种读、写数据的操作
) : BaseViewModel<ReposContract.Event, ReposContract.State, ReposContract.Effect>() {

    init { getAll() }

    private val _viewState: MutableState<UiState> = mutableStateOf(initialState)
    // ViewModel 一般只会对外暴露一个 State 供 View 使用
    val viewState: State<UiState> = _viewState

	// ViewModel 通过这一个方法处理所有来自 View 的事件,调用内部方法
    override fun handleEvents(event: ReposContract.Event) {
        when (event) {
            ReposContract.Event.BackButtonClicked -> {
                setEffect { ReposContract.Effect.Navigation.Back }
            }
            ReposContract.Event.Retry -> getAll()
        }
    }

	// ViewModel 要实现的逻为内部方法,可以自由修改实现
    private fun getAll() {
        viewModelScope.launch {
            getUser()
            getRepos()
        }
    }
}

上述 ViewModel 暴露出来的数据,在 View 中使用如下:

Scaffold(
        topBar = { ReposTopBar {
            onEventSent(ReposContract.Event.BackButtonClicked)
        } }
    ) {
		// 根据不同的 state 展示 UI
        when {
            state.isUserLoading || state.isReposLoading -> Progress()
			// View 通过 Intent 和 ViewModel 交互
            state.isError -> NetworkError { onEventSent(ReposContract.Event.Retry) }
            else -> {
                state.user?.let { user ->
                    ReposList(
                        header = { ReposListHeader(userDetail = user) },
                        reposList = state.reposList
                    )
                }
            }
        }
    }

分析上述代码的逻辑,可以看出,在 MVI 中,View 发送将用户操作使用Intent 给ViewModel 处理,并从 ViewModel 获取到处理后的状态更新界面,此外 ViewModel 还负责使用 Repository 实际处理 Data,大体的逻辑示意如下:

https://www.scaler.com/topics/mvi-architecture-android/
https://www.scaler.com/topics/mvi-architecture-android/open in new window

Clean Architecture

通过观察上述分析,我们不难发现,无论是 MVVM 还是 MVI 中,随着业务发展,VM 处理的逻辑会日益增长,繁重。在 MVVM/MVI 的基础之上,通过对业务逻辑的进一步抽象,可以实现Clean Architecture模式:

这也是 Android 官方如今力推的模式,将 Android APP 分为三部分:

  • UI Layer,应用层,用于和用户交互,由 View(下图的 UI elements)和 ViewModel/单纯类(下图中的 State holders)等组成,和 Android API 强关联,发起 event,展示新的 state。
  • Domain Layer(复杂项目可选),业务逻辑层,对于复杂项目,可以将一些重要的业务抽象出来(比如登录逻辑:检验密码,请求登录,更新登录状态等流程),也包括一些之前使用工具类实现的方法(比如格式化日期等)。这部分的代码与 Android API 没有任何关联,是平台无关的抽象逻辑,与 Data Layer 通过抽象接口解耦(Domain Layer 只通过 repository 的抽象类与 Data 交互,而 Data Layer 则负责实现具体逻辑,比如请求数据库,网络等)。不依赖于具体的技术或框架,方便移植。
  • Data Layer,数据层,这一层负责实现上一层调用的抽象接口,真正实现对数据的增删改查。

上述三者中,UI Layer 依赖于 Domain Layer 处理业务逻辑,Data Layer 依赖于基础框架(网络,数据库等)来访问数据,而 Domain Layer 则不依赖上述二者方便调试和复用

💡 注意 Clean Architecture 是一种架构思想,本文只介绍基于 MVVM/MVI 的实现,基于 MVP 的实现可参考 android-clean-architecture-boilerplateopen in new window

Domain layer相当于将一些常用,复杂的逻辑单独提取出来(比如过滤新闻列表,提供日期格式化工具),避免 UI 过于繁重。这个层单纯处理业务逻辑,主线程安全,无生命周期,可以复用。一定程度上减轻了 ViewModel 的负担。

Android Clean Arhictecture https://developer.android.com/topic/architecture?hl=zh-cn
Android Clean Arhictecture https://developer.android.com/topic/architecture?hl=zh-cnopen in new window

在上图中,UI elements 用来向用户展示 UI,State holder 则使用 ViewModel/普通类等类管理数据,对 UI 暴露 data,并处理逻辑。这二者组合实现了在屏幕上展示应用数据,并在用户交互等情况下导致数据变化时,将最新数据展示到 UI 上。

在 Clean Architecture 中Presentation/UI LayerDomain LayerData Layer这三者的层级关系,可以看下图:

https://proandroiddev.com/clean-architecture-data-flow-dependency-rule-615ffdd79e29
https://proandroiddev.com/clean-architecture-data-flow-dependency-rule-615ffdd79e29open in new window

一个重要的原则就是,高层模块(圆圈内部)不应该依赖底层模块(圆圈外部),两者应该依赖于抽象。

这个架构满足了以下常见架构原则:

  • 关注点分离
  • 数据驱动 UI(更新)
  • 单一数据来源
  • 单向数据流

Keep in mind that you don't own implementations of Activity and Fragment; rather, these are just glue classes that represent the contract between the Android OS and your app. The OS can destroy them at any time based on user interactions or because of system conditions like low memory.

开发者并不实际拥有/控制 Activity 和 Fragment,系统可以在必要时候回收他们,因此要尽量减少对其的依赖,只在这里处理和 UI、操作系统相关的逻辑,将关注点分离

从数据驱动 UI:应该使用数据模型 data models(最好是持久模型)驱动 UI,这些数据模型独立于 UI 和其他组件,实现在操作系统销毁 APP 进程时他们也被销毁后,用户不会丢失数据(可以从本地,网络恢复)。

单一事实来源 Single source of truth:应用中定义的数据类型,应该分配一个单一事实来源(SSOT),这种情况下 SSOT 唯一持有/修改数据,对外暴露无可修改数据,在接受函数或事件时更新数据,比如数据库,ViewModel 等。这样将对数据修改集中到一个地方,方便定位问题。

单向数据流 Unidirectional Data Flow:在 UDF 中,数据 state 只会沿一个方向流动,而 event 沿着相反方向修改数据。这样保证数据一致性,不容易出错,便于调试。比如 jetpack compose 中,每个 composable 函数从上一级接受数据,并将事件暴露给上一级(数据向下,事件向上)

https://developer.android.com/topic/architecture/ui-layer
https://developer.android.com/topic/architecture/ui-layeropen in new window

⚠️ 警告:请避免在 ViewModel 的 init 块或构造函数中启动异步操作open in new window。异步操作不应是创建对象时的附带效应,因为异步代码在对象完全初始化之前可能会对该对象执行读写操作。这也称为对象泄露,可能会导致细微且难以诊断的错误。使用 Compose 状态时,这一点尤为重要。当 ViewModel 存储了 Compose 状态字段时,请勿在更新 Compose 状态字段的 ViewModel 的 init 块中启动协程,否则可能会出现 IllegalStateException。

看一个 Clean Architecture 在 Andorid 中代码实现(Android-CleanArchitecture-Kotlinopen in new window):

下面代码的整体架构如下:

https://fernandocejas.com/2018/05/07/architecting-android-reloaded/
https://fernandocejas.com/2018/05/07/architecting-android-reloaded/open in new window

UI Layer中,使用 Activity/Fragment 等与 ViewModel 交互:

class MovieDetailsFragment : BaseFragment() {
	private val movieDetailsViewModel: MovieDetailsViewModel by inject()

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

		// 监听 ViewModel 的状态,刷新 UI
        with(movieDetailsViewModel) {
            observe(movieDetails, ::renderMovieDetails)
            failure(failure, ::handleFailure)
        }
		// 调用 ViewModel 的方法传递用户 event
		moviePlay.setOnClickListener {
            movieDetailsViewModel.playMovie(trailer)
            }
    }
}

Domain Layer,使用 XXXUseCase 提取通用的业务逻辑传递给 ViewModel,将 ViewModel 和 DataLayer 隔离解耦:

class MovieDetailsViewModel(
	// 这里传入的两个参数都是 UseCase,而非 Repository
    private val getMovieDetails: GetMovieDetails,
    private val playMovie: PlayMovie
) : BaseViewModel() {

    private val _movieDetails: MutableLiveData<MovieDetailsView> = MutableLiveData()
	// 对 View 只暴露这个状态
    val movieDetails: LiveData<MovieDetailsView> = _movieDetails

    fun loadMovieDetails(movieId: Int) =
        getMovieDetails(Params(movieId), viewModelScope) {
            it.fold(::handleFailure,::handleMovieDetails)
        }

    fun playMovie(url: String) = playMovie(PlayMovie.Params(url), viewModelScope)

再看一下 UseCase 的代码:

abstract class UseCase<out Type, in Params> where Type : Any {...}

// 这里 UseCase 跟具体的 Repository 交互
class GetMovieDetails(private val moviesRepository: MoviesRepository) :
    UseCase<MovieDetails, Params>() {

    override suspend fun run(params: Params) = moviesRepository.movieDetails(params.id)

    data class Params(val id: Int)
}

Data Layer,暴露给 Domain Layer 的也是一个抽象类(重要,这样对 Domain Layer 屏蔽了底层的实现:网络、数据库、本地缓存),具体的实例则通过 DI 插入:

interface MoviesRepository {// MoviesRepository 类是一个抽象类
    fun movies(): Either<Failure, List<Movie>>
    fun movieDetails(movieId: Int): Either<Failure, MovieDetails>

    class Network(
        // 下面这两个是实际实现网络/数据库读取的工具类
        private val networkHandler: NetworkHandler,
        private val service: MoviesService
    ) : MoviesRepository {

        override fun movies(): Either<Failure, List<Movie>> {
            // 这里真正实现 MoviesRepository 的逻辑,比如请求网络/数据库数据
        }
    }
}

上述代码的详细分析可以参考作者的文章《构建 Android...重装上阵》open in new window,这里简要分析一下:

通过使用 Clean Architecture,我们将 Android APP 分为 UI Layer,Domain Layer,Data Layer 三部分,其中 Domain Layer 是 APP 的主要业务逻辑,不依赖于其他部分,和 Android API 无关,可移植,是 APP 的中心,而 UI Layer 专注处理与用户的交互,而 Data Layer 则实际负责数据读写,这部分的实现可以自由切换。

总结

从 MVC、MVP,到 MVVM、MVI,再到结合 Clean Architecture,随着 Android 开发架构的演进,代码层级越多,抽象越深,带来的是不同层级之间耦合降低,各个层级的关注点分离,更容易测试和维护。

参考资料

《Android 架构指南》https://developer.android.com/topic/architectureopen in new window

《The Clean Architectur uncle-bob》https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.htmlopen in new window

《How to architect Android apps: a deep dive into principles, not rules 如何构建 Android 应用程序:深入探讨原则,而不是规则》https://proandroiddev.com/how-to-architect-android-apps-a-deep-dive-into-principles-not-rules-2f1eb7f26402open in new window

《干净的架构初学者指南》https://betterprogramming.pub/the-clean-architecture-beginners-guide-e4b7058c1165open in new window

《Architecting Android...The clean way?》https://fernandocejas.com/blog/engineering/2014-09-03-architecting-android-the-clean-way/open in new window

《Android Clean Architecture Kotlin》https://github.com/android10/Android-CleanArchitecture-Kotlinopen in new window

《构建 Android 重装上阵》https://fernandocejas.com/2018/05/07/architecting-android-reloaded/open in new window

《Google 推荐使用 MVI 架构?卷起来了~》https://juejin.cn/post/7048980213811642382open in new window

文章标题:《Android 架构模式一览》
本文作者: JI,XIAOYONG
发布时间: 2023/12/15 17:10:00 UTC+8
更新时间: 2023/12/30 16:17:02 UTC+8
written by human, not by AI
本文地址: https://jixiaoyong.github.io/blog/posts/6f46f598.html
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 许可协议。转载请注明出处!
你认为这篇文章怎么样?
  • 0
  • 0
  • 0
  • 0
  • 0
  • 0
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.8