Slide 1

Slide 1 text

Demystifying Molecule Running Your Own Compositions for Fun and Profit Berlindroid - Nov 22’ Ash Davies Android / Kotlin GDE - Berlin @askashdavies [email protected]

Slide 2

Slide 2 text

No content

Slide 3

Slide 3 text

Compose UI A Brief Introduction [email protected]

Slide 4

Slide 4 text

@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") } }

Slide 5

Slide 5 text

@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")

Slide 6

Slide 6 text

@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" ) } }

Slide 7

Slide 7 text

No content

Slide 8

Slide 8 text

@Composable private fun LoginView(service: SessionService, goTo: (Screen) *> Unit) { val username = remember { mutableStateOf("") } val password = remember { mutableStateOf("") } var click by remember { mutableStateOf(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() } }

Slide 9

Slide 9 text

Cannot validate anything without validating everything Validation [email protected]

Slide 10

Slide 10 text

sealed class LoginUiModel { object Loading : LoginUiModel() object Content: LoginUiModel() } sealed class LoginUiEvent { data class Submit( val username: String, val password: String, ): LoginUiEvent() }

Slide 11

Slide 11 text

@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) ) }) } } }

Slide 12

Slide 12 text

class LoginPresenter( private val sessionService: SessionService, private val goTo: (Screen) *> Unit, ) { */ Implement some business logic here }

Slide 13

Slide 13 text

Molecular Motivation Demystifying Molecule [email protected]

Slide 14

Slide 14 text

@Suppress("DEPRECATION") class CallbackLoginPresenter(val service: SessionService, val goTo: (Screen) *> Unit) { var onModel: (LoginUiModel) *> Unit = {} var task: AsyncTask? = 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() { 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 } } }

Slide 15

Slide 15 text

Reactive Programming RxJava RxJava in 2022? Srsly? ● Reactive Pipelines (Push Updates) ● Explicit thread handling ● Inline error-handling ● Lifecycle awareness [email protected]

Slide 16

Slide 16 text

No content

Slide 17

Slide 17 text

class RxLoginPresenter(val service: SessionService, val goTo: (Screen) *> Unit) { fun present(events: Observable) = events.flatMap { 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) }

Slide 18

Slide 18 text

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...

Slide 19

Slide 19 text

kotlin.coroutines Imperative Strikes Back ● Native Library ● Less Yoda code (startWith) ● suspend fun rules ● Observable -> Flow [email protected]

Slide 20

Slide 20 text

class CoLoginPresenter(val sessionService: SessionService, val goTo: (Screen) *> Unit) { fun present(events: Flow): Flow = flow { emit(LoginUiModel.Content) val loginEvent = events.filterIsInstance().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}")) } } }

Slide 21

Slide 21 text

class BigCombinePresenter( val connectivity: ConnectivityManager, val session: SessionService, val goTo: (Screen) *> Unit, ) { suspend fun present(events: Flow, emit: (LoginUiModel) *> Unit) { combine(connectivity.isActive(), session.sessionStatus(), events) { isActive, status, event *> if (isActive) { emit(LoginUiModel.Content) val loginEvent = events.filterIsInstance().first() emit(LoginUiModel.Loading) if (status *= SessionStatus.Active) { /** **. */

Slide 22

Slide 22 text

Compose != Compose UI Kotti knows it’s true, and so should you Kotti [email protected]

Slide 23

Slide 23 text

Compose...? The Last Framework? ● Can consume flows ● Compositional state (no more ever-expanding combines!) ● Declarative job management with LaunchedEffect [email protected]

Slide 24

Slide 24 text

@Composable fun UiModel(events: Flow): LoginUiModel { var login by remember { mutableStateOf(null) } LaunchedEffect(events) { events.filterIsInstance().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}”)) } } Loading } else { Content } }

Slide 25

Slide 25 text

But how? ● How do we test the presenters? ● How do we fit it into our architecture? [email protected]

Slide 26

Slide 26 text

Molecule: Turning Compositions Into Flows Demystifying Molecule [email protected]

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

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 }

Slide 29

Slide 29 text

Demystifying Molecule val recomposer = Recomposer(coroutineContext) ● Owns recomposition process ● Runs loop with context ● Lives in a Coroutine Recomposer

Slide 30

Slide 30 text

Demystifying Molecule val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) ● Composition builds content ● Pass recomposer as context ● Applier builds slot allocation Composition

Slide 31

Slide 31 text

Demystifying Molecule val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } ● Launch undispatched coroutine ● Runs in background runRecomposeAndApplyChanges()

Slide 32

Slide 32 text

Demystifying Molecule val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val snapshotHandle = Snapshot.registerGlobalWriteObserver { sendApplyNotification.trySend(Unit) } ● Owns mutable composition state ● Send notifications on change ● Triggers often SnapshotState

Slide 33

Slide 33 text

Demystifying Molecule val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val sendApplyNotification = Channel(CONFLATED) launch { for (notification in sendApplyNotification) { Snapshot.sendApplyNotifications() } } val snapshotHandle = Snapshot.registerGlobalWriteObserver { sendApplyNotification.trySend(Unit) } ● Conflated coroutine Channel ● Sends notifications via channel ● Marks state as invalidated Rate Limit

