Crafting Resilient Android Overlays: Service, Compose & Lifecycle

Building UIs That Float Above the Rest, Safely.

Android Service Overlay Architecture

The Allure and Challenge of Overlay UIs

Android overlays – UI elements drawn on top of other applications – unlock powerful capabilities. From live score widgets and accessibility tools to productivity enhancers like floating chat heads, they provide persistent, context-aware information. However, building them robustly is a significant undertaking. It demands careful management of Android Services, the WindowManager, Jetpack Compose rendering outside a typical Activity, and intricate lifecycle considerations, especially when dealing with live data updates and multiple overlay instances.

This article delves into the journey of creating such a system, focusing on the architectural decisions, lifecycle management, and data flow strategies that proved crucial for a production-ready overlay feature in an Android app built with Kotlin and Jetpack Compose.

Core Architectural Pillars

Our overlay system stands on several key Android components:

  • Foreground Service: The backbone. A Foreground Service is essential to keep the overlay logic running reliably even when the main app isn't visible. It requires a persistent notification, informing the user of its operation.
  • WindowManager: The Android system service that allows an app to add, remove, and update views that are drawn on top of all other app windows. This is how our overlay becomes visible globally.
  • Jetpack Compose (`ComposeView`): To leverage the power and declarative nature of Jetpack Compose for the overlay's UI, we embed a ComposeView into the window managed by the WindowManager.
  • ViewModel: Each overlay instance (if displaying unique data) often needs its own ViewModel. This handles UI logic, data fetching (from APIs or local sources), and state management, keeping the Service itself cleaner.

Navigating the Lifecycle Labyrinth

This is where things get tricky. A Service has its own lifecycle, distinct from an Activity. A ComposeView needs a LifecycleOwner, a ViewModelStoreOwner, and a SavedStateRegistryOwner to function correctly with Jetpack lifecycle-aware components like ViewModels and collectAsStateWithLifecycle.

The solution was to make our OverlayService implement these interfaces:

class OverlayService : Service(), LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner {
override val viewModelStore = ViewModelStore()
private lateinit var lifecycleRegistry: LifecycleRegistry
private lateinit var savedStateRegistryController: SavedStateRegistryController
override val lifecycle: Lifecycle get() = lifecycleRegistry
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry

override fun onCreate() {
super.onCreate()
lifecycleRegistry = LifecycleRegistry(this)
savedStateRegistryController = SavedStateRegistryController.create(this)
savedStateRegistryController.performRestore(null)
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
// ... other onCreate setup ...
}

override fun onStartCommand(...): Int {
if (lifecycleRegistry.currentState < Lifecycle.State.STARTED) {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)
}
// Ensure RESUMED when content is set
return START_STICKY
}

override fun onDestroy() {
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
viewModelStore.clear()
// ... other cleanup ...
super.onDestroy()
}
// ...
}

Then, when creating a ComposeView programmatically:

val composeView = ComposeView(this).apply {
setViewTreeLifecycleOwner(this@OverlayService)
setViewTreeViewModelStoreOwner(this@OverlayService)
setViewTreeSavedStateRegistryOwner(this@OverlayService)
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
}

This ensures our Composables within the overlay behave correctly with ViewModels and live data.

Managing Multiple Overlay Instances

Our app needed to support multiple, distinct overlays simultaneously (e.g., pinned scores for different matches). Each needed its own data and state, meaning its own ViewModel instance. Simply getting a ViewModel the standard way would return a singleton scoped to the Service.

The solution involved:

  • Unique Identifiers: Each overlay instance is associated with a unique key (e.g., matchKey).
  • Custom ViewModel Factory: We created a Hilt-assisted custom ViewModelProvider.Factory.
  • CreationExtras: This factory expects the unique key to be passed via CreationExtras when requesting a ViewModel.
  • ViewModelStore Keying: When obtaining the ViewModel from the provider, we used the overload that accepts a custom String key. This custom key, derived from our unique identifier (e.g., "OverlayViewModel_$matchKey"), ensures the Service's ViewModelStore creates and manages a separate instance for each overlay.
