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