☰
Android架构笔记:状态管理、依赖注入与模块化实战
2026/10/13 7:47:30 网站建设 项目流程

1. 为什么我把架构笔记写成这样

如果你和我一样,经历过一段“能跑就行”的开发阶段,再回头看自己写过的Activity,多少都会冒冷汗。启动页跳转写满了碎逻辑,接口回调直接在页面里改UI,一个方法超过200行——不是不想改,是真不知道从哪里下手。后来我开始系统梳理Android架构,把踩过的坑、想通的原理、反复调整的写法整理成了一套笔记。这份笔记不是什么标准答案,也不是什么“最优实践”,它只是我在实际项目里验证过、并且还在继续演进的方案。

整套笔记的核心线索只有三个问题:状态放哪里、事件往哪走、依赖怎么接。这三个问题想清楚了,架构基本就立起来了。剩下的大部分框架层东西,都只是为这三个问题服务的工具。

1.1 架构不是模型图,是一条条约束

我最早对架构的误解是:只要画得出漂亮的模型图,项目就“有架构”了。后来发现根本不是。图是给人看的,约束才是给代码用的。真正有效的架构,是把“不该做的事情”写死。比如:

  • UI层不能直接持有网络请求的具体实现细节;
  • Data层不能反向依赖UI层,不能在网络回调里去弹Toast;
  • 页面状态必须集中在ViewModel里维护,Activity只负责展示和反馈事件。

这些约束不是怕新人犯错,而是减少编码时的决策成本。人每天能做的决策有限,如果写每一行代码都要纠结“该放哪”,到下午基本就废了。约束定好了,大部分常见场景照着规则走就行,脑子留给真正需要判断的业务逻辑。

用一个生活类比:一个不做分区、调料全放台面、抹布和菜刀混在一起放的厨房,做完第一道菜也许很快。但要连续做十道菜,你连锅都找不到。架构不是在开火前把厨房照得漂漂亮亮,而是让做第十道菜的时候,你还能像做第一道菜一样知道东西在哪儿。

1.2 适合谁参考、怎么参考

这份笔记适合正在从一个“功能能跑”到“后悔还能改”阶段的人。如果你刚入行,先把单一Activity、ViewModel、Repository这些基础组件用熟,不要一开始就上多模块。如果你已经做过一两个完整项目,那笔记里的分层、依赖注入、状态管理部分会更有参照价值。

我建议的阅读方式是:先看结论,再回去理解推导过程。某个具体方案为什么要这么选,比方案本身更重要。因为项目不同、团队不同、业务不同,适合的架构不会一样。我的分工是:把“取舍逻辑”写清楚,把“可直接抄的配置”放在代码块里。这样你既知道怎么用,也知道什么时候不该用。

2. 模式选型:从MVC到MVVM再到MVI的取舍记录

模式选型是架构学习里最容易吵起来的部分。我一开始也很纠结:到底用MVVM还是MVI?后来发现,根本不需要站队。每一种模式解决的问题都不一样,适用场景也不一样。

2.1 四种模式我用下来的亲身体会

先放一张简表,是我在不同项目里实际用过之后的结论。

模式核心特点优点痛点我的使用场景
MVCActivity既是View又是Controller简单直接生命周期与业务逻辑混杂,类快速膨胀早期小Demo
MVPPresenter通过接口隔离View和Model业务逻辑可单独测试接口过多,维护成本高遗留项目维护
MVVMViewModel持有状态,View观察状态状态集中,生命周期安全使用不当会隐式更新,状态流不清晰日常主力方案
MVI状态单向下行,事件单向上行状态变化可回溯,逻辑链路清晰样板代码多,上手门槛高复杂页面、表单、聊天

MVVM里我推荐用StateFlow + ViewModel + Repository的搭配,而不是重度依赖DataBinding。DataBinding能少写很多样板代码,但表达式里塞逻辑之后,代码变得很难调试。我个人的分界线是:布局表达式只做展示和简单格式化,不写业务判断。业务判断全部放到ViewModel里。

