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

现代 Android 应用里,跨页面、跨模块联动不应该靠"互相通知"完成,而应该靠稳定的数据源、清晰的状态管道和可组合的 UI 状态完成。


前言

如果你还没看过这篇,请先移步看看这篇

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

在一个真实 App 中,页面之间经常需要联动。

比如:

  • 登录成功后,首页要显示用户信息
  • 消息数量变化后,底部 Tab 要显示红点
  • 未登录时,消息页要展示受限状态
  • 修改昵称后,首页和个人中心都要同步
  • 切换深色模式后,整个 App 要立即生效
  • 某个操作完成后,需要弹出全局 Snackbar

这些需求表面看是"组件间通信",但本质上其实是几个问题:

text 复制代码
状态从哪里来?
谁负责维护状态?
页面需要什么状态?
多个状态如何组合?
一次性提示如何消费?
UI 如何安全地观察状态?

这篇文章不讨论应该淘汰什么,而是基于一个完整 Demo,具体讲一套可落地的做法:

text 复制代码
Repository 作为状态源
Flow 暴露持续变化
ViewModel 组合业务状态
Compose 观察状态并自动重组
SharedFlow 承载一次性副作用
DataStore 负责持久化
Hilt 显式组织依赖

一、Demo 场景

这个 Demo 模拟了一个常见应用的核心链路:

text 复制代码
用户登录
  ↓
登录态变化
  ↓
用户信息同步
  ↓
消息红点变化
  ↓
消息页权限变化
  ↓
个人中心按钮状态变化
  ↓
全局 Snackbar 提示
  ↓
主题持久化并全局生效

对应的业务状态有五类:

业务 状态源
登录态 AuthRepository.loginState
用户信息 UserRepository.user
消息数量 MessageRepository.count
主题设置 ThemeRepository.theme
全局提示 NotificationRepository.notifications

每个页面不直接调用别的页面。

每个 ViewModel 也不互相通知。

所有联动都来自 Repository 暴露出来的状态流。


二、整体架构

项目结构可以按职责拆成四层:

text 复制代码
domain/
  model/              业务模型
  repository/         Repository 接口

data/
  local/              DataStore 数据源
  repository/         Repository 实现

ui/
  viewmodel/          页面 ViewModel
  compose/            Compose 页面

di/
  AppModule.kt        Hilt 依赖绑定

整体数据流是:

text 复制代码
DataStore / 内存状态
        ↓
Repository 暴露 Flow
        ↓
ViewModel 组合、过滤、派生 UI 状态
        ↓
Compose collectAsStateWithLifecycle
        ↓
UI 自动刷新

这套结构里,每一层职责很明确:

层级 职责
DataSource 读写本地数据,比如 DataStore
Repository 暴露业务状态,提供业务操作
ViewModel 把业务状态组合成页面状态
Compose UI 展示状态,触发用户意图
DI 显式声明依赖关系

三、第一步:先定义业务状态模型

状态流架构的第一步,不是写页面,而是把业务状态建模清楚。

登录态:

kotlin 复制代码
sealed interface LoginState {
    object Logout : LoginState
    object Loading : LoginState
    data class Login(val token: String) : LoginState
}

用户信息:

kotlin 复制代码
data class User(val name: String)

主题:

kotlin 复制代码
enum class AppTheme {
    Light, Dark
}

这些模型很简单,但它们是整个系统的"共同语言"。

页面不应该自己猜什么叫已登录、什么叫未登录。

业务状态应该被显式建模,然后被所有层共享。


四、第二步:用 DataStore 保存真实状态

登录态背后的真实数据是 token。

TokenManager 只负责读写 token:

kotlin 复制代码
private val Context.dataStore by preferencesDataStore(name = "auth_prefs")

@Singleton
class AuthDataSource @Inject constructor(
    @ApplicationContext private val context: Context
) {
    private val tokenKey = stringPreferencesKey("auth_token")

    val token: Flow<String?> = context.dataStore.data.map { prefs ->
        prefs[tokenKey]
    }

    suspend fun saveToken(token: String) {
        context.dataStore.edit { prefs ->
            prefs[tokenKey] = token
        }
    }

    suspend fun clearToken() {
        context.dataStore.edit { prefs ->
            prefs.remove(tokenKey)
        }
    }
}

