跪拜 Guibai
← Back to the summary

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:

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:


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:

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:

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.

Screen_recording_20260621_154235.gif

NoEventBus is just a demo to illustrate the architecture; please forgive any imperfections.