Skip to content
This repository has been archived by the owner on Dec 30, 2020. It is now read-only.

Commit

Permalink
Merge pull request #33 from babylonhealth/experiment/removing-ignorin…
Browse files Browse the repository at this point in the history
…g-dsl

DSL overhaul
  • Loading branch information
Mikolaj Leszczynski authored Nov 12, 2019
2 parents dd37760 + 0867304 commit 2c1a440
Show file tree
Hide file tree
Showing 37 changed files with 2,372 additions and 817 deletions.
3 changes: 3 additions & 0 deletions .idea/codeStyles/Project.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/copyright/Babylon.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,10 @@ class CalculatorViewModel : OrbitViewModel<State, SideEffect> (State(), {

perform("addition")
.on<AddAction>()
.postSideEffect { SideEffect.Toast("Adding ${action.number}") }
.withReducer { state.copy(currentState.total + event.number) }
.sideEffect { post(SideEffect.Toast("Adding ${event.number}")) }
.reduce {
getCurrentState().copy(getCurrentState().total + event.number)
}

...
})
Expand Down
4 changes: 2 additions & 2 deletions buildSrc/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

/*
* Copyright 2019 Babylon Partners Limited
*
Expand All @@ -16,6 +14,8 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
* limitations under the License.
*/

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
`kotlin-dsl`
}
Expand Down
11 changes: 9 additions & 2 deletions buildSrc/src/main/kotlin/DependencyManagement.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,13 @@ object Versions {
const val groupie = "2.6.0"

// Testing
const val spek = "2.0.7"
const val spek = "2.0.8"
const val junitPlatform = "1.5.2"
const val assertJ = "3.13.2"
const val mockitoKotlin = "2.1.0"
const val robolectric = "4.3"
const val junit4 = "4.12"
const val junitVintage = "5.5.2"
}

