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

Droidcon London: Demystifying Molecule

Droidcon London: Demystifying Molecule

Molecule is a library for turning Composables into Flows. But how does that happen? What are the advantages and disadvantages to building and running your own Compositions, and where to even start?

In this talk we’ll cover the history of reactive design patterns with Kotlin, Coroutines, Compose, and how Molecule gives you the ability to compose your own business logic into functional units. How you might use this for unit testing, and how the role of architecture components fits into the equation.

We’ll show you why you might want to do such a thing, and where it might make sense not to. How Molecule works under the hood, and help you to understand where, when and how you should use it.

Ash Davies

October 28, 2022
Tweet

More Decks by Ash Davies

Other Decks in Programming

Transcript

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

    Droidcon London - Oct 22’ 󰏅 Ash Davies Android / Kotlin GDE - Berlin @askashdavies @[email protected]
  2. None
  3. Compose UI A Brief Introduction @[email protected]

  4. @Composable fun LoginInputView(email: String, pass: String, onSubmit: () *> Unit)

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

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

    { Column(modifier = Modifier.padding(all = 48.dp)) { Text(style = cursiveTextStyle, text = "Login") RhythmSpacer() LabeledTextField(text = email, hidden = false, label = "Username") RhythmSpacer() LabeledTextField(text = pass, hidden = true, label = "Password") RhythmSpacer() TextButton(onSubmit, "Login") } } @Composable fun LoginInputView(email: String, pass: String, onSubmit: () *> Unit) { val currentUsername = remember { mutableStateOf(email) } val currentPassword = remember { mutableStateOf(pass) } Column(modifier = Modifier.padding(all = 48.dp)) { Text(style = cursiveTextStyle, text = "Login") RhythmSpacer() LabeledTextField(text = email, hidden = false, label = "Username") RhythmSpacer() LabeledTextField(text = pass, hidden = true, label = "Password") RhythmSpacer() TextButton(onSubmit, "Login") } } @Composable fun LoginInputView(email: String, pass: String, onSubmit: () *> Unit) { val currentUsername = remember { mutableStateOf(email) } val currentPassword = remember { mutableStateOf(pass) } Column(modifier = Modifier.padding(all = 48.dp)) { Text(style = cursiveTextStyle, text = "Login") RhythmSpacer() LabeledTextField(state = currentUsername, hidden = false, label = "Username") RhythmSpacer() LabeledTextField(state = currentPassword, hidden = true, label = "Password") RhythmSpacer() TextButton(onSubmit, "Login") } } @Composable fun LoginInputView(email: String, pass: String, onSubmit: (String, String) *> Unit) { val currentUsername = remember { mutableStateOf(email) } val currentPassword = remember { mutableStateOf(pass) } Column(modifier = Modifier.padding(all = 48.dp)) { Text(style = cursiveTextStyle, text = "Login") RhythmSpacer() LabeledTextField(state = currentUsername, hidden = false, label = "Username") RhythmSpacer() LabeledTextField(state = currentPassword, hidden = true, label = "Password") RhythmSpacer() TextButton( { onSubmit(currentUsername.value, currentPassword.value) }, "Login" ) } }
  7. None
  8. @Composable private fun LoginView(service: 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 = service.login(username.value, password.value)) { LoginResult.Success *> goTo(LoggedInScreen(username.value)) is LoginResult.Failure *> goTo(ErrorScreen("${result.throwable.message}")) } } ProgressView() } } Can’t validate ANYTHING Without validating EVERYTHING
  9. sealed class LoginUiModel { object Loading : LoginUiModel() object Content:

    LoginUiModel() } sealed class LoginUiEvent { data class Submit( val username: String, val password: String, ): LoginUiEvent() }
  10. @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) ) }) } } }
  11. class LoginPresenter( private val sessionService: SessionService, private val goTo: (Screen)

    *> Unit, ) { */ Implement some business logic here }
  12. Molecular Motivation Demystifying Molecule @[email protected]

  13. @Suppress("DEPRECATION") class CallbackLoginPresenter(val service: SessionService, 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) } } 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 *> Unit } } }
  14. Reactive Programming RxJava RxJava in 2022? Srsly? • Reactive Pipelines

    (Push Updates) • Explicit thread handling • Inline error-handling • Lifecycle awareness @askashdavies
  15. None
  16. class RxLoginPresenter(val service: SessionService, val goTo: (Screen) *> Unit) {

    fun present(events: Observable<LoginUiEvent>) = 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) }
  17. 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...
  18. kotlin.coroutines Imperative Strikes Back • Native Library • Less Yoda

    code (startWith) • suspend fun rules • Observable -> Flow @[email protected]
  19. class CoLoginPresenter(val sessionService: SessionService, 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}")) } } }
  20. class BigCombinePresenter( val connectivity: ConnectivityManager, val session: SessionService, 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) { /** **. */
  21. Compose != Compose UI Kotti knows it’s true, and so

    should you Kotti @[email protected]
  22. Compose...? The Last Framework? • Can consume flows • Compositional

    state (no more ever-expanding combines!) • Declarative job management with LaunchedEffect @[email protected]
  23. @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 } }
  24. But how? • How do we test the presenters? •

    How do we fit it into our architecture? @[email protected]
  25. Molecule: Turning Compositions Into Flows Demystifying Molecule @[email protected]

  26. fun integersFlow() = flow { emit(0) for (i in 1*.5)

    { delay(1000) emit(i) } }
  27. fun integersFlow() = flow { emit(0) for (i in 1*.5)

    { delay(1000) emit(i) } } @Composable fun integersComposable(): Int { val output by produceState(0) { for (i in 1*.5) { delay(1000) value = i } } return output }
  28. val recomposer = Recomposer(coroutineContext)

  29. val recomposer = Recomposer(coroutineContext) val composition = Composition(applier, compositionContext)

  30. val recomposer = Recomposer(coroutineContext) val composition = Composition(applier, recomposer)

  31. val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer)

  32. val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start

    = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() }
  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() } For this to recompose, two things must happen:
  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: 1. content() must be dirty (e.g. new snapshot state)
  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) 2. Frame clock must be ticked
  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() } 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
  44. fun <T> CoroutineScope.launchMolecule( clock: RecompositionClock, body: @Composable () *> T,

    ): StateFlow<T> fun <T> moleculeFlow( clock: RecompositionClock, body: @Composable () *> T, ): Flow<T>
  45. Turbine github.com/cashapp/turbine flowOf("one", "two").test { assertEquals("one", awaitItem()) assertEquals("two", awaitItem()) awaitComplete()

    } @[email protected]
  46. Pros / Cons Composables • “Are” a StateFlow • Composable

    state Flows • Defined number of items • Meaning is (mostly) not contextual @[email protected]
  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 (username, password) =

    "username" to "password" val events = MutableSharedFlow<LoginUiEvent>() val goTos = Channel<Screen>(UNLIMITED) val clock = BroadcastFrameClock() val presenter = LoginPresenter(FakeSessionService(), goTos*:trySend) withContext(clock) { moleculeFlow(RecompositionClock.ContextClock) { presenter.UiModel(events) }.test { yield() */ Fire up initial LaunchedEffects (if any) assertEquals(LoginUiModel.Content, awaitItem()) events.emit(LoginUiEvent.Submit(username, password)) yield() */ push event into composition yield() */ wait for recomposer to request a new frame 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() 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()) } }
  51. Role of Architecture Demystifying Molecule @[email protected]

  52. Jetpack ViewModel

  53. View Lifecycle Role of Architecture @[email protected]

  54. 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 ) } } }
  55. 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() }
  56. <activity android:configChanges="colorMode|density|fontScale|keyboard| keyboardHidden|layoutDirection|locale|mcc|mnc|navigation|orientation| screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode" *> https://developer.android.com/guide/topics/resources/runtime-changes#HandlingTheChange

  57. @Composable fun <T> rememberSaveable( vararg inputs: Any?, stateSaver: Saver<T, out

    Any>, key: String? = null, init: () *> MutableState<T> ): MutableState<T> = rememberSaveable( *inputs, saver = mutableStateSaver(stateSaver), key = key, init = init ) https://developer.android.com/jetpack/compose/state
  58. slackhq.github.io/circuit/ 🚧 Circuit @[email protected]

  59. @Parcelize object PetListScreen : Screen { sealed interface State :

    Parcelable { @Parcelize object Loading : State @Parcelize object Success : State } } val backstack = rememberSaveableBackStack { push(PetListScreen) } val navigator = rememberCircuitNavigator( onRootPop = onBackPressedDispatcher*:onBackPressed, backstack = backstack, ) navigator.goTo(** **. */)
  60. arkivanov.github.io/Decompose/child-stack/overview Decompose @[email protected]

  61. 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() }
  62. “Every existing thing is born without reason, prolongs itself out

    of weakness, and dies by chance.” - Jean-Paul Sartre
  63. Demystifying Molecule github.com/ashdavies/demystifying-molecule Py CENSORED @[email protected]

  64. Ash Davies Android & Kotlin GDE Berlin @askashdavies Thanks! Kotti

    Follow for more cat pics! @[email protected]
  65. Demystifying Molecule Links • Demystifying Molecule - DC NYC [droidcon.com/2022/09/29/demystifying-molecule-running-your-own-compositions-for-fun-and-profit/]

    • 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] @[email protected]