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

Droidcon NYC: Demystifying Molecule

Ash Davies
September 01, 2022

Droidcon NYC: Demystifying Molecule

Molecule is a library for turning Composables into Flows. But how does that happen? And why would you want to do such a thing? And why *not*? In this talk, Ash and Bill will dive a bit into how Molecule does what it does, and help you understand where, when, and how you should use it.

Ash Davies
twitter.com/askashdavies

Bill Phillips
twitter.com/billjings

Ash Davies

September 01, 2022
Tweet

More Decks by Ash Davies

Other Decks in Programming

Transcript

  1. Demystifying Molecule Running Your Own Compositions for Fun and Profit

    Ash Davies Senior Android Developer Android / Kotlin GDE Berlin Bill Phillips Staff Android Engineer Cash App @askashdavies @billjings Droidcon NYC - Sep 22’ 󰑔 😷
  2. Who We Are Ash Davies 󰏅 󰎲 Android & Kotlin

    GDE Senior Android Developer Snapp Mobile GmbH Thought about writing a book at some point… Bill Phillips 󰑔 Uncredentialed opinionated person • Cash App, SF • Contributor to Molecule • Rx To Coroutines Concepts series • Wrote a book ages ago (Big Nerd Ranch Guide To Android Programming)
  3. @Composable private fun LoginInputView( username: String, password: String, onSubmit: ()->Unit,

    ) { Column(modifier = Modifier.padding(all = 48.dp)) { Text(style = cursiveTextStyle, text = "Login") RhythmSpacer() LabeledTextField(text = username, hidden = false, label = "Username") RhythmSpacer() LabeledTextField(text = password, hidden = true, label = "Password") RhythmSpacer() TextButton(onSubmit, "Login") } }
  4. @Composable private fun LoginInputView( username: String, password: String, onSubmit: ()->Unit,

    ) { Column(modifier = Modifier.padding(all = 48.dp)) { Text(style = cursiveTextStyle, text = "Login") RhythmSpacer() LabeledTextField(text = username, hidden = false, label = "Username") RhythmSpacer() LabeledTextField(text = password, hidden = true, label = "Password") RhythmSpacer() TextButton(onSubmit, "Login") } }
  5. @Composable private fun LoginInputView( username: String, password: String, onSubmit: ()->Unit,

    ) { Column(modifier = Modifier.padding(all = 48.dp)) { Text(style = cursiveTextStyle, text = "Login") RhythmSpacer() LabeledTextField(text = username, hidden = false, label = "Username") RhythmSpacer() LabeledTextField(text = password, hidden = true, label = "Password") RhythmSpacer() TextButton(onSubmit, "Login") } } “billjings” “12345”
  6. @Composable private fun LoginInputView( username: String, password: String, onSubmit: ()->Unit,

    ) { Column(modifier = Modifier.padding(all = 48.dp)) { Text(style = cursiveTextStyle, text = "Login") RhythmSpacer() LabeledTextField(text = username, hidden = false, label = "Username") RhythmSpacer() LabeledTextField(text = password, hidden = true, label = "Password") RhythmSpacer() TextButton(onSubmit, "Login") } } “billjings” “12345”
  7. @Composable private fun LoginInputView( username: String, password: String, onSubmit: ()->Unit,

    ) { Column(modifier = Modifier.padding(all = 48.dp)) { Text(style = cursiveTextStyle, text = "Login") RhythmSpacer() LabeledTextField(text = username, hidden = false, label = "Username") RhythmSpacer() LabeledTextField(text = password, hidden = true, label = "Password") RhythmSpacer() TextButton(onSubmit, "Login") } } “ashdavies” “12345”
  8. @Composable private fun LoginInputView( username: String, password: String, onSubmit: ()->Unit,

    ) { Column(modifier = Modifier.padding(all = 48.dp)) { Text(style = cursiveTextStyle, text = "Login") RhythmSpacer() LabeledTextField(text = username, hidden = false, label = "Username") RhythmSpacer() LabeledTextField(text = password, hidden = true, label = "Password") RhythmSpacer() TextButton(onSubmit, "Login") } } “ashdavies” “12345”
  9. @Composable private fun LoginInputView( username: String, password: String, onSubmit: ()->Unit,

    ) { Column(modifier = Modifier.padding(all = 48.dp)) { Text(style = cursiveTextStyle, text = "Login") RhythmSpacer() LabeledTextField(text = username, hidden = false, label = "Username") RhythmSpacer() LabeledTextField(text = password, hidden = true, label = "Password") RhythmSpacer() TextButton(onSubmit, "Login") } }
  10. @Composable private fun LoginInputView( username: String, password: String, onSubmit: ()->Unit,

    ) { Column(modifier = Modifier.padding(all = 48.dp)) { Text(style = cursiveTextStyle, text = "Login") RhythmSpacer() val currentUsername = remember { mutableStateOf(username) } val currentPassword = remember { mutableStateOf(password) } LabeledTextField(text = username, hidden = false, label = "Username") RhythmSpacer() LabeledTextField(text = password, hidden = true, label = "Password") RhythmSpacer() TextButton(onSubmit, "Login") } }
  11. @Composable private fun LoginInputView( username: String, password: String, onSubmit: ()->Unit,

    ) { Column(modifier = Modifier.padding(all = 48.dp)) { Text(style = cursiveTextStyle, text = "Login") RhythmSpacer() val currentUsername = remember { mutableStateOf(username) } val currentPassword = remember { mutableStateOf(password) } LabeledTextField(state = currentUsername, hidden = false, label = "Username") RhythmSpacer() LabeledTextField(state = currentPassword, hidden = true, label = "Password") RhythmSpacer() TextButton(onSubmit, "Login") } }
  12. @Composable private fun LoginInputView( username: String, password: String, onSubmit: (String,

    String)->Unit, ) { Column(modifier = Modifier.padding(all = 48.dp)) { Text(style = cursiveTextStyle, text = "Login") RhythmSpacer() val currentUsername = remember { mutableStateOf(username) } val currentPassword = remember { mutableStateOf(password) } LabeledTextField(state = currentUsername, hidden = false, label = "Username") RhythmSpacer() LabeledTextField(state = currentPassword, hidden = true, label = "Password") RhythmSpacer() TextButton({ onSubmit(currentUsername.value, currentPassword.value)}, "Login") } }
  13. @Composable private fun LoginInputView( username: String, password: String, onSubmit: (String,

    String)->Unit, ) { Column(modifier = Modifier.padding(all = 48.dp)) { Text(style = cursiveTextStyle, text = "Login") RhythmSpacer() val currentUsername = remember { mutableStateOf(username) } val currentPassword = remember { mutableStateOf(password) } LabeledTextField(state = currentUsername, hidden = false, label = "Username") RhythmSpacer() LabeledTextField(state = currentPassword, hidden = true, label = "Password") RhythmSpacer() TextButton({ onSubmit(currentUsername.value, currentPassword.value)}, "Login") } }
  14. @Composable private fun LoginView( sessionService: SessionService, goTo: (Screen) *> Unit,

    ) { val username = remember { mutableStateOf("") } val password = remember { mutableStateOf("") } var click by remember { mutableStateOf<Int?>(null) } if (click *= null) { LoginInputView(username, password, onSubmit = { click = (click *: 0) + 1 }) } else { LaunchedEffect(click) { when (val result = sessionService.login(username.value, password.value)) { LoginResult.Success *> goTo(LoggedInScreen(username.value)) is LoginResult.Failure *> goTo(ErrorScreen(result.throwable.message *: "Failed to login")) } } ProgressView() } }
  15. @Composable private fun LoginView( sessionService: SessionService, goTo: (Screen) *> Unit,

    ) { val username = remember { mutableStateOf("") } val password = remember { mutableStateOf("") } var click by remember { mutableStateOf<Int?>(null) } if (click *= null) { LoginInputView(username, password, onSubmit = { click = (click *: 0) + 1 }) } else { LaunchedEffect(click) { when (val result = sessionService.login(username.value, password.value)) { LoginResult.Success *> goTo(LoggedInScreen(username.value)) is LoginResult.Failure *> goTo(ErrorScreen(result.throwable.message *: "Failed to login")) } } ProgressView() } }
  16. @Composable private fun LoginView( sessionService: SessionService, goTo: (Screen) *> Unit,

    ) { val username = remember { mutableStateOf("") } val password = remember { mutableStateOf("") } var click by remember { mutableStateOf<Int?>(null) } if (click *= null) { LoginInputView(username, password, onSubmit = { click = (click *: 0) + 1 }) } else { LaunchedEffect(click) { when (val result = sessionService.login(username.value, password.value)) { LoginResult.Success *> goTo(LoggedInScreen(username.value)) is LoginResult.Failure *> goTo(ErrorScreen(result.throwable.message *: "Failed to login")) } } ProgressView() } } Can’t validate ANYTHING
  17. @Composable private fun LoginView( sessionService: SessionService, goTo: (Screen) *> Unit,

    ) { val username = remember { mutableStateOf("") } val password = remember { mutableStateOf("") } var click by remember { mutableStateOf<Int?>(null) } if (click *= null) { LoginInputView(username, password, onSubmit = { click = (click *: 0) + 1 }) } else { LaunchedEffect(click) { when (val result = sessionService.login(username.value, password.value)) { LoginResult.Success *> goTo(LoggedInScreen(username.value)) is LoginResult.Failure *> goTo(ErrorScreen(result.throwable.message *: "Failed to login")) } } ProgressView() } } Can’t validate ANYTHING Without validating EVERYTHING
  18. sealed class LoginUiModel { object Loading : LoginUiModel() object Content:

    LoginUiModel() } sealed class LoginUiEvent { data class Submit(val username: String, val password: String): LoginUiEvent() }
  19. @Composable private fun LoginView(model: LoginUiModel, onEvent: (LoginUiEvent)*>Unit) { when (model)

    { is LoginUiModel.Loading *> ProgressView() is LoginUiModel.Content *> { LoginInputView(onSubmit = { login -> onEvent(LoginUiEvent.Submit(login.username, login.password) ) }) } } }
  20. @Suppress("DEPRECATION") class CallbackLoginPresenter( private val service: SessionService, private val goTo:

    (Screen) *> Unit, ) { var onModel: (LoginUiModel) *> Unit = {} var task: AsyncTask<Submit,Void,LoginResult>? = null fun start() { onModel(Content) } fun stop() { task*.cancel(true) } fun onEvent(event: LoginUiEvent) { when (event) { is Submit *> task = LoginAsyncTask() .also { it.execute(event) } } } **. }
  21. @Suppress("DEPRECATION") class CallbackLoginPresenter( private val service: SessionService, private val goTo:

    (Screen) *> Unit, ) { **. inner class LoginAsyncTask : AsyncTask<Submit,Void,LoginResult>() { private var username: String = "" override fun doInBackground(vararg events: Submit?): LoginResult { val event = events[0]*! username = event.username return runBlocking { service.login(event.username, event.password) } } override fun onPostExecute(result: LoginResult?) { when (result) { is Success *> goTo(LoggedInScreen(username)) is Failure *> goTo(ErrorScreen(result.throwable*.message *: "")) else *> {} } } } }
  22. Reactive Programming RxJava @askashdavies @billjings RxJava in 2022? Srsly? •

    Reactive Pipelines (Push Updates) • Explicit thread handling • Inline error-handling • Lifecycle awareness
  23. class RxLoginPresenter(val service: SessionService, val goTo: (Screen) *> Unit) {

    fun present(events: Observable<LoginUiEvent>): Observable<LoginUiModel> = events.flatMap<LoginUiModel> { event *> when (event) { is Submit *> service.loginSingle(event.username, event.password).toObservable().map { result *> when (result) { is Failure *> goTo(ErrorScreen(result.throwable*.message *: "Something went wrong")) is Success *> goTo(LoggedInScreen(event.username)) } Loading }.startWith(Loading) } }.startWith(LoginUiModel.Content) }
  24. Observable .fromIterable(resourceDraft.getResources()) .flatMap(resourceServiceApiClient*:createUploadContainer) .zipWith(Observable.fromIterable(resourceDraft.getResources()), Pair*:create) .flatMap(uploadResources()) .toList() .toObservable() .flatMapMaybe(resourceCache.getResourceCachedItem()) .defaultIfEmpty(Resource.getDefaultItem())

    .flatMap(postResource(resourceId, resourceDraft.getText(), currentUser, getIntent())) .observeOn(AndroidSchedulers.mainThread()) .subscribeOn(Schedulers.io()) .subscribe( resource *> repository.setResource(resourceId, resource, provisionalResourceId), resourceUploadError(resourceId, resourceDraft, provisionalResourceId) ); Well that escalated quickly...
  25. kotlin.coroutines Imperative Strikes Back @askashdavies @billjings • Native Library •

    Less Yoda code (startWith) • suspend fun rules • Observable -> Flow
  26. class CoLoginPresenter( private val sessionService: SessionService, private val goTo: (Screen)

    *> Unit, ) { fun present(events: Flow<LoginUiEvent>): Flow<LoginUiModel> = flow { emit(LoginUiModel.Content) val loginEvent = events.filterIsInstance<LoginUiEvent.Submit>().first() emit(LoginUiModel.Loading) val result = sessionService.login(loginEvent.username, loginEvent.password) when (result) { is LoginResult.Success *> goTo(LoggedInScreen(loginEvent.username)) is LoginResult.Failure *> goTo(ErrorScreen(result.throwable*.message *: "Hmm")) } } }
  27. class BigCombinePresenter( private val connectivity: ConnectivityManager, private val session: SessionService,

    private val goTo: (Screen) *> Unit, ) { suspend fun present(events: Flow<LoginUiEvent>, emit: (LoginUiModel) *> Unit) { combine(connectivity.isActive(), session.sessionStatus(), events) { isActive, status, event *> if (isActive) { emit(LoginUiModel.Content) val loginEvent = events.filterIsInstance<LoginUiEvent.Submit>().first() emit(LoginUiModel.Loading) if (status *= SessionStatus.Active) { /** **. */
  28. Compose != Compose UI Kotti knows it’s true, and so

    do you Kotti @askashdavies @billjings
  29. Compose...? The Last Framework? @askashdavies @billjings • Can consume flows

    • Compositional state (no more ever-expanding combines!) • Declarative job management with LaunchedEffect
  30. @Composable fun UiModel(events: Flow<LoginUiEvent>): LoginUiModel { var login by remember

    { mutableStateOf<Submit?>(null) } LaunchedEffect(events) { events.filterIsInstance<Submit>().collect { login = it } } return if (login *= null) { LaunchedEffect(login) { when (val result = sessionService.login(login*!.username, login*!.password)) { Success *> goTo(LoggedInScreen(login*!.username)) is Failure *> goTo(ErrorScreen(result.throwable.message *: "Failed to login")) } } Loading } else { Content } }
  31. But how? @askashdavies @billjings • How do we test it?

    • How do we fit it into our architecture?
  32. fun integersFlow() = flow { emit(0) for (i in 1*.5)

    { delay(1000) emit(i) } } @Composable fun integersComposable(): Int { var output by remember { mutableStateOf(0) } LaunchedEffect(Unit) { for (i in 1*.5) { delay(1000) output = i } } return output }
  33. val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start

    = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val snapshotHandle = Snapshot.registerGlobalWriteObserver { Snapshot.sendApplyNotifications() }
  34. val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start

    = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val sendApplyNotification = Channel<Unit>(CONFLATED) launch { for (notification in sendApplyNotification) { Snapshot.sendApplyNotifications() } } val snapshotHandle = Snapshot.registerGlobalWriteObserver { Snapshot.sendApplyNotifications() }
  35. val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start

    = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val sendApplyNotification = Channel<Unit>(CONFLATED) launch { for (notification in sendApplyNotification) { Snapshot.sendApplyNotifications() } } val snapshotHandle = Snapshot.registerGlobalWriteObserver { sendApplyNotification.trySend(Unit) }
  36. val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start

    = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val sendApplyNotification = Channel<Unit>(CONFLATED) launch { for (notification in sendApplyNotification) { Snapshot.sendApplyNotifications() } } val snapshotHandle = Snapshot.registerGlobalWriteObserver { sendApplyNotification.trySend(Unit) } composition.setContent { val output = content() }
  37. val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start

    = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val sendApplyNotification = Channel<Unit>(CONFLATED) launch { for (notification in sendApplyNotification) { Snapshot.sendApplyNotifications() } } val snapshotHandle = Snapshot.registerGlobalWriteObserver { sendApplyNotification.trySend(Unit) } composition.setContent { val output = content() callback(output) }
  38. val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start

    = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val sendApplyNotification = Channel<Unit>(CONFLATED) launch { for (notification in sendApplyNotification) { Snapshot.sendApplyNotifications() } } val snapshotHandle = Snapshot.registerGlobalWriteObserver { sendApplyNotification.trySend(Unit) } composition.setContent { val output = content() callback(output) } coroutineContext.job.invokeOnCompletion { composition.dispose() snapshotHandle.dispose() }
  39. val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start

    = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val sendApplyNotification = Channel<Unit>(CONFLATED) launch { for (notification in sendApplyNotification) { Snapshot.sendApplyNotifications() } } val snapshotHandle = Snapshot.registerGlobalWriteObserver { sendApplyNotification.trySend(Unit) } composition.setContent { val output = content() callback(output) } coroutineContext.job.invokeOnCompletion { composition.dispose() snapshotHandle.dispose() } That’s Molecule!
  40. val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start

    = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val sendApplyNotification = Channel<Unit>(CONFLATED) launch { for (notification in sendApplyNotification) { Snapshot.sendApplyNotifications() } } val snapshotHandle = Snapshot.registerGlobalWriteObserver { sendApplyNotification.trySend(Unit) } composition.setContent { val output = content() callback(output) } coroutineContext.job.invokeOnCompletion { composition.dispose() snapshotHandle.dispose() } That’s Molecule!
  41. val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start

    = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val sendApplyNotification = Channel<Unit>(CONFLATED) launch { for (notification in sendApplyNotification) { Snapshot.sendApplyNotifications() } } val snapshotHandle = Snapshot.registerGlobalWriteObserver { sendApplyNotification.trySend(Unit) } composition.setContent { val output = content() callback(output) } coroutineContext.job.invokeOnCompletion { composition.dispose() snapshotHandle.dispose() } For this to recompose, two things must happen:
  42. val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start

    = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val sendApplyNotification = Channel<Unit>(CONFLATED) launch { for (notification in sendApplyNotification) { Snapshot.sendApplyNotifications() } } val snapshotHandle = Snapshot.registerGlobalWriteObserver { sendApplyNotification.trySend(Unit) } composition.setContent { val output = content() callback(output) } coroutineContext.job.invokeOnCompletion { composition.dispose() snapshotHandle.dispose() } For this to recompose, two things must happen: 1. content() must be dirty (e.g. new snapshot state)
  43. val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start

    = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val sendApplyNotification = Channel<Unit>(CONFLATED) launch { for (notification in sendApplyNotification) { Snapshot.sendApplyNotifications() } } val snapshotHandle = Snapshot.registerGlobalWriteObserver { sendApplyNotification.trySend(Unit) } composition.setContent { val output = content() callback(output) } coroutineContext.job.invokeOnCompletion { composition.dispose() snapshotHandle.dispose() } For this to recompose, two things must happen: 1. content() must be dirty (e.g. new snapshot state) 2. Frame clock must be ticked
  44. val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start

    = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val sendApplyNotification = Channel<Unit>(CONFLATED) launch { for (notification in sendApplyNotification) { Snapshot.sendApplyNotifications() } } val snapshotHandle = Snapshot.registerGlobalWriteObserver { sendApplyNotification.trySend(Unit) } composition.setContent { val output = content() callback(output) } coroutineContext.job.invokeOnCompletion { composition.dispose() snapshotHandle.dispose() } Must have a frame clock For this to recompose, two things must happen: 1. content() must be dirty (e.g. new snapshot state) 2. Frame clock must be ticked
  45. fun <T> moleculeFlow( clock: RecompositionClock, body: @Composable () *> T,

    ): Flow<T> fun <T> CoroutineScope.launchMolecule( clock: RecompositionClock, body: @Composable () *> T, ): StateFlow<T>
  46. Pros/cons Composables • “Are” a StateFlow • Composable state Flows

    • Defined number of items • Meaning is (mostly) not contextual
  47. @Test fun withContextClock() = runBlocking { val goTos = Channel<Screen>(UNLIMITED)

    val sessionService = FakeSessionService() val events = MutableSharedFlow<LoginUiEvent>() val username = "username" val password = "password" val clock = BroadcastFrameClock() val presenter = LoginPresenter(sessionService, goTos*:trySend) withContext(clock) { moleculeFlow(RecompositionClock.ContextClock) { presenter.UiModel(events) }.test { ... } } }
  48. @Test fun withContextClock() = runBlocking { val goTos = Channel<Screen>(UNLIMITED)

    val sessionService = FakeSessionService() val events = MutableSharedFlow<LoginUiEvent>() val username = "username" val password = "password" val clock = BroadcastFrameClock() val presenter = LoginPresenter(sessionService, goTos*:trySend) withContext(clock) { moleculeFlow(RecompositionClock.ContextClock) { presenter.UiModel(events) }.test { */ Fire up initial LaunchedEffects (if any) yield() assertEquals(LoginUiModel.Content, awaitItem()) events.emit(LoginUiEvent.Submit(username, password)) */ push event into composition yield() */ wait for recomposer to request a new frame yield() clock.sendFrame(0) val nextItem = awaitItem() assertEquals(LoginUiModel.Loading, nextItem) ... }
  49. @Test fun works() = runBlocking { val goTos = Channel<Screen>(UNLIMITED)

    val sessionService = FakeSessionService() val events = MutableSharedFlow<LoginUiEvent>() val username = "username" val password = "password" val presenter = LoginPresenter(sessionService, goTos*:trySend) moleculeFlow(RecompositionClock.Immediate) { presenter.UiModel(events) }.test { */ write a unit test! } }
  50. @Test fun works() = runBlocking { val goTos = Channel<Screen>(UNLIMITED)

    val sessionService = FakeSessionService() val events = MutableSharedFlow<LoginUiEvent>() val username = "username" val password = "password" val presenter = LoginPresenter(sessionService, goTos*:trySend) moleculeFlow(RecompositionClock.Immediate) { presenter.UiModel(events) }.test { */ Fire up initial LaunchedEffects (if any) yield() } }
  51. @Test fun works() = runBlocking { val goTos = Channel<Screen>(UNLIMITED)

    val sessionService = FakeSessionService() val events = MutableSharedFlow<LoginUiEvent>() val username = "username" val password = "password" val presenter = LoginPresenter(sessionService, goTos*:trySend) moleculeFlow(RecompositionClock.Immediate) { presenter.UiModel(events) }.test { */ Fire up initial LaunchedEffects (if any) yield() assertEquals(LoginUiModel.Content, awaitItem()) events.emit(LoginUiEvent.Submit(username, password)) assertEquals(LoginUiModel.Loading, awaitItem()) assertEquals(LoginAttempt(username, password), sessionService.loginAttempts.awaitValue()) sessionService.loginResults.trySend(LoginResult.Success) assertEquals(LoggedInScreen(username), goTos.awaitValue()) } }
  52. class LoginViewModel(private val service: SessionService) : ViewModel() { var viewState

    by mutableStateOf<LoginResult?>(null) private set fun login(username: String, password: String) { viewModelScope.launch { viewState = service.login( username = username, password = password ) } } }
  53. class MoleculeViewModel(private val presenter: LoginPresenter) : ViewModel() { fun present(events:

    Flow<LoginUiEvent>): StateFlow<LoginUiModel> = viewModelScope.launchMolecule(RecompositionClock.ContextClock) { presenter.UiModel(events) } } @Composable fun LoginScreen(viewModel: MoleculeViewModel = viewModel()) { val events = remember { MutableSharedFlow<LoginUiEvent>() } val viewState: LoginUiModel? by viewModel .present(events) .collectAsState() }
  54. @Parcelize object PetListScreen : Screen { sealed interface State :

    Parcelable { @Parcelize object Loading : State @Parcelize object Success : State } } val circuit: Circuit = ** **. */ val navigator = circuit.navigator( { content *> setContent { StarTheme { content() } } }, onBackPressedDispatcher*:onBackPressed, ) navigator.goTo(PetListScreen)
  55. class RootComponent(context: ComponentContext) : Root, ComponentContext { private val navigation

    = StackNavigation<Config>() override val childStack = childStack(** **. */) fun createChild(config: Config, context: ComponentContext): Child = when (config) { is Config.List *> Child.List(itemList(context)) is Config.Details *> ** **. */ } private fun itemList(context: ComponentContext): ItemList = ItemListComponent(context) { navigation.push(Config.Details(itemId = it)) } } private sealed class Config : Parcelable { @Parcelize object List : Config() @Parcelize data class Details(val itemId: Long) : Config() }
  56. “Every existing thing is born without reason, prolongs itself out

    of weakness, and dies by chance.” - Jean-Paul Sartre
  57. Thanks! Ash Davies Senior Android Developer Android / Kotlin GDE

    Berlin Bill Phillips Supreme Android Overlord Cash App @askashdavies @billjings Sam dog
  58. Demystifying Molecule Links • State of Managing State with Compose

    [code.cash.app/the-state-of-managing-state-with-compose] • Crouching Theme Hidden DI [code.cash.app/crouching-theme-hidden-di] • Do iiiiiiit. [twitter.com/billjings/status/1514772865869967370] • Feature [monkeyuser.com] @askashdavies @billjings