☰
Android MVVM 示例 Demo:从分层到状态收敛的完整实践
2026/10/11 21:39:30 网站建设 项目流程

简介:这份资源是面向Android开发者、尤其是希望从MVC或MVP转向MVVM架构的中级学习者的示例Demo,通过完整可运行的项目演示Model、View、ViewModel三层如何协作,解决架构分层不清、UI逻辑臃肿、数据与视图耦合过紧等常见问题。压缩包共737个文件,约20.65MB,以xml布局、json配置、class与dex字节码、java与kt源码、gradle构建脚本及properties属性文件为主,另含少量jar、webp资源与apk成品,覆盖从源码到打包的完整工程结构。目前已有200人学习下载。Demo重点展示ViewModel持有数据、LiveData感知生命周期、Data Binding在XML中声明绑定关系以及Binding Adapter处理自定义属性等关键环节,读者可据此理解仓库类如何向ViewModel供数、视图如何自动响应数据变化,并参考其目录组织方式将MVVM快速落地到自己的项目中,提升代码可测试性与团队协作效率。

1. 从一次“改不动”的界面说起:这个 Android MVVM 示例 Demo 到底能解决什么

接手过一个二手页面,Activity 里塞了八百多行,网络请求、JSON 解析、按钮状态、列表刷新全搅在一起。产品要加一个“加载失败重试”,我改了三处,崩了两处。那次之后我开始认真找一套能照着抄的 MVVM 骨架,而不是只看概念图。这个 Android MVVM 示例 Demo 就是干这个的:它把 View、ViewModel、Model 三层拆开,用可运行的最小工程演示数据怎么从数据源流到界面、状态怎么回传、配置变更后数据怎么不丢。适合两类人:一是刚接触 Android 设计模式、被 MVP 和 MVC 绕晕的新手,二是想把手头 Activity 拆干净、但缺一个可对照模板的熟手。它不教你写炫酷 UI,只解决一件事——让界面逻辑和数据逻辑各回各家。

2. 拆开这个 Demo:三层结构、数据流向与选型理由

2.1 View 层只做“看”和“转发”,不碰业务

在这个 Demo 里,Activity 或 Fragment 的职责被压到极窄:初始化视图、监听用户操作、把结果丢给 ViewModel、订阅状态变化刷新 UI。它不直接 new 数据仓库,也不写解析逻辑。常见做法是让 View 持有 ViewModel 引用,通过观察者模式接收数据。这样做的直接好处是,你把 Activity 删掉换成 Fragment,业务代码一行不用动。

判断一个 View 层写得合不合格,有个土办法:看它有没有 import 网络库或数据库类。如果 import 了,说明分层没做干净。Demo 里刻意把这类依赖全部挡在 View 之外,就是为了让这个边界看得见。

2.2 ViewModel 层扛住状态,配置变更不丢数据

ViewModel 是这个模式的核心。它持有界面状态,暴露可观察的数据流,处理来自 View 的事件。关键点在于它的生命周期比 Activity 长——屏幕旋转时 Activity 重建,ViewModel 还活着,数据不用重新拉。Demo 里通常用一个可变的状态容器加一个只读暴露,外部只能读不能直接改,改必须走方法。

// ViewModel 持有状态,对外只读暴露 class UserViewModel(private val repo: UserRepository) : ViewModel() { // 内部可变,外部不可见 private val _uiState = MutableLiveData<UiState>() // 对外只读,View 只能观察 val uiState: LiveData<UiState> = _uiState fun loadUser(id: String) { // 切到后台线程做数据请求 viewModelScope.launch { _uiState.value = UiState.Loading try { val user = repo.fetchUser(id) // 挂起函数,不阻塞主线程 _uiState.value = UiState.Success(user) } catch (e: Exception) { _uiState.value = UiState.Error(e.message) } } } }

这段代码里,_uiState和uiState的命名约定是刻意为之:下划线前缀表示“仅内部可写”。viewModelScope绑定 ViewModel 生命周期,页面销毁时协程自动取消,避免回调打到已销毁的 View 上。UiState一般是个密封类,把 Loading、Success、Error 三种状态收拢,View 层用 when 分支处理,不会出现“又加载又报错”的中间态。

参数上要注意:loadUser接收的 id 建议做非空校验,别指望调用方一定传对。Demo 里如果用了LiveData,setValue只能在主线程调,子线程要用postValue,这是新手最容易翻车的地方之一。

2.3 Model 层与仓库:数据从哪来、怎么换源

