Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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

August 14, 2019
Tweet

More Decks by Ubiratan Soares

Other Decks in Programming

Transcript

  1. DataSource Use case ViewModel External World User Interface suspend fun

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

    (T) suspend fun (T) LiveData<T> data flow format
  3. 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
  4. Activity( ) doSomething ( ) ViewModel fun doSomething( ) :

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

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

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

    call justOneMoreThing ( ) LiveData Observer LiveData Observer LiveData Observer LiveData LiveData LiveData
  8. DataSource Use case ViewModel External World User Interface suspend fun

    (T) suspend fun (T) suspend fun (T) data flow format
  9. 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 ???
  10. Activity( ) handle(someInteraction) ViewModel method call FlowConsumer StateMachine fun handle(i

    : UserInteraction) bind( ) fun bind( ) : Flow<ViewState> fun handle(d : Data) fun showLoading( ) fun showError( ) data flow format Flow<ViewState>
  11. Started Loading Success Error interaction (suspend wrapper) interaction (suspend wrapper)

    interaction (suspend wrapper) bind( ) suspend fun resumed suspend fun resumed
  12. sealed class StateTransition<out T> { interface Parameters class Unparametrized<T> internal

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

    constructor( val task: suspend () !" T ) : StateTransition<T>() class Parametrized<T> internal constructor( val task: suspend (Parameters) !" T, val parameters: Parameters ) : StateTransition<T>() }
  14. class StateMachine<T>( private val container: StateContainer<T>, private val executor: TaskExecutor

    ) { fun states() = container.observableStates() fun consume(execution: StateTransition<T>) = executor.execute { wrapWithStates(execution) } // Continue on next slide }
  15. class StateMachine<T>(#$%) { private suspend fun wrapWithStates(execution: StateTransition<T>) { val

    first = executionStarted() moveTo(first) val next = executeWith(execution) moveTo(next) } private suspend fun executeWith(transition: StateTransition<T>) = 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) } }
  16. class StateMachine<T>(#$%) { private suspend fun wrapWithStates(execution: StateTransition<T>) { val

    first = executionStarted() moveTo(first) val next = executeWith(execution) moveTo(next) } private suspend fun executeWith(transition: StateTransition<T>) = 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) } }
  17. class StateMachine<T>(#$%) { private suspend fun wrapWithStates(execution: StateTransition<T>) { val

    first = executionStarted() moveTo(first) val next = executeWith(execution) moveTo(next) } private suspend fun executeWith(transition: StateTransition<T>) = 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) } }
  18. 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" } }
  19. 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() } }
  20. 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 } } }
  21. class FactsViewModel( private val factsFetcher: FetchFacts, private val queryManager: ManageSearchQuery,

    private val machine: StateMachine<FactsPresentation> ) { 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 }
  22. 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) } } }
  23. 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) } } }
  24. 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) } } }
  25. private fun setup() { setSupportActionBar(factsToolbar) factsRecyclerView.layoutManager = LinearLayoutManager(this) factsSwipeToRefresh.setOnRefreshListener {

    refresh() } lifecycleScope.launch { viewModel.bind().collect { renderState(it) } } } private fun renderState(state: ViewState<FactsPresentation>) = 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() } }
  26. private fun renderState(state: ViewState<FactsPresentation>) = 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) } }
  27. State Machine TaskExecutor StateContainer collaborates with class StateMachine<T>( private val

    container: StateContainer<T>, private val executor: TaskExecutor ) { // Implementation }
  28. 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
  29. State Machine TaskExecutor StateContainer collaborates with The task executor defines

    a model of execution for the state transition : either Concurrent or Synchronous
  30. 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) } } } }
  31. 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) } } } }
  32. Executor Coroutine (runs the suspend function Scope = AAC ViewModel

    Consumer Coroutine (consume the states) Scope = Activity Lifecycle Channel
  33. class ConfigChangesAwareStateContainer<T> : StateContainer<T>, ViewModel() { private val broadcaster by

    lazy { ConflatedBroadcastChannel<ViewState<T*+(ViewState.FirstLaunch) } override val emissionScope = viewModelScope override fun observableStates() = broadcaster.asFlow() override fun current(): ViewState<T> = broadcaster.value override suspend fun store(state: ViewState<T>) { broadcaster.send(state) }
  34. class ConfigChangesAwareStateContainer<T> : StateContainer<T>, ViewModel() { private val broadcaster by

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

    lazy { ConflatedBroadcastChannel<ViewState<T*+(ViewState.FirstLaunch) } override val emissionScope = viewModelScope override fun observableStates() = broadcaster.asFlow() override fun current(): ViewState<T> = broadcaster.value override suspend fun store(state: ViewState<T>) { broadcaster.send(state) }
  36. companion object { operator fun <T> invoke(host: FragmentActivity): StateContainer<T> {

    val f = object : ViewModelProvider.Factory { override fun <Model : ViewModel> create(klass: Class<Model>) = ConfigChangesAwareStateContainer<T>() as Model } val keyClazz = ConfigChangesAwareStateContainer()class.java return ViewModelProviders.of(host, f)[keyClazz] as StateContainer<T> } } }
  37. class FetchCategoriesTests { private val categoriesCache = mock<CategoriesCacheService>() private val

    remoteFacts = mock<RemoteFactsService>() 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) } } }
  38. 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") )
  39. 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") } }
  40. class FactsViewModelTests { @get:Rule val helper = CoroutinesTestHelper() private val

    fetchFacts = mock<FetchFacts>() private val manageQuery = mock<ManageSearchQuery>() private lateinit var viewModel: FactsViewModel @Before fun `before each test`() { val stateMachine = StateMachine<FactsPresentation>( executor = TaskExecutor.Synchronous(helper.scope), container = StateContainer.Unbounded(helper.scope) ) viewModel = FactsViewModel(fetchFacts, manageQuery, stateMachine) } @Test fun `should report failure when fetching from remote`() {
  41. @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 !"
  42. afterCollect { emissions !" &' Then val viewStates = listOf(

    FirstLaunch, Loading.FromEmpty, Failed(UnexpectedResponse) ) assertThat(emissions).isEqualTo(viewStates) } } } }
  43. class FlowTest<T>(private val parentJob: Job, private val emissions: List<T>) {

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

    fun triggerEmissions(action: suspend () !" Job) { runBlocking { action().join() } } fun afterCollect(verification: (List<T>) !" Unit) { parentJob.invokeOnCompletion { verification.invoke(emissions) } } companion object { fun <T> flowTest( target: Flow<T>, scope: CoroutineScope = GlobalScope, block: FlowTest<T>.() !" Unit ) { target.test(scope, block) } } }
  45. UBIRATAN SOARES Computer Scientist by ICMC/USP Software Engineer, curious guy

    Google Developer Expert for Android / Kotlin Teacher, speaker, etc, etc