前言
写这篇文章的契机,是我反复思考的结果:
- 有在 Repository 里直接
launch一个协程,理由是"调用方更省事"; - Code Review 时发现 Data 层用
start()/stop()管理广播注册,争论了很久"谁该负责 stop"; - 还有同事仍在用 EventBus 做组件间通信,理由是"简单好用"。
这些问题表面上看起来毫无关联,一个是协程写法,一个是资源管理,一个是通信框架。但深入讨论下去,会发现它们指向的是同一个底层问题:
任务、资源、事件的归属权,到底应该在哪一层被决定?
这个问题的答案,就是"结构化"。
第一章:什么是结构化
在进入协程之前,先抛开技术,看现实世界的一个例子。
搬家时,所有东西直接堆在地上:
text
衣服 / 袜子 / 电脑 / 充电器 / 牙刷 / 书籍 / 数据线
找东西全靠运气------这是无结构。
如果分类整理:
text
衣柜
├─ 上衣
├─ 裤子
└─ 袜子
书架
├─ 技术书
└─ 小说
工具箱
├─ 数据线
├─ 充电器
└─ 鼠标
此时每个东西都有归属,每个归属都有边界,每个边界都有规则。这就是结构化:
结构化 = 组织关系 + 生命周期关系 + 边界关系
整个软件工程史,其实一直在做同一件事------把"混乱"变成"结构":
| 阶段 | 解决的混乱 |
|---|---|
| GOTO → if/for/while | 流程跳转失控 |
| 面向对象 | 数据满天飞、职责不清 |
| MVC / MVVM / Clean Architecture | 代码堆在一起、谁都能改谁都能依赖 |
| 结构化并发(协程) | 并发任务脱离生命周期,没人知道谁创建、谁管理、谁取消 |
传统线程模型的问题正是"无结构并发":
java
new Thread(() -> {
while (true) { doWork(); }
}).start();
父线程结束、Activity 销毁、页面退出------子线程依然在跑,没有人知道它何时该停。
协程真正的创新不是"线程切换更方便",而是结构化并发(Structured Concurrency):让每一个并发任务都知道"谁创建我、谁管理我、谁取消我、谁等待我、谁负责我的异常"。
第二章:协程的两层结构
结构化并发具体落在两个层面上。
第一层:生命周期结构
协程必须属于某个 Scope,不能凭空存在,就像文件必须属于文件夹一样:
kotlin
class UserViewModel : ViewModel() {
fun load() {
viewModelScope.launch {
repository.loadUser()
}
}
}
ViewModel 销毁时,viewModelScope 自动取消,整棵协程树一起回收。
第二层:取消结构
kotlin
coroutineScope {
launch { loadUser() }
launch { loadOrder() }
}
形成父子关系:取消 Parent,子任务自动跟着取消。这种传播是自动的,不需要任何手动 stop()。
为什么 GlobalScope 被官方建议避免
kotlin
GlobalScope.launch { }
它的问题不是 API 难用,而是脱离了结构:没有父节点、没有生命周期归属、没有取消关系、没有异常归属。它实际上绑定到了进程级别的生命周期,而这通常不是开发者真正想要的边界。
withContext 与 launch 的本质区别
这是很多开发者容易混淆的一个点,但它恰恰是理解"结构化"的关键。
kotlin
// withContext:只是切换执行上下文,仍属于同一个任务
suspend fun loadUser(): User = withContext(Dispatchers.IO) {
api.getUser()
}
withContext 切换的只是线程,生命周期、取消关系、异常传播都没有变,整个调用链仍然保持结构化。
kotlin
// launch:创建了一个新的、独立的协程
fun loadUserBad() {
repositoryScope.launch {
api.getUser()
}
}
launch 则创建了一个新的 Job,意味着生命周期、取消关系、异常传播关系都发生了变化------调用方取消自己,并不一定能取消这个新任务。
withContext改变的是执行线程;launch改变的是任务归属。
很多公众号把这两者混为一谈,是导致"Repository 滥用 launch"这一类反模式的重要原因之一。
第三章:三大反模式现场
理解了理论,再看现实代码里"结构"是怎么被破坏的。下面这段代码,在很多项目里都能看到:
kotlin
class UserViewModel : ViewModel() {
fun load() {
viewModelScope.launch(Dispatchers.IO) {
try {
val user = repository.loadUser()
_uiState.value = UiState.Success(user)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
_uiState.value = UiState.Error(e.message)
}
}
}
}
}
代码能跑,但从架构角度看,问题已经出现了。
反模式一:ViewModel 决定线程模型
kotlin
viewModelScope.launch(Dispatchers.IO)
核心问题不是"IO 用得对不对",而是 ViewModel 不应该做这个决定 。ViewModel 的职责应当是组织业务流程、暴露 UI State,而不是决定运行在哪个线程上。一旦 ViewModel 指定了 Dispatchers.IO,意味着 UI 层开始感知线程模型,线程语义从数据层泄漏到了展示层------这是典型的职责倒置。
反模式二:UI 层手动切回 Main,是结构已经失衡的信号
kotlin
withContext(Dispatchers.Main) {
_uiState.value = UiState.Error(e.message)
}
如果需要在 ViewModel 里"手动切回 Main",说明协程的启动上下文已经不合理。结果是形成了 Main → IO → Main 这样的执行路径。问题不在于多了一次切换,而在于 ViewModel 正在承担"线程编排"的职责。
反模式三:异常处理逻辑泄漏到 UI 层
kotlin
try {
val user = repository.loadUser()
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message)
}
这段代码表面是在"兜底",但实际暴露了更深的问题:UI 层正在直接感知底层异常 。ViewModel 需要知道 Repository 会抛什么异常,UI State 与异常类型产生耦合,网络错误、业务错误、数据错误在 UI 层被混为一谈。异常是实现细节,UI 层只应该关心结果语义。
正确的分层原则
**Repository 的职责收敛为三点:**确定线程模型、捕获并转换异常、返回稳定可消费的结果。
**ViewModel 的职责只有一件事:**将 Repository 的结果映射为 UI State。
kotlin
// Repository 层:线程切换和异常转换在这里完成
class UserRepository {
suspend fun loadUser(): User = withContext(Dispatchers.IO) {
try {
api.getUser()
} catch (t: Throwable) {
throw mapToAppException(t)
}
}
}
// ViewModel 层:只消费结果,没有 Dispatchers,没有 try-catch
class UserViewModel : ViewModel() {
fun load() {
viewModelScope.launch {
repository.loadUser()
.onSuccess { _uiState.value = UiState.Success(it) }
.onFailure { _uiState.value = UiState.Error(it.message) }
}
}
}
当需求变化(增加缓存、重试、fallback、日志、替换数据来源)时,所有改动只发生在 Repository 层,UI 层与 ViewModel 的代码无需调整。
| 表象 | 本质问题 |
|---|---|
| ViewModel 切 IO | 线程职责泄漏 |
| UI 手动切 Main | 协程结构失衡 |
| UI try-catch | 异常模型未收敛 |
协程的问题,往往不是 API 用错了,而是边界被模糊了。
第四章:一次性任务------Repository 该不该 launch
前面讲的是"结构被破坏后是什么样子",这一章讲一个更具体的工程判断:面对一次性数据操作,Repository 内部到底能不能 launch?
很多文章把下面这种写法包装成"最佳实践":
kotlin
class UserRepository {
private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun load(onResult: (User) -> Unit) {
repositoryScope.launch {
val user = api.getUser()
onResult(user)
}
}
}
理由通常是"Repository 自己负责异步""调用方不用 launch,更简单"。但这真正改变的不是"是否异步",而是任务的生命周期归属。
很多人误以为"耗时操作 = launch",于是只要访问数据库、发起网络请求,就习惯性在 Repository 里偷偷开一个新协程。但现代 Android 推荐的设计是:对于一次性数据操作,Repository 应该暴露 suspend fun,由调用方决定在哪个 CoroutineScope 中启动协程。
suspend表达的是能力,而launch表达的是任务归属。
官方推荐背后的统一口径
Data / Domain 层对外暴露挂起函数和 Flow,由调用方控制协程的创建、取消和生命周期。
- 一次性操作 → 暴露
suspend fun - 持续数据流 → 暴露
Flow - 谁来启动协程 → 通常是更上层:ViewModel、UseCase 入口、WorkManager、应用级协调者
两种写法的本质区别
写法 A(推荐):
kotlin
// Repository
suspend fun load(): User = withContext(Dispatchers.IO) {
api.getUser()
}
// ViewModel
fun refresh() {
viewModelScope.launch {
val user = repository.load()
_uiState.value = UiState.Success(user)
}
}
任务归 viewModelScope 管,页面销毁时任务自动取消,异常能沿调用链自然传播。
写法 B(不推荐):
kotlin
// Repository
fun load(onResult: (User) -> Unit) {
repositoryScope.launch {
onResult(api.getUser())
}
}
ViewModel 只是"发了个命令",真正的协程生命周期不再归 ViewModel 管。调用方不知道任务什么时候结束,也不知道异常会怎么处理。页面销毁后,这个任务可能还在继续跑。
区别不是"谁写 launch 更方便",而是:谁拥有这次异步任务的控制权。
为什么 suspend fun 更符合现代架构
**1. 调用方才能决定生命周期。**同一个 Repository 可能被很多调用方使用(Fragment、ViewModel、WorkManager),它们的生命周期完全不同。如果 Repository 自己偷偷 launch,就等于假设"这份任务的生命周期由我来定",但这恰恰是问题所在。
2. suspend 天然支持结构化并发的组合能力。
kotlin
suspend fun loadAll() = coroutineScope {
val user = async { repository.loadUser() }
val notice = async { repository.loadNotice() }
UiData(user.await(), notice.await())
}
因为是 suspend fun,可以自由用 coroutineScope 做并发组合、用 supervisorScope 做失败隔离、用 withTimeout 控制超时。如果 Repository 内部直接 launch,这些组合能力会明显变差,因为已经拿不到这个任务的完成时机了。
3. launch 会让完成时机变得模糊。 suspend 版本调用方很清楚"等到这一行,结果就出来了";而内部 launch 版本,调用方只能依赖回调、事件通知、额外状态流来感知完成------Repository 内部 launch 并没有减少复杂度,只是把时序复杂度藏起来了。
4. 异常传播会被打断。 suspend fun 的异常可以沿调用链自然上传:Repository 抛错 → ViewModel 捕获 → UI 决定如何展示。但如果 Repository 自己 launch,异常处理就成了一个悬而未决的问题------自己吃掉?打日志?发事件?更致命的是,上层的 try-catch 其实根本包不住内部 launch 抛出的异常。
**5. 取消能力会被削弱。**如果调用链保持结构化,viewModelScope 被取消时,repository.load() 会跟着取消。但如果 Repository 用自己的 repositoryScope,调用方取消自己并不一定能取消 Repository 内部那个任务------这会导致"页面没了,网络还在跑""请求结果回来了但页面已经销毁"等常见问题。
职责越界
直觉上以为只是"封装了一下异步",但本质上已经额外承担了这些不该属于 Repository 的职责:决定协程在哪个 Scope 里跑、决定任务是否可取消、决定异常由谁处理、决定任务能否并发重入、决定调用方如何感知完成。
普通 Repository 更应该只是数据访问抽象、本地/远端组合、查询与写入语义封装------而不是后台任务调度器、生命周期托管器。
launch 应该放在哪里
launch放在边界层;suspend留在可组合的业务能力层。
- ViewModel:页面按钮点击、首次加载、下拉刷新等页面生命周期触发的任务
- UseCase 入口:定义更高层业务执行过程的地方
- WorkManager / 应用级任务协调者 :任务天然需要脱离页面生命周期存在(日志上传、离线同步、大文件下载),不该绑定到
viewModelScope
Repository 就绝对不能创建协程吗?
也不是,需要区分两种情况。
情况 1:一次性操作,对外暴露 suspend,内部可以用 coroutineScope、supervisorScope、async 在当前调用链内部组织子任务------这是"在当前调用链内部组织子任务",而不是偷偷创建一个脱离调用方的顶层任务,两者差别很大。
情况 2:任务必须比调用方活得更久。比如用户点击"收藏",希望即使离开页面也尽量完成写入。这时更准确的设计是用外部注入 的长生命周期 Scope,而不是 Repository 内部随便 new 一个。重点是:这是特例,不是默认模式;任务为什么需要更长生命周期,需要被明确设计过。
可落地的判断规则
代码评审时纠结 Repository 要不要自己 launch,可以直接问四个问题:
- 这是一次性操作,还是持续数据源? 一次性 →
suspend fun;持续 →Flow。 - 谁最适合决定它的生命周期? 页面相关 → ViewModel;业务动作入口 → UseCase;脱离页面 → WorkManager。如果答案不是 Repository,它就不该默认自己
launch。 - 调用方需不需要知道它何时完成、是否失败? 只要答案是"需要",内部
launch通常就不是好主意。 - 这个任务是否必须活得比调用方更久? 不需要 →
suspend fun;需要 → 才考虑外部注入长生命周期 Scope。
团队约定建议:
- Repository 对一次性操作默认暴露
suspend fun - Repository 对持续数据默认暴露
Flow - 协程的启动通常由 ViewModel / UseCase / WorkManager 决定
- Repository 内部可以用
coroutineScope/withContext组织实现,但不要默认偷开顶层launch - 只有当任务明确需要脱离调用方生命周期时,才考虑注入外部长生命周期
Scope
现代 Android 推荐 Repository 暴露
suspend fun,不是因为launch不能用,而是因为一次性数据操作的生命周期,默认应该由调用方而不是 Repository 来控制。
第五章:持续性资源------start/stop 该退场了
上一章讨论的是一次性任务,这一章讨论另一类更隐蔽的结构破坏:持续性资源(系统广播、监听器)的生命周期管理。
很多项目里,Data 层会用这样的模式监听系统状态变化(例如系统语言变化):
kotlin
class LocaleRepository(context: Context) {
private val appContext = context.applicationContext
private var receiver: BroadcastReceiver? = null
private var isRegistered = false
fun start(onChange: (String) -> Unit) {
if (isRegistered) return
receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
onChange(Locale.getDefault().toLanguageTag())
}
}
appContext.registerReceiver(receiver, IntentFilter(Intent.ACTION_LOCALE_CHANGED))
isRegistered = true
}
fun stop() {
if (!isRegistered) return
appContext.unregisterReceiver(receiver)
isRegistered = false
}
}
start() 注册广播,stop() 反注册广播------看起来直观,但在真实工程里会逐渐演变成生命周期难以统一、stop 漏调导致资源泄漏、并发状态容易错乱等长期隐患。
真正的问题:它破坏了结构化生命周期
很多人第一次看到这个问题会觉得"不就是记得调用 stop() 吗",但真正的问题并不只是"容易忘",而是:
start/stop让资源生命周期脱离了结构化作用域管理。
结构化并发的核心思想是:生命周期必须被父作用域托管。谁创建资源、谁持有资源、谁取消资源、谁释放资源,都应该是可追踪、可收敛的。viewModelScope.launch { } 之所以不需要手动 stop,是因为 ViewModel 销毁时子协程自动 cancel------生命周期跟随作用域自动收敛 。而 start/stop 恰恰让生命周期开始依赖人为约定、调用顺序、外部记忆力,这本质上属于非结构化资源管理。
为什么不建议 Data 层暴露 stop()
**1)职责不纯:Data 层不应该暴露资源控制细节。**Data 层应该回答"数据是什么",而不是"你什么时候来 start/stop 我"。当 Repository 暴露 start/stop,上层就必须理解内部资源模型、知道什么时候注册释放、知道 stop 是否必须成对调用------内部生命周期细节已经泄漏到了外部。
2)调用契约脆弱:系统稳定性依赖"记忆力"。 start/stop 模型本质上依赖调用方保证严格成对调用。但真实项目里,页面跳转、生命周期中断、协程取消、异常提前 return、多页面共享,都可能导致 stop() 被遗漏,最终带来 Receiver 泄漏、重复注册、状态错位、Receiver not registered 崩溃。
**3)并发复杂度高:共享状态容易失控。**很多 start/stop 实现还会维护一个 isRegistered 标志位。但在并发场景下,A 协程调用 start()、B 协程几乎同时调用 stop(),标志位更新顺序与系统调用顺序很容易错位------isRegistered 变成了额外维护的"生命周期真相源(Source of Truth)",而多个线程同时修改它时,复杂度会迅速上升。
推荐方案:callbackFlow + awaitClose
kotlin
class NetworkRepository(context: Context) {
private val appContext = context.applicationContext
private val connectivityManager =
appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
fun observeNetworkConnected(): Flow<Boolean> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(true)
}
override fun onLost(network: Network) {
trySend(false)
}
override fun onUnavailable() {
trySend(false)
}
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
// 初始值(避免 cold flow 没有首发状态)
val activeNetwork = connectivityManager.activeNetwork
val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
trySend(capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true)
awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
}
}
}
核心思想:collect 开始时注册资源,collect 结束时自动释放资源,生命周期跟随订阅关系自动开关 ,而不是依赖外部手动 stop()。
这本质上很像 try/finally------try 对应"开始收集时建立资源",finally 对应 awaitClose 里的释放逻辑,区别在于这套机制完全绑定在协程作用域这条链路里,不需要外部 stop,不需要共享状态,不需要额外生命周期同步。
上层使用建议
kotlin
class SettingsViewModel(
repository: NetworkRepository
) : ViewModel() {
val isNetworkConnected: StateFlow<Boolean> =
repository.observeNetworkConnected()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false
)
}
}
这里有两个收益:
- UI 重建后立即恢复最新状态 :
StateFlow会缓存最后一个值,页面重建无需等待广播,不会出现空白状态。 WhileSubscribed(5_000)减少频繁注册/反注册抖动:避免页面短暂切后台、配置切换、Compose 重组导致广播频繁注册/释放,这是非常重要的工程优化点。
这不仅是"代码更优雅"
很多人第一次接触 callbackFlow 会觉得"只是把 register/unregister 写到了 awaitClose 里"。但真正重要的不是写法变化,而是:
资源生命周期从"人为管理",变成了"订阅关系驱动" 也就是结构化管理。
工程收益:
- 稳定性收益:collect 开始自动注册,collect 结束自动释放,不再依赖调用方记忆 stop,大量 Receiver 泄漏、Context 泄漏、重复 unregister、崩溃问题被消灭。
- 架构收益:Data 层只负责"输出数据流",不再暴露资源控制行为。未来从 BroadcastReceiver 切换成 callback API 或 ContentObserver,上层几乎无需改动。
- 并发收益 :订阅关系本身就是真实生命周期状态,不再需要
isRegistered这类共享布尔状态,不再维护额外的"生命周期真相源",天然降低并发状态竞争。 - 协作收益 :新同学看到
Flow<T>会天然知道"collect 即可";看到start/stop则必须继续追踪生命周期、调用链、边界状态、是否允许重复调用。统一 Flow 风格后,API 更一致、Code Review 更聚焦、协作效率更高。
Code Review 检查清单
- Data 层是否仍对外暴露
start/stop - 是否泄漏内部生命周期控制细节
- 是否使用
callbackFlow,是否在awaitClose中释放资源 - 是否使用 Application Context,避免 Context 泄漏
- 是否存在重复注册风险
迁移步骤
- 新增 Flow 接口,例如
observeSystemLocaleTag() - 逐步移除
start()/stop()依赖 - 上层改为按生命周期
collect(例如配合repeatOnLifecycle) - 旧接口先标
@Deprecated,稳定一版后再彻底删除
传统
start()/stop()模型,本质上是"由调用方负责资源生命周期";而callbackFlow + awaitClose的本质,则是"由订阅关系驱动资源生命周期"。这两种设计最大的区别,不在于代码多少,而在于生命周期是否结构化、资源是否能自动收敛。
第六章:事件总线该退场了------EventBus 与本地广播的现代替代
前面五章都在讲"协程内部"的结构化,这一章把视角扩展到组件间通信------这是结构化思想最容易被忽视的一个角落,因为 EventBus、LocalBroadcastManager 这类工具,本质上就是把"归属关系"彻底打散了的反面教材。
EventBus 和本地广播的问题
EventBus、Otto 这些事件总线框架在某个时代解决了组件间通信的难题,但弊端也很明显:
- 隐式依赖:订阅/发布关系散落各处,调用链根本无法追踪
- 内存泄漏 :忘记
unregister是家常便饭 - 生命周期不感知:在错误的时机收到事件,轻则界面异常,重则直接崩溃
- 无类型安全:早期版本依赖反射,IDE 无法静态检查,重构是噩梦
而很多项目转而用 LocalBroadcastManager 做应用内通信,同样是错误的:它已在 AndroidX 1.1.0 被官方废弃;Intent 靠字符串 key 传数据,改个字段名编译不报错、运行直接 NPE;广播本为跨进程/跨应用设计,用于应用内是职责错位;无法与 ViewModel、生命周期自然集成。
这些问题的共同根源,正是前面几章反复强调的那件事:通信关系脱离了结构化的归属管理------谁发布、谁订阅、生命周期归谁,全部隐式散落在代码各处,无法追踪。
替代方案逐一拆解
1. UI 状态更新:StateFlow + ViewModel
kotlin
class UserViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
}
无需手动注册/注销,状态是单一数据源,UI 永远与状态同步。
2. Fragment 间通信:共享 ViewModel
kotlin
class SharedViewModel : ViewModel() {
val selectedItem = MutableStateFlow<Item?>(null)
}
// FragmentA 和 FragmentB
private val sharedViewModel: SharedViewModel by activityViewModels()
两个 Fragment 通过同一个 SharedViewModel 实例通信,依赖关系显式、可测试、无副作用。
3. 一次性事件(Toast、导航):SharedFlow / Channel
StateFlow 新订阅者会收到最新值,这对持续状态合适,但对一次性事件(弹 Toast、导航跳转)不合适------页面旋转重建后会重复触发。应使用:
kotlin
private val _navEvent = MutableSharedFlow<NavEvent>()
val navEvent: SharedFlow<NavEvent> = _navEvent.asSharedFlow()
4. 全局状态与跨模块事件:Repository + Flow
这是最容易被滥用的场景,也是事件总线和本地广播的重灾区。很多项目把跨模块能力抽成接口,但方法设计成了返回瞬时值的普通函数:
kotlin
// ❌ 拿到的是瞬时值,调用方只能反复主动查询
interface UserRepo {
fun getCurrentUser(): User?
}
这和 EventBus 传状态犯的是同一个错误------丢失了响应式的优势。应该按语义正确设计接口:
kotlin
// ✅ 真正响应式
interface UserRepo {
val currentUser: Flow<User?>
suspend fun login(account: String, password: String): Result<Unit>
}
kotlin
// 全局状态同步:Repository 作为单一数据源
class UserRepository {
private val _currentUser = MutableStateFlow<User?>(null)
val currentUser: StateFlow<User?> = _currentUser.asStateFlow()
suspend fun login(account: String, password: String): Result<Unit> = runCatching {
val user = api.login(account, password)
_currentUser.value = user
}
}
登录成功后所有页面自动响应,退出登录同理,零广播、零事件总线。
跨模块命令型动作信号(不是持续状态)用 SharedFlow;Service 与 UI 通信也不需要广播,直接 @Inject Repository + StateFlow 即可。
完整替代方案对照表
| 场景 | ❌ 旧方案 | ✅ 现代替代方案 |
|---|---|---|
| UI 状态更新 | EventBus.post(StateEvent) |
StateFlow + collect |
| Fragment 间通信 | EventBus.post / @Subscribe |
共享 ViewModel(activityViewModels()) |
| 一次性事件(Toast/导航) | EventBus.post(NavEvent) |
SharedFlow 或 Channel |
| 全局登录/用户状态 | LocalBroadcastManager.sendBroadcast |
Repository + StateFlow |
| 跨模块命令型事件 | LocalBroadcastManager.sendBroadcast |
Repository + SharedFlow |
| Service 通知 UI | LocalBroadcastManager.sendBroadcast |
@Inject Repository + StateFlow |
| 跨模块能力调用 | 直接依赖实现类 | 接口(Flow + suspend)+ Hilt 注入 |
| 跨进程/跨应用 | --- | 系统 BroadcastReceiver(广播的正确用途) |
迁移建议
渐进式迁移:
- 禁止新代码引入 EventBus 和 LocalBroadcastManager(lint 规则强制)
- 找出所有存量使用点(
grep -r "EventBus.getDefault()") - 按场景逐一替换:全局/跨模块状态 → Repository + StateFlow;一次性事件 → SharedFlow/Channel;Service 通信 →
@Inject Repository - 全部替换后移除 EventBus 依赖,删除所有
@Subscribe注解代码
**架构陷阱告警:**不要用 Flow/LiveData 强行"手造总线"。如果发现自己在写 GlobalEventBus.post(MyEvent()),哪怕底层用的是 Kotlin Flow,依然是在制造隐式耦合和不可追踪的代码------换了工具,但没有解决结构问题。
组件化场景下,三板斧完全不变,只需遵守一个额外约定:接口、Repository、公共 Model 都定义在 module_base,业务模块之间不允许互相依赖。
总结:所有问题都是同一个问题
回头看这六章内容,会发现一个有趣的事实:协程的结构化、Repository 的 suspend/launch 之争、广播的 start/stop 之争、EventBus 的存废之争,本质上讨论的都是同一件事------
任务、资源、事件的归属权,到底应该在哪一层被决定?
- 协程告诉我们:并发任务必须有父作用域托管
- Repository 的设计告诉我们:一次性操作的生命周期应该由调用方决定,而不是数据层偷偷代管
callbackFlow告诉我们:持续性资源的生命周期应该跟随订阅关系自动收敛,而不是依赖人为的 start/stop- EventBus 的退场告诉我们:组件间通信也应该有清晰可追踪的归属,而不是发布到一个无人知晓边界的全局总线里
这四件事看似分散在不同的技术点上,实际上都在回答同一个架构问题:谁创建、谁管理、谁取消、谁负责异常、谁决定生命周期边界。
Dispatchers.IO 写在 ViewModel 里、try-catch 暴露在 UI 层、launch 藏在 Repository 里、start/stop 散落在 Data 层、事件发布到一个全局总线里------这些看起来是写法问题,实际上都是同一件事没做好:
任务的归属权被放错了地方。
协程从语言层面给了我们结构化并发的能力,但代码里到底"结不结构",取决于每一层是否守住了自己的边界。这也是这篇文章想传达的核心。
参考:
现代 Android 官方为什么更推荐 Repository 暴露 suspend fun,而不是在内部 launch
# Android 架构指南之Data 层不要再暴露 start/stop 了:用 Flow 接管生命周期
别再 launch(IO) 了:协程线程切换的 3隐藏反模式
从送外卖看Android Clean架构:为什么老板不需要知道外卖员开什么车