MVI也不是银弹。它的核心思想是“页面只有一个不可变状态,所有变更通过事件触发,最终归约成新状态”。这听起来很美好,但在实际页面里,如果一个状态对象里有七八个字段,每次只改一个字段也要copy整个状态,样板代码会非常烦人。我现在的做法是:常规页面用MVVM,状态复杂、交互链路长的页面引入MVI的单向数据流思想,但不完全照搬。

2.2 单Activity是不是唯一选项

我现在大部分项目都采用单Activity + Navigation组件。这样做的好处非常明显:屏幕上显示什么页面,完全由导航栈决定,ViewModel的scope也按返回栈管理。Activity不再是“一页一Activity”里的页面容器,而更像是一个应用外壳。

但如果你接手的是老项目,几百个Activity已经存在了,别为了“架构正确”硬改成单Activity。架构调整要跟着业务节奏走,而不是跟着概念走。我在某个项目里花了两个迭代做了部分页面的单Activity化,只挑高频跳转链路改,低频页面等自然重构时再收敛。效果不错,风险也可控。

2.3 一个可以直接套用的ViewModel骨架

下面这段是我日常写页面的基础模板,去掉了业务细节,保留了结构:

class MainViewModel( private val repository: MainRepository ) : ViewModel() { private val _uiState = MutableStateFlow(MainUiState()) val uiState: StateFlow<MainUiState> = _uiState.asStateFlow() fun loadData() { viewModelScope.launch { _uiState.update { it.copy(loading = true, error = null) } runCatching { repository.fetchData() } .onSuccess { data -> _uiState.update { it.copy(loading = false, data = data) } } .onFailure { e -> _uiState.update { it.copy(loading = false, error = e.message) } } } } } data class MainUiState( val loading: Boolean = false, val error: String? = null, val data: List<String> = emptyList() )

这里的关键不是用了runCatching,而是页面状态被显式建模。Loading、Error、Data都写在同一个不可变对象里。UI层只需要观察uiState,根据状态组合刷新页面。这样做的直接好处是:你不用再为“网络请求后还要不要刷新上一个页面”这种问题单独写一堆回调接口。

3. 分层与依赖方向:最容易被忽略的架构纪律

很多人在讲架构时只谈UML图,不谈依赖方向。但在我眼里,依赖方向才是架构里最核心的纪律。什么分层、模块化,本质都是在控制依赖方向。把这个想明白了,后面很多东西都顺了。

3.1 三层依赖方向到底在防什么

官方现在推荐的仍然是三层结构:UI层、Domain层、Data层。但严格来说,中小项目里Domain层可以很薄,甚至可以省略。我个人的划分方式是:

  • UI层:Activity、Fragment、Compose页面、ViewModel;
  • Domain层:纯业务逻辑,比如计算规则、状态流转;
  • Data层:本地存储、网络接口、数据库、数据仓库。

依赖方向必须是 UI层 → Domain层 → Data层。反过来不行。Data层不能知道UI层的存在,这是这条纪律的红线。

为什么这么严格?因为一旦Data层可以直接操作UI,就会出现“网络回调里弹对话框”这种代码。表面上看只是方便,实际上会让测试、复用、模块拆分全部变得不可能。Data层里的一个类不再只是一个数据来源,它悄悄变成了一个UI控制器,任何地方想使用它,都得带着它依赖的Activity上下文。时间一长,整个项目就像一团缠死的线。

顺带说一句:Domain层到底要不要留,我的经验是看业务复杂度。如果你的业务规则只是“请求一个列表然后展示”,那Domain层就是过度设计。但如果你的业务里有复杂的计算、状态机、多数据源合并,那Domain层能把规则从UI和Data之间解放出来,值得保留。

3.2 Hilt落地时的几个取舍

依赖注入到了Android上,最常见的选择是Hilt。它最大的作用是让依赖关系显式化,而不是靠object单例满天飞。我用Hilt时有三条自己定的规矩:

  1. 能用构造注入就用构造注入,避免字段注入。字段注入让依赖关系藏在类内部,构造注入则强迫所有依赖在创建时可见。
  2. 作用域要克制。@Singleton不是万能钥匙。如果某个对象只在某个页面生命周期内有效,把它注成全局单例,轻则浪费内存,重则持有Activity引用导致泄漏。
  3. 跨模块依赖不能偷懒。不要在模块A里为了用模块B的接口,直接implementation依赖模块B。应该把接口下沉到更底层的公共模块。