这里有两个设计原则:

第一,DataSource 暴露 Flow,而不是只提供 getToken()

因为登录态会变化,调用方应该能持续观察。

第二,写操作用 suspend fun

因为 DataStore 是异步持久化操作,调用方必须在协程里执行。

同样,用户昵称、消息数量、主题设置也可以用 DataStore 保存。


五、第三步:Repository 暴露业务状态

DataSource 是存储细节,Repository 才是业务入口。

登录 Repository 把 token 转换成业务可理解的 LoginState

kotlin 复制代码
@Singleton
class AuthRepositoryImpl @Inject constructor(
   private val authDataSource: AuthDataSource
) : AuthRepository {

    override val loginState: Flow<LoginState> = authDataSource.token
        .map { token ->
            if (token != null) LoginState.Login(token) else LoginState.Logout
        }

    override suspend fun login() {
        val mockToken = "persistent_token_${System.currentTimeMillis()}"
        authDataSource.saveToken(mockToken)
    }

    override suspend fun logout() {
        authDataSource.clearToken()
    }
}

这里的关键是:

kotlin 复制代码
val loginState: Flow<LoginState>

登录态不是一次查询结果,而是持续变化的数据流。

Repository 对外提供两类能力:

text 复制代码
Flow 属性:暴露可观察状态
suspend 方法:执行会改变状态的操作

这是非常重要的接口设计习惯。

例如:

kotlin 复制代码
interface AuthRepository {
    val loginState: Flow<LoginState>
    suspend fun login()
    suspend fun logout()
}

不要把接口设计成:

kotlin 复制代码
fun isLogin(): Boolean
fun getUserName(): String

这种瞬时查询会让调用方失去响应式能力,只能反复主动刷新。


六、第四步:用 Hilt 绑定接口和实现

Repository 应该依赖接口,而不是到处直接依赖实现类。 请直接看源码。

这样做的好处是:

  • ViewModel 只依赖抽象接口
  • Repository 是全局单例状态源
  • 测试时可以替换 fake 实现
  • 模块之间依赖关系清楚

七、第五步:ViewModel 把业务状态组合成 UI 状态

Repository 提供的是原始业务状态。

页面真正需要的是 UI 状态。

比如首页需要:

  • 当前用户
  • 当前登录态
  • 消息数量是否可展示
  • 底部消息红点是否显示

HomeViewModel 这样组织:

kotlin 复制代码
@HiltViewModel
class HomeViewModel @Inject constructor(
    userRepository: UserRepository,
    messageRepository: MessageRepository,
    private val authRepository: AuthRepository
) : ViewModel() {

    val user: StateFlow<User> = userRepository.user
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = User("加载中...")
        )

    val loginState: StateFlow<LoginState> = authRepository.loginState
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = LoginState.Logout
        )

    val displayMessageCount: StateFlow<Int?> = combine(
        authRepository.loginState,
        messageRepository.count
    ) { state, count ->
        if (state is LoginState.Login) count else null
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = null
    )

    val shouldShowMessageBadge: StateFlow<Boolean> = combine(
        authRepository.loginState,
        messageRepository.count
    ) { state, count ->
        state is LoginState.Login && count > 0
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = false
    )
}

这里最核心的是 combine

红点显示规则并不是某个页面临时判断出来的,而是一个稳定的状态推导:

text 复制代码
shouldShowMessageBadge = 已登录 && 消息数 > 0

消息展示规则也是状态推导:

text 复制代码
displayMessageCount = 已登录 ? 消息数 : null

ViewModel 的价值就在这里:

把多个业务状态组合成页面真正需要的状态。


八、第六步:Compose 只负责观察和展示(xml 本质上也是一样)

到了 UI 层,就不要再写复杂业务判断。

首页只收集状态:

kotlin 复制代码
val showBadge by homeVm.shouldShowMessageBadge.collectAsStateWithLifecycle()

底部 Tab 根据状态展示:

kotlin 复制代码
BadgedBox(badge = {
    if (screen is Screen.Message && showBadge) {
        Badge()
    }
}) {
    Icon(screen.icon, contentDescription = screen.label)
}

消息页也是一样:

kotlin 复制代码
val displayCount by msgVm.displayMessageCount.collectAsStateWithLifecycle()

