Thinking in React for native Android apps
I like writing apps using React Native. In contrast, working on Java Android apps has always been less satisfying because:
- Boilerplate.
- ConstraintLayout? LinearLayout? RelativeLayout? TableLayout? GridLayout?
- ???
It's a simple fact that the more code you write, the more likely you are to write bugs. I knew using Kotlin would improve this aspect, but I wanted to get more experienced with Java, so that I'd better appreciate Kotlin when I made the move. I also didn't believe that just by using Kotlin, I'd suddenly really enjoy developing native apps.
Recently, I came across MvRx (Mavericks). An Android framework open-sourced by Airbnb. I learned that it is conceptually inspired by React which piqued my interest. It even brings over the familiar setState()
syntax. Since MvRx is Kotlin-only, it tipped me over to start learning Kotlin.
The same todo app written using MvRx is about 1000 lines of code less than the same app written in Java.
Syntax-wise Kotlin has many similarties to TypeScript which I've always preferred using. I learned by going through the Kotlin official docs (which are awesome) and doing some of the the Kotlin Koans.
They say that no Java developer has tried Kotlin and wanted to go back to writing Java. I concur.
I previously used MVP architecture, whereas MvRx is similar to MVVM/MVI. Presenters and ViewModels harbour the logic of your screens (similar to container components in React). The main difference between them is that a ViewModel never holds a reference to any view. It simply updates its state and the view observes the data changes and much like React, re-renders accordingly. Therefore, there is no fear of referencing a null
view (similar to calling setState()
on an unmounted React component). This greatly simplifies dealing with view lifecycles.
During my process of learning MvRx and Kotlin, I came across a few helper libraries that improve development experience. I decided to learn and use them.
I slowly came to the realization that sometimes, we might not enjoy working with a framework not because of the framework itself, but simply because of the way we apply it.
Guide
We're going to make a simple app utilizing the following libraries:
- Navigation Component
- Kotlin Android Extensions
- MvRx
- Flexbox Layout
The app flow will be as such:
- Login Screen:
- Two text input fields.
- Login button.
Once the user presses the login button, we will mock a request using a simple delay. During the mock request, we will hide the view and show a loading indicator. Once the request is done, we will restore our view, hide the loading indicator and navigate to the landing screen.
- Landing Screen:
- This screen will simply show the data entered in the previous text inputs and a logout button.
Simple enough.
Dependencies
Let's start by creating a blank Android Studio project with Kotlin and add our dependencies.
Add MvRx and the navigation component to your dependencies block, under app/build.gradle
:
dependencies {
def navVersion = "2.1.0"
def mvrxVersion = '1.1.0'
// Navigation component
implementation "androidx.navigation:navigation-fragment-ktx:$navVersion"
implementation "androidx.navigation:navigation-ui-ktx:$navVersion"
// MvRx
implementation "com.airbnb.android:mvrx:$mvrxVersion"
// Flexbox
implementation 'com.google.android:flexbox:1.1.0'
// ...
At the top of the same file:
// Kotlin Android Extensions
apply plugin: 'kotlin-android-extensions'
Layouts
We will use single activity - multiple fragments pattern. Fragments should be designed as resuable and modular components, just like presentational components in React.
Create our layout files: Right-click the res
folder then select New > Android Resource File. Set the type as Layout
.
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<fragment
android:id="@+id/host"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
login_fragment.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F1F1F1">
<com.google.android.flexbox.FlexboxLayout
android:layout_width="match_parent"
android:layout_height="250dp"
android:layout_marginTop="200dp"
app:alignItems="center"
app:flexWrap="wrap"
app:justifyContent="center">
<EditText
android:id="@+id/loginNameText"
android:layout_width="120dp"
android:layout_height="60dp"
android:hint="Name"
android:importantForAutofill="no"
android:inputType="text"
app:layout_flexBasisPercent="80%"
tools:text="Name" />
<EditText
android:id="@+id/loginCityText"
android:layout_width="120dp"
android:layout_height="60dp"
android:hint="City"
android:importantForAutofill="no"
android:inputType="text"
app:layout_flexBasisPercent="80%"
tools:text="City" />
<Button
android:id="@+id/loginButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:backgroundTint="#6200EE"
android:text="LOGIN"
android:textColor="#FFF"
app:layout_flexBasisPercent="80%" />
<ProgressBar
android:id="@+id/loadingIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_flexBasisPercent="100%"
/>
</com.google.android.flexbox.FlexboxLayout>
</FrameLayout>
The root is a <FrameLayout/>
. The <fragment/>
tag in activity_main.xml
will be swapped for the contents (children) of <FrameLayout/>
in each of our fragments. (a bit like React's children?)
The child of the root layout is <FlexboxLayout/>
. I've highlight ed flexbox specific properties. Pretty cool. ConstraintLayout
is nice if you prefer to customize the layout visually.
Feel free to use whatever you fancy.
landing_fragment.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F1F1F1">
<com.google.android.flexbox.FlexboxLayout
android:layout_width="match_parent"
android:layout_height="250dp"
android:layout_marginTop="200dp"
app:alignItems="center"
app:flexWrap="wrap"
app:justifyContent="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@android:color/black"
android:textSize="24sp"
app:layout_flexBasisPercent="50%"
android:text="Name:" />
<TextView
android:id="@+id/landingNameText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@android:color/black"
android:textSize="24sp"
app:layout_flexBasisPercent="50%"
tools:text="placeholder" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@android:color/black"
android:textSize="24sp"
app:layout_flexBasisPercent="50%"
android:text="City:" />
<TextView
android:id="@+id/landingCityText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@android:color/black"
android:textSize="24sp"
app:layout_flexBasisPercent="50%"
tools:text="placeholder" />
<Button
android:id="@+id/logoutButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:backgroundTint="#F05E54"
android:text="LOGOUT"
android:textColor="#FFF"
app:layout_flexBasisPercent="80%" />
</com.google.android.flexbox.FlexboxLayout>
</FrameLayout>
Classes
Create our Kotlin classes to associate with each layout.
To create an activity, we would usually extend the AppCompatActivity
class directly. But since we want to use MvRx, we will extend BaseMvRxActivity
instead (which inherits from AppCompatActivity
) for MvRx support. We will also override onCreate()
and inflate activity_main.xml
here.
Tip: here is a super useful page for looking up Android lingo like 'inflate'.
MainActivity.kt
package com.example.mymvrxapp
import android.os.Bundle
import com.airbnb.mvrx.BaseMvRxActivity
class MainActivity() : BaseMvRxActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}
Every activity must be registered in the manifest. We will register MainActivity
and set it as the starting activity.
AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.mymvrxapp">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name="com.example.mymvrxapp.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
For our fragments, we will extend BaseMvRxFragment
instead of Fragment
. We must also implement invalidate()
. We will leave it empty for now and go over it later on.
LoginFragment.kt
package com.example.mymvrxapp
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.airbnb.mvrx.BaseMvRxFragment
class LoginFragment : BaseMvRxFragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.login_fragment, container, false)
}
override fun invalidate() {
}
}
LandingFragment.kt
package com.example.mymvrxapp
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.airbnb.mvrx.BaseMvRxFragment
class LandingFragment : BaseMvRxFragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.landing_fragment, container, false);
}
override fun invalidate() {
}
}
Navigation
If we run the app at this point, its going to crash. Our <fragment/>
in activity_main.xml
needs an ID and a name to associate it with a fragment. We've given it an ID but we haven't told it what fragment its going to host yet.
We're not going to do this. Instead, we are going to associate it with our navigation graph. using the Navigation Component.
Simply put, it's a library that simplifies how we handle navigation with a neat API and a friendly interface to visualize our routes.
The navigation component fulfills the same job as React Navigation and React Router.
Fun React Navigation Fact: We can configure it to use FragmentActivity instead of plain
View
's under the hood for performance improvements.
Create our navigation graph. Right-click the res
folder and then select New > Android Resource File. Set the type as Navigation
.
nav_graph.xml
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/nav_graph" />
Now that we've created the file for our navigation graph, we will add an ID to <fragment/>
and designate it as our navigation host by adding the following attributes:
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<fragment
android:id="@+id/host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph" />
</LinearLayout>
Lets add our fragment classes to the navigation graph to mark them as possible destinations. I like to use the visual editor for this part.
Open nav_graph.xml
in the visual editor, and add LoginFragment
and LandingFragment
.
Select the login fragment in the graph then click the home icon to mark it as the starting destination.
Next, drag from the edge of the login fragment to the landing fragment to create a navigation action.
Now your navigation graph and markup should look similar to this.
nav_graph.xml
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
app:startDestination="@id/loginFragment">
<fragment
android:id="@+id/landingFragment"
android:name="com.example.mymvrxapp.LandingFragment"
android:label="LandingFragment"
tools:layout="@layout/landing_fragment" />
<fragment
android:id="@+id/loginFragment"
android:name="com.example.mymvrxapp.LoginFragment"
android:label="LoginFragment"
tools:layout="@layout/login_fragment">
<action
android:id="@+id/action_loginFragment_to_landingFragment2"
app:destination="@id/landingFragment" />
</fragment>
</navigation>
tools
attribute is for previewing your layouts in the navigation graph, otherwise you'd only see a plain and boring rectangle.
If we run the app now, we should see the Login screen.
Logic
Lets start by adding state to our text inputs. We need to do the following:
- Create our data class which describes the shape of our state.
- Create our view model class which will hold functions that trigger our state changes.
I created both the data class and the view model in the same file as MainActivity.kt
for convenience but that is not a requirement.
FormState
data class FormState(
val name: String = "",
val city: String = "",
val loggedIn: Async<Boolean> = Uninitialized
) : MvRxState
We must set the initial state by providing default arguments. Notice it implements MvRxState
. That's required for any data class we wish to use as state.
In React, we might have a loading
state and set it before and after the completion of asynchronous tasks. In MvRx, Async
is a sealed class that comes with types like Loading
and Success
. We can simply refer to the current type of the async value to react to loading and success states. Super helpful.
FormViewModel
class FormViewModel(initialState: FormState) :
BaseMvRxViewModel<FormState>(initialState, debugMode = BuildConfig.DEBUG) {
init {
logStateChanges()
}
fun setNameAndCity(name: String, city: String) {
setState { copy(city = city, name = name) }
}
// We will go over this one in depth later on
fun doLogIn() {
Single.just(true)
.delaySubscription(5, TimeUnit.SECONDS)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.execute { copy(loggedIn = it) }
}
fun doLogout() {
setState {
copy(loggedIn = Uninitialized)
}
}
}
debugMode = BuildConfig.DEBUG
will make some safety checks when working with the debug build. The init
block and logStateChanges()
are also optional. logStateChanges()
does exactly what it says. We will show its output when we finish our app.
Our setState
reducers will be called from our views to update the state. Similar to React, the setState
block is an async operation and a pure function that takes in the current state, and returns the new state.
Notice the copy()
syntax within the setState
block. Inside setState
, this
keyword would be our data class and copy()
is a method that belongs to data classes in Kotlin. It allows you to modify select properties instead of all (we don't need to spread the current state, in React lingo).
Next, we want to be able to access state from our fragments. Our login and landing fragments must subscribe to the same view model instance we defined in our main activity.
LoginFragment.kt
class LoginFragment : BaseMvRxFragment() {
// Fetch the ViewModel scoped to the current activity or create one if it doesn't exist
private val formViewModel: FormViewModel by activityViewModel()
// ...
LandingFragment.kt
class LandingFragment : BaseMvRxFragment() {
// Fetch the existing ViewModel scoped to the current activity
private val formViewModel: FormViewModel by existingViewModel()
// ...
Notice by activityViewModel()
. It is a Kotlin delegate that lazily returns a view model scoped to the current activity. Since both of our fragments belong to the same activity, sharing state is very straightforward.
For LandingFragment.kt
, we used existingViewModel()
which returns the existing view model in scope of the current activity. The difference is that this function will throw an exception if no view model exists, instead of creating a new one.
MvRx lets you easily choose the scope of the view model you'd like to work with.
As soon as our view loads (React: mounts), we're going to:
- Add a click listener to our login button.
- When the user presses the button we will grab the user's input and update our
name
andcity
states, then calldoLogIn
to start the mock request/delay. - When the delay starts, we must hide our view and show the loading indicator.
- When the delay ends, we must hide the loading indicator and show our view. Then, we navigate to the landing screen.
Override onViewCreated
and implement the on-click listener as described:
LoginFragment.kt
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
loginButton.setOnClickListener {
// Update the state
formViewModel.setNameAndCity(
loginNameText.text.toString(),
loginCityText.text.toString()
)
formViewModel.doLogIn()
}
}
Because of Kotlin Android Extensions, we are able to directly reference the view without calling findViewById
. This is called View Binding (similar to getting a ref
to a node in React) .
Note: in the future, it will be recommended to use the View Binding component which has some advantages and none of the drawbacks. It will be available in newer versions of Android Studio.
fun doLogIn() {
Single.just(true)
.delaySubscription(5, TimeUnit.SECONDS)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.execute { copy(loggedIn = it) }
}
MvRx makes use of RxJava. Primer: RxJava is very useful in Android development as it offers a neat API for handling asynchronous logic (Like JavaScript promises, but with more capabilities).
It is important to execute long running tasks off the main thread (also called UI thread) to prevent the app from dropping frames. RxJava allows you to declaratively switch threads with ease.
To use any of RxJava's APIs, we must work with the
observable
type. RxJava has APIs to create observables from things.
doLogin()
is called when the login button is pressed. Let's go through it in detail as Rx can be intimidating if you've never used it before:
Single
is a type of observable that resolves to a single value, exactly like a JavaScript promise.just()
is used to denote that this observable resolves to just this item, in this case the item istrue
. So the first line is equivalent toPromise.resolve(true)
in JavaScript.
// ...
.delaySubscription(5, TimeUnit.SECONDS)
- We need to subscribe to an observable to receive a result from it. This line states that any subscription should be delayed by 5 seconds.
// ...
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
- Here, we state that we wish to subscribe to this observable using an
io
thread by the help of theio
scheduler and ask for value notifications to be sent to the main thread.
// ...
.execute { copy(loggedIn = it) }
execute
is a neat helper function by MvRx that maps the current state of this process to ourloggedIn
async type. When the observable is executed,loggedIn
type is updated toLoading
. When it is done,loggedIn
type and value are updated toSuccess
andtrue
.
Now, the invalidate()
function comes in handy. This function is called every time our state is updated (just like a React re-render). Here, we can make changes to our view according to the current state.
LoginFragment.kt
// ...
override fun invalidate() {
withState(formViewModel) { state ->
loadingIndicator.isVisible = state.loggedIn is Loading
loginNameText.isVisible = state.loggedIn !is Loading
loginCityText.isVisible = state.loggedIn !is Loading
loginButton.isVisible = state.loggedIn !is Loading
if (state.loggedIn is Success) {
findNavController().navigate(R.id.action_loginFragment_to_landingFragment2)
}
}
}
withState
allows us to access the current state of our view model. Inside, we map the loading state of loggedIn
to the visibility of our loading indicator, inputs and button. If loggedIn
is of type Success
, then we navigate to the landing screen.
Navigation KTX helps us obtain a reference to the navigation controller here.
For the landing fragment, we need to implement invalidate()
and update our texts using the current state. We will add a listener to our logout button that sets loggedIn
to Uninitialized
and then pops our fragment off of the navigation stack, going back to the login screen.
LandingFragment.kt
// ...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
logoutButton.setOnClickListener {
formViewModel.doLogout()
findNavController().popBackStack()
}
}
override fun invalidate() {
withState(formViewModel) { state ->
landingCityText.text = state.city
landingNameText.text = state.name
}
}
Courtesy of logStateChanges()
:
D/FormViewModel: New State: FormState(name=Osama, city=Cyberjaya, loggedIn=com.airbnb.mvrx.Uninitialized@24591c4)
D/FormViewModel: New State: FormState(name=Osama, city=Cyberjaya, loggedIn=com.airbnb.mvrx.Loading@7749791c)
D/FormViewModel: New State: FormState(name=Osama, city=Cyberjaya, loggedIn=Success(value=true))
All done! Hope you've enjoyed this guide and found it useful.
If you'd like to learn more about MvRx, I suggest going through their wiki and code samples on their repo.