下面是一个常见的网络层Module示例:

@Module @InstallIn(SingletonComponent::class) object NetworkModule { @Provides @Singleton fun provideApiService( okHttpClient: OkHttpClient ): ApiService { return Retrofit.Builder() .baseUrl(BuildConfig.BASE_URL) .client(okHttpClient) .build() .create(ApiService::class.java) } }

这里有个容易被忽略的细节:ApiService本身通常是无状态的接口,它可以放在任何模块里。但OkHttpClient这种带连接池和线程池的对象,一定要做成单例。如果你在多个地方各建一个OkHttpClient,最直接的问题是连接池失效,SSL握手频繁重来,网络请求会莫名变慢。

注意:Hilt的@ActivityScoped、@ViewModelScoped不要滥用。我见过一个项目,几乎每个类都标@Singleton,最后变成全局单例满天飞,依赖注入失去了意义。

4. 协程、Flow与状态管理的实操笔记

架构不能只谈分层,还要解决异步和状态。早期Android用回调,后来用RxJava,再后来是协程。我这套笔记里把协程和Flow单拎出来,是因为它们已经成了现代Android架构的事实基建。

4.1 从回调切换到协程的真实体会

协程最大的价值不是写代码更短,而是结构化并发。一个viewModelScope启动的协程,会跟着ViewModel的销毁自动取消,不需要你手动管理。这个特性直接解决了以前回调时代最头疼的问题:页面销毁后,还在执行的请求结果回头来操作已经销毁的UI。

我用协程之后最大的感受是:错误处理终于变成线性的了。以前回调嵌套里,每层都要单独处理失败,失败分支越叠越多。现在一个runCatching或try/catch就能包住整段逻辑,代码的可读性提升不是一点半点。

当然协程也有坑。最典型的是在Dispatchers.IO里直接更新UI对象,或者忘记切回主线程。我这边的惯例是:ViewModel里的协程不显式指定线程,因为viewModelScope默认在主线程执行,网络和数据库操作会在Repository内部通过suspend函数的实现去切换线程。这样UI层和业务层都不需要关心线程细节。

4.2 StateFlow、SharedFlow 和一次性事件

这是我被问得最多的问题:页面通知用StateFlow还是SharedFlow?

我的判断很简单:

  • UI状态用StateFlow。StateFlow只保留最新值,适合“当前应该显示什么”这种状态。
  • 一次性事件(Snackbar、导航跳转、Toast)用SharedFlow或Channel。因为一次性事件如果用一个普通Flow来传,在配置变更后重放,用户就会看到弹窗重复出现。

SharedFlow我也很少直接用原始API,而是在ViewModel里做一个事件封装:

class MainViewModel : ViewModel() { private val _events = Channel<MainEvent>(Channel.BUFFERED) val events = _events.receiveAsFlow() fun onButtonClicked() { viewModelScope.launch { _events.send(MainEvent.ShowMessage("按钮被点击")) } } } sealed interface MainEvent { data class ShowMessage(val message: String) : MainEvent }

这里的要点是:事件和状态分开处理。状态持续存在,可以被组合;事件是一次性的,消费完就消失。合在一起会出各种奇怪问题。比如你用MutableStateFlow<UiEvent?>保存事件,事件发出去后如果没人消费,下一次状态更新时这个旧事件可能还在,导致相同逻辑触发了两次。

4.3 loading、error、empty 的组合处理

状态管理的难点通常不是单个状态,而是多个状态同时存在。比如一个页面既在刷新列表,又在下拉加载更多,还同时收到一个网络错误。如果状态建模得不好,就会出现“loading把error遮住”之类的问题。

我的习惯是给页面建一个不可变状态对象,字段组合表达当前界面:

data class ListUiState( val list: List<Item> = emptyList(), val loadingMore: Boolean = false, val refreshing: Boolean = false, val errorMessage: String? = null, val endReached: Boolean = false )

页面根据loadingMore、refreshing、list.size、errorMessage这几个字段的组合,决定显示什么。不要写“if loading show loading, else if error show error, else show list”这种简单树,因为真实页面的状态永远比这复杂。用字段组合表达后,UI层只是状态的翻译器,不再是业务判断的堆砌点。

