Getting into Orbit
Getting into Orbit 2
Orbit 1
A look back
Orbit design goals
- Simple
- Flexible
- Testable
- Designed for Android
Did we meet our goals?
Mostly, however...
Mostly, however...
- Could be simpler
- Not very flexible
- Hard dependency on RxJava 2
- Everything is a stream
- No coroutine support
- Verbose tests
Orbit 2 design goals
Orbital maneuver
Orbit 2 design goals
- Simple
- Flexible
- Testable
- Designed for Android
- Composition over inheritance
- (Mostly) Framework agnostic
Orbit 2 in action
Orbit 1
- OrbitContainer
- Middleware
- OrbitViewModel
Orbit 2
- Container
- ContainerHost
- Android ViewModel
Orbit 1
OrbitViewModel
Middleware
OrbitContainer
Orbit 2
OrbitViewModel
ContainerHost, ViewModel
OrbitContainer
Container
interface Container<STATE : Any, SIDE_EFFECT : Any> {
val currentState: STATE
val stateStream: Stream<STATE>
val sideEffectStream: Stream<SIDE_EFFECT>
fun orbit(build: Builder<...>.() -> Builder<...>)
}
ContainerHost
interface ContainerHost<STATE : Any, SIDE_EFFECT : Any> {
val container: Container<STATE, SIDE_EFFECT>
fun orbit(build: Builder<...>.() -> Builder<...>) =
container.orbit(build)
}
Building a Twitter client
data class TweetListState(
val tweets: List<Tweet> = emptyList(),
val loading: Boolean = false,
val filteredTweets: List<Tweet> = emptyList()
)
class TweetListViewModel : ContainerHost<TweetListState, Nothing>, ViewModel() {
override val container = container<TweetListState, Nothing>(TweetListState())
}
Container Scoping
open class RealContainer<STATE : Any, SIDE_EFFECT : Any>(
...,
parentScope: CoroutineScope
) : Container<STATE, SIDE_EFFECT> {
fun <STATE : Any, SIDE_EFFECT : Any> CoroutineScope.container(
...
): Container<STATE, SIDE_EFFECT> =
RealContainer(
...
parentScope = this
)
fun <STATE : Any, SIDE_EFFECT : Any> ViewModel.container(
...
): Container<STATE, SIDE_EFFECT> {
return viewModelScope.container(...)
}
Getting the tweet list
interface TwitterApi {
fun getTweetList(): List<Tweet>
}
class TweetListViewModel @Inject constructor(
private val twitterApi: TwitterApi
) : ContainerHost<TweetListState, Nothing>, ViewModel() {
override val container = container<TweetListState, Nothing>(TweetListState())
fun getTweetList() = ???
}
Getting the tweet list
interface TwitterApi {
fun getTweetList(): List<Tweet>
}
class TweetListViewModel @Inject constructor(
private val twitterApi: TwitterApi
) : ContainerHost<TweetListState, Nothing>, ViewModel() {
override val container = container<TweetListState, Nothing>(TweetListState())
fun getTweetList() = orbit {
???
}
}
Basic operators
interface TwitterApi {
fun getTweetList(): List<Tweet>
}
class TweetListViewModel @Inject constructor(
private val twitterApi: TwitterApi
) : ContainerHost<TweetListState, Nothing>, ViewModel() {
override val container = container<TweetListState, Nothing>(TweetListState())
fun getTweetList() = orbit {
reduce { state.copy(loading = true) }
.transform { twitterApi.getTweetList() }
.reduce { state.copy(loading = false, tweets = event) }
}
}
RxJava 2 operators
interface TwitterApi {
fun getTweetList(): Single<List<Tweet>>
}
class TweetListViewModel @Inject constructor(
private val twitterApi: TwitterApi
) : ContainerHost<TweetListState, Nothing>, ViewModel() {
override val container = container<TweetListState, Nothing>(TweetListState())
fun getTweetList() = orbit {
reduce { state.copy(loading = true) }
.transformRx2Single { twitterApi.getTweetList() }
.reduce { state.copy(loading = false, tweets = event) }
}
}
Coroutine operators
interface TwitterApi {
suspend fun getTweetList(): List<Tweet>
}
class TweetListViewModel @Inject constructor(
private val twitterApi: TwitterApi
) : ContainerHost<TweetListState, Nothing>, ViewModel() {
override val container = container<TweetListState, Nothing>(TweetListState())
fun getTweetList() = orbit {
reduce { state.copy(loading = true) }
.transformSuspend { twitterApi.getTweetList() }
.reduce { state.copy(loading = false, tweets = event) }
}
}
Mix and match
interface TwitterApi {
suspend fun getTweetList(): List<Tweet>
}
class TweetListViewModel @Inject constructor(
private val twitterApi: TwitterApi
) : ContainerHost<TweetListState, Nothing>, ViewModel() {
override val container = container<TweetListState, Nothing>(TweetListState())
fun getTweetList() = orbit {
reduce { state.copy(loading = true) }
.transformSuspend { twitterApi.getTweetList() }
.transformRx2Single { twitterApi.getStats() }
.reduce { state.copy(loading = false, tweets = event) }
}
}
Basic DSL syntax
- orbit
- transform
- sideEffect
- reduce
Coroutine plugin
- transformFlow
- transformSuspend
RxJava2 plugin
- transformObservable
- transformSingle
- transformMaybe
- transformCompletable
onCreate
interface TwitterApi {
suspend fun getTweetList(): List<Tweet>
}
class TweetListViewModel @Inject constructor(
private val twitterApi: TwitterApi
) : ContainerHost<TweetListState, Nothing>, ViewModel() {
override val container = container<TweetListState, Nothing>(TweetListState())
fun getTweetList() = orbit {
reduce { state.copy(loading = true) }
.transformSuspend { twitterApi.getTweetList() }
.reduce { state.copy(loading = false, tweets = event) }
}
}
onCreate
interface TwitterApi {
suspend fun getTweetList(): List<Tweet>
}
class TweetListViewModel @Inject constructor(
private val twitterApi: TwitterApi
) : ContainerHost<TweetListState, Nothing>, ViewModel() {
override val container = container<TweetListState, Nothing>(TweetListState()) {
getTweetList()
}
fun getTweetList() = orbit {
reduce { state.copy(loading = true) }
.transformSuspend { twitterApi.getTweetList() }
.reduce { state.copy(loading = false, tweets = event) }
}
}
Filtering
class TweetListViewModel @Inject constructor(
private val twitterApi: TwitterApi
) : ContainerHost<TweetListState, Nothing>, ViewModel() {
override val container = container<TweetListState, Nothing>(TweetListState()) {
getTweetList()
}
fun getTweetList() = orbit {
reduce { state.copy(loading = true) }
.transformSuspend { twitterApi.getTweetList() }
.reduce { state.copy(loading = false, tweets = event) }
}
}
Passing parameters
class TweetListViewModel @Inject constructor(
private val twitterApi: TwitterApi
) : ContainerHost<TweetListState, Nothing>, ViewModel() {
override val container = container<TweetListState, Nothing>(TweetListState()) {
getTweetList()
}
fun getTweetList() = orbit { ... }
fun filterTweets(filter: String) = orbit {
reduce {
state.copy(
filteredTweets = state.tweets.filter {
it.author.contains(filter) || it.content.contains(filter)
}
)
}
}
}
Passing parameters
class TweetListViewModel @Inject constructor(
private val twitterApi: TwitterApi
) : ContainerHost<TweetListState, Nothing>, ViewModel() {
override val container = container<TweetListState, Nothing>(TweetListState()) {
getTweetList()
}
fun getTweetList() = orbit { ... }
fun filterTweets(filter: String) = orbit {
reduce {
if (filter.isBlank()) {
state.copy(filteredTweets = state.tweets)
} else {
state.copy(
filteredTweets = state.tweets.filter {
it.author.contains(filter) || it.content.contains(filter)
}
)
}
}
}
}
Subscribing to a container
class TweetListActivity: AppCompatActivity() {
private val viewModel by viewModel<TweetListViewModel>()
private val adapter: RecyclerView.Adapter
override fun onCreate(savedState: Bundle?) {
...
swipeRefresh.setOnRefreshListener { viewModel.getTweetList() }
filterEditText.setOnTextChanged { viewModel.filter(it.text) }
viewModel.container.state.observe(this, Observer { render(it) })
}
private fun render(state: TweetListState) {
swipeRefresh.setRefreshing(state.loading)
adapter.setData(state.filteredTweets)
}
}
Saving the state
@Parcelize
data class TweetListState(
val tweets: List<Tweet> = emptyList(),
val loading: Boolean = false,
val filteredTweets: List<Tweet> = emptyList()
): Parcelable
class TweetListViewModel @Inject constructor(
private val savedStateHandle,
private val twitterApi: TwitterApi
) : ContainerHost<TweetListState, Nothing>, ViewModel() {
override val container =
container<TweetListState, Nothing>(TweetListState(), savedStateHandle) {
getTweetList()
}
fun getTweetList() = orbit { ... }
fun filterTweets(filter: String) = orbit { ... }
}
Threading
Orbit containers have a dedicated thread
transformX functions run on IO dispatcher
Testing
Black-box tests
- Use for simple ViewModels
- Can be done without special framework support
- Run in a normal multithreaded way - need blocking awaits
White-box tests
- Use for complex ViewModels
- Needs framework support
- Run on the test thread
- Flow isolation
No flow isolation
Flow isolation
Black-box tests
class TweetListTests {
private val tweetList = listOf(...)
private val twitterApi = mock<TwitterApi> {
on { getTweetList() } thenReturn { tweetList }
}
@Test
fun gettingTweetList() {
val testSubject = TweetListViewModel(twitterApi).test(TweetListState())
testSubject.getTweetList()
testSubject.assert {
states(
{ copy(loading = true) },
{ copy(loading = false, tweets = tweetList) }
)
}
}
}
Asserting states
class ExampleTests {
@Test
fun exampleTest() {
...
testSubject.assert {
states(
{ copy(loading = true) },
{ copy(loading = false, data = someData) }
)
}
}
}
Asserting side effects
class ExampleTests {
@Test
fun exampleTest() {
...
testSubject.assert {
sideEffects(
ExampleSideEffects.Toast("foo"),
ExampleSideEffects.Toast("bar")
)
}
}
}
Asserting loopbacks
class ExampleTests {
private class ExampleViewModel : ContainerHost<ExampleState, Nothing>, ViewModel() {
override val container = container<ExampleState, Nothing>(ExampleState())
fun doSomething() = orbit {
reduce { state.copy(loading = true) }
.sideEffect { doSomethingElse() }
}
fun doSomethingElse() = orbit {
...
}
}
@Test
fun exampleTest() {
...
testSubject.doSomething()
testSubject.assert {
loopBack { doSomethingElse() }
}
}
}
Orbit 2 development state
Getting into Orbit
By Mikołaj Leszczyński
Getting into Orbit
- 606