Android Cross-Page Sync Without an Event Bus: A Full Worked Demo
In modern Android applications, cross-page and cross-module coordination should not be achieved through "mutual notification" but through stable data sources, clear state pipelines, and composable UI state.
Foreword
If you haven't read this yet, please read this first:
Modern Android Architecture Doesn't Need Event Buses
In a real app, pages often need to coordinate.
For example:
- After a successful login, the home page should display user information
- When the message count changes, the bottom tab should show a red badge
- When not logged in, the message page should display a restricted state
- After changing a nickname, the home page and profile center should both sync
- After switching dark mode, the entire app should take effect immediately
- After an operation completes, a global Snackbar needs to pop up
On the surface, these requirements look like "inter-component communication," but in essence, they boil down to a few questions:
Where does the state come from?
Who is responsible for maintaining the state?
What state does a page need?
How are multiple states combined?
How are one-time prompts consumed?
How does the UI safely observe state?
This article does not discuss what should be eliminated but instead, based on a complete demo, specifically explains a set of implementable practices:
Repository as the state source
Flow exposes continuous changes
ViewModel combines business state
Compose observes state and automatically recomposes
SharedFlow carries one-time side effects
DataStore handles persistence
Hilt explicitly organizes dependencies
1. Demo Scenario
This demo simulates the core chain of a common application:
User Login
↓
Login State Change
↓
User Info Sync
↓
Message Badge Change
↓
Message Page Permission Change
↓
Profile Center Button State Change
↓
Global Snackbar Prompt
↓
Theme Persistence and Global Application
The corresponding business states are of five types:
| Business | State Source |
|---|---|
| Login State | AuthRepository.loginState |
| User Info | UserRepository.user |
| Message Count | MessageRepository.count |
| Theme Setting | ThemeRepository.theme |
| Global Prompt | NotificationRepository.notifications |
No page directly calls another page. No ViewModel notifies another. All coordination comes from the state flows exposed by the Repository.
2. Overall Architecture
The project structure can be divided into four layers by responsibility:
domain/
model/ Business models
repository/ Repository interfaces
data/
local/ DataStore data sources
repository/ Repository implementations
ui/
viewmodel/ Page ViewModels
compose/ Compose pages
di/
AppModule.kt Hilt dependency bindings
The overall data flow is:
DataStore / In-memory state
↓
Repository exposes Flow
↓
ViewModel combines, filters, derives UI state
↓
Compose collectAsStateWithLifecycle
↓
UI auto-refreshes
In this structure, each layer has a clear responsibility:
| Layer | Responsibility |
|---|---|
| DataSource | Reads and writes local data, e.g., DataStore |
| Repository | Exposes business state, provides business operations |
| ViewModel | Combines business state into page state |
| Compose UI | Displays state, triggers user intent |
| DI | Explicitly declares dependency relationships |
3. Step 1: Define Business State Models First
The first step in a state-flow architecture is not writing pages but clearly modeling the business state.
Login State:
sealed interface LoginState {
object Logout : LoginState
object Loading : LoginState
data class Login(val token: String) : LoginState
}
User Info:
data class User(val name: String)
Theme:
enum class AppTheme {
Light, Dark
}
These models are simple, but they are the "common language" of the entire system.
Pages should not guess for themselves what "logged in" or "logged out" means. Business state should be explicitly modeled and then shared by all layers.
4. Step 2: Use DataStore to Save Real State
The real data behind the login state is a token.
TokenManager is only responsible for reading and writing the token:
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)
}
}
}
There are two design principles here:
First, the DataSource exposes a Flow, not just a getToken(). Because the login state can change, the caller should be able to observe it continuously.
Second, write operations use suspend fun. Because DataStore is an asynchronous persistence operation, the caller must execute it within a coroutine.
Similarly, user nicknames, message counts, and theme settings can also be saved using DataStore.
5. Step 3: Repository Exposes Business State
The DataSource is a storage detail; the Repository is the business entry point.
The login Repository converts the token into a business-understandable LoginState:
@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()
}
}
The key here is:
val loginState: Flow<LoginState>
The login state is not a one-time query result but a continuously changing data stream.
The Repository provides two types of capabilities externally:
Flow properties: Expose observable state
suspend methods: Execute operations that change state
This is a very important interface design habit.
For example:
interface AuthRepository {
val loginState: Flow<LoginState>
suspend fun login()
suspend fun logout()
}
Do not design interfaces like:
fun isLogin(): Boolean
fun getUserName(): String
This kind of instantaneous query causes the caller to lose reactive capability and can only repeatedly and actively refresh.
6. Step 4: Use Hilt to Bind Interfaces and Implementations
Repositories should depend on interfaces, not directly on implementation classes everywhere. Please look directly at the source code.
The benefits of doing this are:
- ViewModels only depend on abstract interfaces
- Repositories are global singleton state sources
- Fake implementations can be substituted during testing
- Dependency relationships between modules are clear
7. Step 5: ViewModel Combines Business State into UI State
The Repository provides raw business state. What a page really needs is UI state.
For example, the home page needs:
- The current user
- The current login state
- Whether the message count can be displayed
- Whether the bottom message badge should be shown
HomeViewModel organizes it like this:
@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("Loading...")
)
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
)
}
The core here is combine.
The badge display rule is not something a page judges temporarily but a stable state derivation:
shouldShowMessageBadge = isLoggedIn && messageCount > 0
The message display rule is also a state derivation:
displayMessageCount = isLoggedIn ? messageCount : null
The value of the ViewModel lies right here:
Combining multiple business states into the state the page actually needs.
8. Step 6: Compose Only Responsible for Observing and Displaying (XML is essentially the same)
At the UI layer, do not write complex business judgments.
The home page only collects state:
val showBadge by homeVm.shouldShowMessageBadge.collectAsStateWithLifecycle()
The bottom Tab displays based on state:
BadgedBox(badge = {
if (screen is Screen.Message && showBadge) {
Badge()
}
}) {
Icon(screen.icon, contentDescription = screen.label)
}
The message page is the same:
val displayCount by msgVm.displayMessageCount.collectAsStateWithLifecycle()
if (displayCount != null) {
Text("You have $displayCount unread messages")
} else {
Text("Please log in to view messages")
}
The UI layer's responsibility is simple:
Observe state
Display state
Convert user actions into ViewModel method calls
This makes Compose pages very stable.
9. Step 7: Use SharedFlow for One-Time Side Effects
Continuous state is suitable for StateFlow.
One-time events are suitable for SharedFlow.
For example, a Snackbar:
@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)
}
}
The main interface collects uniformly:
LaunchedEffect(Unit) {
notificationVm.notifications.collectLatest { message ->
snackbarHostState.showSnackbar(message)
}
}
After a successful login, just call:
notificationRepository.showNotification("Welcome back, $userName! State synced.")
After changing a nickname, you can also call:
notificationRepository.showNotification("Nickname successfully updated and persisted.")
This type of data is not suitable for StateFlow because it is not a "current state" but a "one-time consumption signal."
Simple rules:
| Type | Recommendation |
|---|---|
| Current login state | StateFlow |
| Current user info | StateFlow |
| Current theme | StateFlow |
| Current message count | StateFlow |
| Snackbar | SharedFlow |
| Navigation | SharedFlow / Channel |
10. Step 8: Login Dialog Automatically Closes Based on State
The login dialog is local UI state:
var showLoginDialog by rememberSaveable { mutableStateOf(false) }
Opens when the login button is clicked:
onLoginClick = { showLoginDialog = true }
Automatically closes after a successful login:
LaunchedEffect(loginState) {
if (loginState is LoginState.Login) {
showLoginDialog = false
}
}
This code does not imperatively close the dialog in some callback but expresses:
When the login state becomes logged in, the login dialog should disappear.
This is the declarative UI way of writing.
The UI does not need to remember "who triggered the login." It only cares about what the current state is.
11. Step 9: Theme as Global State
Theme switching is also a complete example of a state flow.
The Repository exposes the theme:
override val theme: Flow<AppTheme> = themeDataSource.theme
The ViewModel converts it to StateFlow:
val theme: StateFlow<AppTheme> = themeRepository.theme
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = AppTheme.Light
)
The Activity observes:
val theme by settingsVm.theme.collectAsStateWithLifecycle()
NoEventBusTheme(darkTheme = theme == AppTheme.Dark) {
NoEventBusDemoApp()
}
The settings page triggers the switch:
fun toggleTheme() {
viewModelScope.launch {
themeRepository.toggleTheme()
}
}
After the theme is written to DataStore, the Activity automatically receives the new theme, and Compose automatically recomposes.
This type of global state is very suitable for:
DataStore + Repository + Flow + Activity collect
12. Step 10: Further Advancement, Push Dependent State Down to the Repository
In the current demo, the message count is exposed like this:
override val count: Flow<Int> = messageDataSource.messageCount
Login state filtering is done in the ViewModel:
combine(
authRepository.loginState,
messageRepository.count
) { state, count ->
if (state is LoginState.Login) count else null
}
This already satisfies page display.
But in real business, you can go further:
Let MessageRepository switch the message flow itself based on the login state.
override val count: Flow<Int> = authRepository.loginState
.flatMapLatest { state ->
if (state is LoginState.Login) {
messageDataSource.getMessageCount(state.token)
} else {
flowOf(0)
}
}
In this way, the messageRepository.count obtained by the caller is naturally "the message count of the currently logged-in user."
This approach has several benefits:
- The ViewModel does not need to understand tokens
- The message module handles user switching itself
- The message count naturally resets to zero on logout
- The data source automatically switches when changing users
- Page logic is cleaner
This is a very important capability in the Flow architecture: dynamic flow grafting.
State is not statically subscribed. It can automatically switch data sources based on changes in another state.
13. Several Design Principles for Implementation
First, distinguish between state and side effects.
What persists, is recoverable, and displayable is state.
What is consumed only once and should not be recovered is a side effect.
Second, Repository interfaces should prioritize exposing Flow and suspend.
interface UserRepository {
val user: Flow<User>
suspend fun updateName(newName: String)
}
Third, ViewModels should not call each other.
When multiple pages need to sync, share the underlying Repository instead of grabbing each other's ViewModels.
Fourth, the UI should not hold business rules.
Rules like "show badge only when logged in and message count > 0" should be placed in the ViewModel; the UI is only responsible for displaying the result.
Fifth, do not create a global event center.
Even if using MutableSharedFlow<Any>, if it takes on the responsibility of "posting everywhere, collecting everywhere," it is still essentially implicit communication.
Sixth, delegate persistent state to DataStore.
States like login tokens, themes, and user nicknames should be persisted, not dependent on the last event in memory.
14. How to Handle Componentized Scenarios
In a componentized project, this structure is equally applicable.
The key is to put interfaces and public models into a base module.
module_base
AuthRepository
UserRepository
MessageRepository
LoginState
User
module_auth
AuthRepositoryImpl
module_user
UserRepositoryImpl
module_message
MessageRepositoryImpl
module_home
HomeViewModel
The dependency direction should be:
module_home → module_base
module_auth → module_base
module_user → module_base
module_message → module_base
Business modules should not depend on each other's implementations.
For example, if the home page needs user information, it depends on:
UserRepository
Not on:
UserRepositoryImpl
Ultimately, Hilt completes the binding at the App layer.
This maintains component decoupling while allowing global state to flow naturally through Repositories.
Summary
What this demo aims to express is not the usage of a specific API but a concrete way to implement an architecture.
The complete process can be summarized as:
1. Define business state models
2. Save real state with DataStore
3. Repository exposes Flow and suspend operations
4. Hilt binds interfaces and implementations
5. ViewModel combines business state, derives UI state
6. Compose uses collectAsStateWithLifecycle to observe state
7. SharedFlow handles one-time side effects like Snackbars and navigation
8. Use flatMapLatest when necessary to dynamically switch state sources
Once this chain is established, page synchronization, cross-module coordination, and global state updates all become natural.
The focus is not "a certain page notifying another page." The focus is:
State is correctly maintained;
State changes can be observed;
Pages only display the state they need.
This is where the true value of combining Repository + Flow + ViewModel + Compose lies.
NoEventBus is just a demo to illustrate the architecture; please forgive any imperfections.