if (displayCount != null) {
    Text("您有 $displayCount 条未读消息")
} else {
    Text("请先登录后查看消息")
}

UI 层的职责很简单:

text 复制代码
观察状态
展示状态
把用户操作转成 ViewModel 方法调用

这会让 Compose 页面非常稳定。


九、第七步:一次性副作用用 SharedFlow

持续状态适合用 StateFlow

一次性事件适合用 SharedFlow

比如 Snackbar:

kotlin 复制代码
@Singleton
class NotificationRepositoryImpl @Inject constructor() : NotificationRepository {
    private val _notifications = MutableSharedFlow<String>()
    override val notifications: SharedFlow<String> = _notifications.asSharedFlow()

    override suspend fun showNotification(message: String) {
        _notifications.emit(message)
    }
}

主界面统一收集:

kotlin 复制代码
LaunchedEffect(Unit) {
    notificationVm.notifications.collectLatest { message ->
        snackbarHostState.showSnackbar(message)
    }
}

登录成功后只需要调用:

kotlin 复制代码
notificationRepository.showNotification("欢迎回来,$userName!状态已同步。")

修改昵称后也可以调用:

kotlin 复制代码
notificationRepository.showNotification("昵称已成功更新并持久化。")

这类数据不适合做成 StateFlow,因为它不是"当前状态",而是"一次性消费信号"。

简单规则:

类型 推荐
当前登录态 StateFlow
当前用户信息 StateFlow
当前主题 StateFlow
当前消息数量 StateFlow
Snackbar SharedFlow
导航跳转 SharedFlow / Channel

十、第八步:登录弹窗根据状态自动关闭

登录弹窗是 UI 本地状态:

kotlin 复制代码
var showLoginDialog by rememberSaveable { mutableStateOf(false) }

点击登录按钮时打开:

kotlin 复制代码
onLoginClick = { showLoginDialog = true }

登录成功后自动关闭:

kotlin 复制代码
LaunchedEffect(loginState) {
    if (loginState is LoginState.Login) {
        showLoginDialog = false
    }
}

这段代码不是在某个回调里命令式地关闭弹窗,而是表达:

text 复制代码
当登录态变成已登录时,登录弹窗应该消失。

这就是声明式 UI 的写法。

UI 不需要记住"是谁触发了登录"。

它只关心当前状态是什么。


十一、第九步:主题作为全局状态

主题切换也是一个完整的状态流例子。

Repository 暴露主题:

kotlin 复制代码
override val theme: Flow<AppTheme> = themeDataSource.theme

ViewModel 转成 StateFlow

kotlin 复制代码
val theme: StateFlow<AppTheme> = themeRepository.theme
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = AppTheme.Light
    )

Activity 观察:

kotlin 复制代码
val theme by settingsVm.theme.collectAsStateWithLifecycle()

NoEventBusTheme(darkTheme = theme == AppTheme.Dark) {
    NoEventBusDemoApp()
}

设置页触发切换:

kotlin 复制代码
fun toggleTheme() {
    viewModelScope.launch {
        themeRepository.toggleTheme()
    }
}

主题被写入 DataStore 后,Activity 自动收到新主题,Compose 自动重组。

这类全局状态很适合用:

text 复制代码
DataStore + Repository + Flow + Activity collect

十二、第十步:继续进阶,把依赖状态下沉到 Repository

当前 Demo 中,消息数量是这样暴露的:

kotlin 复制代码
override val count: Flow<Int> = messageDataSource.messageCount

登录态过滤是在 ViewModel 中做的:

kotlin 复制代码
combine(
    authRepository.loginState,
    messageRepository.count
) { state, count ->
    if (state is LoginState.Login) count else null
}

这已经能满足页面展示。

但真实业务中,还可以进一步进阶:

MessageRepository 自己根据登录态切换消息流。

kotlin 复制代码
override val count: Flow<Int> = authRepository.loginState
    .flatMapLatest { state ->
        if (state is LoginState.Login) {
            messageDataSource.getMessageCount(state.token)
        } else {
            flowOf(0)
        }
    }

这样,调用方拿到的 messageRepository.count 就天然是"当前登录用户的消息数"。