Model 层在 Demo 里通常拆成两块:数据实体(Entity/Model)和数据仓库(Repository)。实体就是纯数据类,仓库负责决定数据从网络拿还是从本地缓存拿。这样拆的意义在于,将来换接口、加缓存、做离线,只动仓库,ViewModel 和 View 无感。

// 仓库层:对上层屏蔽数据来源 class UserRepository( private val api: UserApi, private val dao: UserDao ) { suspend fun fetchUser(id: String): User { // 先查本地缓存,命中直接返回 dao.findById(id)?.let { return it } // 未命中再走网络,并写入缓存 val remote = api.getUser(id) dao.insert(remote) return remote } }

fetchUser是挂起函数,调用方必须在协程里执行。先本地后网络的策略叫 Cache-Aside,适合读多写少的场景。如果业务要求数据必须最新,就把顺序反过来或加过期时间。Demo 里用接口注入 api 和 dao,是为了方便替换成假数据做测试——这也是选 MVVM 而不是 MVP 的一个现实理由:ViewModel 不依赖 Android 框架类,单元测试跑得飞快。

2.4 为什么是 MVVM,而不是继续用 MVP

MVP 里 Presenter 持有 View 接口引用,页面销毁时得手动解绑,漏了就内存泄漏。MVVM 用可观察数据流替代了这层手动绑定,View 观察 ViewModel,ViewModel 不知道 View 存在,解耦更彻底。再加上 ViewModel 天然扛配置变更,旋转屏幕不用重新请求,这是 MVP 要额外写代码才能做到的。代价是学习曲线:LiveData、协程、数据绑定这些概念得先过一遍。Demo 的价值就在于把这些概念压进一个能跑起来的最小工程,而不是散落在十几篇文章里。

3. 把这个 Demo 跑起来:环境、依赖与最小可运行步骤

3.1 环境准备与依赖配置

拿到工程先别急着点运行,版本对不上是最高频的翻车点。Demo 一般基于 Kotlin 编写,构建工具用 Gradle。你需要确认本地 JDK 版本、Android Gradle Plugin 版本、Kotlin 版本三者兼容。常见做法是打开工程根目录的build.gradle,看插件版本,再对照官方兼容表调整。

// app/build.gradle 中 MVVM 相关核心依赖 dependencies { // ViewModel 与 LiveData implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2" implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.6.2" // 协程 implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" // 网络与数据库按 Demo 实际选型保留 implementation "com.squareup.retrofit2:retrofit:2.9.0" implementation "androidx.room:room-runtime:2.5.2" }

lifecycle-viewmodel-ktx提供viewModelScope,没有它协程作用域得自己建。lifecycle-livedata-ktx提供liveData {}构建块。协程版本和 Kotlin 版本强相关,Kotlin 1.8 配 1.7.x 协程是稳的,跨大版本容易出NoSuchMethodError。Retrofit 和 Room 是 Demo 里常见的网络与本地存储组合,如果你的工程只演示内存数据,这两行可以去掉,但仓库层的接口要相应改成假实现。

提示:依赖拉不下来时先看是不是仓库地址没配全,再确认网络能访问 Maven 中央仓库,别一上来就怀疑代码。

3.2 从入口到数据流:一次完整调用链

跑起来之后,建议按一次按钮,顺着日志把整条链路走一遍。以“加载用户”为例:Activity 的点击监听调用viewModel.loadUser(id),ViewModel 切协程发请求,仓库先查本地再走网络,结果通过 LiveData 回传,Activity 的观察者收到后刷新 TextView 或列表。

// Activity 中订阅状态,按状态分支刷新 UI viewModel.uiState.observe(this) { state -> when (state) { is UiState.Loading -> progressBar.visibility = View.VISIBLE is UiState.Success -> { progressBar.visibility = View.GONE nameText.text = state.user.name // 只在成功态取数据 } is UiState.Error -> { progressBar.visibility = View.GONE toast(state.message) } } }

observe的第一个参数传this(Activity 或 Fragment),生命周期感知,页面不可见时不回调,避免空指针。when必须穷举密封类的所有子类,编译器会帮你查漏,这是密封类比枚举加字符串好用的地方。注意state.user只在 Success 分支能访问,其他分支拿不到,从类型上杜绝了“数据没来就刷新”的错误。

3.3 用假数据验证分层是否真的解耦

想确认分层有没有做到位,有个低成本验证法:把仓库实现换成一个返回固定假数据的版本,不改 ViewModel 和 View,看界面能不能正常显示。如果能,说明依赖方向是对的。

