Share
Embed
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
Transcript
74 slides
-
DataSource Use case ViewModel External World User Interface Observable<T> Observable<T>
Observable<T> data flow format -
DataSource Use case ViewModel External World User Interface Observable<T> Observable<T>
LiveData<T> data flow format -
DataSource Use case ViewModel External World User Interface suspend fun
(T) suspend fun (T) LiveData<T> data flow format -
DataSource Use case ViewModel External World User Interface suspend fun
(T) suspend fun (T) LiveData<T> data flow format -
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 -
Activity( ) doSomething ( ) ViewModel fun doSomething( ) :
LiveData<Data> fun handle(d : Data) fun showLoading( ) fun showError( ) doAnotherThing ( ) fun doAnotherThing( ) : LiveData<Data> method call LiveData Observer -
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 -
Activity( ) doSomething ( ) ViewModel doAnotherThing ( ) method
call justOneMoreThing ( ) LiveData Observer LiveData Observer LiveData Observer LiveData LiveData LiveData -
Activity( ) doSomething ( ) ViewModel doAnotherThing ( ) method
call justOneMoreThing ( ) LiveData Observer LiveData Observer LiveData Observer LiveData LiveData LiveData -
DataSource Use case ViewModel External World User Interface suspend fun
(T) suspend fun (T) suspend fun (T) data flow format -
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 ??? -
DataSource Use case ViewModel External World User Interface suspend fun
(T) suspend fun (T) Flow<T> -
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> -
Started Loading Success Error interaction (suspend wrapper) bind( ) suspend
fun resumed suspend fun resumed -
Started Loading Success Error interaction (suspend wrapper) interaction (suspend wrapper)
interaction (suspend wrapper) bind( ) suspend fun resumed suspend fun resumed -
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>() } -
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>() } -
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 } -
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) } } -
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) } } -
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) } } -
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" } } -
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() } } -
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 } } } -
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 } -
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) } } } -
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) } } } -
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) } } } -
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() } } -
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) } } -
State Machine TaskExecutor StateContainer collaborates with class StateMachine<T>( private val
container: StateContainer<T>, private val executor: TaskExecutor ) { // Implementation } -
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 -
State Machine TaskExecutor StateContainer collaborates with The task executor defines
a model of execution for the state transition : either Concurrent or Synchronous -
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) } } } } -
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) } } } } -
Executor Coroutine (runs the suspend function Scope = AAC ViewModel
Consumer Coroutine (consume the states) Scope = Activity Lifecycle Channel -
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) } -
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 -
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) } -
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> } } } -
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) } } } -
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") ) -
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") } } -
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`() { -
@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 !" -
afterCollect { emissions !" &' Then val viewStates = listOf(
FirstLaunch, Loading.FromEmpty, Failed(UnexpectedResponse) ) assertThat(emissions).isEqualTo(viewStates) } } } } -
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) } } } -
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) } } } -
UBIRATAN SOARES Computer Scientist by ICMC/USP Software Engineer, curious guy
Google Developer Expert for Android / Kotlin Teacher, speaker, etc, etc