跪拜 Guibai
← All articles
Android

Android Cross-Page Sync Without an Event Bus: A Full Worked Demo

By 潜龙勿用之化骨龙 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Event buses and global mutable state remain common in Android codebases, but they create implicit coupling that breaks testability and makes state restoration fragile. This demo shows a complete, Hilt-wired alternative using only standard library components — `Flow`, `DataStore`, `ViewModel`, and Compose — that scales from a single module to a multi-module project.

Summary

Cross-page coordination in Android — login state, message badges, theme switching, global snackbars — is reframed as a state-management problem, not a notification problem. A full demo walks through defining sealed business models, persisting state with DataStore, exposing continuous flows from Repository singletons, and binding interfaces with Hilt. ViewModels combine raw business streams into derived UI state using `combine` and `stateIn`, while Compose screens observe only what they need via `collectAsStateWithLifecycle`.

One-time side effects like snackbars move to `SharedFlow` and are collected once at the top level. The login dialog closes declaratively by watching `loginState`, and theme changes propagate from DataStore through a Repository to the Activity without any manual dispatch. The demo then pushes dependent state logic down into the Repository itself, using `flatMapLatest` to switch message streams when the user logs in or out, so ViewModels never touch tokens.

Componentized projects slot into the same pattern by placing Repository interfaces in a base module and letting Hilt wire implementations at the app level. The result is a system where pages never call each other, ViewModels never reference each other, and all synchronization flows from a single source of truth.

Takeaways
All cross-page coordination derives from Repository-level state flows, not from ViewModels calling each other.
Login state, user info, message counts, and theme settings each live in their own Repository as `Flow` properties.
ViewModels combine raw business flows into UI-specific state using `combine` and expose it as `StateFlow` with `WhileSubscribed(5000)`.
Compose screens collect state with `collectAsStateWithLifecycle` and contain no business rules.
One-time events like snackbars use `SharedFlow` collected once in a top-level `LaunchedEffect`, not `StateFlow`.
Login dialogs close declaratively by watching `loginState` in a `LaunchedEffect` rather than through imperative callbacks.
Theme changes persist to DataStore, flow through a Repository, and are observed at the Activity level to trigger automatic recomposition.
Dependent state can be pushed into the Repository with `flatMapLatest`, so message streams automatically switch on login/logout without ViewModels handling tokens.
Repository interfaces expose `Flow` properties and `suspend` functions; they never expose one-shot getters like `isLogin(): Boolean`.
Componentized projects keep Repository interfaces in a shared base module and let Hilt bind implementations in feature modules.
DataSource classes expose `Flow` from DataStore and use `suspend` for writes, enabling continuous observation instead of polling.
Avoid creating a global event center — even a `MutableSharedFlow<Any>` used as a universal post/collect bus reintroduces implicit coupling.
Conclusions

Framing cross-page sync as a state-derivation problem eliminates an entire category of lifecycle bugs and ordering dependencies that event buses introduce.

The `combine` operator in ViewModels acts as a declarative rule engine — "show badge = logged in AND count > 0" — that is testable without any UI or navigation framework.

Using `WhileSubscribed(5000)` on `stateIn` keeps upstream flows active for a grace period during configuration changes, avoiding restart costs while still cleaning up when the consumer truly disappears.

Pushing dependent state logic into the Repository via `flatMapLatest` is underused; it removes token-awareness from ViewModels and makes the data layer self-consistent when users switch.

The distinction between `StateFlow` for persistent state and `SharedFlow` for one-shot signals is simple but frequently violated in practice, leading to missed events or stale UI.

DataStore paired with Repository flows gives global settings like theme a single source of truth that survives process death without any manual save/restore code.

Componentized architecture works naturally with this pattern because Repository interfaces act as the API surface between modules — no shared ViewModels or event objects needed.

The demo's refusal to let ViewModels reference each other is the architectural linchpin; it forces every piece of shared state to have an explicit, testable home.

Concepts & terms
Repository as single source of truth
A pattern where business state is owned and exposed by a Repository singleton as a `Flow`, and all consumers (ViewModels, other Repositories) derive their state from it rather than from direct inter-component messages.
stateIn with WhileSubscribed
A Kotlin Flow operator that converts a cold flow into a hot `StateFlow` shared among collectors. `WhileSubscribed(5000)` keeps the upstream active for 5 seconds after the last subscriber disappears, handling configuration changes gracefully.
combine for UI state derivation
A Flow operator that takes multiple source flows and emits a new value whenever any source changes. Used in ViewModels to derive boolean flags, nullable display values, or other UI-specific state from raw business streams.
SharedFlow for one-shot side effects
A hot flow that emits values to collectors without replaying the most recent value. Suitable for snackbars, navigation events, or any signal that should be consumed exactly once and never restored on config change.
flatMapLatest for dynamic flow switching
A Flow operator that, when the upstream emits a new value, cancels the previous inner flow and switches to a new one. Used here to change the message data source automatically when the user logs in or out.
collectAsStateWithLifecycle
A Compose API that collects a flow in a lifecycle-aware manner, pausing collection when the UI is not visible and restarting it when the lifecycle resumes, preventing wasted work and stale state.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