MVVM Architecture in Kotlin
MVVM (Model-View-ViewModel) is a software architectural pattern that helps separate the UI from the business logic. Let’s implement MVVM in a Kotlin Android app.
Project Structure
Basic Structure
app/
├── data/
│ ├── repository/
│ └── model/
├── di/
├── ui/
│ ├── main/
│ └── common/
└── utils/
Dependencies
Add Required Dependencies
// build.gradle.kts
dependencies {
// ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// Dependency Injection
implementation("com.google.dagger:hilt-android:2.50")
kapt("com.google.dagger:hilt-compiler:2.50")
}
Model Layer
Data Class
// data/model/User.kt
data class User(
val id: Int,
val name: String,
val email: String
)
Repository
// data/repository/UserRepository.kt
interface UserRepository {
suspend fun getUser(id: Int): User
suspend fun updateUser(user: User)
}
class UserRepositoryImpl : UserRepository {
override suspend fun getUser(id: Int): User {
// Simulate network call
return User(id, "John Doe", "john@example.com")
}
override suspend fun updateUser(user: User) {
// Update user logic
}
}
ViewModel Layer
Basic ViewModel
// ui/main/MainViewModel.kt
class MainViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _user = MutableLiveData<User>()
val user: LiveData<User> = _user
fun loadUser(id: Int) {
viewModelScope.launch {
try {
val user = repository.getUser(id)
_user.value = user
} catch (e: Exception) {
// Handle error
}
}
}
}
View Layer
Activity
// ui/main/MainActivity.kt
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupObservers()
viewModel.loadUser(1)
}
private fun setupObservers() {
viewModel.user.observe(this) { user ->
binding.nameText.text = user.name
binding.emailText.text = user.email
}
}
}
Dependency Injection
Hilt Module
// di/AppModule.kt
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideUserRepository(): UserRepository {
return UserRepositoryImpl()
}
}
Error Handling
Result Wrapper
// utils/Result.kt
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val exception: Exception) : Result<Nothing>()
object Loading : Result<Nothing>()
}
// ViewModel with Result
class MainViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _userState = MutableLiveData<Result<User>>()
val userState: LiveData<Result<User>> = _userState
fun loadUser(id: Int) {
viewModelScope.launch {
_userState.value = Result.Loading
try {
val user = repository.getUser(id)
_userState.value = Result.Success(user)
} catch (e: Exception) {
_userState.value = Result.Error(e)
}
}
}
}
State Management
UI State
// ui/main/MainUiState.kt
data class MainUiState(
val isLoading: Boolean = false,
val user: User? = null,
val error: String? = null
)
// ViewModel with UI State
class MainViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(MainUiState())
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
fun loadUser(id: Int) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
try {
val user = repository.getUser(id)
_uiState.value = _uiState.value.copy(
isLoading = false,
user = user
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message
)
}
}
}
}
Best Practices
Repository Pattern
class UserRepositoryImpl(
private val api: ApiService,
private val db: UserDatabase
) : UserRepository {
override suspend fun getUser(id: Int): User {
return try {
// Try to get from cache first
db.userDao().getUser(id) ?: run {
// If not in cache, fetch from network
val user = api.getUser(id)
// Save to cache
db.userDao().insertUser(user)
user
}
} catch (e: Exception) {
// If network fails, try to get from cache
db.userDao().getUser(id) ?: throw e
}
}
}
Coroutine Scopes
class MainViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(MainUiState())
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
fun loadUser(id: Int) {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
try {
withContext(Dispatchers.IO) {
repository.getUser(id)
}.let { user ->
_uiState.value = _uiState.value.copy(
isLoading = false,
user = user
)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message
)
}
}
}
}
Conclusion
MVVM architecture helps you:
- Separate concerns
- Make code testable
- Handle configuration changes
- Maintain clean code
Remember:
- Keep ViewModels focused
- Use proper scoping
- Handle errors gracefully
- Follow SOLID principles
Stay tuned for our next post about Using LiveData and ViewModel in Kotlin!