这个做法有几个好处:

  • ViewModel 不需要理解 token
  • 消息模块自己处理用户切换
  • 退出登录时消息数自然归零
  • 切换用户时自动切换数据源
  • 页面逻辑更干净

这就是 Flow 架构里很重要的能力:动态流嫁接。

状态不是静态订阅的。

它可以根据另一个状态变化,自动切换数据来源。


十三、落地时的几个设计原则

第一,先分清状态和副作用。

text 复制代码
持续存在、可恢复、可展示的是状态。
只消费一次、不应该恢复的是副作用。

第二,Repository 接口优先暴露 Flowsuspend

kotlin 复制代码
interface UserRepository {
    val user: Flow<User>
    suspend fun updateName(newName: String)
}

第三,ViewModel 不要互相调用。

多个页面要同步时,共享底层 Repository,而不是互相拿对方 ViewModel。

第四,UI 不要持有业务规则。

像"已登录且消息数大于 0 才显示红点"这种规则应该放在 ViewModel,UI 只负责展示结果。

第五,不要创建全局事件中心。

即使用的是 MutableSharedFlow<Any>,如果它承担的是"到处 post、到处 collect"的职责,本质上仍然是隐式通信。

第六,持久状态交给 DataStore。

登录 token、主题、用户昵称这类状态应该持久化,而不是依赖内存中的最后一次事件。


十四、组件化场景怎么做

组件化项目中,这套结构同样适用。

关键是把接口和公共模型放到基础模块。

text 复制代码
module_base
  AuthRepository
  UserRepository
  MessageRepository
  LoginState
  User

module_auth
  AuthRepositoryImpl

module_user
  UserRepositoryImpl

module_message
  MessageRepositoryImpl

module_home
  HomeViewModel

依赖方向应该是:

text 复制代码
module_home    → module_base
module_auth    → module_base
module_user    → module_base
module_message → module_base

业务模块之间不要互相依赖实现。

例如首页需要用户信息,它依赖:

kotlin 复制代码
UserRepository

而不是依赖:

kotlin 复制代码
UserRepositoryImpl

最终由 Hilt 在 App 层完成绑定。

这样既能保持组件解耦,又能让全局状态通过 Repository 自然流动。


总结

这套 Demo 想表达的不是某个 API 的用法,而是一种具体的架构落地方式。

完整流程可以总结为:

text 复制代码
1. 定义业务状态模型
2. 用 DataStore 保存真实状态
3. Repository 暴露 Flow 和 suspend 操作
4. Hilt 绑定接口和实现
5. ViewModel 组合业务状态,派生 UI 状态
6. Compose 使用 collectAsStateWithLifecycle 观察状态
7. SharedFlow 处理 Snackbar、导航等一次性副作用
8. 必要时用 flatMapLatest 让状态源动态切换

当这套链路建立起来后,页面同步、跨模块联动、全局状态更新都会变得自然。

重点不是"某个页面通知另一个页面"。

重点是:

text 复制代码
状态被正确维护;
状态变化能被观察;
页面只展示自己需要的状态。

这就是 Repository + Flow + ViewModel + Compose 组合起来真正有价值的地方。

NoEventBus 只是demo,为了演示架构,不够完美请见谅。

相关推荐
杉氧16 小时前
深入理解 Compose 重组机制:快照系统如何驱动 UI 精准刷新?
android·架构·android jetpack
召钱熏16 小时前
状态枚举正确≠渲染正确:一个语音按钮的状态机边界修复实录
android·前端
杉氧16 小时前
深度解析:Jetpack Compose 核心架构与底层原理 —— 十年安卓老兵的“破茧重生”
android·架构·android jetpack
通玄17 小时前
Jetpack Compose 入门系列(七):ViewModel 与界面状态管理
android
落魄Android在线炒饭17 小时前
Android Framework 开发技巧:android.jar 生成与系统快速编译验证
android
如此风景18 小时前
Kotlin Flow操作符学习
android·kotlin
plainGeekDev18 小时前
GreenDAO → Room
android·java·kotlin
weiggle19 小时前
第八篇:ViewModel + Compose——生产级状态管理实践
android
恋猫de小郭1 天前
Amper 正式转正 Kotlin Toolchain ,Gradle 未来何去何从
android·前端·flutter