5. 模块化:从单模块到多模块的实战清单

架构笔记写到最后一个大环节,就是模块化。如果你是个人开发者,单模块完全够用。如果你在一个业务迭代很快的团队里,多模块的价值就会体现出来:并行开发不冲突、构建缓存更友好、代码边界更清晰。

但模块化不是拆得越碎越好。拆得太碎,依赖图会变成一团乱麻,构建时间不降反升。

5.1 模块划分该按什么切

常见的划分方式有两种:按层切和按功能切。

按层切就是把app、core、data、network拆成独立模块。这种方式结构清晰,适合团队规模小、业务耦合少的场景。按功能切则是把登录、首页、订单这些业务拆成feature_login、feature_home,每个feature内部自己决定是否需要data和domain。

我的经验是:先按层切,再在层内按功能切。也就是说,先有一个公共的core模块放基础能力,再按业务功能拆出feature模块,feature之间不互相依赖。如果两个feature有公共代码,就下沉到core里,而不是直接依赖对方。这样能把业务边界和复用逻辑同时照顾好。

具体的Gradle配置我通常用Kotlin DSL统一管理:

// gradle.properties android.useAndroidX=true android.enableJetifier=true

模块化最怕的是在根项目的Gradle配置里写一堆各自独立的重复配置。我会抽出一个公共的构建逻辑模块,统一处理SDK版本、编译选项、依赖版本。这样新开模块时只需要几行配置,不会“复制粘贴一份又改漏一个版本”。

5.2 构建时间与依赖循环的平衡

模块多起来之后,编译时间不一定会线性缩短。如果A模块依赖B,B模块又依赖A,Gradle直接报循环依赖错误。更隐蔽的问题是:模块之间的间接依赖太多,任何改动都会牵连一片模块重新编译。

我用来控制的做法有几个:

  1. 每周用Gradle的依赖报告检查一次模块依赖方向;
  2. 把网络、工具函数、通用组件下沉到core模块,减少feature之间的直接依赖;
  3. 如果某个依赖只在一个模块内部使用,不要往上抛到公共模块的api配置里。

api和implementation的区别值得单独说一句。用implementation时,传递依赖不会暴露给下游模块;用api时,下游模块也能拿到这个依赖。很多人图省事直接用api,结果一个依赖变化,所有模块都重新编译。我的准则是:能不用api就不用api。这不仅能加快编译,还能让模块之间的耦合暴露得更晚、更少。

5.3 模块化后最容易踩的坑

模块化踩坑是家常便饭,我这里列几个自己真踩过的:

  • R类找不到。每个模块有自己的R类,不能再只写import xxx.R。必须显式import当前模块的R。
  • AndroidManifest合并冲突。多个依赖库都有自己的Manifest,合并时如果设置了同名activity或权限,会报冲突。处理办法是检查合并后的Manifest,而不是猜。
  • Navigation跨模块跳转困难。官方Navigation的findNavController能在同一个Activity内跨模块使用,但深链接和路由表如果散落在各个模块,要有人统一维护。
  • Hilt跨模块缺少module。feature模块需要使用core模块的依赖时,需要在core模块的@InstallIn里把相关Module安装到同一个Component;漏了,编译期不会马上报错,运行期才提示无法注入。

这些坑单模块时代基本不会出现,进入模块化后会轮着踩一遍。我在笔记里专门留了一张检查清单,每次新建模块就照着走一遍,能省很多排查时间。

6. 常见问题速查与我的处理习惯

架构笔记写到最后,最值钱的反而不是理论,而是问题排查的路径。下面这张表是我实际开发中遇到的高频问题,以及对应的定位思路。它不能覆盖所有情况,但能帮你少走一半弯路。

