Elmo is the Android framework from Badge Labs that I use for nearly all product development at Badge. When I began creating Elmo, my goal was not to create yet another architecture pattern like MVP, MVC,MVVM, it was to allow even a single person to deliver a large scale application easier, faster, and to have fun in the process.
Elmo will help you write your android applications in a modern, unidirectional and functional way. You will have easy and great tests for free, and you will never have to deal with concurrency and async issues.
I am a firm believer in functional programming, and I know that for many Android developers this is still uncharted territory, but if you keep reading until the end, I promise you that you will rise again, harder and stronger! For what is immutable my never change!
What does it looks like:
data class HelloWorldModel(val title: String)
sealed class Msg {
object Reverse : Msg()
}
class HelloWorldUpdate : Update<HelloWorldModel, Msg> {
override fun update(msg: Msg, model: HelloWorldModel): HelloWorldModel {
return when (msg) {
Msg.Reverse -> model.copy(title = model.title.reversed())
}
}
}
class HelloWorldActivity : Activity(), View<HelloWorldModel> {
private lateinit var sandbox: Sandbox<Msg>
override fun view(model: HelloWorldModel) {
textview.text = model.title
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.hello_activity)
sandbox = Sandbox.create(HelloWorldModel(title = "Hello World!"), HelloWorldUpdate(), this)
button.setOnClickListener { sandbox.accept(Msg.Reverse) }
}
override fun onDestroy() {
super.onDestroy()
sandbox.dispose()
}
}
For those familiar with React, Elmo will feel a lot like Redux with fancy async middleware. For those familiar with Elm, well it will look a lot like Elm. And for those still unfamiliar with unidirectional data flow architectures , immutable state-containers and the lot, it will be something new and exciting to learn and it will empower you to write easy, fast and thread-safe UI applications.
You can find a tutorial about how to use Elmo on my blog
- Technologies and concepts
- Installation
- Model
- Update
- Messages
- View
- Update with side-effects
- Error handling
- Result
- Multithreading
- Testing pure code
- Testing code with side-effects in a pure way
- Testing code with side-effects by mocking
- Working with lists
Elmo is built on top of the following existing technologies and concepts:
Elmo is best consumed with Kotlin. It is possible to use it from java, but not having a language support for immutable data classes and basic pattern matching will cause you a lot of trouble.
What makes Elmo different from all other "Elm Architecture for Android" thingies out there:
- library not a framework
- choose between pure and side effecting 'update'
- use your own
cmd
andmsg
types - subscriptions
- correct and property based tested code
- disposable sandboxes
result
andmaybe
types- great tutorial
Just add the dependency to your project build.gradle
file:
dependencies {
implementation 'dev.boby.elmo:elmo:2.y.z'
}
Replace and y
and z
with the latest version number:
The state of your application. It must be immutable Kotlin data class that contains the properties necessary to render your screen.
Because the model updates are done concurrently,utilising many of the cores available in modern phones, it is very important that the model is immutable.This means that you must always use copy in order to change your model.
Example:
data class WalletModel(val userName: String, val cents: Long)
Provides a way to update your Model
It specifies how the application's model changes in
response to Messages
sent to it. If you prefer this term - it is the thing that handles all
business logic.
Example:
class WalletUpdate : Update<WalletModel, Msg> {
override fun update(msg: Msg, model: WalletModel): WalletModel {
return when (msg) {
is Msg.Receive -> model.copy(cents = model.cents + msg.cents)
is Msg.Spend -> model.copy(cents = model.cents - msg.cents)
}
}
}
Are what the Update
reacts in order to to update your model. They can represent taps on the
screen, responses from external api, data from your phone sensors, etc.
The Message
types must form sealed class
hierarchies and must be immutable. Then you can use them to do pattern match in return when
statements as shown in the Update
example. Also when you add new message type, the compiler
will ask you to add the remaining branches in order for the when
statement to be exhaustive,
saving you from bugs.
Example:
sealed class Msg { // The base `Message` sealed class
data class Receive(val cents: Long) : Msg() // Receive message extends the base class
data class Spend(val cents: Long) : Msg() // So does and the Spend
}
Responsible for rendering your model on screen. It is a interface that is usually implemented in your fragments or activities.
Example:
class WalletActivity : View<WalletModel>, Activity() {
override fun view(model: WalletModel) {
userName.text = model.userName
cents.text =model.cents
}
}
Used to trigger side-effects ( async api calls, etc) that can produce new
Messages
. The Command
types must form sealed class hierarchies just like your Message
.
Example:
sealed class Cmd {
data class RequestFromMom(val cents: Long) : Cmd()
}
There is another version of Update
that can interact with the outside world, trigger
side-effects and handle errors. In order to do so, there are three things that you must do:
- Describe how to get
Observable<Msg>
from aCommand
- Provide a way to transform unhandled errors back to
Messages
. - Return either a
Pure(model)
orEffect(model,command)
when calling theupdate
method
Example:
class WalletUpdate : effect.Update<WalletModel, Msg, Cmd> {
override fun update(msg: Msg, model: WalletModel): Return<WalletModel, Cmd> {
return when (msg) {
is Msg.Receive -> Pure(model.copy(cents = model.cents + msg.cents))
is Msg.Spend -> {
val newModel = model.copy(cents = model.cents - msg.cents)
if (newModel.cents < 0) {
Effect(newModel, Cmd.RequestFromMom(0 - newModel.cents))
} else {
Pure(newModel)
}
}
}
}
override fun call(cmd: Cmd): Observable<out Msg> {
return when (cmd) {
is Cmd.RequestFromMom -> Observable.fromCallable {
Msg.Receive(cmd.cents) //this can be a async http call or database read or any other side effect
}
}
}
override fun onUnhandledError(cmd: Cmd, t: Throwable): Msg {
System.err.println("Error while processing $cmd: $t")
return Msg.Receive(0)
}
}
You can think of Pure like it is a pair(model,
none
) and of Effect like it is a pair(model,command).
RxJava
Observables
can be created from most standard types and many http-clients, database drivers, etc, provide RxJava adapters. If you are new to RxJava elmo can help you use it while you learn it.
How the Update
deal with errors:
-
Runtime errors in
update
method calls will crash your application, and they should because they show a bug in the code and lack of tests. -
Unhandled errors from Commands will be delivered to
onUnhandledError
where you can deal with them. Usually you will want to transform them back to Message. -
Fatal errors from
update
method calls and from Commands will crash your application
You can deal with specific Command errors at declaration site using onErrorReturn
and the Result
type
The Result type represents values with two possibilities:
a value of type Result<E,A>
is either Err<E>
or Ok<A>
.
The Result type is sometimes used to represent a computation that may fail and it is a great
way to manage errors. The Err
constructor is used to hold an error value and the Ok
constructor is used to hold a correct value.
Example :
sealed class Msg {
/**
*In this case the Err type is String ( error message ) and the Ok type is Long ( cents received)
*/
data class BankResponse(val result: Result<String, Long>) : Msg()
}
sealed class Cmd {
data class RequestFromBankApi(val cents: Long) : Cmd()
}
class WalletUpdate : Update<WalletModel, Msg, Cmd> {
override fun update(msg: Msg, model: WalletModel): Return<WalletModel, Cmd> {
return when (msg) {
is Msg.BankResponse -> {
when (msg.result) {
is Ok -> Pure(model.copy(cents = model.cents + msg.result.value))
is Err -> Pure(model)
}
}
}
}
override fun call(cmd: Cmd): Observable<out Msg> {
return when (cmd) {
is Cmd.RequestFromBankApi -> Observable.fromCallable {
if (cmd.cents > 100) {
throw RuntimeException()
} else {
Msg.BankResponse( Ok(cmd.cents) )
}
}.onErrorReturn { t -> Msg.BankResponse( Err("Snap!")) }
}
}
}
RxJava is so popular with Android developers because it makes switching computation contexts easy. Elmo makes it even easier. What most of us want is to render our model on the main thread, do model updates and execute side-effects concurrently.
So to have a Update that asynchronously do updates and executes commands you can do:
interface Update<Model, Msg> : dev.boby.elmo.pure.Update<Model, Msg> {
override val updateScheduler: Scheduler
get() = Schedulers.io()
}
And to have a View that performs rendering on the main thread
interface View<Model> : dev.boby.elmo.View<Model> {
override val viewScheduler: Scheduler
get() = AndroidSchedulers.mainThread()
}
You can then extend this interfaces everywhere in your app.
AndroidSchedulers.mainThread() is courtesy of the RxAndroid authors
Testing pure Update
is pretty straightforward. We can just pass a test model and a message to the update function and assert we have the correct response.
This is why it is very important the update function is kept referentially transparent,
If you are using immutable model and not doing any side-effects it should be, so tests will come by easy. In future versions
elmo will have a linter that warn you if your update function is not pure.
Example:
class WalletUpdate : Update<WalletModel, Msg> {
override fun update(msg: Msg, model: WalletModel): WalletModel {
return when (msg) {
is Msg.Receive -> model.copy(cents = model.cents + msg.cents)
is Msg.Spend -> model.copy(cents = model.cents - msg.cents)
}
}
}
class Test {
@Test
fun sanity() {
val update = WalletUpdate()
val initialState = WalletModel("Richie Rich", 0)
val richer = update.update(Msg.Receive(100), initialState)
assertTrue(richer.cents == 100L)
val poorer = update.update(Msg.Spend(100), richer)
assertTrue(poorer == initialState)
}
}
Testing asynchronous side-effects is always hard and the test results struggle to reach 100% reliability. Some languages and tools make it reasonable enough. Elmo allows you to test your logic without executing and mocking your side-effects, but it makes it easy if you want to do so.
Let's see how you can test side-effecting code without executing any side-effects. This update bellow is supposed to retry only once after an error.
data class WalletModel(val cents: Long, val canRetry: Boolean)
sealed class Msg {
data class BankResponse(val result: Result<Long, Long>) : Msg()
}
sealed class Cmd {
data class RequestFromBankApi(val cents: Long) : Cmd()
}
class Update : dev.boby.elmo.effect.Update<WalletModel, Msg, Cmd> {
override fun update(msg: Msg, model: WalletModel): Return<WalletModel, Cmd> {
return when (msg) {
is Msg.BankResponse -> {
when (msg.result) {
is Ok -> Pure(model.copy(canRetry = true, cents = model.cents + msg.result.value))
is Err -> if (model.canRetry) {
Effect(model.copy(canRetry = false), Cmd.RequestFromBankApi(msg.result.error))
} else {
Pure(model)
}
}
}
}
}
}
@Test
fun shouldRetryOnlyOnce() {
val initialState = WalletModel(cents = 0, canRetry = true)
val errRes = Msg.BankResponse(Err(100))
val update = Update()
val r1 = update.update(errRes, initialState)
val r2 = update.update(errRes, r1.model)
r1 shouldBe Effect(WalletModel(cents = 0, canRetry = false), Cmd.RequestFromBankApi(100))
r2 shouldBe Pure(WalletModel(cents = 0, canRetry = false))
}
Since exceptions from commands are always transformed into messages writing your tests this is sane, and if there is a runtime error in your update it will show up in your tests.
So, if you want to test the complete machinery, it doesnt matter what language or tool you use, you need to choose between using a 'mock' interpreter for your side-effects or actually run them. Running them can be slow and unpredictable if you are making network calls. The example bellow can be easily and reliably tested as shown in Testing code with side-effects in a pure way, but there is nothing stopping you to follow the example bellow.
This example is very involved, and I am working on easy to follow tutorial in a series of blog posts.
Example:
sealed class Msg {
data class RequestMoney(val requestedAmount: Long) : Msg()
data class BankResponse(val result: Result<BankError, Long>) : Msg()
data class Error(val errorMessage: String) : Msg()
}
sealed class Cmd {
data class RequestFromBankApi(val cents: Long) : Cmd()
}
interface CommandInterpreter<Message, Command> {
fun call(cmd: Command): Observable<out Message>
}
class TestInterpreter(private val shouldThrow: Boolean) : CommandInterpreter<Msg, Cmd> {
override fun call(cmd: Cmd): Observable<out Msg> {
return when (cmd) {
is Cmd.RequestFromBankApi -> Observable.fromCallable {
if (shouldThrow) {
throw RuntimeException("Bang")
} else {
Msg.BankResponse(Ok(cmd.cents))
}
}
}
}
}
class TestView<Model>(override val viewScheduler: Scheduler) : View<Model> {
val models = CopyOnWriteArrayList<Model>()
override fun view(model: Model) {
models.add(model)
}
}
class Update(override val updateScheduler: Scheduler,
private val interpreter: CommandInterpreter<Msg, Cmd>) : dev.boby.elmo.effect.Update<WalletModel, Msg, Cmd> {
override fun update(msg: Msg, model: WalletModel): Return<WalletModel, Cmd> {
return when (msg) {
is Msg.BankResponse -> {
when (msg.result) {
is Ok -> Pure(model.copy(canRetry = true, cents = model.cents + msg.result.value))
is Err -> if (model.canRetry) {
Effect(model.copy(canRetry = false), Cmd.RequestFromBankApi(msg.result.error.requestedAmount))
} else {
Pure(model.copy(error = msg.result.error.errorMessage))
}
}
}
is Msg.RequestMoney -> {
Effect(model, Cmd.RequestFromBankApi(msg.requestedAmount))
}
is Msg.Error -> {
Pure(model.copy(error = msg.errorMessage))
}
}
}
override fun call(cmd: Cmd): Observable<out Msg> {
return interpreter.call(cmd)
}
override fun onUnhandledError(cmd: Cmd, t: Throwable): Msg {
return Msg.Error("Unknown error occurred")
}
}
class UpdateTest : StringSpec() {
val initial = Pure(WalletModel(cents = 0, canRetry = true, error = null))
val sameThreadScheduler = Schedulers.trampoline()
val notThrowingInterpreter = TestInterpreter(shouldThrow = false)
val throwingInterpreter = TestInterpreter(shouldThrow = true)
init {
"If there is no error while processing a command, the request should succeed" {
val testView = TestView<WalletModel>(sameThreadScheduler)
val sandbox = Sandbox.create(initial, Update(sameThreadScheduler, notThrowingInterpreter), testView)
sandbox.accept(Msg.RequestMoney(100))
testView.models.last() shouldBe WalletModel(100, true, null)
sandbox.dispose()
}
"If there is unhandled error while processing a command, the request should fail" {
val testView = TestView<WalletModel>(sameThreadScheduler)
val sandbox = Sandbox.create(initial, Update(sameThreadScheduler, throwingInterpreter), testView)
sandbox.accept(Msg.RequestMoney(100))
testView.models.last() shouldBe WalletModel(0, true, "Unknown error occurred")
sandbox.dispose()
}
}
}
Lists present unique challenges, especially on Android.
- There are no persistent ( immutable ) collections present so updating them in place will introduce concurrency problems
- When you render them, you must use RecyclerViews, otherwise you risk OOM , GC pauses and all other things that steal those precious fps and make your apps unresponsive. Replacing the RecyclerView collection every time your Update provides you with a new list means you need to use DiffUtil to calculate the difference between the old and the new list, otherwise the fps gained by using a RecyclerView are lost.
The first problem is easily solvable by using the pcollections lib which will provide you with immutable collections. Let's hope in the near future kotlin stdlib will provide us with persistent and immutable collections.
The second problem is already solved by the folks at AirBnb with epoxy. It is incredible library simplifying working with RecyclerViews so much, that in my opinion it should be part of the android stdlib.
Google already are making steps towards a functional UI programming model with Jetpack Compose It is still in alpha but when it is production ready it will be a perfect match for Elmo's views.
Copyright 2019 Borislav Ivanov
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.