MVVM Architecture and Jetpack AAC
Why Do We Need Architecture?
In an Android project without a distinct architecture, code inevitably piles up inside Activity or Fragment classes. Network requests, database operations, business logic, and UI updates become severely entangled, quickly degrading the component into a "God Object." This results in:
- Untestable Code: It becomes impossible to write unit tests decoupled from the Android framework environment.
- State Loss: Screen rotations (Activity recreation) cause data loss or redundant network requests.
- High Coupling: Logic and UI are so tightly interwoven that touching one piece of code inadvertently breaks another.
MVVM (Model-View-ViewModel) resolves these fundamental issues and is the architectural pattern officially recommended by Google for modern Android development.
Responsibilities of MVVM Layers
┌─────────────────────────────────────────────────────┐
│ View (Activity / Fragment / Composable) │
│ Role: Render UI, capture user input, observe ViewModel │
│ ⚠️ NEVER write business logic or access data sources │
└────────────────────────┬────────────────────────────┘
│ Observes StateFlow/LiveData
│ Invokes ViewModel.xxx()
┌────────────────────────▼────────────────────────────┐
│ ViewModel │
│ Role: Hold UI state, handle user events, coordinate data layer │
│ ⚠️ NEVER reference Activity Context (use applicationContext) │
│ ⚠️ NEVER hold View references │
│ Lifecycle: Outlives Activity, survives screen rotations │
└────────────────────────┬────────────────────────────┘
│ Invokes Repository
┌────────────────────────▼────────────────────────────┐
│ Repository │
│ Role: Single data entry point, determines data source (Network/Cache/DB)│
│ ⚠️ ViewModel is agnostic to whether data is from Network or DB │
└───────────┬──────────────────────────┬──────────────┘
│ │
┌───────────▼──────┐ ┌───────────▼──────────────┐
│ Remote (API) │ │ Local (Room/DataStore) │
│ Retrofit APIs │ │ DAO / SharedPreferences │
└──────────────────┘ └──────────────────────────┘
ViewModel: Surviving Configuration Changes
A ViewModel is held by a ViewModelStore, which is not destroyed when the Activity undergoes configuration changes (like a screen rotation). The ViewModel is only garbage-collected (and its onCleared() invoked) when the user definitively exits the page (e.g., via the back button or finish()).
// Define the ViewModel
class UserListViewModel(
private val userRepository: UserRepository
) : ViewModel() {
// UI State: Read-only externally, writable internally
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
// Search query
private val _searchQuery = MutableStateFlow("")
// User list filtered by search query (Reactive Flow binding)
val users: StateFlow<List<User>> = _searchQuery
.debounce(300) // Debounce: Prevent excessive requests within 300ms
.flatMapLatest { query ->
userRepository.searchUsers(query)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
init {
loadUsers()
}
fun loadUsers() {
_uiState.value = UiState.Loading
viewModelScope.launch {
try {
userRepository.refreshUsers() // Sync local database from network
_uiState.value = UiState.Success
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "Load failed")
}
}
}
fun onSearchQueryChanged(query: String) {
_searchQuery.value = query
}
override fun onCleared() {
super.onCleared()
// Release unmanaged resources here if necessary
}
}
// View (Fragment)
class UserListFragment : Fragment() {
private val viewModel: UserListViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Bind search input
searchInput.addTextChangedListener { text ->
viewModel.onSearchQueryChanged(text.toString())
}
// Collect UI state (automatically cancelled based on lifecycle)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.uiState.collect { state ->
when (state) {
UiState.Loading -> showLoading()
UiState.Success -> hideLoading()
is UiState.Error -> showError(state.message)
}
}
}
launch {
viewModel.users.collect { users ->
adapter.submitList(users)
}
}
}
}
}
sealed class UiState {
object Loading : UiState()
object Success : UiState()
data class Error(val message: String) : UiState()
}
}
Repository: The Single Source of Truth
The Repository serves as the facade for the data layer. The ViewModel interacts exclusively with the Repository and remains blissfully unaware of the data's origin:
class UserRepository(
private val userApi: UserApi, // Remote data source
private val userDao: UserDao // Local data source
) {
// Recommended Pattern: Local as truth, network for synchronization
// Flow originates directly from Room; UI auto-updates when Room data changes
fun searchUsers(query: String): Flow<List<User>> {
return userDao.searchUsers(query)
}
// Proactive refresh: Fetch from network and write to Room.
// Room changes will automatically trigger the Flow to notify the UI.
suspend fun refreshUsers() {
val remoteUsers = userApi.getUsers()
userDao.insertAll(remoteUsers.map { it.toEntity() })
}
// Fetch single user by ID (Check local first, request network if absent)
suspend fun getUser(id: String): User {
return userDao.getUserById(id) ?: run {
val remote = userApi.getUser(id)
userDao.insertUser(remote.toEntity())
remote.toEntity()
}
}
}
Hilt: Simplifying ViewModels with Dependency Injection
Manually instantiating Repositories and ViewModels is tedious. Hilt is Google's recommended DI framework for Android:
// 1. Annotate Application
@HiltAndroidApp
class MyApplication : Application()
// 2. Provide Dependencies (Module)
@Module
@InstallIn(SingletonComponent::class) // Application-scoped singletons
object DatabaseModule {
@Singleton
@Provides
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(context, AppDatabase::class.java, "app_db").build()
}
@Provides
fun provideUserDao(database: AppDatabase): UserDao = database.userDao()
}
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Singleton
@Provides
fun provideRetrofit(): Retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
@Provides
fun provideUserApi(retrofit: Retrofit): UserApi = retrofit.create(UserApi::class.java)
}
// 3. Inject Repository
@Singleton
class UserRepository @Inject constructor(
private val userApi: UserApi,
private val userDao: UserDao
)
// 4. Inject ViewModel
@HiltViewModel
class UserListViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel()
// 5. Retrieve ViewModel in Fragment (Zero manual instantiation)
@AndroidEntryPoint
class UserListFragment : Fragment() {
private val viewModel: UserListViewModel by viewModels()
// Hilt automatically injects all dependencies into the ViewModel
}
SavedStateHandle: Handling Process Death
While ViewModel effortlessly survives screen rotations, it is wiped from memory if the process is killed by the system (e.g., due to low memory while in the background). SavedStateHandle allows you to restore critical transient state after process death:
@HiltViewModel
class DetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, // Automatically injected by Hilt
private val repository: UserRepository
) : ViewModel() {
// Retrieve userId passed via Navigation arguments or restored from savedState
private val userId: String = savedStateHandle.get<String>("userId")!!
// The search query will seamlessly recover even after process death
var searchQuery: StateFlow<String> = savedStateHandle.getStateFlow("query", "")
fun setQuery(query: String) {
savedStateHandle["query"] = query // Automatically persisted to the Bundle
}
}
SavedStateHandle vs Room:
- Minor UI states (scroll positions, search queries, filter toggles) $\rightarrow$
SavedStateHandle - Heavyweight business data (user profiles, cached feeds) $\rightarrow$
RoomDatabase
DataStore: The Modern Replacement for SharedPreferences
SharedPreferences relies on synchronous I/O, which can induce ANRs (Application Not Responding) on the Main Thread, and lacks type safety. DataStore is the modern, Coroutine and Flow-based alternative:
// Preferences DataStore (KV Storage, similar to SharedPreferences)
val Context.dataStore: DataStore<Preferences> by preferencesDataStore("settings")
class SettingsRepository(private val dataStore: DataStore<Preferences>) {
companion object {
val DARK_MODE = booleanPreferencesKey("dark_mode")
val USER_ID = stringPreferencesKey("user_id")
}
// Read (Flow automatically emits when data changes)
val darkMode: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[DARK_MODE] ?: false
}
// Write (Suspend function, executes safely on IO thread)
suspend fun setDarkMode(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[DARK_MODE] = enabled
}
}
}