问题现象常见原因我的一般解决路径
页面从后台回来,接口被重复请求在lifecycleScope里启动collect,没有搭配repeatOnLifecycle在Fragment的onStart里用repeatOnLifecycle包裹
配置变更后,一次性事件重复弹出用StateFlow当事件通道改用Channel/SharedFlow,事件消费后再发送
Hilt运行期提示无法注入缺少@AndroidEntryPoint或模块没有安装到对应Component检查Activity/Application注解,再查Module的@InstallIn
协程里的网络请求没有取消用了GlobalScope而不是viewModelScope全局搜索GlobalScope,替换成有清晰生命周期的scope
模块化后R资源编译不过没有显式import当前模块的R确认资源所在模块名,改成显式import
依赖版本冲突不同模块声明了同一个库的不同版本用统一的版本目录或Gradle的依赖约束

6.1 环境与构建的五个坑,每个都卡过我半天

构建环境的问题单独拿出来说。这几个坑不处理好,架构笔记写得再漂亮,项目也跑不起来。

JDK版本不匹配是最常见的。比如某个库要求JDK 17,但你本机默认还是JDK 11,Gradle会报一些莫名其妙的错误。解决办法是在build.gradle.kts里统一配置compileOptions的sourceCompatibility和targetCompatibility,并且确认AS使用的JDK路径和命令行用的JDK一致。

SDK路径带空格也算老生常谈。早期的Android SDK如果装在Windows的Program Files目录下,路径里的空格会让某些原生工具无法工作。我现在都把SDK放在D:\Android\Sdk这类纯英文无空格路径下。

Gradle缓存损坏看起来是玄学。现象是突然某个依赖下载不完,报checksum错误。最直接的修复是删除~/.gradle/caches下的对应目录,再重新同步项目。不要一上来就重装AS,那样成本太高。

AndroidX迁移遗漏是历史项目改架构时的高频事故。只改了compileSdk和targetSdk,没把android.useAndroidX=true配上,或者还有旧库在拉非AndroidX的support包。迁移时我建议用官方帮助脚本自动扫描一遍,同时打开Build窗口搜索android.support关键词。

Kotlin插件版本冲突则通常出现在多模块项目里。某个模块用了新版的Kotlin插件,另一个模块还在用旧版,Gradle会提示版本不一致。我现在的做法是:所有版本的Kotlin、Hilt、KSP/APT插件统一写在一个版本目录文件里,全项目引用同一处,避免各模块自定版本。

6.2 我复盘一个架构问题的固定步骤

遇到架构相关的问题,我很少直接翻Stack Overflow或者问同事。先自己按一套固定步骤走一遍,通常会找到答案。

第一步,复现。明确触发条件,把操作步骤和前后状态写下来。这一步看起来笨,但至少能排除“我这行代码压根没执行”的乌龙。

第二步,画依赖链。从触发点开始,把UI层调了谁、Repository调了谁、谁持有了谁,画出来。很多时候问题不在代码逻辑,而在错误的依赖方向。比如Data层的网络回调里直接更新UI,这条依赖链本身就违规了。

第三步,判断是状态问题、事件问题,还是生命周期问题。我常问自己:如果这个页面不销毁,问题会不会出现?如果把状态改成不可变对象,问题会不会消失?这样能快速定位是数据更新方式不对,还是生命周期scope没处理好。

第四步,最小化修改。架构问题最忌讳大范围重构。我倾向于先加一个防御性判断或调整一个对象的scope,验证方向是否正确,再决定要不要大面积改。

第五步,补测试。至少补一个覆盖这次问题场景的单元测试。实测下来,补测试的意义不只是防止回归,而是逼你把问题描述清楚。写测试的时候,你会发现很多之前“感觉是bug”的地方,其实是自己对状态模型的定义不完整。

最后说一个我的实操习惯

我这份Android架构笔记,与其说是记录答案,不如说是记录问题。每次遇到一个架构问题,我都会先记现象、再记原因、最后记“下一次怎么提前预防”。一开始笔记很乱,后来慢慢形成了上面这套结构:模式选型、分层纪律、异步状态、模块化、问题排查。最近一次重构老项目时,我靠这些笔记少踩了至少三个大坑,也把团队里新人培训的时间从两周压缩到了一周左右。

如果你也想整理自己的架构笔记,我建议不要直接抄我的目录。先把你最近半年遇到的最痛的问题列出来,按这些问题去组织笔记。架构本质上是用来解决问题的,问题在哪里,架构笔记就从哪里生长。

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

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

立即咨询