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,为了演示架构,不够完美请见谅。

相关推荐
千里马学框架1 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台1 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone1 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc1 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo1 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077001 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼1 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone1 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen1 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone1 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui