Created
March 31, 2026 18:23
-
-
Save RohitSurwase/5e61638c8c29821cf8f0380331c27b81 to your computer and use it in GitHub Desktop.
Replacing LocalBroadcastManager in Android: SharedFlow + Sealed Classes = ❤️
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --- | |
| Replacing LocalBroadcastManager in Android: SharedFlow + Sealed Classes = ❤️ | |
| A modern, lifecycle-safe, type-driven in-app event bus using Kotlin Coroutines and SharedFlow - demonstrated with a News Reader app. | |
| TL;DR - If you are already familiar with Kotlin Coroutines, SharedFlow, and sealed classes, skip the basics and jump straight to the LocalBroadcast + BroadcastEvent section. | |
| Preface | |
| Every non-trivial Android app eventually runs into the same problem: how do you communicate between two completely unrelated parts of your app - a background sync service and a fragment, a repository and a ViewModel - without coupling them together? | |
| For years, the go-to answer was LocalBroadcastManager. It was simple. It worked. And then Google deprecated it. | |
| "LocalBroadcastManager is an application-wide event bus and embraces layer violations in your app; any component may listen to events from any other component… Use other means of communicating between components." | |
| - Android Developer Documentation | |
| So, what do we use instead? There are options - EventBus, RxJava's PublishSubject, or callbacks passed through constructors - but each comes with its own cost. We are in the coroutines era now. There is a cleaner answer. | |
| Let me walk you through an event bus architecture I've been using in production - built on MutableSharedFlow, Kotlin sealed classes, and coroutine extension functions. To keep things concrete, we'll build it in the context of a News Reader app that syncs articles in the background, handles user authentication, and notifies the UI of changes from anywhere in the stack. | |
| --- | |
| ⭐ Why Not Just Use LiveData or StateFlow? | |
| Before we get to the solution, let's quickly rule out the obvious alternatives. | |
| LiveData is lifecycle-aware but it maintains state. If you emit "show a loading spinner" via LiveData and the user rotates the screen, the spinner re-shows on re-subscription. That's not what you want for fire-and-forget commands. | |
| StateFlow has the same problem - it always replays the last value to new collectors. It's perfect for UI state, not for events. | |
| SharedFlow with replay = 0 is exactly what an event bus needs. It does not replay missed emissions. It delivers to all current subscribers simultaneously, and then the event is gone. Think of it like a real broadcast - if you weren't listening, you missed it. | |
| --- | |
| ⭐ The Two Core Classes | |
| The entire system is built on two files. One defines what can be broadcast. The other defines how to broadcast and observe it. | |
| BroadcastEvent - The Contract | |
| BroadcastEvent is a sealed class. Every possible in-app event is a subclass - either a data class (if it carries a payload) or an object (if it's just a signal). | |
| For our News Reader app, these are the events that flow across the app: | |
| sealed class BroadcastEvent { | |
| // An article's content was updated remotely | |
| data class ArticleUpdated(val articleId: String? = null) : BroadcastEvent() | |
| // Background feed sync is in progress | |
| data class FeedRefreshing( | |
| val currentProgress: Float, | |
| val total: Float, | |
| val remaining: Long | |
| ) : BroadcastEvent() | |
| // Background feed sync completed | |
| object FeedRefreshComplete : BroadcastEvent() | |
| // App has finished initializing and is ready | |
| object AppReady : BroadcastEvent() | |
| // A human-readable sync status message (e.g. "Fetching top stories...") | |
| data class SyncStatus(val message: String) : BroadcastEvent() | |
| // OTP received via SMS for login | |
| data class OtpReceived(val otp: String, val smsBody: String) : BroadcastEvent() | |
| // A news category's articles were updated | |
| data class CategoryUpdated( | |
| val categoryId: String?, | |
| val articlesChanged: Boolean, | |
| val updateTime: Long | |
| ) : BroadcastEvent() | |
| // A category update is currently in progress | |
| data class CategoryUpdateInProgress(val categoryId: String?, val updateTime: Long) : | |
| BroadcastEvent() | |
| // Bookmark cloud sync started or completed | |
| data class BookmarkSyncUpdate(val started: Boolean, val bookmarkCount: Int) : | |
| BroadcastEvent() | |
| // Request to prompt the user for notification permission | |
| object RequestNotificationPermission : BroadcastEvent() | |
| // User changed their app lock / security settings | |
| data class SecurityChanged(val lockTypeChanged: Boolean) : BroadcastEvent() | |
| // User signed out | |
| object UserSignOut : BroadcastEvent() | |
| // Sign-out flow was initiated (before confirmation) | |
| object UserSignOutInitiated : BroadcastEvent() | |
| // Toggle read-count visibility in the UI | |
| data class HideReadCount(val hide: Boolean) : BroadcastEvent() | |
| // Onboarding was reset (e.g. after account switch) | |
| object OnboardingReset : BroadcastEvent() | |
| // Restore from backup is in progress | |
| object RestoreInProgress : BroadcastEvent() | |
| } | |
| The sealed class is your contract. If you need to add a new event type to the app, this is the one place you go. Your IDE will immediately flag any when expression that doesn't handle the new subtype - that's the compiler enforcing your architecture. | |
| FilterType - The Subscription Filter | |
| Alongside BroadcastEvent, we define a FilterType enum. Each entry corresponds directly to one event type. Observers subscribe to a list of filters, not to all events. | |
| enum class FilterType { | |
| ARTICLE_UPDATED, // BroadcastEvent.ArticleUpdated | |
| FEED_REFRESHING, // BroadcastEvent.FeedRefreshing | |
| FEED_REFRESH_COMPLETE, // BroadcastEvent.FeedRefreshComplete | |
| APP_READY, // BroadcastEvent.AppReady | |
| SYNC_STATUS, // BroadcastEvent.SyncStatus | |
| OTP_RECEIVED, // BroadcastEvent.OtpReceived | |
| CATEGORY_UPDATED, // BroadcastEvent.CategoryUpdated | |
| CATEGORY_UPDATE_IN_PROGRESS, // BroadcastEvent.CategoryUpdateInProgress | |
| BOOKMARK_SYNC_UPDATE, // BroadcastEvent.BookmarkSyncUpdate | |
| REQUEST_NOTIFICATION_PERM, // BroadcastEvent.RequestNotificationPermission | |
| SECURITY_CHANGED, // BroadcastEvent.SecurityChanged | |
| USER_SIGN_OUT, // BroadcastEvent.UserSignOut | |
| USER_SIGN_OUT_INITIATED, // BroadcastEvent.UserSignOutInitiated | |
| HIDE_READ_COUNT, // BroadcastEvent.HideReadCount | |
| ONBOARDING_RESET, // BroadcastEvent.OnboardingReset | |
| RESTORE_IN_PROGRESS, // BroadcastEvent.RestoreInProgress | |
| } | |
| This filter pattern lets a subscriber declare exactly what it cares about. The ArticleDetailViewModel only subscribes to ARTICLE_UPDATED and BOOKMARK_SYNC_UPDATE. It never wakes up for OTP_RECEIVED or SECURITY_CHANGED. Clean boundaries. | |
| --- | |
| ⭐ LocalBroadcast - The Bus | |
| Now the bus itself. It's a Kotlin object - a singleton - holding a single MutableSharedFlow. | |
| object LocalBroadcast { | |
| private val _events = MutableSharedFlow<Pair<FilterType, BroadcastEvent>>() | |
| private const val TAG = "LocalBroadcast" | |
| @JvmStatic | |
| suspend fun postData(type: FilterType, data: BroadcastEvent) { | |
| Log.d(TAG, "Broadcasting: filter=$type event=$data") | |
| _events.emit(Pair(type, data)) | |
| } | |
| @JvmStatic | |
| fun observeData( | |
| scope: CoroutineScope, | |
| filters: List<FilterType>, | |
| onReceive: (FilterType, BroadcastEvent) -> Unit | |
| ) { | |
| scope.launch(Dispatchers.Main) { | |
| _events | |
| .filter { filters.contains(it.first) } | |
| .collect { event -> | |
| onReceive.invoke(event.first, event.second) | |
| } | |
| } | |
| } | |
| } | |
| Let's break down what's happening here: | |
| _events is a MutableSharedFlow<Pair<FilterType, BroadcastEvent>> - private, not exposed externally. | |
| postData() is a suspend function. Emitting into a SharedFlow is a suspend operation - it must be called from within a coroutine. | |
| observeData() takes a CoroutineScope. Pass viewModelScope or lifecycleScope, and when that scope is cancelled, the collector automatically stops. No manual cleanup needed. | |
| Kotlin-First Call Sites - Extension Functions | |
| For Kotlin callers inside a CoroutineScope, we expose two extension functions that make posting a broadcast feel completely natural: | |
| fun CoroutineScope.broadcastEvent(type: FilterType, data: BroadcastEvent) { | |
| launch { | |
| LocalBroadcast.postData(type = type, data = data) | |
| } | |
| } | |
| fun CoroutineScope.broadcastEventWithCompletion( | |
| type: FilterType, | |
| data: BroadcastEvent, | |
| onCompleted: () -> Unit | |
| ) { | |
| launch { | |
| LocalBroadcast.postData(type = type, data = data) | |
| }.invokeOnCompletion { | |
| onCompleted.invoke() | |
| } | |
| } | |
| The first is your everyday fire-and-forget broadcast. The second gives you a callback once the emission coroutine has finished - useful when you need to trigger navigation or a UI update after the broadcast is guaranteed to have been emitted. | |
| ⭐ How to Use It | |
| Sending a broadcast from the background sync repository when articles are fetched: | |
| class NewsSyncRepository(private val scope: CoroutineScope) { | |
| suspend fun syncFeed() { | |
| scope.broadcastEvent( | |
| type = FilterType.FEED_REFRESHING, | |
| data = BroadcastEvent.FeedRefreshing( | |
| currentProgress = 0f, | |
| total = 100f, | |
| remaining = 50 | |
| ) | |
| ) | |
| // ... do actual sync work ... | |
| scope.broadcastEvent( | |
| type = FilterType.FEED_REFRESH_COMPLETE, | |
| data = BroadcastEvent.FeedRefreshComplete | |
| ) | |
| } | |
| } | |
| Observing in the home screen ViewModel: | |
| class HomeViewModel : ViewModel() { | |
| init { | |
| LocalBroadcast.observeData( | |
| scope = viewModelScope, | |
| filters = listOf( | |
| FilterType.FEED_REFRESH_COMPLETE, | |
| FilterType.ARTICLE_UPDATED, | |
| FilterType.CATEGORY_UPDATED | |
| ) | |
| ) { _, event -> | |
| when (event) { | |
| is BroadcastEvent.FeedRefreshComplete -> reloadFeed() | |
| is BroadcastEvent.ArticleUpdated -> refreshArticle(event.articleId) | |
| is BroadcastEvent.CategoryUpdated -> refreshCategory(event.categoryId) | |
| else -> Unit | |
| } | |
| } | |
| } | |
| } | |
| No BroadcastReceiver. No IntentFilter. No onResume/onPause registration juggling. The ViewModel subscribes once in init{}, and viewModelScope handles teardown automatically when the ViewModel is cleared. | |
| Further Improvements | |
| The FilterType enum is redundant in pure Kotlin projects. | |
| Kotlin sealed classes already carry type identity. You can filter the flow using filterIsInstance<T>() directly, removing the need for the enum entirely. The enum was likely introduced for Java interoperability. If your codebase is fully Kotlin, consider simplifying to: | |
| // Type-safe, no enum needed | |
| _events.filterIsInstance<BroadcastEvent.ArticleUpdated>().collect { event -> | |
| refreshArticle(event.articleId) | |
| } | |
| 2. replay = 0 means late subscribers miss events. | |
| The current MutableSharedFlow() uses default replay = 0. If a subscriber registers after an emission, it will never see that event. For one-time signals like AppReady, consider replay = 1 or a dedicated StateFlow for those specific signals. | |
| 3. invokeOnCompletion fires on cancellation too. | |
| In broadcastEventWithCompletion, the onCompleted callback triggers even if the coroutine is cancelled mid-flight. Always check the cause parameter: | |
| }.invokeOnCompletion { cause -> | |
| if (cause == null) onCompleted.invoke() // Only on clean completion | |
| } | |
| 4. Sensitive data on a shared bus. | |
| OtpReceived(val otp: String, val smsBody: String) routes raw OTP strings through a global singleton. Any subscriber with access to the right FilterType gets the full payload. Scope sensitive events as tightly as possible - consider a dedicated, narrowly scoped channel for authentication signals rather than the global bus. | |
| 5. Use Set instead of List for filters. | |
| observeData() accepts a List<FilterType>. A Set is semantically more correct here - duplicate filter entries are meaningless and Set.contains() is O(1) vs O(n) for a List. | |
| fun observeData( | |
| scope: CoroutineScope, | |
| filters: Set<FilterType>, // Set, not List | |
| onReceive: (FilterType, BroadcastEvent) -> Unit | |
| ) | |
| Closing Thoughts | |
| The combination of MutableSharedFlow, a sealed class event hierarchy, and coroutine scopes gives you everything LocalBroadcastManager did - and more - with far less ceremony. The sealed class is your single source of truth for all in-app signals. The FilterType enum keeps subscriptions explicit and auditable. The CoroutineScope parameter does all lifecycle management for you. | |
| This is not groundbreaking architecture - it's just coroutines doing what they're already good at. No new dependencies, no new abstractions to learn. Just Kotlin, used well. | |
| --- | |
| Thanks for reading! If this helped you move away from LocalBroadcastManager, clap clap clap 👏👏👏 as many times as you can. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment