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

Molecule: Using Compose for presentation logic

Chris Horner
September 27, 2022

Molecule: Using Compose for presentation logic

Jetpack Compose can be used for more than just emitting a user interface. Molecule is a library that allows `Flow` or `StateFlow` streams to be built using Compose.

This talk covers:
- Some historical approaches to presentation logic on Android
- Comparing Compose to Rx and Flow APIs
- The benefits Compose can have on readability
- How Molecule helps decouple Compose from Compose UI
- How to build a StateFlow using Compose
- Testing strategies and gotchas

Chris Horner

September 27, 2022
Tweet

More Decks by Chris Horner

Other Decks in Technology

Transcript

  1. What does Molecule enable? @Composable fun models(events: Flow<Event>): Model {

    // Calculate Model here. } Why is this interesting?
  2. class MyActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) {

    button.setOnClickListener { object : AsyncTask() { override fun doInBackground(vararg arg: Void?) { // Update "state" somehow? } } } } } 2010
  3. 2017 Single.just(input) .flatMap { longRunningOp(it) } .map { it.asSomethingElse() }

    .filter { someCheck(it) } .subscribe { … } val result = longRunningOp(input) val output = result.asSomethingElse() return if someCheck(output) output else null
  4. 2017 Single.just(input) .flatMap { longRunningOp(it) } .map { it.asSomethingElse() }

    .filter { someCheck(it) } .subscribeOn(Schedulers.Io) .observeOn(Schedulers.Main) .subscribe { … } val result = longRunningOp(input) val output = result.asSomethingElse() return if someCheck(output) output else null
  5. 2019 fun map(mapper: (T) -> R): Flowable<R> fun flatMapSingle(mapper: (T)

    -> SingleSource<R>): Flowable<R> Mapper Asynchronous Data Streams with Kotlin Flow https://youtu.be/tYcqn48SMT8 Roman Elizarov
  6. 2019 Sync Async fun map(mapper: (T) -> R): Flowable<R> fun

    flatMapSingle(mapper: (T) -> SingleSource<R>): Flowable<R> Mapper Asynchronous Data Streams with Kotlin Flow https://youtu.be/tYcqn48SMT8 Roman Elizarov
  7. fun map(mapper: (T) -> R): Flowable<R> fun flatMapSingle(mapper: (T) ->

    SingleSource<R>): Flowable<R> Mapper 2019 fun filter(predicate: (T) -> Boolean): Flowable<T> fun … 🤯 Predicate Sync Async Sync Async Asynchronous Data Streams with Kotlin Flow https://youtu.be/tYcqn48SMT8 Roman Elizarov Asynchronous Data Streams with Kotlin Flow https://youtu.be/tYcqn48SMT8 Roman Elizarov
  8. Operator Power val queries: Flowable<String> sealed interface Model { object

    Loading : Model data class Loaded( val results: List<Result> ): Model }
  9. Operator Power Mel val queries: Flowable<String> sealed interface Model {

    object Loading : Model data class Loaded( val results: List<Result> ): Model }
  10. Operator Power Mel val queries: Flowable<String> sealed interface Model {

    object Loading : Model data class Loaded( val results: List<Result> ): Model }
  11. Operator Power queries.switchMap { query - > search(query) .delaySubscription(300, TimeUnit.MILLISECONDS)

    .toObservable() .startWith { Model.Loading } } queries.transformLatest { query -> emit(Model.Loading) delay(300) emit(search(query)) }
  12. Operator Power queries.switchMap { query - > search(query) .delaySubscription(300, TimeUnit.MILLISECONDS)

    .toObservable() .startWith { Model.Loading } } queries.transformLatest { query -> emit(Model.Loading) delay(300) emit(search(query)) } 1
  13. Operator Power queries.switchMap { query - > search(query) .delaySubscription(300, TimeUnit.MILLISECONDS)

    .toObservable() .startWith { Model.Loading } } queries.transformLatest { query -> emit(Model.Loading) delay(300) emit(search(query)) } 2
  14. Operator Power queries.switchMap { query - > search(query) .delaySubscription(300, TimeUnit.MILLISECONDS)

    .toObservable() .startWith { Model.Loading } } queries.transformLatest { query -> emit(Model.Loading) delay(300) emit(search(query)) } 3
  15. Operator Power queries.switchMap { query - > search(query) .delaySubscription(300, TimeUnit.MILLISECONDS)

    .toObservable() .startWith { Model.Loading } } queries.transformLatest { query -> emit(Model.Loading) delay(300) emit(search(query)) } 1
  16. Operator Power queries.switchMap { query - > search(query) .delaySubscription(300, TimeUnit.MILLISECONDS)

    .toObservable() .startWith { Model.Loading } } queries.transformLatest { query -> emit(Model.Loading) delay(300) emit(search(query)) } 2
  17. Operator Power queries.switchMap { query - > search(query) .delaySubscription(300, TimeUnit.MILLISECONDS)

    .toObservable() .startWith { Model.Loading } } queries.transformLatest { query -> emit(Model.Loading) delay(300) emit(search(query)) } 3
  18. So suspend + Flow wins. End of story? • suspend

    is great because we can write imperative code • Flow has advantages over Rx because it’s powered by coroutines • It still has many operators to learn • It’s still a slightly different way of writing code
  19. queries .map { ... } .transformLatest { query -> emit(State.Loading)

    delay(300) emit(search(query)) } .flowOn( .. . ) .onEmpty { ... } .catch { .. . } .scan(emptyList()) { list, items -> list + items .map { ... } .zip() } .flatMapMerge(concurrency = 4) { .. . } .distinctUntilChanged()
  20. What are we doing? We’re composing a model in response

    to events and values changing over time.
  21. What if we actually used Compose to build that model?

    Molecule asks the question: What are we doing? We’re composing a model in response to events and values changing over time.
  22. Column Text Row Image @Composable fun UserInterface(model: Model) { var

    someState: Int by remember { mutableStateOf(1) } } Box Image Icon
  23. @Composable fun UserInterface() { var someState: Int by remember {

    mutableStateOf(1) } LaunchedEffect(Unit) { while (true) { delay(1_000) someState ++ } } }
  24. @Composable fun UserInterface() { var someState: Int by remember {

    mutableStateOf(1) } LaunchedEffect(Unit) { while (true) { delay(1_000) someState ++ } } Text(someState.toString()) }
  25. @Composable fun UserInterface() { var someState: Int by remember {

    mutableStateOf(1) } LaunchedEffect(Unit) { while (true) { delay(1_000) someState ++ } } Text(someState.toString()) } Presentation logic
  26. @Composable fun UserInterface() { } sealed interface Model { object

    Loading : Model data class Loaded( val results: List<Result> ) : Model }
  27. @Composable fun UserInterface(queries: Flow<String>) { val models = queries .onStart

    { emit("") } .transformLatest { query -> emit(Model.Loading) } }
  28. @Composable fun UserInterface(queries: Flow<String>) { val models = queries .onStart

    { emit("") } .transformLatest { query -> emit(Model.Loading) delay(300) val results = search(query) emit(Model.Loaded(results)) } }
  29. e terface(queries: Flow<String>) { ls = queries rt { emit("")

    } formLatest { query -> (Model.Loading) y(300) results = search(query) (Model.Loaded(results)) interface Database { fun observeDataset(): Flow<Dataset> }
  30. e terface(queries: Flow<String>) { ls = queries rt { emit("")

    } formLatest { query -> (Model.Loading) y(300) results = search(query, dataset) (Model.Loaded(results)) interface Database { fun observeDataset(): Flow<Dataset> }
  31. @Composable fun UserInterface(queries: Flow<String>) { val models = queries .onStart

    { emit("") } .transformLatest { query -> emit(Model.Loading) delay(300) val results = search(query, dataset) emit(Model.Loaded(results)) } }
  32. @Composable fun UserInterface(queries: Flow<String>, datasets: Flow<Dataset>) { val models =

    queries .onStart { emit("") } .transformLatest { query -> emit(Model.Loading) delay(300) val results = search(query, dataset) emit(Model.Loaded(results)) } }
  33. @Composable fun UserInterface(queries: Flow<String>, datasets: Flow<Dataset>) { combineTransform(queries, datasets) val

    models = queries .onStart { emit("") } .transformLatest { query -> emit(Model.Loading) delay(300) val results = search(query, dataset) emit(Model.Loaded(results)) } } ?
  34. @Composable fun UserInterface(queries: Flow<String>, datasets: Flow<Dataset>) { combineTransformLatest(queries, dataset) val

    models = queries .onStart { emit("") } .transformLatest { query -> emit(Model.Loading) delay(300) val results = search(query, dataset) emit(Model.Loaded(results)) } } ?
  35. @Composable fun UserInterface(queries: Flow<String>, datasets: Flow<Dataset>) { val models =

    datasets.flatMapLatest { dataset -> queries .onStart { emit("") } .transformLatest { query - > emit(Model.Loading) delay(300) val results = search(query, dataset) emit(Model.Loaded(results)) } } }
  36. @Composable fun UserInterface(queries: Flow<String>, datasets: Flow<Dataset>) { val models =

    datasets.flatMapLatest { dataset -> queries .onStart { emit("") } .transformLatest { query - > emit(Model.Loading) delay(300) val results = search(query, dataset) emit(Model.Loaded(results)) } } } What if we used Compose?
  37. @Composable fun UserInterface(queries: Flow<String>, datasets: Flow<Dataset>) { val query by

    queries.collectAsState("") val models = dataset s​ .flatMapLatest { dataset -> queries .onStart { emit("") } .transformLatest { query - > emit(Model.Loading) delay(300) val results = search(query, dataset) emit(Model.Loaded(results)) } } }
  38. @Composable fun UserInterface(queries: Flow<String>, datasets: Flow<Dataset>) { val query by

    queries.collectAsState("") val dataset by datasets.collectAsState(InitialSet) val models = dataset s​ .flatMapLatest { dataset -> queries .onStart { emit("") } .transformLatest { query - > emit(Model.Loading) delay(300) val results = search(query, dataset) emit(Model.Loaded(results)) } } }
  39. @Composable fun UserInterface(queries: Flow<String>, datasets: Flow<Dataset>) { val query by

    queries.collectAsState("") val dataset by datasets.collectAsState(InitialSet) var model by remember { mutableStateOf(Model.Loaded(emptyList())) } val models = dataset s​ .flatMapLatest { dataset -> queries .onStart { emit("") } .transformLatest { query - > emit(Model.Loading) delay(300) val results = search(query, dataset) emit(Model.Loaded(results)) } } }
  40. @Composable fun UserInterface(queries: Flow<String>, datasets: Flow<Dataset>) { val query by

    queries.collectAsState("") val dataset by datasets.collectAsState(InitialSet) var model by remember { mutableStateOf(Model.Loaded(emptyList())) } val models = dataset s​ .flatMapLatest { dataset -> queries .onStart { emit("") } .transformLatest { query - > emit(Model.Loading) delay(300) val results = search(query, dataset) emit(Model.Loaded(results )​ ) } } }
  41. @Composable fun UserInterface(queries: Flow<String>, datasets: Flow<Dataset>) { val query by

    queries.collectAsState("") val dataset by datasets.collectAsState(InitialSet) var model by remember { mutableStateOf(Model.Loaded(emptyList())) } LaunchedEffect(query, dataset) { emit(Model.Loading) delay(300) val results = search(query, dataset) emit(Model.Loaded(results )​ ) } }
  42. @Composable fun UserInterface(queries: Flow<String>, datasets: Flow<Dataset>) { val query by

    queries.collectAsState("") val dataset by datasets.collectAsState(InitialSet) var model by remember { mutableStateOf(Model.Loaded(emptyList())) } LaunchedEffect(query, dataset) { model = Model.Loading delay(300) val results = search(query, dataset) model = Model.Loaded(results) } } Still reactive!
  43. @Composable fun UserInterface(queries: Flow<String>, datasets: Flow<Dataset>) { } • remember

    • LaunchedEffect • collectAsState • mutableStateOf • if, else, when, for, while
  44. @Composable fun UserInterface(queries: Flow<String>, datasets: Flow<Dataset>) { val query by

    queries.collectAsState("") val dataset by datasets.collectAsState(InitialSet) var model by remember { mutableStateOf(Model.Loaded(emptyList())) } LaunchedEffect(query, dataset) { model = Model.Loading delay(300) val results = search(query, dataset) model = Model.Loaded(results) } }
  45. @Composable fun UserInterface(queries: Flow<String>, datasets: Flow<Dataset>) { val query by

    queries.collectAsState("") val dataset by datasets.collectAsState(InitialSet) var model by remember { mutableStateOf(Model.Loaded(emptyList())) } LaunchedEffect(query, dataset) { model = ​ Model.Loadin g​ delay(300) val results = search(query, dataset) model = ​ Model.Loaded(results) } Column { // .. . } }
  46. @Composable fun UserInterface(queries: Flow<String>, datasets: Flow<Dataset>) { val query by

    queries.collectAsState("") val dataset by datasets.collectAsState(InitialSet) var model by remember { mutableStateOf(Model.Loaded(emptyList())) } LaunchedEffect(query, dataset) { model = Model.Loading delay(300) val results = search(query, dataset) model = Model.Loaded(results) } Column { // .. . } } Presentation logic
  47. How do we use it? val scope = CoroutineScope(AndroidUiDispatcher.Main) val

    models: StateFlow<Model> = scope.launchMolecule() { }
  48. How do we use it? val scope = CoroutineScope(AndroidUiDispatcher.Main) val

    models: StateFlow<Model> = scope.launchMolecule() { var model by remember { mutableStateOf(Model(A)) } }
  49. val scope = CoroutineScope(AndroidUiDispatcher.Main) val models: StateFlow<Model> = scope.launchMolecule() {

    var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() } }
  50. val scope = CoroutineScope(AndroidUiDispatcher.Main) val models: StateFlow<Model> = scope.launchMolecule() {

    var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() } model }
  51. val scope = CoroutineScope(AndroidUiDispatcher.Main) val models: StateFlow<Model> = scope.launchMolecule() {

    var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() } model } 1
  52. val scope = CoroutineScope(AndroidUiDispatcher.Main) val models: StateFlow<Model> = scope.launchMolecule() {

    var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() } model } 2
  53. val scope = CoroutineScope(AndroidUiDispatcher.Main) val models: StateFlow<Model> = scope.launchMolecule() {

    var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() } model } 3 A Emissions
  54. val scope = CoroutineScope(AndroidUiDispatcher.Main) val models: StateFlow<Model> = scope.launchMolecule() {

    var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() } model } 4 A Emissions
  55. val scope = CoroutineScope(AndroidUiDispatcher.Main) val models: StateFlow<Model> = scope.launchMolecule() {

    var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() } model } 5 A Emissions
  56. 6 A B val scope = CoroutineScope(AndroidUiDispatcher.Main) val models: StateFlow<Model>

    = scope.launchMolecule() { var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() } model } Emissions
  57. val scope = CoroutineScope(AndroidUiDispatcher.Main) val models: StateFlow<Model> = scope.launchMolecule() {

    var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() model = Model(C) } model } A B Emissions ?
  58. val scope = CoroutineScope(AndroidUiDispatcher.Main) val models: StateFlow<Model> = scope.launchMolecule() {

    var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() model = Model(C) } model } A B C Emissions
  59. val scope = CoroutineScope(AndroidUiDispatcher.Main) val models: StateFlow<Model> = scope.launchMolecule() {

    model } • Some State is invalidated • A MonotonicFrameClock ticks For a new emission, two things must happen Emissions
  60. val scope = CoroutineScope(AndroidUiDispatcher.Main) val models: StateFlow<Model> = scope.launchMolecule() {

    var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() model = Model(C) } model } A B C Emissions
  61. A B C Emissions val scope = CoroutineScope(AndroidUiDispatcher.Main) val models:

    StateFlow<Model> = scope.launchMolecule() { var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() model = Model(C) } model }
  62. A B C Frame tick Emissions val scope = CoroutineScope(AndroidUiDispatcher.Main)

    val models: StateFlow<Model> = scope.launchMolecule() { var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() model = Model(C) } model }
  63. A B C Emissions val scope = CoroutineScope(AndroidUiDispatcher.Main) val models:

    StateFlow<Model> = scope.launchMolecule() { var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() model = Model(C) } model }
  64. A B C Emissions C val scope = CoroutineScope(AndroidUiDispatcher.Main) val

    models: StateFlow<Model> = scope.launchMolecule() { var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() model = Model(C) } model }
  65. A B C Emissions val scope = CoroutineScope(AndroidUiDispatcher.Main) val models:

    StateFlow<Model> = scope.launchMolecule() { var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() model = Model(C) } model }
  66. val scope = CoroutineScope(AndroidUiDispatcher.Main) val models: StateFlow<Model> = scope.launchMolecule() {

    var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() model = Model(C) } model }
  67. val scope = CoroutineScope(AndroidUiDispatcher.Main) val models: StateFlow<Model> = scope.launchMolecule( clock

    = RecompositionClock.ContextClock ) { var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() model = Model(C) } model }
  68. val scope = CoroutineScope(AndroidUiDispatcher.Main) val models: StateFlow<Model> = scope.launchMolecule( clock

    = RecompositionClock.ContextClock ) { var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() model = Model(C) } model }
  69. val scope = CoroutineScope (​ ) val models: StateFlow<Model> =

    scope.launchMolecule( clock = RecompositionClock.ContextClock ) { var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() model = Model(C) } model }
  70. fun unitTest() = runBlocking { val scope = this val

    models: StateFlow<Model> = scope.launchMolecule( clock = RecompositionClock.ContextClock ) { var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() model = Model(C) } model }
  71. fun unitTest() = runBlocking { val scope = this val

    models: StateFlow<Model> = scope.launchMolecule( clock = RecompositionClock.Immediate ) { var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() model = Model(C) } model }
  72. fun unitTest() = runBlocking { val scope = this val

    models: StateFlow<Model> = scope.launchMolecule( clock = RecompositionClock.Immediate ) { var model by remember { mutableStateOf(Model(A)) } LaunchedEffect(Unit) { model = loadModelB() model = Model(C) } model }
  73. ContextClock Immediate • Emissions match Android’s built in frame clock

    • Need to control emissions using BroadcastFrameClock • Unit tests requiring time manipulation Choosing a RecompositionClock • Frames tick automatically when snapshot state changes • Unit tests that don’t require time manipulation
  74. sealed interface Event { data class EnterText(val text: String) :

    Event } sealed interface Model { object Loading : Model data class Loaded( val results: List<Result> = emptyList() ) : Model }
  75. class SearchPresenter( val service: Service ) : Presenter<Event, Model> {

    @Composable fun models(events: Flow<Event>): Model { // Calculate Model here. } } Realistic example
  76. class SearchPresenter( val service: Service ) : Presenter<Event, Model> {

    @Composable fun models(events: Flow<Event>): Model { val modelState = remember { mutableStateOf(Model.Loaded()) } } } Realistic example
  77. class SearchPresenter( val service: Service ) : Presenter<Event, Model> {

    @Composable fun models(events: Flow<Event>): Model { val modelState: MutableState<Model> = remember { mutableStateOf(Mod } } Realistic example
  78. class SearchPresenter( val service: Service ) : Presenter<Event, Model> {

    @Composable fun models(events: Flow<Event>): Model { val modelState = remember { mutableStateOf(Model.Loaded()) } } } Realistic example
  79. class SearchPresenter( val service: Service ) : Presenter<Event, Model> {

    @Composable fun models(events: Flow<Event>): Model { val modelState = remember { mutableStateOf(Model.Loaded()) } return modelState.value } } Realistic example
  80. class SearchPresenter( val service: Service ) : Presenter<Event, Model> {

    @Composable fun models(events: Flow<Event>): Model { val modelState = remember { mutableStateOf(Model.Loaded()) } LaunchedEffect(Unit) { events.collect { event -> when (event) { ... } } } return modelState.value } }
  81. class SearchPresenter( val service: Service ) : Presenter<Event, Model> {

    @Composable fun models(events: Flow<Event>): Model { val modelState = remember { mutableStateOf(Model.Loaded()) } var query by remember { mutableStateOf("") } LaunchedEffect(Unit) { events.collect { event -> when (event) { EnterText -> query = event.text } } } return modelState.value }
  82. @Composable fun models(events: Flow<Event>): Model { val modelState = remember

    { mutableStateOf(Model.Loaded()) } var query by remember { mutableStateOf("") } LaunchedEffect(Unit) { events.collect { event -> when (event) { EnterText -> query = event.text } } } LaunchedEffect(query) { runSearch(query, modelState) } return modelState.value }
  83. private suspend fun runSearch( query: String, state: MutableState<Model>, ) {

    state.value = Model.Loading val results = service.search(query) state.value = Model.Loaded(results) } LaunchedEffect(query) { runSearch(query, modelState) }
  84. private suspend fun runSearch( query: String, state: MutableState<Model>, ) {

    state.value = Model.Loading val results = service.search(query) state.value = Model.Loaded(results) } LaunchedEffect(query) { runSearch(query, modelState) }
  85. private suspend fun runSearch( query: String, state: MutableState<Model>, ) {

    state.value = Model.Loading val results = service.search(query) state.value = Model.Loaded(results) } LaunchedEffect(query) { runSearch(query, modelState) }
  86. Writing a test @Test fun `entering text runs search`() =

    runBlocking { val events = MutableSharedFlow<Event>(replay = 1) }
  87. Writing a test @Test fun `entering text runs search`() =

    runBlocking { val events = MutableSharedFlow<Event>(replay = 1) launchMolecule(RecompositionClock.Immediate) { presenter.models(events) }.test { } }
  88. Writing a test @Test fun `entering text runs search`() =

    runBlocking { val events = MutableSharedFlow<Event>(replay = 1) launchMolecule(RecompositionClock.Immediate) { presenter.models(events) }.test { assertThat(awaitItem()).isEqualTo(Model.Loading) } }
  89. Writing a test @Test fun `entering text runs search`() =

    runBlocking { val events = MutableSharedFlow<Event>(replay = 1) launchMolecule(RecompositionClock.Immediate) { presenter.models(events) }.test { assertThat(awaitItem()).isEqualTo(Model.Loading) events.emit(Event.EnterText("query")) fakeService.setResults( .. . ) } }
  90. Writing a test @Test fun `entering text runs search`() =

    runBlocking { val events = MutableSharedFlow<Event>(replay = 1) launchMolecule(RecompositionClock.Immediate) { presenter.models(events) }.test { assertThat(awaitItem()).isEqualTo(Model.Loading) events.emit(Event.EnterText("query")) fakeService.setResults( .. . ) assertThat(awaitItem()).isEqualTo(Model.Loaded( .. . )) } }
  91. Writing a test @Test fun `entering text runs search`() =

    runBlocking { presenter.test { assertThat(awaitItem()).isEqualTo(Model.Loading) sendEvent(Event.EnterText("query")) fakeService.setResults( .. . ) assertThat(awaitItem()).isEqualTo(Model.Loaded( .. . )) } }
  92. Takeaways • Compose manages a tree of nodes - it

    doesn’t have to be UI • Managing state involves tying together streams • Compose can do to streams what suspend did to Single • State is reactive. Think of it like a stream • There’s still a learning curve, but it’s less steep compared to Rx/Flow • Tricks you’ve learnt in Compose UI work in Molecule too
  93. Interested in more? Building StateFlows with Jetpack Compose droidcon.com/2022/09/29/building-stateflows-in-android-with-jetpack-compose Mohit

    Sarveiya Demystifying Molecule droidcon.com/2022/09/29/demystifying-molecule-running-your-own-compositions-for-fun-and-profit Bill Phillips & Ash Davies Opening the Shutter on Snapshots droidcon.com/2022/09/29/opening-the-shutter-on-snapshots Zach Klippenstein