“结构化”这个词,本质上就是——把混乱的东西变成有组织、有规则、有边界的东西

前言

写这篇文章的契机,是我反复思考的结果:

  • 有在 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 难用,而是脱离了结构:没有父节点、没有生命周期归属、没有取消关系、没有异常归属。它实际上绑定到了进程级别的生命周期,而这通常不是开发者真正想要的边界。

withContextlaunch 的本质区别

这是很多开发者容易混淆的一个点,但它恰恰是理解"结构化"的关键。

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,内部可以用 coroutineScopesupervisorScopeasync 在当前调用链内部组织子任务------这是"在当前调用链内部组织子任务",而不是偷偷创建一个脱离调用方的顶层任务,两者差别很大。

情况 2:任务必须比调用方活得更久。比如用户点击"收藏",希望即使离开页面也尽量完成写入。这时更准确的设计是用外部注入 的长生命周期 Scope,而不是 Repository 内部随便 new 一个。重点是:这是特例,不是默认模式;任务为什么需要更长生命周期,需要被明确设计过。

可落地的判断规则

代码评审时纠结 Repository 要不要自己 launch,可以直接问四个问题:

  1. 这是一次性操作,还是持续数据源? 一次性 → suspend fun;持续 → Flow
  2. 谁最适合决定它的生命周期? 页面相关 → ViewModel;业务动作入口 → UseCase;脱离页面 → WorkManager。如果答案不是 Repository,它就不该默认自己 launch
  3. 调用方需不需要知道它何时完成、是否失败? 只要答案是"需要",内部 launch 通常就不是好主意。
  4. 这个任务是否必须活得比调用方更久? 不需要 → suspend fun;需要 → 才考虑外部注入长生命周期 Scope。

团队约定建议:

  1. Repository 对一次性操作默认暴露 suspend fun
  2. Repository 对持续数据默认暴露 Flow
  3. 协程的启动通常由 ViewModel / UseCase / WorkManager 决定
  4. Repository 内部可以用 coroutineScope / withContext 组织实现,但不要默认偷开顶层 launch
  5. 只有当任务明确需要脱离调用方生命周期时,才考虑注入外部长生命周期 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 里"。但真正重要的不是写法变化,而是:

资源生命周期从"人为管理",变成了"订阅关系驱动" 也就是结构化管理。

工程收益:

  1. 稳定性收益:collect 开始自动注册,collect 结束自动释放,不再依赖调用方记忆 stop,大量 Receiver 泄漏、Context 泄漏、重复 unregister、崩溃问题被消灭。
  2. 架构收益:Data 层只负责"输出数据流",不再暴露资源控制行为。未来从 BroadcastReceiver 切换成 callback API 或 ContentObserver,上层几乎无需改动。
  3. 并发收益 :订阅关系本身就是真实生命周期状态,不再需要 isRegistered 这类共享布尔状态,不再维护额外的"生命周期真相源",天然降低并发状态竞争。
  4. 协作收益 :新同学看到 Flow<T> 会天然知道"collect 即可";看到 start/stop 则必须继续追踪生命周期、调用链、边界状态、是否允许重复调用。统一 Flow 风格后,API 更一致、Code Review 更聚焦、协作效率更高。

Code Review 检查清单

  • Data 层是否仍对外暴露 start/stop
  • 是否泄漏内部生命周期控制细节
  • 是否使用 callbackFlow,是否在 awaitClose 中释放资源
  • 是否使用 Application Context,避免 Context 泄漏
  • 是否存在重复注册风险

迁移步骤

  1. 新增 Flow 接口,例如 observeSystemLocaleTag()
  2. 逐步移除 start() / stop() 依赖
  3. 上层改为按生命周期 collect(例如配合 repeatOnLifecycle
  4. 旧接口先标 @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 共享 ViewModelactivityViewModels()
一次性事件(Toast/导航) EventBus.post(NavEvent) SharedFlowChannel
全局登录/用户状态 LocalBroadcastManager.sendBroadcast Repository + StateFlow
跨模块命令型事件 LocalBroadcastManager.sendBroadcast Repository + SharedFlow
Service 通知 UI LocalBroadcastManager.sendBroadcast @Inject Repository + StateFlow
跨模块能力调用 直接依赖实现类 接口(Flow + suspend)+ Hilt 注入
跨进程/跨应用 --- 系统 BroadcastReceiver(广播的正确用途)

迁移建议

渐进式迁移:

  1. 禁止新代码引入 EventBus 和 LocalBroadcastManager(lint 规则强制)
  2. 找出所有存量使用点(grep -r "EventBus.getDefault()"
  3. 按场景逐一替换:全局/跨模块状态 → Repository + StateFlow;一次性事件 → SharedFlow/Channel;Service 通信 → @Inject Repository
  4. 全部替换后移除 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架构:为什么老板不需要知道外卖员开什么车

用一个小 Demo,带你入门安卓 Clean Architecture

Android 现代架构不需要事件总线

为什么我不在 Android ViewModel 中直接处理异常?

相关推荐
方白羽18 小时前
Android Gradle 缓存与文件目录深度解析
android·gradle·android studio
曲幽1 天前
Termux里的二进制和脚本,到底怎么运行才不踩坑?Termux-service 保活妙招!
android·termux·nohup·services·wake-lock
plainGeekDev1 天前
单例模式 → object 声明
android·java·kotlin
程序员陆业聪1 天前
读者点单·03|Compose 与传统 View 混用的 12 个真实坑
android
程序员陆业聪1 天前
读者点单·02|Android 启动优化实战:Trace 抓取→Application 编排→冷启动全流程拆解
android
Coffeeee1 天前
帮你快速理解AI Agent之我想招个Android实习生
android·人工智能·agent
恋猫de小郭1 天前
苹果 AirPods 协议,Android 也可以使用完整版 AirPods 能力
android·前端·flutter
黄林晴1 天前
告别无效重建:Gradle 9.6.0 解决 CI 构建缓存失效痛点告别无效重建:Gradle 9.6.0 解决 CI 建筑缓存失效痛点
android·gradle
张风捷特烈1 天前
Flutter 类库大揭秘#01 | path_provider架构与设计
android·flutter