object ProjectDependencies {
Expand All @@ -68,7 +71,6 @@ object ProjectDependencies {
// Reactive extension related stuff
const val rxJava2 = "io.reactivex.rxjava2:rxjava:${Versions.rxJava2}"
const val rxJava2Extensions = "com.github.akarnokd:rxjava2-extensions:${Versions.rxJava2Extensions}"
const val rxRelay = "com.jakewharton.rxrelay2:rxrelay:${Versions.rxRelay}"
const val rxKotlin = "io.reactivex.rxjava2:rxkotlin:${Versions.rxKotlin}"
const val rxAndroid = "io.reactivex.rxjava2:rxandroid:${Versions.rxAndroid}"
const val autodispose = "com.uber.autodispose:autodispose:${Versions.autodispose}"
Expand All @@ -86,6 +88,11 @@ object ProjectDependencies {
const val junitPlatformConsole = "org.junit.platform:junit-platform-console:${Versions.junitPlatform}"
const val assertJ = "org.assertj:assertj-core:${Versions.assertJ}"
const val mockitoKotlin = "com.nhaarman.mockitokotlin2:mockito-kotlin:${Versions.mockitoKotlin}"

// Testing
const val robolectric = "org.robolectric:robolectric:${Versions.robolectric}"
const val junit4 = "junit:junit:${Versions.junit4}"
const val junitVintage = "org.junit.vintage:junit-vintage-engine:${Versions.junitVintage}"
}

object PluginDependencies {
Expand Down
189 changes: 135 additions & 54 deletions docs/orbits.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@ the glue between small, distinct functions.
``` kotlin
perform("add random number")
.on<AddRandomNumberButtonPressed>()
.transform { this.compose(getRandomNumberUseCase) }
.withReducer { state.copy(currentState.total + event.number) }
.transform { eventObservable.compose(getRandomNumberUseCase) }
.reduce { getCurrentState().copy(getCurrentState().total + event.number) }
```

We can break an orbit into its constituent parts to be able to understand it
better. A typical orbit is made of three sections:
better. A typical orbit is made of:

1. Action filter
1. Action filter (required)
1. Transformer(s) (optional)
1. React to events: Ignore, reduce or loopback
1. Reducers (optional)
1. Loopbacks (optional)
1. Side effects (optional)

## Action filter

Expand All @@ -29,14 +31,21 @@ Every orbit must begin with an action filter. Here we declare a orbit
description using the `perform` keyword. The description passed in will appear
in debugging logs if debugging is turned on (WIP).

Unique names must be used for each `perform` within a middleware or view model.

Then we declare an action that this orbit will react to using the `on` keyword.
We can also declare a list of actions if this orbit reacts to a few different
actions.

**NOTE**
Be careful not to use generic types in these filters! Due to type erasure
e.g. `List<Int>` and `List<String>` resolve to the same class, potentially
causing unintended events or crashes.

## Transformers

``` kotlin
.transform { this.compose(getRandomNumberUseCase) }
.transform { eventObservable.compose(getRandomNumberUseCase) }
```

Next we apply transformations to the action observable within the lambda here.
Expand All @@ -46,20 +55,15 @@ observable into an observable of a different type of events.
Transformers can be chained to be able to break logic down into smaller pieces
and enable reuse.

## Reactions

Next we have to declare how we will treat the emissions from the transformed
observable. There are three possible reactions:

### Reducers
## Reducers

``` kotlin
.withReducer {
state.copy(currentState.total + event.number)
.reduce {
state.copy(getCurrentState().total + event.number)
}
```

We can apply the `withReducer` function to a transformed observable in order to
We can apply the `reduce` function to a transformed observable in order to
reduce its events and the current state to produce a new state.

Reducers can also be applied directly to an action observable, without any
Expand All @@ -68,39 +72,33 @@ transformations beforehand:
``` kotlin
perform("addition")
.on<AddAction>()
.withReducer { state.copy(currentState.total + event.number) }
```

### Ignored events

``` kotlin
perform("add random number")
.on<AddRandomNumberButtonPressed>()
.transform { this.map{ … } }
.ignoringEvents()
.reduce { state.copy(getCurrentState().total + event.number) }
```

If we have an orbit that mainly invokes side effects in response to an action
and does not need to produce a new state, we can ignore it.
The reducers are passthrough transformers. This means that after applying
a reducer, the upstream events are passed through unmodified.

### Loopbacks
## Loopbacks

``` kotlin
perform("add random number")
.on<AddAction>()
.transform { this.compose(getRandomNumberUseCase) }
.transform { eventObservable.compose(getRandomNumberUseCase) }
.loopBack { event }

perform("reduce add random number")
.on<GetRandomNumberUseCaseEvent>()
.withReducer { state.copy(currentState.total + event.number) }
.on<GetRandomNumberUseCaseStatus>()
.reduce { state.copy(getCurrentState().total + event.number) }
```

Loopbacks allow you to create feedback loops where events coming from one orbit
can create new actions that feed into the system. These are useful to represent
a cascade of events.

### Side effects
The loopbacks are passthrough transformers. This means that after applying
a loopback, the upstream events are passed through unmodified.

## Side effects

We cannot run away from the fact that working with Android will
inherently have some side effects. We've made side effects a first class
Expand All @@ -110,13 +108,10 @@ view of what side effects are possible in a particular view model.
This functionality is commonly used for things like truly one-off events,
navigation, logging, analytics etc.

It comes in two flavors:

1. `sideEffect` lets us perform side effects that are not intended for
consumption outside the Orbit container.
1. `postSideEffect` sends the value returned from the closure to a relay
that can be subscribed when connecting to the view model. Use this for
view-related side effects like Toasts, Navigation, etc.
You can use the `post` method within `sideEffect` in order to
send the value to a relay that can be subscribed to when connecting to the
view model. Use this for view-related side effects like Toasts, Navigation,
etc.

``` kotlin
sealed class SideEffect {
Expand All @@ -128,34 +123,120 @@ OrbitViewModel<State, SideEffect>(State(), {

perform("side effect straight on the incoming action")
.on<SomeAction>()
.sideEffect { state, event ->
Timber.log(inputState)
Timber.log(action)
.sideEffect {
Timber.log(getCurrentState())
Timber.log(event)
}
.ignoringEvents()

perform("side effect after transformation")
.on<OtherAction>()
.transform { this.compose(getRandomNumberUseCase) }
.transform { eventObservable.compose(getRandomNumberUseCase) }
.sideEffect { Timber.log(event) }
.ignoringEvents()

perform("add random number")
perform("post side effect after transformation")
.on<YetAnotherAction>()
.transform { this.compose(getRandomNumberUseCase) }
.postSideEffect { SideEffect.Toast(event.toString()) }
.postSideEffect { SideEffect.Navigate(Screen.Home) }
.ignoringEvents()
.transform { eventObservable.compose(getRandomNumberUseCase) }
.sideEffect { post(SideEffect.Toast(event.toString())) }
.sideEffect { post(SideEffect.Navigate(Screen.Home)) }

perform("post side effect straight on the incoming action")
.on<NthAction>()
.postSideEffect { SideEffect.Toast(inputState.toString()) }
.postSideEffect { SideEffect.Toast(action.toString()) }
.postSideEffect { SideEffect.Navigate(Screen.Home) }
.ignoringEvents()
.sideEffect { post(SideEffect.Toast(getCurrentState().toString())) }
.sideEffect { post(SideEffect.Toast(event.toString())) }
.sideEffect { post(SideEffect.Navigate(Screen.Home)) }
})
```

The `OrbitContainer` hosted in the `OrbitViewModel` provides a relay that
you can subscribe through the `connect` method on `OrbitViewModel` in order
to receive view-related side effects.

The side effects are passthrough transformers. This means that after applying
a side effect, the upstream events are passed through unmodified.

## Accessing the current state

It's fairly common to read the current state in order to perform some
operation in your transformer, or side effect. You can capture the current
state at any point within each DSL block by simply calling `getCurrentState()`

For example:

``` kotlin
perform("Toast the current state")
.on<SomeAction>()
.sideEffect { post(SideEffect.Toast(getCurrentState().toString())) }
```

This property always captures the current state, and so calling this
multiple times within the same DSL block could result in receiving different
values each time as the state gets updated externally.

The only place where we can consider the current state to be non-volatile
is within a reducer.

## Flexible flows

You can chain as many operators as you want along the way. Remember that
the three passthrough transformer functions are:

1. reducers
1. side effects
1. loopbacks

This means they do not modify the upstream, allowing you to build flexible
chains of logic:

``` kotlin
perform("load patient prescriptions")
.on<LifecycleAction.Created>()
.transform { eventObservable.compose(getCurrentPatientUseCase) }
.reduce {
when(event) {
is Status.Result -> getCurrentState().copy(
data = event.data,
loading = false
)
is Status.Loading -> getCurrentState().copy(loading = true)
is Status.Error -> getCurrentState().copy(
error = event.throwable,
loading = true
)
}
}
.transform {
// Run only if previous use case was successful
eventObservable.ofType<Status.Result>()
.compose(getPatientPrescriptionsUseCase)
}
.reduce { ... }
```

The important thing to note is to not overdo it. Finish a chain with a
loopback if you think it's getting too complicated.

Or don't do this at all if that's your preference! You can always assume a
rule to always finish on the first reducer / loopback to keep things simple.

Orbit gives you the flexibility to express your logic however you think is
sensible.

## Lifecycle actions

For convenience we automatically send lifecycle actions into the MVI system.

Currently the only lifecycle action available is `LifecycleAction.Created`
which is sent to the MVI system when a `BaseOrbitContainer` is instantiated.

``` kotlin
perform("load initial data")
.on<LifecycleAction.Created>()
.transform { eventObservable.compose(getSomeDataUseCase) }
.reduce { ... }
```

This is useful to e.g. start loading initial screen data as soon as the
`OrbitViewModel` is created.

Currently we do not provide any more lifecycle actions but we are considering
extending that list to provide automatic lifecycle events from Android.
16 changes: 16 additions & 0 deletions gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
#
# Copyright 2019 Babylon Partners Limited
#
# 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.
#

distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-bin.zip
Expand Down
Loading

0 comments on commit 2c1a440

Please sign in to comment.