Slide 34

Slide 34 text

Demystifying Molecule val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val sendApplyNotification = Channel(CONFLATED) launch { for (notification in sendApplyNotification) { Snapshot.sendApplyNotifications() } } val snapshotHandle = Snapshot.registerGlobalWriteObserver { sendApplyNotification.trySend(Unit) } ● Conflated coroutine Channel ● Sends notifications via channel ● Marks state as invalidated Rate Limit

Slide 35

Slide 35 text

Demystifying Molecule val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val sendApplyNotification = Channel(CONFLATED) launch { for (notification in sendApplyNotification) { Snapshot.sendApplyNotifications() } } val snapshotHandle = Snapshot.registerGlobalWriteObserver { sendApplyNotification.trySend(Unit) } composition.setContent { val output = content() callback(output) } ● Conflated coroutine Channel ● Sends notifications via channel ● Marks state as invalidated Content

Slide 36

Slide 36 text

Demystifying Molecule val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val sendApplyNotification = Channel(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() } ● Invoke on completion ● Dispose composition ● Dispose snapshot Dispose

Slide 37

Slide 37 text

Demystifying Molecule val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val sendApplyNotification = Channel(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!

Slide 38

Slide 38 text

Demystifying Molecule ● Content must be invalidated ● Context Frame Clock ● Clock independent of state Note: val recomposer = Recomposer(coroutineContext) val composition = Composition(NoOpApplier, recomposer) launch(start = UNDISPATCHED) { recomposer.runRecomposeAndApplyChanges() } val sendApplyNotification = Channel(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() }

Slide 39

Slide 39 text

fun CoroutineScope.launchMolecule( clock: RecompositionClock, body: @Composable () *> T, ): StateFlow fun moleculeFlow( clock: RecompositionClock, body: @Composable () *> T, ): Flow

Slide 40

Slide 40 text

Turbine github.com/cashapp/turbine flowOf("one", "two").test { assertEquals("one", awaitItem()) assertEquals("two", awaitItem()) awaitComplete() } [email protected]

Slide 41

Slide 41 text

Pros / Cons Composables ● “Are” a StateFlow ● Composable state Flows ● Defined number of items ● Meaning is (mostly) not contextual [email protected]

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

@Test fun withContextClock() = runBlocking { val (username, password) = "username" to "password" val events = MutableSharedFlow() val goTos = Channel(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)

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

@Test fun works() = runBlocking { val goTos = Channel(UNLIMITED) val sessionService = FakeSessionService() val events = MutableSharedFlow() 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()) } }

Slide 46

Slide 46 text

Role of Architecture Demystifying Molecule [email protected]

Slide 47

Slide 47 text

Jetpack ViewModel

Slide 48

Slide 48 text

View Lifecycle Role of Architecture [email protected]

Slide 49

Slide 49 text

class LoginViewModel(private val service: SessionService) : ViewModel() { var viewState by mutableStateOf(null) private set fun login(username: String, password: String) { viewModelScope.launch { viewState = service.login( username = username, password = password ) } } }

Slide 50

Slide 50 text

class MoleculeViewModel(private val presenter: LoginPresenter) : ViewModel() { fun present(events: Flow): StateFlow = viewModelScope.launchMolecule(RecompositionClock.ContextClock) { presenter.UiModel(events) } } @Composable fun LoginScreen(viewModel: MoleculeViewModel = viewModel()) { val events = remember { MutableSharedFlow() } val viewState: LoginUiModel? by viewModel .present(events) .collectAsState() }

Slide 51

Slide 51 text

https://developer.android.com/guide/topics/resources/runtime-changes#HandlingTheChange

Slide 52

Slide 52 text

@Composable fun rememberSaveable( vararg inputs: Any?, stateSaver: Saver, key: String? = null, init: () *> MutableState ): MutableState = rememberSaveable( *inputs, saver = mutableStateSaver(stateSaver), key = key, init = init ) https://developer.android.com/jetpack/compose/state

Slide 53

Slide 53 text

slackhq.github.io/circuit/ 🚧 Circuit [email protected]

Slide 54

Slide 54 text

@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(** **. */)

Slide 55

Slide 55 text

arkivanov.github.io/Decompose/child-stack/overview Decompose [email protected]

Slide 56

Slide 56 text

class RootComponent(context: ComponentContext) : Root, ComponentContext { private val navigation = StackNavigation() 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() }

Slide 57

Slide 57 text

“Every existing thing is born without reason, prolongs itself out of weakness, and dies by chance.” Jean-Paul Sartre

Slide 58

Slide 58 text

Demystifying Molecule github.com/ashdavies/demystifying-molecule Py CENSORED [email protected]

Slide 59

Slide 59 text

Ash Davies Android & Kotlin GDE Berlin @askashdavies Thanks! Kotti Follow for more cat pics! [email protected]

Slide 60

Slide 60 text

Demystifying Molecule Demystifying Molecule - DC NYC droidcon.com/2022/09/29/demystifying-molecule-running-your-own-compositions-for-fun-and-profit/ Using Jetpack Compose with Square’s Molecule Library codingwithmohit.com/mobile/jetpack-compose-square-molecule-library 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]