Master Android
Lifecycle
A comprehensive deep dive into every component lifecycle in Android — from Application to Jetpack Compose. Understand how Android manages your app's state, memory, and navigation.
What is the Application class?
The Application class is a base class for maintaining global application state. Android instantiates it before any other component in your app's process. There is exactly one Application instance per process — it persists for the entire lifetime of that process.
You extend it to perform global initializations: dependency injection containers (Dagger/Hilt), crash reporting SDKs, analytics, database initialization, and anything that must exist before the first Activity appears.
onTrimMemory gives granular levels: TRIM_MEMORY_UI_HIDDEN, RUNNING_LOW, RUNNING_CRITICAL. Release caches here.@HiltAndroidApp class MyApp : Application() { override fun attachBaseContext(base: Context) { super.attachBaseContext(base) // MultiDex or custom wrapper goes here } override fun onCreate() { super.onCreate() // Hilt injects the dependency graph automatically via @HiltAndroidApp Timber.plant(Timber.DebugTree()) FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG) } override fun onTrimMemory(level: Int) { super.onTrimMemory(level) when (level) { ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> imageCache.evictAll() ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> networkCache.evictAll() } } }
Application vs. Process
One process can have multiple applications if you specify android:process in the manifest. Conversely, a single app can run in multiple processes — each gets its own Application instance. The Application class is per-process, not per-app.
The 7 core callbacks
Every Activity moves through a defined set of states. The system calls these methods to signal transitions. Implementing them correctly is the difference between an app that feels solid and one that leaks memory, loses user data, or crashes on rotation.
Deep dive: each callback
onCreate() — The setup callback
Called once when the Activity is first created, or after a process kill when the user returns. Receives a savedInstanceState Bundle — null on first launch, non-null when restoring. This is where you call setContentView(), initialize ViewModels, set up RecyclerView adapters, and restore saved UI state.
onStart() — Becoming visible
The Activity is about to become visible. At this point it's not yet interactive. This is a good place to register BroadcastReceivers that are only needed when the Activity is visible, or to begin animating UI elements that are always shown.
onResume() — Taking focus
The Activity is now fully visible, in the foreground, and receiving user input. This is the "running" state. Acquire exclusive resources here: camera, microphone, sensors. Resume paused animations. This callback is called every time the Activity comes to the foreground — including when returning from another Activity or dismissing a dialog.
onPause() — Losing focus (fast!)
Called when the Activity is partially obscured — another Activity in multi-window, a dialog, or the system about to transition away. Must complete very quickly — the next Activity cannot start until onPause returns. Release camera/mic here. Save critical but lightweight state. Do NOT do disk I/O, network calls, or complex animations.
onStop() — Invisible
The Activity is completely hidden. This is where you release expensive resources: database cursors, location updates, expensive animations. The system may call onSaveInstanceState just before this if it may need to restore the Activity later. After onStop the process can be killed without further callbacks — this makes onStop the last guaranteed callback for saves.
onRestart() — Coming back from stopped
Called when an Activity that was stopped (but not destroyed) is about to start again. Followed immediately by onStart. Use this to refresh data that might have changed while the user was away — for example, re-querying a contact that might have been edited in another app.
onDestroy() — Cleanup
Called before the Activity is destroyed. This happens either because the user finished the Activity, or because the system is destroying it due to a configuration change. Check isFinishing() to distinguish. Release any remaining resources, close threads, unregister any remaining listeners. Not guaranteed to be called if the process is killed — never rely on it for critical saves.
class MainActivity : AppCompatActivity() { private val viewModel: MainViewModel by viewModels() private var camera: Camera? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // ViewModel survives rotation — data already loaded viewModel.uiState.observe(this) { state -> render(state) } // Restore transient UI state (e.g. scroll position) savedInstanceState?.let { binding.recycler.scrollToPosition(it.getInt("scroll_pos", 0)) } } override fun onResume() { super.onResume() camera = Camera.open() // acquire exclusive resource } override fun onPause() { super.onPause() camera?.release() // must release before next Activity can use camera camera = null } override fun onStop() { super.onStop() // Heavy resource release, last guaranteed callback } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putInt("scroll_pos", binding.recycler.computeVerticalScrollOffset()) outState.putString("search_query", binding.searchView.query.toString()) } override fun onDestroy() { super.onDestroy() if (isFinishing()) { // Permanent destruction — user navigated away } else { // Config change (rotation) — ViewModel will re-attach } } }
Configuration changes & rotation
Screen rotation triggers a full destroy-recreate cycle by default. The system calls onSaveInstanceState, destroys the Activity, then creates a new one with the saved Bundle. The ViewModel is kept alive across this — it's the primary tool for surviving rotation without saving/restoring complex data through a Bundle.
Fragment vs Activity lifecycle
A Fragment has two lifecycles: its own (getLifecycle()) and its view's (getViewLifecycleOwner()). The fragment can exist without a view — when it's on the back stack, the view is destroyed but the fragment object lives on. This is the single most important distinction to understand.
onDestroyView. The view is destroyed but your field keeps it alive. Always: private var _binding: FragmentFooBinding? = null and null it in onDestroyView.class ProfileFragment : Fragment(R.layout.fragment_profile) { private val viewModel: ProfileViewModel by viewModels() private var _binding: FragmentProfileBinding? = null private val binding get() = _binding!! override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // ViewModel acquired here — survives config changes // No view references! } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentProfileBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // Use viewLifecycleOwner — NOT 'this' viewModel.profile.observe(viewLifecycleOwner) { profile -> binding.nameText.text = profile.name } binding.editBtn.setOnClickListener { findNavController().navigate(R.id.action_profile_to_edit) } } override fun onDestroyView() { super.onDestroyView() _binding = null // ← critical: prevents memory leak } }
Why viewLifecycleOwner matters
If you observe LiveData with this (the Fragment as LifecycleOwner), the observer lives as long as the Fragment object. When the Fragment goes on the back stack, its view is destroyed but the fragment lives on. When the user navigates back, a new view is created and onViewCreated runs again — adding a second observer. Now you have two observers for the same LiveData, and UI updates fire twice. Using viewLifecycleOwner ties the observer to the view's lifecycle, so it's automatically removed in onDestroyView.
How ViewModel survives rotation
ViewModels are stored in a ViewModelStore, which is retained by the system during configuration changes. When you call ViewModelProvider(this)[MyViewModel::class.java], you're either creating a new ViewModel (first call) or getting the existing one from the retained store. The ViewModel's onCleared() is only called when the owning scope is truly finished — not during rotation.
ViewModel scoping
ViewModels can be scoped to different lifecycle owners. This determines their lifetime and which components can share the same instance.
| Scope | Lifetime | Shared between | How to get |
|---|---|---|---|
| Activity | Until activity finishes | All fragments in that activity | by activityViewModels() |
| Fragment | Until fragment detaches | That fragment only | by viewModels() |
| NavGraph | Until nav graph is popped | Fragments in that subgraph | by navGraphViewModels(R.id.graph) |
| Custom | Custom owner lifetime | Explicit owner | ViewModelProvider(owner)[...] |
class MainViewModel(private val repo: UserRepository) : ViewModel() { private val _uiState = MutableStateFlow(UiState.Loading) val uiState: StateFlow<UiState> = _uiState.asStateFlow() val users = repo.getAllUsers() // Flow from Room — lifecycle-aware private val job: Job init { // viewModelScope is cancelled in onCleared() job = viewModelScope.launch { try { val result = repo.fetchUsers() _uiState.value = UiState.Success(result) } catch (e: Exception) { _uiState.value = UiState.Error(e.message) } } } override fun onCleared() { super.onCleared() // viewModelScope auto-cancels — but cancel explicit jobs here // Called ONLY when Activity/Fragment is truly finished (not rotation) } }
viewModelScope. It's automatically cancelled when onCleared() fires, preventing leaks from in-flight network calls after the user has left the screen.SavedStateHandle — it's injected into ViewModels by Hilt and backed by the Bundle mechanism.SavedStateHandle — bridging ViewModel and Bundle
SavedStateHandle is a key-value map that stores primitive and Parcelable values, and is serialized into the saved state Bundle. It lets a ViewModel hold state that survives both configuration changes AND process kill — without the ViewModel needing to know about the Activity/Fragment lifecycle directly.
class SearchViewModel( private val savedState: SavedStateHandle, private val repo: SearchRepository ) : ViewModel() { // Survives rotation AND process kill var query: String get() = savedState["query"] ?: "" set(value) { savedState["query"] = value } val results: StateFlow<List<Result>> = savedState.getStateFlow("query", "") .flatMapLatest { q -> repo.search(q) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) }
The Composition lifecycle
A composable's lifecycle has three phases: entering the Composition (initial call), recomposing (subsequent calls when inputs change), and leaving the Composition (removed from the tree). There are no inherited lifecycle methods — state and effects are expressed through Compose's built-in APIs.
Side effects in Compose
Because composable functions may run at any time and multiple times, side effects (network calls, subscriptions, analytics) must be handled through structured effect APIs that tie their lifecycle to the Composition.
@Composable fun UserProfile(userId: String, viewModel: ProfileViewModel) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() // Reload when userId changes — coroutine cancelled on leave LaunchedEffect(userId) { viewModel.loadUser(userId) } // Listen to a callback-based API DisposableEffect(userId) { val listener = PresenceListener { online -> viewModel.setOnline(online) } PresenceApi.register(userId, listener) onDispose { PresenceApi.unregister(userId, listener) } } // Runs after EVERY recomposition — push state to analytics SideEffect { Analytics.setCurrentUser(uiState.userId) } val scope = rememberCoroutineScope() Button(onClick = { // User-triggered: use scope, not LaunchedEffect scope.launch { viewModel.saveProfile() } }) { Text("Save") } }
Recomposition — what triggers it
A composable recomposes when its inputs (parameters or read State objects) change. Compose is smart — it tracks which composables read which state objects and only recomposes the minimal subtree necessary.
Compose and Activity/Fragment lifecycle
Compose composables observe the Android lifecycle through LocalLifecycleOwner. When collecting flows, use collectAsStateWithLifecycle() (from lifecycle-runtime-compose) instead of collectAsState() — it automatically stops collection when the host lifecycle drops below STARTED, preventing work when the app is in the background.
@Composable fun MyScreen(viewModel: MyViewModel = hiltViewModel()) { // ✓ Stops collecting when app goes to background val state by viewModel.uiState.collectAsStateWithLifecycle() // ✗ Keeps collecting even when invisible — wastes battery // val state by viewModel.uiState.collectAsState() // Observe the host lifecycle directly val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(lifecycle) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) viewModel.refresh() } lifecycle.addObserver(observer) onDispose { lifecycle.removeObserver(observer) } } }
Three service types
| Type | Lifecycle | Killed by system? | Use case |
|---|---|---|---|
| Started | onCreate → onStartCommand → onDestroy | Maybe | Fire-and-forget background tasks |
| Bound | onCreate → onBind → onUnbind → onDestroy | When no clients | IPC between components |
| Foreground | Started + persistent notification | Rarely | Music, navigation, downloads |
class MusicPlayerService : Service() { private val binder = LocalBinder() private lateinit var mediaPlayer: MediaPlayer override fun onCreate() { super.onCreate() mediaPlayer = MediaPlayer() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val notification = buildNotification() startForeground(NOTIF_ID, notification) // promote to foreground mediaPlayer.start() return START_STICKY // restart if killed, don't re-deliver intent } override fun onBind(intent: Intent): IBinder = binder inner class LocalBinder : Binder() { fun getService() = this@MusicPlayerService } override fun onDestroy() { super.onDestroy() mediaPlayer.stop() mediaPlayer.release() } }
WorkManager (guaranteed execution with constraints) over raw Services. Reserve Services for long-running user-facing operations that need a notification (music, navigation, file upload) or for binding between app components.LiveData
LiveData is lifecycle-aware — it only delivers updates to observers that are in the STARTED or RESUMED state. When the observer goes to STOPPED, updates are queued. When it resumes, the latest value is delivered. When the observer is DESTROYED, the observation is automatically removed.
| Feature | LiveData | StateFlow | SharedFlow |
|---|---|---|---|
| Lifecycle aware | ✓ built-in | ✓ with collectAsStateWithLifecycle | ✓ with collectAsStateWithLifecycle |
| Initial value required | No | Yes | No |
| Replays last value | Yes | Yes (1) | Configurable |
| Coroutine friendly | Limited | Native | Native |
| One-shot events | Problematic | Problematic | Yes (replay=0) |
class FeedViewModel(repo: FeedRepository) : ViewModel() { // StateFlow — current state, always has a value val feedState: StateFlow<FeedState> = repo.getFeed() .map { FeedState.Success(it) } .catch { emit(FeedState.Error(it.message)) } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), // stays alive 5s after last subscriber initialValue = FeedState.Loading ) // SharedFlow — one-shot events (navigation, snackbar) private val _events = MutableSharedFlow<UiEvent>() val events: SharedFlow<UiEvent> = _events fun onLikeClicked(postId: String) { viewModelScope.launch { repo.likePost(postId) _events.emit(UiEvent.ShowSnackbar("Liked!")) } } } // In the Fragment: viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { launch { viewModel.feedState.collect { render(it) } } launch { viewModel.events.collect { handleEvent(it) } } } }
Process lifecycle
Android assigns each process an importance level and kills lower-priority processes when memory is needed. Your process can be killed without any callbacks if it's not in the foreground. The importance hierarchy:
The back stack (Task)
A Task is a collection of Activities arranged in a stack. When you start Activity B from A, B goes on top. Back press pops B and returns to A. Tasks can be in the foreground (visible) or background (cached). Multiple tasks can exist — switching apps means switching tasks.
Launch modes
The android:launchMode manifest attribute and Intent flags control how Activities are added to the back stack:
| Launch mode | Behavior | Common use |
|---|---|---|
| standard | New instance every time, stacked | Default — most Activities |
| singleTop | Reuse if already at top (onNewIntent) | Notification deep links |
| singleTask | One instance in task, clears above | Main/home screen |
| singleInstance | Own private task, one instance | System-level screens, Dialer |
The three survival scenarios
| Storage | Rotation | Process kill + return | App uninstall | Size limit |
|---|---|---|---|---|
| ViewModel | ✓ | ✗ | ✗ | Unlimited |
| Instance state (Bundle) | ✓ | ✓ | ✗ | ~1MB |
| SharedPreferences | ✓ | ✓ | ✗ | Unlimited |
| Room (SQLite) | ✓ | ✓ | ✗ | Device storage |
| DataStore | ✓ | ✓ | ✗ | Practical unlimited |
The complete save & restore flow
// ViewModel: survives rotation, holds heavy data class FormViewModel(private val handle: SavedStateHandle) : ViewModel() { // SavedStateHandle: survives process kill var draftTitle: String get() = handle["draft_title"] ?: "" set(v) { handle["draft_title"] = v } // Complex data: in ViewModel memory (rotation), Room (process kill) val draftItems: StateFlow<List<Item>> = handle .getStateFlow("draft_id", "") .flatMapLatest { id -> draftRepo.getDraft(id) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) } // Activity: saves ephemeral UI state (scroll, selection) class FormActivity : AppCompatActivity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) // Only ephemeral UI state not in ViewModel outState.putInt("tab_index", binding.tabs.selectedTabPosition) outState.putBoolean("keyboard_open", binding.searchBar.hasFocus()) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_form) savedInstanceState?.let { bundle -> binding.tabs.getTabAt(bundle.getInt("tab_index", 0))?.select() } } }
| Callback | Component | When called | Do this | Don't do this |
|---|---|---|---|---|
| onCreate() | Activity Fragment | First creation or after process kill | setContentView, init ViewModel, restore state | Heavy I/O, network calls |
| onStart() | Activity Fragment | Becoming visible | Register visible-only receivers | Exclusive resources (camera) |
| onResume() | Activity Fragment | Taking foreground focus | Acquire camera/mic, resume animations | Slow operations |
| onPause() | Activity Fragment | Losing focus (must be FAST) | Release camera/mic, save critical state | Disk I/O, heavy work |
| onStop() | Activity Fragment | Becoming invisible | Release heavy resources, pause sensors | Starting new activities |
| onSaveInstanceState() | Activity Fragment | Before onStop when state may need saving | Save ephemeral UI state to Bundle | Save domain data here |
| onDestroyView() | Fragment | View hierarchy destroyed (back stack) | Null _binding reference | Reference binding after this |
| onDestroy() | Activity Fragment | Activity/Fragment being destroyed | Final cleanup, check isFinishing() | Rely on this for critical saves |
| onCleared() | ViewModel | Owner scope truly finished | Cancel non-viewModelScope coroutines | Trust this for process-kill saves |
| LaunchedEffect / DisposableEffect | Compose | Enter/leave composition, key changes | Side effects, subscriptions | UI work outside recomposition |
Android lifecycle is complex because the OS must manage memory aggressively for billions of devices. Every callback exists for a reason — respect its contract and your apps will be solid.