现代 Android 应用里,跨页面、跨模块联动不应该靠"互相通知"完成,而应该靠稳定的数据源、清晰的状态管道和可组合的 UI 状态完成。
前言
如果你还没看过这篇,请先移步看看这篇
在一个真实 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 接口优先暴露 Flow 和 suspend。
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,为了演示架构,不够完美请见谅。