MVVM with Kotlin : Code less, Read less
https://kotlinlang.org/

MVVM with Kotlin : Code less, Read less

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

Abakasha Panda

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

回复
Mohamed Nomeer

GIS Development Team Lead

7 年
回复
Ali Akram

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 .

要查看或添加评论,请登录

Ahmed Adel Ismail的更多文章

  • Sharing data across multiple Mobile Squads - with examples

    Sharing data across multiple Mobile Squads - with examples

    Earlier I shared an article suggesting a solution to a common problem with teams following the "Spotify Model", which…

  • SDD - Squad Driven Design

    SDD - Squad Driven Design

    Working in multiple big teams I've found that we are always trying to apply our known best practices in software, but…

    4 条评论
  • Easier Testing with MVVM, MVI, MVP and Kotlin Multiplatform

    Easier Testing with MVVM, MVI, MVP and Kotlin Multiplatform

    Before we start, this article requires basic knowledge about the following topics : Clean Architecture Unit Testing…

    10 条评论
  • Android - A Cleaner Clean Architecture

    Android - A Cleaner Clean Architecture

    It has been a while now since Clean Architecture was out, and even many of us started embracing hexagonal (ports and…

    10 条评论
  • Beyond Functional Programming

    Beyond Functional Programming

    In the Android industry, lately functional programming was the all new stuff to learn, RxJava, Kotlin, and the whole…

    7 条评论
  • Dependency Injection in Clean Architecture

    Dependency Injection in Clean Architecture

    After Google's Opinionated Guide to Dependency Injection Video, Google made a clear statement that they want developers…

    18 条评论
  • Meta Programming in Android

    Meta Programming in Android

    Year after year we are getting rid of the boilerplate code that we need to write for small and simple tasks in Android,…

    2 条评论
  • MVI Pattern For Android In 4 Steps

    MVI Pattern For Android In 4 Steps

    Lately I wrote an article about MVI pattern, but as we are facing new problems every day and face more use-cases, we…

    7 条评论
  • Agile - Moving Fast

    Agile - Moving Fast

    We always here about Agile, and think about which methodology do we use, what practices do we have, team velocity…

    1 条评论
  • Kotlin Unit Testing with Mockito

    Kotlin Unit Testing with Mockito

    I've always believed that, if the code is designed to be tested, we wont need any testing framework or library ..

    17 条评论

社区洞察

其他会员也浏览了