$30 off During Our Annual Pro Sale. View Details »

Exploring Coroutines

Exploring Coroutines

Presentation about architecting a unidirectional data flow Android application using Kotlin's coroutines, with UI drive by state machines.

Presented at

- KotlinSP Meetup #10 (August/2019 - São Paulo, Brazil)

Ubiratan Soares
PRO

August 14, 2019
Tweet

More Decks by Ubiratan Soares

Other Decks in Programming

Transcript

  1. Ubiratan Soares
    August / 2019
    EXPLORING
    COROUTINES

    View Slide

  2. How can we
    architect with
    Coroutines ?

    View Slide

  3. View Slide

  4. View Slide

  5. DataSource
    Use case ViewModel
    collaborates with

    View Slide

  6. DataSource
    Use case ViewModel
    External
    World
    User
    Interface
    Observable
    Observable Observable
    data flow format

    View Slide

  7. DataSource
    Use case ViewModel
    External
    World
    User
    Interface
    Observable
    Observable LiveData
    data flow format

    View Slide

  8. DataSource
    Use case ViewModel
    External
    World
    User
    Interface
    suspend fun (T)
    suspend fun (T) LiveData
    data flow format

    View Slide

  9. DataSource
    Use case ViewModel
    External
    World
    User
    Interface
    suspend fun (T)
    suspend fun (T) LiveData
    data flow format

    View Slide

  10. Unidirectional
    Data Flow

    View Slide

  11. Activity( ) : ViewProtocol doSomething ( ) Presenter
    View Protocol
    fun handle(d : Data)
    fun showLoading( )
    fun showError( )
    fun doSomething( )
    override fun handle(d : Data)
    override fun showLoading( )
    override fun showError( )
    doAnotherThing ( )
    fun doAnotherThing( )
    method call

    View Slide

  12. Activity( )
    doSomething ( )
    ViewModel
    fun doSomething( ) : LiveData
    fun handle(d : Data)
    fun showLoading( )
    fun showError( )
    doAnotherThing ( )
    fun doAnotherThing( ) : LiveData
    method call
    LiveData Observer

    View Slide

  13. Activity( ) doSomething ( ) ViewModel
    fun doSomething( ) : LiveData
    fun handle(d : Data)
    fun showLoading( )
    fun showError( )
    doAnotherThing ( )
    fun doAnotherThing( ) : LiveData
    method call
    fun justOneMoreThing( ) : LiveData
    justOneMoreThing ( )
    LiveData Observer

    View Slide

  14. Activity( ) doSomething ( ) ViewModel
    doAnotherThing ( )
    method call
    justOneMoreThing ( )
    LiveData Observer
    LiveData Observer
    LiveData Observer
    LiveData
    LiveData
    LiveData

    View Slide

  15. Activity( ) doSomething ( ) ViewModel
    doAnotherThing ( )
    method call
    justOneMoreThing ( )
    LiveData Observer
    LiveData Observer
    LiveData Observer
    LiveData
    LiveData
    LiveData

    View Slide

  16. View Slide

  17. DataSource
    Use case ViewModel
    External
    World
    User
    Interface
    suspend fun (T)
    suspend fun (T) suspend fun (T)
    data flow format

    View Slide

  18. DataSource
    Use case ViewModel
    External
    World
    User
    Interface
    suspend fun (T)
    suspend fun (T) suspend fun (T)
    data flow format
    How can we push
    multiple states
    to the View level ???

    View Slide

  19. View Slide

  20. View Slide

  21. View Slide

  22. View Slide

  23. View Slide

  24. View Slide

  25. DataSource
    Use case ViewModel
    External
    World
    User
    Interface
    suspend fun (T)
    suspend fun (T) Flow

    View Slide

  26. View Slide

  27. Activity( )
    handle(someInteraction)
    ViewModel
    method call
    FlowConsumer
    StateMachine
    fun handle(i : UserInteraction)
    bind( )
    fun bind( ) : Flow
    fun handle(d : Data)
    fun showLoading( )
    fun showError( )
    data flow format
    Flow

    View Slide

  28. Started
    bind( )

    View Slide

  29. Started Loading
    interaction
    (suspend wrapper)
    bind( )

    View Slide

  30. Started Loading
    Success
    interaction
    (suspend wrapper)
    bind( )
    suspend fun resumed

    View Slide

  31. Started Loading
    Error
    interaction
    (suspend wrapper)
    bind( )
    suspend fun resumed

    View Slide

  32. Started Loading
    Success
    Error
    interaction
    (suspend wrapper)
    bind( )
    suspend fun resumed
    suspend fun resumed

    View Slide

  33. Started Loading
    Success
    Error
    interaction
    (suspend wrapper)
    interaction
    (suspend wrapper)
    interaction
    (suspend wrapper)
    bind( )
    suspend fun resumed
    suspend fun resumed

    View Slide

  34. sealed class StateTransition {
    interface Parameters
    class Unparametrized internal constructor(
    val task: suspend () !" T
    ) : StateTransition()
    class Parametrized internal constructor(
    val task: suspend (Parameters) !" T,
    val parameters: Parameters
    ) : StateTransition()
    }

    View Slide

  35. sealed class StateTransition {
    interface Parameters
    class Unparametrized internal constructor(
    val task: suspend () !" T
    ) : StateTransition()
    class Parametrized internal constructor(
    val task: suspend (Parameters) !" T,
    val parameters: Parameters
    ) : StateTransition()
    }

    View Slide

  36. class StateMachine(
    private val container: StateContainer,
    private val executor: TaskExecutor
    ) {
    fun states() = container.observableStates()
    fun consume(execution: StateTransition) =
    executor.execute {
    wrapWithStates(execution)
    }
    // Continue on next slide
    }

    View Slide

  37. class StateMachine(#$%) {
    private suspend fun wrapWithStates(execution: StateTransition) {
    val first = executionStarted()
    moveTo(first)
    val next = executeWith(execution)
    moveTo(next)
    }
    private suspend fun executeWith(transition: StateTransition) =
    try {
    val execution =
    when (transition) {
    is Unparametrized !" transition.task.invoke()
    is Parametrized !" with(transition) { this.task.invoke(parameters) }
    }
    Success(execution)
    } catch (error: Throwable) {
    Failed(error)
    }
    }

    View Slide

  38. class StateMachine(#$%) {
    private suspend fun wrapWithStates(execution: StateTransition) {
    val first = executionStarted()
    moveTo(first)
    val next = executeWith(execution)
    moveTo(next)
    }
    private suspend fun executeWith(transition: StateTransition) =
    try {
    val execution =
    when (transition) {
    is Unparametrized !" transition.task.invoke()
    is Parametrized !" with(transition) { this.task.invoke(parameters) }
    }
    Success(execution)
    } catch (error: Throwable) {
    Failed(error)
    }
    }

    View Slide

  39. class StateMachine(#$%) {
    private suspend fun wrapWithStates(execution: StateTransition) {
    val first = executionStarted()
    moveTo(first)
    val next = executeWith(execution)
    moveTo(next)
    }
    private suspend fun executeWith(transition: StateTransition) =
    try {
    val execution =
    when (transition) {
    is Unparametrized !" transition.task.invoke()
    is Parametrized !" with(transition) { this.task.invoke(parameters) }
    }
    Success(execution)
    } catch (error: Throwable) {
    Failed(error)
    }
    }

    View Slide

  40. A complete
    data flow

    View Slide

  41. interface ChuckNorrisDotIO {
    @GET("/jokes/categories")
    suspend fun categories(): RawCategories
    @GET("/jokes/search")
    suspend fun search(@Query("query") query: String): RawSearch
    companion object {
    const val API_URL = "https:&'api.chucknorris.io"
    }
    }

    View Slide

  42. class FactsInfrastructure(
    private val rest: ChuckNorrisDotIO
    ) : RemoteFactsService {
    override suspend fun availableCategories() =
    managedExecution {
    rest.categories().asRelatedCategories()
    }
    override suspend fun fetchFacts(searchTerm: String) =
    managedExecution {
    rest.search(searchTerm).asChuckNorrisFacts()
    }
    }

    View Slide

  43. class FetchFacts(
    private val factsService: RemoteFactsService
    ) {
    suspend fun search(term: String) =
    when {
    term.isEmpty() !" throw EmptyTerm
    else !" {
    val facts = factsService.fetchFacts(term)
    if (facts.isEmpty()) throw NoResultsFound else facts
    }
    }
    }

    View Slide

  44. class FactsViewModel(
    private val factsFetcher: FetchFacts,
    private val queryManager: ManageSearchQuery,
    private val machine: StateMachine
    ) {
    fun bind() = machine.states()
    fun handle(interaction: UserInteraction) =
    interpret(interaction)
    .let { transition !"
    machine.consume(transition)
    }
    private fun interpret(interaction: UserInteraction) =
    when (interaction) {
    OpenedScreen, RequestedFreshContent !" StateTransition(()showFacts)
    else !" throw UnsupportedUserInteraction
    }

    View Slide

  45. private fun interpret(interaction: UserInteraction) =
    when (interaction) {
    OpenedScreen, RequestedFreshContent !" StateTransition(()showFacts)
    else !" throw UnsupportedUserInteraction
    }
    private suspend fun showFacts() =
    queryManager.actualQuery().let { query !"
    factsFetcher
    .search(query)
    .map { FactDisplayRow(it) }
    .let { rows !"
    FactsPresentation(query, rows)
    }
    }
    }

    View Slide

  46. private fun interpret(interaction: UserInteraction) =
    when (interaction) {
    OpenedScreen, RequestedFreshContent !" StateTransition(::showFacts)
    else !" throw UnsupportedUserInteraction
    }
    private suspend fun showFacts() =
    queryManager.actualQuery().let { query !"
    factsFetcher
    .search(query)
    .map { FactDisplayRow(it) }
    .let { rows !"
    FactsPresentation(query, rows)
    }
    }
    }

    View Slide

  47. class FactsActivity : AppCompatActivity(), KodeinAware {
    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    setup()
    }
    private fun setup() {
    setSupportActionBar(factsToolbar)
    factsRecyclerView.layoutManager = LinearLayoutManager(this)
    factsSwipeToRefresh.setOnRefreshListener { refresh() }
    lifecycleScope.launch {
    viewModel.bind().collect { renderState(it) }
    }
    }

    View Slide

  48. private fun setup() {
    setSupportActionBar(factsToolbar)
    factsRecyclerView.layoutManager = LinearLayoutManager(this)
    factsSwipeToRefresh.setOnRefreshListener { refresh() }
    lifecycleScope.launch {
    viewModel.bind().collect { renderState(it) }
    }
    }
    private fun renderState(state: ViewState) =
    when (state) {
    is Failed !" handleError(state.reason)
    is Success !" showFacts(state.value)
    is Loading.FromEmpty !" startExecution()
    is Loading.FromPrevious !" showFacts(state.previous)
    is FirstLaunch !" loadFacts()
    }
    }

    View Slide

  49. private fun renderState(state: ViewState) =
    when (state) {
    is Failed !" handleError(state.reason)
    is Success !" showFacts(state.value)
    is Loading.FromEmpty !" startExecution()
    is Loading.FromPrevious !" showFacts(state.previous)
    is FirstLaunch !" loadFacts()
    }
    }
    private fun loadFacts() {
    viewModel.handle(OpenedScreen)
    }
    }

    View Slide

  50. Solving Lifecycles
    with Structured
    Concurrency

    View Slide

  51. State Machine
    TaskExecutor
    StateContainer
    collaborates with
    class StateMachine(
    private val container: StateContainer,
    private val executor: TaskExecutor
    ) {
    // Implementation
    }

    View Slide

  52. State Machine
    TaskExecutor
    StateContainer
    collaborates with
    The State Container registers the current state processed by the
    State Machine.
    The implementation is CoroutineScope-aware and uses a
    ConflatedBroadcastChannel as communication engine to
    propagate state to consumers.
    We can build a StateContainer instance with a CoroutineScope
    differente than the consumer’s scope

    View Slide

  53. State Machine
    TaskExecutor
    StateContainer
    collaborates with
    The task executor defines a model of execution for the state
    transition : either Concurrent or Synchronous

    View Slide

  54. interface TaskExecutor {
    fun execute(block: suspend TaskExecutor.() !" Unit): Job
    class Concurrent(
    private val scope: CoroutineScope,
    private val dispatcher: CoroutineDispatcher
    ) : TaskExecutor {
    override fun execute(block: suspend TaskExecutor.() !" Unit) =
    scope.launch(dispatcher) {
    block.invoke(this@Concurrent)
    }
    }
    class Synchronous(private val scope: CoroutineScope) : TaskExecutor {
    override fun execute(block: suspend TaskExecutor.() !" Unit) =
    runBlocking {
    scope.launch { block.invoke(this@Synchronous) }
    }
    }
    }

    View Slide

  55. interface TaskExecutor {
    fun execute(block: suspend TaskExecutor.() !" Unit): Job
    class Concurrent(
    private val scope: CoroutineScope,
    private val dispatcher: CoroutineDispatcher
    ) : TaskExecutor {
    override fun execute(block: suspend TaskExecutor.() !" Unit) =
    scope.launch(dispatcher) {
    block.invoke(this@Concurrent)
    }
    }
    class Synchronous(private val scope: CoroutineScope) : TaskExecutor {
    override fun execute(block: suspend TaskExecutor.() !" Unit) =
    runBlocking {
    scope.launch { block.invoke(this@Synchronous) }
    }
    }
    }

    View Slide

  56. Executor Coroutine
    (runs the suspend function)
    Consumer Coroutine
    (consume the states)
    Channel

    View Slide

  57. Executor Coroutine
    (runs the suspend function
    Scope = AAC ViewModel
    Consumer Coroutine
    (consume the states)
    Scope = Activity Lifecycle
    Channel

    View Slide

  58. class ConfigChangesAwareStateContainer : StateContainer, ViewModel() {
    private val broadcaster by lazy {
    ConflatedBroadcastChannel}
    override val emissionScope = viewModelScope
    override fun observableStates() = broadcaster.asFlow()
    override fun current(): ViewState = broadcaster.value
    override suspend fun store(state: ViewState) {
    broadcaster.send(state)
    }

    View Slide

  59. class ConfigChangesAwareStateContainer : StateContainer, ViewModel() {
    private val broadcaster by lazy {
    ConflatedBroadcastChannel}
    override val emissionScope = viewModelScope
    override fun observableStates() = broadcaster.asFlow()
    override fun current(): ViewState = broadcaster.value
    override suspend fun store(state: ViewState) {
    broadcaster.send(state)
    }
    Cold flow over
    Hot Channel

    View Slide

  60. class ConfigChangesAwareStateContainer : StateContainer, ViewModel() {
    private val broadcaster by lazy {
    ConflatedBroadcastChannel}
    override val emissionScope = viewModelScope
    override fun observableStates() = broadcaster.asFlow()
    override fun current(): ViewState = broadcaster.value
    override suspend fun store(state: ViewState) {
    broadcaster.send(state)
    }

    View Slide

  61. companion object {
    operator fun invoke(host: FragmentActivity): StateContainer {
    val f = object : ViewModelProvider.Factory {
    override fun create(klass: Class) =
    ConfigChangesAwareStateContainer() as Model
    }
    val keyClazz = ConfigChangesAwareStateContainer()class.java
    return ViewModelProviders.of(host, f)[keyClazz] as StateContainer
    }
    }
    }

    View Slide

  62. Testing like
    a pro

    View Slide

  63. class FetchCategoriesTests {
    private val categoriesCache = mock()
    private val remoteFacts = mock()
    private lateinit var usecase: FetchCategories
    private val categories = mutableListOf(
    RelatedCategory.Available("dev"),
    RelatedCategory.Available("code")
    )
    @Before fun `before each test`() {
    usecase = FetchCategories(categoriesCache, remoteFacts)
    }
    @Test fun `should fetch from cache from available`() {
    runBlocking {
    `given that remote service not available`()
    `given that cache returns categories`()
    assertThat(usecase.execute()).isEqualTo(categories)
    }
    }
    }

    View Slide

  64. internal class FactsInfrastructureTests {
    @get:Rule val rule = InfrastructureRule()
    private lateinit var infrastructure: FactsInfrastructure
    @Before fun `before each test`() {
    infrastructure = FactsInfrastructure(rule.api)
    }
    @Test fun `should fetch categories`() {
    rule.defineScenario(
    status = 200,
    response = loadFile("200_categories.json")
    )

    View Slide

  65. val expected = listOf(
    Available("career"),
    Available("celebrity"),
    Available("dev")
    )
    val categories = runBlocking {
    infrastructure.availableCategories()
    }
    assertThat(categories).isEqualTo(expected)
    }
    private fun simpleSearch() = runBlocking {
    infrastructure.fetchFacts("Norris")
    }
    }

    View Slide

  66. class FactsViewModelTests {
    @get:Rule val helper = CoroutinesTestHelper()
    private val fetchFacts = mock()
    private val manageQuery = mock()
    private lateinit var viewModel: FactsViewModel
    @Before fun `before each test`() {
    val stateMachine = StateMachine(
    executor = TaskExecutor.Synchronous(helper.scope),
    container = StateContainer.Unbounded(helper.scope)
    )
    viewModel = FactsViewModel(fetchFacts, manageQuery, stateMachine)
    }
    @Test fun `should report failure when fetching from remote`() {

    View Slide

  67. @Test fun `should report failure when fetching from remote`() {
    &' Given
    flowTest(viewModel.bind()) {
    triggerEmissions {
    &' When
    whenever(fetchFacts.search(anyString()))
    .thenAnswer { throw UnexpectedResponse }
    &' And
    viewModel.handle(UserInteraction.OpenedScreen)
    }
    afterCollect { emissions !"

    View Slide

  68. afterCollect { emissions !"
    &' Then
    val viewStates = listOf(
    FirstLaunch,
    Loading.FromEmpty,
    Failed(UnexpectedResponse)
    )
    assertThat(emissions).isEqualTo(viewStates)
    }
    }
    }
    }

    View Slide

  69. class FlowTest(private val parentJob: Job, private val emissions: List) {
    fun triggerEmissions(action: suspend () !" Job) {
    runBlocking { action().join() }
    }
    fun afterCollect(verification: (List) !" Unit) {
    parentJob.invokeOnCompletion {
    verification.invoke(emissions)
    }
    }
    companion object {
    fun flowTest(
    target: Flow,
    scope: CoroutineScope = GlobalScope,
    block: FlowTest.() !" Unit
    ) {
    target.test(scope, block)
    }
    }
    }

    View Slide

  70. class FlowTest(private val parentJob: Job, private val emissions: List) {
    fun triggerEmissions(action: suspend () !" Job) {
    runBlocking { action().join() }
    }
    fun afterCollect(verification: (List) !" Unit) {
    parentJob.invokeOnCompletion {
    verification.invoke(emissions)
    }
    }
    companion object {
    fun flowTest(
    target: Flow,
    scope: CoroutineScope = GlobalScope,
    block: FlowTest.() !" Unit
    ) {
    target.test(scope, block)
    }
    }
    }

    View Slide

  71. View Slide

  72. https://speakerdeck.com/ubiratansoares

    View Slide

  73. UBIRATAN
    SOARES
    Computer Scientist by ICMC/USP
    Software Engineer, curious guy
    Google Developer Expert for Android / Kotlin
    Teacher, speaker, etc, etc

    View Slide

  74. THANK YOU
    @ubiratanfsoares
    ubiratansoares.dev
    https://br.linkedin.com/in/ubiratanfsoares

    View Slide