// 测试用假仓库,不碰网络和数据库 class FakeUserRepository : UserRepository() { override suspend fun fetchUser(id: String): User { delay(500) // 模拟网络延迟 return User(id, "测试用户", 18) // 固定返回 } }

delay模拟耗时,用来观察 Loading 态是否正常显示。如果换成假仓库后界面卡死或状态不刷新,问题多半出在 ViewModel 的线程切换或观察者注册时机上,而不是数据源。这个替换动作本身也是单元测试的基础:ViewModel 的测试不需要 Android 环境,传个假仓库进去就能跑。

4. 避坑与排查:MVVM 落地时最容易翻车的五件事

4.1 现象:旋转屏幕后数据重新加载,Loading 又转一圈

原因:ViewModel 没有真正独立于 Activity,或者数据请求写在了 Activity 的 onCreate 里而不是 ViewModel 的初始化块里。解决:把加载逻辑放进 ViewModel 的init或首次调用时判断状态,Activity 重建时只重新订阅,不重新触发请求。检查viewModelScope是否被误建成了普通CoroutineScope。

4.2 现象:LiveData 的 observe 回调不执行

原因:observe注册在了错误的生命周期所有者上,或者用了observeForever却忘了移除。解决:Activity/Fragment 里统一传this或viewLifecycleOwner,Fragment 里尤其要用后者,否则会收到已销毁视图的回调。observeForever只在确实需要常驻监听时用,且必须配对removeObserver。

4.3 现象:子线程更新 UI 报 “Only the original thread that created a view hierarchy can touch its views”

原因:在协程里直接改了 View,或者用setValue在子线程更新 LiveData。解决:UI 操作放回主线程,LiveData 子线程更新用postValue。更稳的做法是 ViewModel 只暴露状态,View 层在观察回调里刷新,回调默认在主线程。

4.4 现象:内存泄漏,LeakCanary 报 Activity 被 ViewModel 持有

原因:ViewModel 里存了 Activity、Context 或 View 的引用。解决:ViewModel 只持有数据仓库和状态,需要 Context 时用 AndroidViewModel 并只拿 Application 上下文。任何 View 类型的字段都不该出现在 ViewModel 里。

4.5 现象:依赖注入没配好,运行时报 “Cannot create an instance of class ViewModel”

原因:ViewModel 构造函数带了参数,但用的是默认工厂,工厂不知道怎么造。解决:要么给 ViewModel 提供无参构造,要么自定义 Factory 或引入依赖注入框架把仓库传进去。Demo 里如果用了 Hilt,检查@HiltViewModel和@AndroidEntryPoint是否都标了,漏一个就报这个错。

5. 进阶:把状态收敛成一个 UiState,再谈测试与复用

Demo 跑通之后,真正拉开差距的是状态管理。早期我习惯在 ViewModel 里散着放isLoading、errorMsg、data三个字段,结果界面偶尔同时显示“加载中”和“加载失败”,排查半天发现是两个字段更新不同步。后来改成单一UiState密封类,任何时刻只有一个状态成立,这类玄学问题直接消失。

// 单一状态源,杜绝中间态打架 sealed class UiState { object Loading : UiState() data class Success(val user: User) : UiState() data class Error(val message: String) : UiState() }

object Loading不需要携带数据,用 object 比 class 省内存。Success 和 Error 带各自需要的数据,类型安全。View 层一个 when 处理完,新增状态时编译器强制你补分支,不会漏。

状态收敛之后,测试就好写了。ViewModel 的测试不需要真机,用runTest加假仓库即可:

@Test fun `加载成功时状态为 Success`() = runTest { val vm = UserViewModel(FakeUserRepository()) vm.loadUser("1") // 推进协程直到空闲 advanceUntilIdle() assertTrue(vm.uiState.value is UiState.Success) }

runTest提供测试调度器,advanceUntilIdle把挂起的协程推到执行完。断言只关心状态类型,不关心具体数据,测试稳定不易碎。这套写法在 CI 上跑几秒出结果,比 instrumentation 测试快一个数量级。

复用层面,一个页面一个 ViewModel 是默认,但列表项这种重复结构可以抽共享的 ViewModel 或状态容器。判断标准是:如果两个页面的状态字段和事件高度重合,就抽;只是长得像但业务不同,别硬抽,否则改一个崩两个。我一般会先让功能跑通,等第三个相似页面出现再动手抽象,避免过早设计。

从那以后我每次拆 Activity,都强制先画一遍数据流向:谁触发、谁处理、谁持有状态、谁刷新界面,四步对不上就不动手写代码。这套 Demo 给我的最大价值不是代码本身,而是让我养成了先定边界再填逻辑的习惯。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询