MVVM with Kotlin : Code less, Read less
Ahmed Adel Ismail
Engineering Manager @ Yassir | x-SadaPay | x-Swvl | x-Talabat | x-TryCarriage | x-Vodafone | More than a decade of experience in Android development and teams leadership
When Kotlin was announced to be an officially supported language for Android Development in Google IO 2017, many people started converting to Kotlin, but it is not about "Syntax" ... it's about what problems that we face in Java that Kotlin offers a solution for ... this article is a case study about implementing a Login Screen with Kotlin, Using the "Kotlin" way of programming, not just different syntax, but also different way of thinking
We will follow the MVVM pattern, to know more about MVVM, you can look at this link :
The example will use the following libraries :
- RxJava2 : https://github.com/ReactiveX/RxJava (RxKotlin2 can be used instead)
- RxBinding : https://github.com/JakeWharton/RxBinding
- RxProperties : https://github.com/Ahmed-Adel-Ismail/RxProperties
Before we start, Kotlin provides a very powerful feature, which is Extension functions, with this feature we can add functions to classes, but write it in different file, this is very helpful in adding new functionality to 3rd party libraries, but also we can benefit from it in applying the Single Responsibility Principle, and make our classes smaller, more cohesive, and more readable ... lets start with the Login-Activity :
class LoginActivity : AppCompatActivity() {
private val disposables = CompositeDisposable()
private var viewModel: LoginViewModel? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
viewModel = Model.of(this, LoginViewModel::class.java)
subscribeToViewModel(viewModel!!, disposables) // extension function
}
override fun onDestroy() {
disposables.clear()
viewModel?.clear(this)
super.onDestroy()
}
}
Those 15 lines of code are our Login Activity, and believe me this is a fully functional Login screen with error handling and progress view handling , in other words ... this is a production code, not a small sample
the subscribeToViewModel() is an extension function, written in another file, this other file is responsible for subscribing the Views to there View-Models ... but before we go to that file, we will take a look at the Login-View-Model class :
// caution : RxProperties is used ...
// Property.java is an Object that holds one item,
// similar to LiveData.java, ?
// and can be converted to RxJava2 Observable
// through Property.asObservable() method,
// then it stops the stream with the Property.clear()
?/**
* the parent class [Model] is a kotlin version from the Model.java
* that survives rotations, the link for this java class :
* https://gist.github.com/Ahmed-Adel-Ismail/c0ec1ed6c8d37c931b3bf42b22430246
*/
class LoginViewModel : Model() {
val userName = Property<String>()
val password = Property<String>()
val progress = BooleanProperty()
val errorMessage = Property<String>()
val userIdResponse = Property<Long>()
override fun clear() {
userName.clear()
password.clear()
progress.clear()
errorMessage.clear()
userIdResponse.clear()
}
}
This 14 Lines class is a fully functional View-Model that holds all the data required for the Login operation to process, handle errors, and handle progress, it can store the state of every detail in the screen
Now we saw two classes, each one with Single Responsibility, now there are two more responsibilities left, the third one is to Subscribe the Views to the View-Models, and this is done in another file that holds the extension function subscribeToViewModel() :
// caution : RxJava2, RxBinding and RxProperties are used :
fun LoginActivity.subscribeToViewModel(viewModel: LoginViewModel, disposables: CompositeDisposable) {
disposables.add(progressView(viewModel))
disposables.add(userNameView(viewModel))
disposables.add(passwordView(viewModel))
disposables.add(loginButton(viewModel, disposables))
disposables.add(errorTextView(viewModel))
disposables.add(loginResponse(viewModel))
}
private fun LoginActivity.progressView(viewModel: LoginViewModel): Disposable {
val progressView = findViewById<ProgressBar>(R.id.activity_login_progress)
return viewModel.progress.asObservable()
.observeOn(AndroidSchedulers.mainThread())
.map { if (it) View.VISIBLE else View.GONE }
.subscribe(progressView::setVisibility)
}
private fun LoginActivity.errorTextView(viewModel: LoginViewModel): Disposable {
val errorTextView = findViewById<TextView>(R.id.activity_login_error_textView)
return viewModel.errorMessage.asObservable()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(errorTextView::setText)
}
private fun LoginActivity.loginButton(viewModel: LoginViewModel, disposables: CompositeDisposable): Disposable {
val loginButton = findViewById<Button>(R.id.activity_login_login_button)
return RxView.clicks(loginButton)
.filter { !viewModel.progress.isTrue }
.throttleFirst(2, TimeUnit.SECONDS)
.subscribe { viewModel.login()(disposables) } // login() returns another function
}
private fun LoginActivity.passwordView(viewModel: LoginViewModel): Disposable {
val passwordEditText = findViewById<EditText>(R.id.activity_login_password_edit_text)
return RxTextView.textChanges(passwordEditText)
.map(CharSequence::toString)
.subscribe(viewModel.password)
}
private fun LoginActivity.userNameView(viewModel: LoginViewModel): Disposable {
val userNameEditText = findViewById<EditText>(R.id.activity_login_username_edit_text)
return RxTextView.textChanges(userNameEditText)
.map(CharSequence::toString)
.subscribe(viewModel.userName)
}
// in production, move to next screen instead of showing toast
private fun LoginActivity.loginResponse(viewModel: LoginViewModel): Disposable {
return viewModel.userIdResponse.asObservable()
.filter { it != INVALID_USER_ID }
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ Toast.makeText(this, "user id : $it", Toast.LENGTH_SHORT).show() },
Throwable::printStackTrace)
}
?
And the Fourth and last responsibility is to Interact with the server to handle login requests (or request data from below layers like database in other screens), and this is done in another extension functions file
private const val EMPTY_TEXT = ""
// both parameters are for unit testing reasons, this function
?// returns a function that adds the login request Disposable to
// ?the CompositeDisposable of the caller
fun LoginViewModel.login(
observeOnScheduler: Scheduler = AndroidSchedulers.mainThread(),
callback: ((Long?) -> Unit)? = null):
(CompositeDisposable) -> Unit {
val userName: String? = this.userName.get()
val password: String? = this.password.get()
val disposable: Disposable? = if (isInvalidData(userName, password)) {
processValidationError(callback)
} else {
processLoginRequest(observeOnScheduler,
LoginRequest(userName!!, password!!), callback)
}
return { if (disposable != null) it.add(disposable) }
}
private fun LoginViewModel.processValidationError(
callback: ((Long?) -> Unit)?):
Nothing? {
handleValidationError()
callback?.invoke(INVALID_USER_ID)
return null
}
private fun isInvalidData(userName: String?, password: String?)
= userName.isNullOrEmpty() || password.isNullOrEmpty()
private fun LoginViewModel.handleValidationError() {
progress.set(false)
errorMessage.set("make sure user name and password are valid")
userIdResponse.set(INVALID_USER_ID)
}
private fun LoginViewModel.processLoginRequest(
observeOnScheduler: Scheduler,
loginRequest: LoginRequest,
callback: ((Long?) -> Unit)?):
Disposable {
progress.set(true)
errorMessage.set(EMPTY_TEXT)
userIdResponse.set(INVALID_USER_ID)
return Observable.just(loginRequest)
.map(::requestLogin)
.subscribeOn(Schedulers.io())
.observeOn(observeOnScheduler)
.flatMapMaybe(ServerResponse<Long>::parse)
.subscribe(handleLoginResponse(callback), this::handleLoginError)
}
private fun LoginViewModel.handleLoginResponse(
callback: ((Long?) -> Unit)?):
(Long) -> Unit {
return {
this.progress.set(false)
this.errorMessage.set(EMPTY_TEXT)
this.userIdResponse.set(it)
callback?.invoke(it) // for unit testing
}
}
private fun LoginViewModel.handleLoginError(error: Throwable) {
this.progress.set(false)
this.errorMessage.set(error.message)
this.userIdResponse.set(INVALID_USER_ID)
}
Now we have four responsibilities in four separate files :
- Login-Activity which is responsible for handling the life-cycle of the screen, and releasing resources when the screen finishes
- Login-View-Model which is responsible for storing the state of the screen, when ever the screen is rotated or an event happens, the screen will draw itself from this state
- Login-Views-Subscriber which is an extension functions file responsible for subscribing the views to the state in the View-Model
- Login-Inter-actor which is an extension functions file responsible for handling requests and responses to server regarding the Login operations
Talking about Test-ability, these are Unit tests that covers around 90% of the business logic of this screen :
class LoginViewModelTest {
@Test
fun loginWithCorrectLoginRequestThenReturnValidUserId() {
val countdownLatch = CountDownLatch(1)
val viewModel = LoginViewModel()
viewModel.userName.set(USERS.keys.first())
viewModel.password.set(PASSWORDS[USERS[viewModel.userName.get()]])
viewModel.login(Schedulers.computation()) {
assertTrue(it != null && it != INVALID_USER_ID)
countdownLatch.countDown()
}
countdownLatch.await()
}
@Test
fun loginWithInvalidLoginRequestThenReturnErrorMessage() {
val countdownLatch = CountDownLatch(1)
val viewModel = LoginViewModel()
viewModel.userName.set("")
viewModel.password.set("")
viewModel.login(Schedulers.computation()) {
assertTrue(it == INVALID_USER_ID
&& !viewModel.errorMessage.get().isNullOrBlank())
countdownLatch.countDown()
}
countdownLatch.await()
}
@Test
fun loginWithValidCredentialsStartsThenShowProgress() {
val viewModel = LoginViewModel()
viewModel.userName.set(USERS.keys.first())
viewModel.password.set(PASSWORDS[USERS[viewModel.userName.get()]])
viewModel.login(Schedulers.computation())
assertTrue(viewModel.progress.isTrue)
}
@Test
fun loginWithValidCredentialsFinishesThenHideProgress() {
val countdownLatch = CountDownLatch(1)
val viewModel = LoginViewModel()
viewModel.userName.set(USERS.keys.first())
viewModel.password.set(PASSWORDS[USERS[viewModel.userName.get()]])
viewModel.login(Schedulers.computation()) {
assertFalse(viewModel.progress.isTrue)
countdownLatch.countDown()
}
countdownLatch.await()
}
}
The USERS and PASSWORDS are Maps indicating a mocked database, which the login() method uses a mocked server that uses this mocked database instead of a real life server and database, in real life, we will need to configure this mocking through an extra step in Unit testing, but for now every thing is run local on the machine ... we can use the mocked server and database through injecting it to the Login-Inter-actor or through adding a parameter to the login() method as we did with the Scheduler and the Callback.
Notice that in our Unit tests we could cover the important parts and test the Business rules, UI behaviors (like error message or progress visibility) without adding any mocked Android components to our tests
Senior Software Engineer – Android | Kotlin | Java | Jetpack | MVVM | MVI | Microservices | Micronaut | PostgreSQL
3 年@Ahmed what are your thoughts now, as the Coroutines are not experimental anymore ?
Coroutines is much better than rx
Head of Sales in IT
7 年On the same topic - https://cases.azoft.com/kotlin-android-development-impressions/
GIS Development Team Lead
7 年Kumait Mohammed
Associate Architect | Team Lead | CodeGeek | Clean Architecture & MVVM | CICD | Dagger | Java | Kotlin | React Native | Flutter | Iot | e-commerce | Fintech | Healthcare
7 年Very good and informative .