// In OverlayService
@Inject lateinit var viewModelFactory: OverlayViewModel.Factory

private fun getOrCreateViewModelForMatch(matchKey: String): OverlayViewModel {
val extras = MutableCreationExtras().apply {
set(OverlayViewModel.Factory.MATCH_KEY_EXTRA, matchKey)
}
val provider = ViewModelProvider(viewModelStore, viewModelFactory, extras)
val viewModelStoreKey = "OverlayViewModel_$matchKey"
return provider[viewModelStoreKey, OverlayViewModel::class.java]
}

// In OverlayViewModel.Factory
class Factory @Inject constructor(...) : ViewModelProvider.Factory {
companion object {
val MATCH_KEY_EXTRA = object : CreationExtras.Key<String> {}
}
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
val matchKey = extras[MATCH_KEY_EXTRA] ?: throw IllegalArgumentException(...)
return OverlayViewModel(matchKey, /* inject dependencies */ ) as T
}
}

We also maintained a map of matchKey to its active ComposeView to manage a pool of up to two overlay views, initializing them lazily.

Data Flow, Threading, and UI Updates

Each OverlayViewModel fetches initial data via a UseCase and then subscribes to real-time updates (e.g., from Sockets managed by a repository) using Kotlin Flow. These flows are collected in the ViewModel's viewModelScope.

// Inside OverlayViewModel
private val _uiState = MutableStateFlow( OverlayUiState(isLoading = true) )
val uiState: StateFlow<OverlayUiState> = _uiState.asStateFlow()

init {
fetchInitialData()
startSocketListeners()
}

private fun startSocketListeners() {
socketIORepository.liveScoreFlow
.filter { it.matchKey == this.matchKey }
.onEach { scoreUpdate -> _uiState.update { /* ... copy with new score ... */ } }
.launchIn(viewModelScope)
// Similar for other data streams...
}

The Composable UI within the overlay then observes this state:

// Inside OverlayScreenComposable
val uiState by viewModel.overlayState.collectAsStateWithLifecycle()

Text(text = uiState.pinnedData?.score ?: "Loading...")

All UI manipulations, especially adding/removing views via WindowManager or calling ComposeView.setContent, must happen on the Main thread. We ensured this using Dispatchers.Main.immediate for relevant coroutines in the Service or by posting to a Handler(Looper.getMainLooper()) for callbacks from other threads (like drag events or broadcast receivers).

Maturing the System: From Concept to Production

Reaching a stable, production-ready overlay system involved significant iteration. Early challenges included managing ViewModel lifecycles correctly for multiple instances, ensuring thread safety for UI updates from various sources (user drag, socket events, service commands), gracefully handling service destruction and view cleanup, and managing a limited pool of actual ComposeView objects that could be recycled for different data instances. Robust error handling, especially around WindowManager operations (which can fail if permissions are revoked or in odd system states), was also key.

Thorough logging and careful attention to component lifecycles were indispensable throughout this process.

Relevant Technologies & Keywords

Android ServiceForeground ServiceWindowManagerJetpack ComposeComposeViewViewModelViewModelStoreOwnerLifecycleOwnerSavedStateRegistryOwnerViewModelProvider.FactoryCreationExtrasKotlin CoroutinesStateFlowDagger HiltAPI IntegrationSocket.IOThread SafetyUI DesignMultiple OverlaysAndroid DevelopmentAdvanced Android

Conclusion

Building sophisticated overlay UIs on Android is a complex but achievable task. By understanding and correctly implementing Service lifecycles, integrating Jetpack Compose with care, and architecting for multiple, independent data instances using ViewModels, developers can create powerful and seamless user experiences that extend beyond the confines of a single app window.