Mohit SarveiyaBuilding State Flows with Compose@heyitsmohit
View Slide
Building State Flows with Compose● How to setup and use Molecule
Building State Flows with Compose● How to setup and use Molecule● Design Patterns
Building State Flows with Compose● How to setup and use Molecule● Design Patterns● Testing with Molecule
Building State Flows with Compose● How to setup and use Molecule● Design Patterns● Testing with Molecule● Molecule Internals
Building State Flows with Compose
Compose vs Compose UI
Compose● General purpose tool for managing tree of nodes.
Compose● General purpose tool for managing tree of nodes.Node can be anything
Compose● Compose Runtime● Kotlin Complier Plugin● State Snapshot System
Compose UI● UI ToolkitViews
Molecule@Composablefun MoleculePresenter(events: Flow,): Model
Molecule@Composablefun MoleculePresenter(events: Flow,): ModelCompose Runtime
Molecule@Composablefun MoleculePresenter(events: Flow,): ModelCompose RuntimeState Flow Flow
val userFlow = db.userObservable()val balanceFlow = db.balanceObservable()
@Composablefun ProfileScreen() {val user by userFlow.subscribeAsState(null)val balance by balanceFlow.subscribeAsState(0L)if (user==null) {Text("Loading…")} else {Text("${user.name} - $balance")}}
@Composablefun ProfileScreen() {val user by userFlow.subscribeAsState(null)val balance by balanceFlow.subscribeAsState(0L)if (user==null) {Text("Loading…")} else {Text("${user.name} - $balance")}}Undesired coupling
@Composablefun ProfileScreen() {val user by userFlow.subscribeAsState(null)val balance by balanceFlow.subscribeAsState(0L)if (user==null) {Text("Loading…")} else {Text("${user.name} - $balance")}}Not Reusable
class ProfilePresenter(val db: Db) {fun transform(events: Flow):Flow {}}
class ProfilePresenter(val db: Db) {fun transform(events: Flow):Flow {}}sealed interface ProfileModel {object Loading : ProfileModeldata class Data(val name: String,val balance: Long,): ProfileModel}
class ProfilePresenter(val db: Db) {fun transform(events: Flow):Flow {}
class ProfilePresenter(val db: Db) {fun transform(events: Flow):Flow {return combine(db.users().onStart { emit(null) },db.balances().onStart { emit(0L) },) { user, balance->}}}
class ProfilePresenter(val db: Db) {fun transform(events: Flow):Flow {return combine(db.users().onStart { emit(null) },db.balances().onStart { emit(0L) },) { user, balance->}}}Can get complex if we add more streams
Molecule@Composablefun MoleculePresenter(...): ModelCompose RuntimeState Flow Flow
@Composablefun ProfilePresenter(userFlow: Flow,balanceFlow: Flow,): ProfileModel {val user by userFlow.collectAsState(null)val balance by balanceFlow.collectAsState(0L)return if (user==null) {Loading} else {Data(user.name, balance)}}
val scope = CoroutineScope(Main)
val scope = CoroutineScope(Main)scope.launchMolecule( clock = RecompositionClock.ContextClock ) {ProfilePresenter(events, randomService)}
val scope = CoroutineScope(Main)val models: StateFlow = scope.launchMolecule( clock = RecompositionClock.ContextClock ) {ProfilePresenter(events, randomService)}
val scope = CoroutineScope(Main) scope.launchMolecule( clock = RecompositionClock.ContextClock, emitter = { value->} ) {ProfilePresenter(events, randomService)}
val scope = CoroutineScope(Main) scope.launchMolecule( clock = RecompositionClock.ContextClock, emitter = { value->} ) {ProfilePresenter(events, randomService)}Will not respect back-pressure
val models: Flow = moleculeFlow( clock = RecompositionClock.ContextClock ) {ProfilePresenter(events, randomService)}
val scope = CoroutineScope(Main) val models: StateFlow = scope.launchMolecule( clock = RecompositionClock.ContextClock ) {ProfilePresenter(events, randomService)}Frame Clock
ComposableRecompositionEnter the composition Leave the composition
ComposableWait for next frameEnter the composition Leave the composition
ComposableWait for next frameEnter the composition Leave the compositionMonotonic Frame Clock
Monotonic Frame ClockPull Based SystemPull Signal
Monotonic Frame ClockPull Based SystemPull SignalChoreographerPerforms Pull for UI
val scope = CoroutineScope(Main) fun CoroutineScope.launchMolecule(...) {with(this + clockContext) { //Pass context element to Recomposer}}
interface MonotonicFrameClock : CoroutineContext.Element {suspend fun withFrameNanos(onFrame): Roverride val key: CoroutineContext.Key<*>get() = Keycompanion object Key : CoroutineContext.Key}
Recomposition Clocks● Context clock ● Immediate
Context Clock● Use MonotonicFrameClock from calling Coroutine Context● Use Molecule with built in Android Frame Clock
fun CoroutineScope.launchMolecule(...) {val clockContext = when (clock) {RecompositionClock.ContextClock->EmptyCoroutineContextRecompositionClock.Immediate->GatedFrameClock(this)}...}
val dispatcher = TestCoroutineDispatcher()val clock = BroadcastFrameClock() Frame clock provided by Compose
val dispatcher = TestCoroutineDispatcher()val clock = BroadcastFrameClock() val scope = CoroutineScope(dispatcher + clock)
var value = 0 scope.launchMolecule(RecompositionClock.ContextClock, { value = it }) {var count by remember { mutableStateOf(0) }LaunchedEffect(Unit) {while (true) {delay(100)count++}}count}
scope.launchMolecule(...) {var count by remember { mutableStateOf(0) }LaunchedEffect(Unit) {while (true) {delay(100)count++}}count}
dispatcher.advanceTimeBy(100)assertEquals(1, value)
scope.launchMolecule(RecompositionClock.ContextClock) {var count by remember { mutableStateOf(0) }LaunchedEffect(Unit) {while (true) {delay(100)count++}}count}
assertEquals(0, value) Initial Composition
assertEquals(0, value) dispatcher.advanceTimeBy(100)
assertEquals(0, value) dispatcher.advanceTimeBy(100) assertEquals(1, value)
assertEquals(0, value) dispatcher.advanceTimeBy(100) clock.sendFrame(0) assertEquals(1, value)
Gated Frame Clock● Request for a frame immediately succeeds ● Can be stopped
Immediateclass GatedFrameClock(scope: CoroutineScope) : MonotonicFrameClock { val frameSends = Channel(CONFLATED)init {scope.launch {for (send in frameSends) sendFrame()}} ...}
Immediateclass GatedFrameClock(scope: CoroutineScope) : MonotonicFrameClock { val clock = BroadcastFrameClock {if (isRunning) frameSends.trySend(Unit).getOrThrow()} ...}
val scope = CoroutineScope(Main) val models: StateFlow = scope.launchMolecule( clock = RecompositionClock.ContextClock ) {ProfilePresenter(events, randomService)}
Launching Molecules● Create coroutine scope● Specify frame clock● Provide Composable function
Design Patterns
View
View PresenterEvents
View PresenterEventsModel
View PresenterEventsModelRemote ServiceResult
interface MoleculePresenter {@Composablefun models(events: Flow): Model}
class ProfilePresenter: MoleculePresenter {@Composableoverridefun models(events: Flow): ProfileModel {}}
class ProfilePresenter(val repo: Repo): MoleculePresenter<...> {@Composableoverridefun models(events: Flow): ProfileModel {}}
@Composablefun models(events: Flow): ProfileModel {var data by remember { mutableStateOf(null) }LaunchedEffect(Unit) {data = repo.getProfileData()}}
@Composablefun models(events: Flow): ProfileModel { ...return if (data==null) {ProfileModel.Loading} else {ProfileModel.Success(data)} }
val models = scope.launchMolecule(...) {presenter.models(events)}Events Flow
sealed interface ProfileEvents {object Edit: ProfileEvents}
ChannelEventPresenter
@Composablefun models(events: Flow): ProfileModel {LaunchedEffect(events) {events.collect {when (it) {Events.Edit->{...} }}}}
@Composablefun models(events: Flow): ProfileModel {CollectEffect(events) {when (it) {Events.ClickedSubmit->{...} }}}
View PresenterEventsModelRemoteServiceResult
Molecule Presenter● Separation of concerns● Composite presenters
Component 1Component 2Component 3
class ProfilePresenter: MoleculePresenter {@Composableoverridefun models(events: Flow): ProfileModel {}}Can get complex
class Component1Presenter: MoleculePresenter {@Composableoverridefun models(events: Flow): Model {}}
class ProfilePresenter( val component1Presenter: Component1Presenter, ... ): MoleculePresenter {@Composableoverridefun models(events: Flow): ProfileModel {}}
class ProfilePresenter(...): MoleculePresenter {@Composableoverridefun models(events: Flow): ProfileModel {val model = component1Presenter.models(events)}}
Testing
val models: StateFlow = scope.launchMolecule( clock = RecompositionClock.ContextClock ) {ProfilePresenter(randomService)}Testing
Testing● Turbine● Molecule Testing Dependency [Deprecated]
Turbine● Queue based testing● Use to be an extension in SQL Delight
Turbineinterface ReceiveTurbine {fun awaitEvent(): Eventfun awaitError(): Throwablesuspend fun awaitItem(): T...}
Turbineflow {emit("one")emit("two")}.test { assertEquals("one", awaitItem()) assertEquals("two", awaitItem())}
Turbinesuspend fun Flow.test(validate: suspend ReceiveTurbine.()->Unit,) {coroutineScope {collectTurbineIn(this).apply {}}}
Turbinefun Flow.collectTurbineIn(scope: CoroutineScope): Turbine { lateinit var channel: Channelval job = scope.launch(start = UNDISPATCHED) {channel = collectIntoChannel(this)}return ChannelTurbine(channel, job)}
Turbineclass ChannelTurbine(channel: Channel = Channel(UNLIMITED)) : Turbine {suspend fun awaitItem(): T = channel.awaitItem()}
Turbineval channel = Turbine()listOf(1, 2, 3).forEach { channel.add(it) }channel.skipItems(2)assertEquals(3, channel.awaitItem())
val channel = Turbine()listOf(1, 2, 3).forEach { channel.add(it) }channel.skipItems(2)assertEquals(3, channel.awaitItem())Turbine
val models: StateFlow = scope.launchMolecule( clock = RecompositionClock.ContextClock ) {profilePresenter.models(events)}Testing
Testing Cases● Verify correct models are created● Verify side effects from events
Testing● runBlocking● Immediate Frame Clock● Use Turbine
Molecule Testing@Testfun `should get success model`() = runBlocking {launchMolecule(RecompositionClock.Immediate) {profilePresenter.models(events)}.test {}}
Molecule Testing@Testfun `should get success model`() = runBlocking {launchMolecule(RecompositionClock.Immediate) {profilePresenter.models(events)}.test {assertEquals(ProfileModel.Loading, awaitItem()) ...}}
Molecule Testing@Testfun `should get success model`() = runBlocking { val events = MutableSharedFlow() launchMolecule(RecompositionClock.Immediate) {profilePresenter.models(events)}.test {}}
Molecule Testing@Testfun `should get success model`() = runBlocking {launchMolecule(RecompositionClock.Immediate) {profilePresenter.models(events)}.test { events.emit(EditProfile)}}
Molecule Testing@Testfun `should get success model`() = runBlocking {launchMolecule(RecompositionClock.Immediate) {profilePresenter.models(events)}.test { events.emit(EditProfile) //Assertions}}
Molecule Internals
val scope = CoroutineScope(Main) fun CoroutineScope.launchMolecule(...) {//Setup Recomposer}
Internals● Compose Compiler & Runtime● How Molecule hooks into Compose Runtime
Compose Components● Composition● Recomposer● Applier
Compose Internalsclass Foo {fun bar() { ... }} Code ParsingAnalysis & ResolveIR
@Composablefun Greeting(name: String) {Text(name)}Compose Internals
@Composablefun Greeting(name: String) {Text(name)}Creates Deferred ChangeCompose Internals
@Composablefun Greeting(name: String, $composer: Composer<*>) {Text(name)}Connects to Compose RuntimeCompose Internals
@Composablefun Greeting(name: String, $composer: Composer<*>) {Text(name, $composer)}Calling context is passed alongCompose Internals
suspend fun getData(payload: Payload) {...}Compose Internals
suspend fun getData(payload: Payload, $cont: Continuation) {anotherMethod($cont)}Compose Internals
@Composablefun Greeting(name: String) {Text(name)}Creates Deferred ChangeComposition
@Composableinline fun Layout(...) { ReusableComposeNode>>(update = {set(measurePolicy, ComposeUiNode.SetMeasurePolicy)set(density, ComposeUiNode.SetDensity)...},)}CompositionTeaching the runtime
@Composableinline fun ResuableComposeNode(...) { ...currentComposer.startReusableNode() ... currentComposer.createNode(factory)}CompositionDelegate to composer
Composition@Composableinline fun remember(...) { ...currentComposer.cache(...) }
@Composableinline fun ResuableComposeNode(...) { ...currentComposer.startReusableNode() ... currentComposer.createNode(factory)}CompositionChanges ListChange(…)Change(…)Change(…)
Compositioninternal typealias Change = (applier: Applier<*>,slots: SlotWriter,rememberManager: RememberManager)->Unit
CompositionChanges ListChange(…)Change(…)Change(…)Slot tableApplier
Compositionclass UiApplier(…): AbstractApplier { fun insertBottomUp(...)fun insertTopDown(...)fun move(...)fun onClear(...)fun onEndChanges(...) fun remove(...)}Applier in Compose UI
Entry Points for Client Libraries● Create a recomposer● Specify Applier● Call setContent with composable function
Recomposer● Trigger recompositions● Determine which thread to compose or recompose on
val contextWithClock = currentThreadContext + (pausableClock?:EmptyCoroutineContext) val recomposer = Recomposer(contextWithClock) Compose UI Example
fun launchMolecule(clock: RecompositionClock, body: @Composable ()->T) {with(this + clockContext) {val recomposer = Recomposer(coroutineContext)val composition = Composition(UnitApplier, recomposer)launch(start = UNDISPATCHED) {recomposer.runRecomposeAndApplyChanges()}}}Molecule Internals
fun launchMolecule(...) {with(this + clockContext) {val recomposer = Recomposer(coroutineContext)val composition = Composition(UnitApplier, recomposer)launch(start = UNDISPATCHED) {recomposer.runRecomposeAndApplyChanges()}}}Molecule Internals
Recomposer● Creates coroutine Job● Applies changes for recomposition using context● Cancels composition or recompositions when shutdown
fun launchMolecule(@Composable body: ()->T) {with(this + clockContext) { ...composition.setContent {body()}}}Molecule Internals
fun launchMolecule(emitter: (value: T)->Unit) {with(this + clockContext) { ...composition.setContent {emitter(body())}}}Molecule Internals
Molecule Internalsvar flow: MutableStateFlow? = nulllaunchMolecule(emitter = { value->outputFlow.value = value},...)
val models: StateFlow = scope.launchMolecule(…) {profilePresenter.models(events)}models.collect {...}
Thank You!www.codingwithmohit.com@heyitsmohit