Share
Embed
Modern Compose Architecture with Circuit
Zac Sweers
April 14, 2023
Transcript
157 slides
-
Compose • React-style declarative UI framework • Kotlin- fi rst
• Originally developed for Android • Two parts • Compose compiler/runtime • Compose UI @Composable fun Example() { Text("Hello World!") } -
Compose Architecture @Composable fun Example() { val viewModel by viewModel<ExampleViewModel>()
Text(viewModel.text) }z -
Compose Architecture @Composable fun Example() { val viewModel by viewModel<ExampleViewModel>()
val text by viewModel.textF l ow().collectAsState() Text(text) }z -
Compose Architecture @Composable fun Example() { val viewModel by viewModel<ExampleViewModel>()
val text by viewModel.textF l ow().collectAsState() Text(text) } -
Compose Architecture @Composable fun Example() { val viewModel by viewModel<ExampleViewModel>()
val text by viewModel.textF l ow().collectAsState() Text(text) } -
Architecture class ExamplePresenter { fun state() : StateF l ow<State>
} @Composable fun Example(state: State) { Text(state.text) } -
class ExamplePresenter { fun state( events: F l ow<Event> )
: StateF l ow<State> } @Composable fun Example( state: State, eventSink: (Event) - > Unit ) { Text(state.text) Button(onClick = { eventSink(Click) }) } Architecture -
class ExamplePresenter { fun state( events: F l ow<Event> )
: StateF l ow<State> } @Composable fun Example( state: State, eventSink: (Event) - > Unit ) Architecture -
class ExamplePresenter { fun state( events: F l ow<Event> )
: StateF l ow<State> } @Composable fun Example( state: State, eventSink: (Event) - > Unit ) Architecture '22 -
Architecture '23 class ExamplePresenter { @Composable fun state() : State
} @Composable fun Example(state: State) -
Circuit • Compose- fi rst, compose all the way down
• Keyed by "Screen"s • UDF- fi rst • Inspired by Cash App's Broadway architecture & others • Multiplatform • DI-oriented https://github.com/slackhq/circuit -
Circuit class ExamplePresenter { fun state( events: F l ow<Event>
) : StateF l ow<State> } -
Circuit class CounterPresenter { fun state( events: F l ow<Event>
) : StateF l ow<State> } -
Circuit class CounterPresenter { fun state( events: F l ow<Event>
) : StateF l ow<State> } data class State(val count: Int) -
Circuit class CounterPresenter { fun state( events: F l ow<Event>
) : StateF l ow<State> } -
Circuit class CounterPresenter { fun state( scope: CoroutineScope, events: F
l ow<Event> ) : StateF l ow<State> } -
Circuit class CounterPresenter { private val count = MutableStateF l
ow(State(0)) fun state( scope: CoroutineScope, events: F l ow<Event> ) : StateF l ow<State> } -
Circuit class CounterPresenter { private val count = MutableStateF l
ow(State(0)) fun state( scope: CoroutineScope, events: F l ow<Event> ) : StateF l ow<State> { return count } } -
Circuit class CounterPresenter { private val count = MutableStateF l
ow(State(0)) fun state( scope: CoroutineScope, events: F l ow<Event> ) : StateF l ow<State> { scope.launch { events.collect { count.emit(State(count.value.count + + )) } } return count } } -
Circuit class CounterPresenter { @Composable fun state() : State {
var count by remember { mutableStateOf(0) } return State(count) { event - > count + + } } } -
Circuit class CounterPresenter { @Composable fun state() : State {
var count by rememberRetained { mutableStateOf(0) } return State(count) { count + + } } } -
Circuit class CounterPresenter { @Composable fun state() : State {
var count by rememberSaveable { mutableStateOf(0) } return State(count) { count + + } } } -
Circuit class CounterPresenter : Presenter<State> { @Composable override fun present()
: State { var count by rememberSaveable { mutableStateOf(0) } return State(count) { count + + } } } -
Circuit interface Presenter<UiState> { @Composable fun present() : UiState fun
interface Factory { fun create( screen: Screen, navigator: Navigator, context: CircuitContext ) : Presenter < * > ? } } -
Circuit interface Presenter<UiState> { @Composable fun present() : UiState fun
interface Factory { fun create( screen: Screen, navigator: Navigator, context: CircuitContext ) : Presenter < * > ? } } interface Ui<UiState> { @Composable fun Content( state: UiState, modif i er: Modif i er ) fun interface Factory { fun create( screen: Screen, context: CircuitContext ) : Ui < * > ? } } -
Circuit interface Presenter<UiState> { @Composable fun present() : UiState fun
interface Factory { fun create( screen: Screen, navigator: Navigator, context: CircuitContext ) : Presenter < * > ? } } interface Ui<UiState> { @Composable fun Content( state: UiState, modif i er: Modif i er ) fun interface Factory { fun create( screen: Screen, context: CircuitContext ) : Ui < * > ? } } -
Circuit class CounterPresenter @AssistedInject constructor( @Assisted private val screen: CounterScreen
) : Presenter<State> { @AssistedFactory interface Factory { fun create(screen: CounterScreen) : CounterPresenter } } -
Circuit interface Presenter<UiState> { @Composable fun present() : UiState fun
interface Factory { fun create( screen: Screen, navigator: Navigator, context: CircuitContext ) : Presenter < * > ? } } -
Circuit interface Presenter<UiState> { @Composable fun present() : UiState fun
interface Factory { fun create( screen: Screen, navigator: Navigator, context: CircuitContext ) : Presenter < * > ? } } -
Navigation interface Navigator { fun goTo(screen: Screen) fun pop() :
Screen? fun resetRoot(newRoot: Screen) : List<Screen> } -
Navigation interface Navigator { fun goTo(screen: Screen) fun pop() :
Screen? fun resetRoot(newRoot: Screen) : List<Screen> } -
Navigation interface Navigator { fun goTo(screen: Screen) fun pop() :
Screen? fun resetRoot(newRoot: Screen) : List<Screen> } -
Navigation interface Navigator { fun goTo(screen: Screen) fun pop() :
Screen? fun resetRoot(newRoot: Screen) : List<Screen> } -
Navigation val backstack = rememberSaveableBackStack { push(HomeScreen) } val navigator
= rememberCircuitNavigator(backstack) / / . . . NavigableCircuitContent(navigator, backstack) gist.github.com/adamp/17b4e5cfafc7d44a0023dc2fbdb972e8 -
Circuit class CounterPresenter @AssistedInject constructor( @Assisted private val screen: CounterScreen,
@Assisted private val navigator: Navigator, ) : Presenter<State> { @Composable override fun present() : State { } } -
Circuit class CounterPresenter @AssistedInject constructor( @Assisted private val screen: CounterScreen,
@Assisted private val navigator: Navigator, ) : Presenter<State> { @Composable override fun present() : State { navigator.goTo(LoginScreen) } } -
Circuit interface Presenter<UiState> { @Composable fun present() : UiState fun
interface Factory { fun create( screen: Screen, navigator: Navigator, context: CircuitContext ) : Presenter < * > ? } } -
Circuit interface Presenter<UiState> { @Composable fun present() : UiState fun
interface Factory { fun create( screen: Screen, navigator: Navigator, context: CircuitContext ) : Presenter < * > ? } } -
Circuit interface Presenter<UiState> { @Composable fun present() : UiState fun
interface Factory { fun create( screen: Screen, navigator: Navigator, context: CircuitContext ) : Presenter < * > ? } } -
@Parcelize object CounterScreen : Screen { data class State( val
count: Int, val eventSink: (Event) - > Unit ) : CircuitUiState object Event : CircuitUiEvent } Circuit -
State object NoState : CircuitUiState data class State( val count:
Int, val eventSink: (Click) - > Unit ) : CircuitUiState -
State object NoState : CircuitUiState data class State( val count:
Int, val eventSink: (Click) - > Unit ) : CircuitUiState -
State object NoState : CircuitUiState data class State( val count:
Int, val eventSink: (Event) - > Unit ) : CircuitUiState -
State sealed interface State : CircuitUiState { object Loading :
State data class Count( val count: Int, val eventSink: (Event) - > Unit ) : State } -
State sealed interface State : CircuitUiState { object Loading :
State data class Count( val count: Int, val eventSink: (Event) - > Unit ) : State } -
State sealed interface State : CircuitUiState { object Loading :
State data class Count( val count: Int, val eventSink: (Event) - > Unit ) : State } -
State sealed interface State : CircuitUiState { object Loading :
State data class Count( val count: Int, val eventSink: (Event) - > Unit ) : State } -
Events object Reset : CircuitUiEvent data class Click( val amount:
Int = 1 ) : CircuitUiEvent -
Events object Reset : CircuitUiEvent data class Click( val amount:
Int = 1 ) : CircuitUiEvent -
Events object Reset : CircuitUiEvent data class Click( val amount:
Int = 1 ) : CircuitUiEvent -
Events sealed interface Event : CircuitUiEvent { object Increment :
Event object Decrement : Event } -
Events sealed interface Event : CircuitUiEvent { object Increment :
Event object Decrement : Event } -
State and Events data class State( val count: Int, val
eventSink: (Event) - > Unit ) : CircuitUiState sealed interface Event { object Increment : Event object Decrement : Event } -
State and Events data class State( val count: Int, val
eventSink: (Event) - > Unit ) : CircuitUiState sealed interface Event { object Increment : Event object Decrement : Event } -
Events in State class CounterPresenter : Presenter<State> { @Composable override
fun present() : State { var count by remember { mutableStateOf(0) } return State(count) { event - > when (event) { is Increment - > count + + is Decrement - > count - - } } } } -
Events in State class CounterPresenter : Presenter<State> { @Composable override
fun present() : State { var count by remember { mutableStateOf(0) } return State(count) { event - > when (event) { is Increment - > count + + is Decrement - > count - - } } } } -
Events in State @Composable fun CounterUi( state: CounterScreen.State, ) {
val sink = state.eventSink / / . . . Button(onClick = { sink(Event.Increment) }) } -
Events in State @Composable fun CounterUi( state: CounterScreen.State, ) {
val sink = state.eventSink / / . . . Button(onClick = { sink(Event.Increment) }) } -
Events in State @Composable override fun present() : State {
var count by remember { mutableStateOf(0) } return State(count) { event - > when (event) { is Increment - > count + + is Decrement - > count - - } } } @Composable fun CounterUi(state: State) { val sink = state.eventSink Text("Count: ${state.count}") Button(onClick = { sink(Event.Increment) }) } -
Events in State @Composable override fun present() : State {
var count by remember { mutableStateOf(0) } return State(count) { event - > when (event) { is Increment - > count + + is Decrement - > count - - } } } @Composable fun CounterUi(state: State) { val sink = state.eventSink Text("Count: ${state.count}") Button(onClick = { sink(Event.Increment) }) } -
Events in State @Composable override fun present() : State {
var count by remember { mutableStateOf(0) } return State(count) { event - > when (event) { is Increment - > count + + is Decrement - > count - - } } } @Composable fun CounterUi(state: State) { val sink = state.eventSink Text("Count: ${state.count}") Button(onClick = { sink(Event.Increment) }) } -
Events in State @Composable override fun present() : State {
var count by remember { mutableStateOf(0) } return State(count) { event - > when (event) { is Increment - > count + + is Decrement - > count - - } } } @Composable fun CounterUi(state: State) { val sink = state.eventSink Text("Count: ${state.count}") Button(onClick = { sink(Event.Increment) }) } -
Events in State @Composable override fun present() : State {
var count by remember { mutableStateOf(0) } return State(count) { event - > when (event) { is Increment - > count + + is Decrement - > count - - } } } @Composable fun CounterUi(state: State) { val sink = state.eventSink Text("Count: ${state.count}") Button(onClick = { sink(Event.Increment) }) } -
Events in State @Composable override fun present() : State {
var count by remember { mutableStateOf(0) } return State(count) { event - > when (event) { is Increment - > count + + is Decrement - > count - - } } } @Composable fun CounterUi(state: State) { val sink = state.eventSink Text("Count: ${state.count}") Button(onClick = { sink(Event.Increment) }) } -
Events in State @Composable override fun present() : State {
var count by remember { mutableStateOf(0) } return State(count) { event - > when (event) { is Increment - > count + + is Decrement - > count - - } } } @Composable fun CounterUi(state: State) { val sink = state.eventSink Text("Count: ${state.count}") Button(onClick = { sink(Event.Increment) }) } -
Events in State @Composable override fun present() : State {
var count by remember { mutableStateOf(0) } return State(count) { event - > when (event) { is Increment - > count + + is Decrement - > count - - } } } @Composable fun CounterUi(state: State) { val sink = state.eventSink Text("Count: ${state.count}") Button(onClick = { sink(Event.Increment) }) } -
Events in State @Composable override fun present() : State {
var count by remember { mutableStateOf(0) } return State(count) { event - > when (event) { is Increment - > count + + is Decrement - > count - - } } } @Composable fun CounterUi(state: State) { val sink = state.eventSink Text("Count: ${state.count}") Button(onClick = { sink(Event.Increment) }) } -
Why is testing hard? • It shouldn’t be 😬 •
Historic best practices (on Android): • Advocate for patterns that make testing hard • Encourage asserting called methods instead of verifying behaviour • Use of Android components in business logic encourages mocking -
Presenter Tests class CounterPresenter : Presenter<State> { @Composable override fun
present() : State { var count by remember { mutableStateOf(0) } return State(count) { event - > when (event) { is Increment - > count + + is Decrement - > count - - } } } } -
Presenter Tests @Test fun `present - verify state and event`()
= runTest { val presenter = CounterPresenter() }a -
Presenter Tests @Test fun `present - verify state and event`()
= runTest { val presenter = CounterPresenter() presenter.test { / / . . . } }a https://github.com/cashapp/turbine -
Presenter Tests @Test fun `present - verify state and event`()
= runTest { val presenter = CounterPresenter() presenter.test { val f i rst = awaitItem() assertThat(f i rst.count).isEqualTo(0) } }a -
Presenter Tests @Test fun `present - verify state and event`()
= runTest { val presenter = CounterPresenter() presenter.test { val f i rst = awaitItem() assertThat(f i rst.count).isEqualTo(0) f i rst.eventSink(Event.Increment) } }a -
Presenter Tests @Test fun `present - verify state and event`()
= runTest { val presenter = CounterPresenter() presenter.test { val f i rst = awaitItem() assertThat(f i rst.count).isEqualTo(0) f i rst.eventSink(Event.Increment) assertThat(awaitItem().count).isEqualTo(1) } }a -
UI Tests @Composable fun CounterUi(state: State) { val sink =
state.eventSink / / . . . Button(onClick = { sink(Event.Increment) }) } -
UI Tests @Test fun display_count_message() { composeTestRule.run { setContent {
CounterUi( CounterScreen.State(5) ) } } } -
UI Tests @Test fun display_count_message() { composeTestRule.run { setContent {
CounterUi( CounterScreen.State(5) ) } onNode(hasText("Count: 5")) .assertIsDisplayed() } } -
Circuit val conf i g = CircuitConf i g.Builder() /
/ . . . .build() -
Circuit val conf i g = CircuitConf i g.Builder() /
/ . . . .build() val backstack = rememberSaveableBackStack { push(HomeScreen) } -
Circuit val conf i g = CircuitConf i g.Builder() /
/ . . . .build() val backstack = rememberSaveableBackStack { push(HomeScreen) } val navigator = rememberCircuitNavigator(backstack) -
Circuit val conf i g = CircuitConf i g.Builder() /
/ . . . .build() val backstack = rememberSaveableBackStack { push(HomeScreen) } val navigator = rememberCircuitNavigator(backstack) CircuitCompositionLocals(conf i g) { NavigableCircuitContent(navigator, backstack) } -
Circuit val conf i g = CircuitConf i g.Builder() /
/ . . . .build() val backstack = rememberSaveableBackStack { push(HomeScreen) } val navigator = rememberCircuitNavigator(backstack) CircuitCompositionLocals(conf i g) { NavigableCircuitContent(navigator, backstack) } -
Circuit val conf i g = CircuitConf i g.Builder() /
/ . . . .build() CircuitCompositionLocals(conf i g) { CircuitContent(HomeScreen) } -
Circuit override fun onCreate(savedInstanceState: Bundle?) { setContent { val conf
i g = CircuitConf i g.Builder() / / . . . .build() CircuitCompositionLocals(conf i g) { CircuitContent(HomeScreen) } } } -
Circuit fun main() = singleWindowApplication("Circuit") { val conf i g
= CircuitConf i g.Builder() / / . . . .build() CircuitCompositionLocals(conf i g) { CircuitContent(HomeScreen) } } -
Overlays val overlayHost = LocalOverlayHost.current LaunchedEffect(Unit) { / / ☇
suspending! val result = overlayHost.show( BottomSheetOverlay( model = . . . , onDismiss = { . . . }, ) { model, overlayNavigator - > / / Content } ) / / Do something with the result } -
Overlays val overlayHost = LocalOverlayHost.current LaunchedEffect(Unit) { / / ☇
suspending! val result = overlayHost.show( BottomSheetOverlay( model = . . . , onDismiss = { . . . }, ) { model, overlayNavigator - > / / Content } ) / / Do something with the result } -
Overlays val overlayHost = LocalOverlayHost.current LaunchedEffect(Unit) { val newFilters =
overlayHost.show( FiltersOverlay() ) eventSink(UpdateFilters(newFilters)) } -
Composite Presenters class TabletHomePresenter @Inject constructor( private val listPresenter: ListPresenter,
private val detailPresenter: DetailPresenter, ) : Presenter<CompositeState> { @Composable override fun present() : CompositeState { val listState = listPresenter.present() val detailState = detailPresenter.present() return CompositeState(listState, detailState) } } -
DI @Module interface CircuitModule { @Multibinds fun presenterFactories() : Set<Presenter.Factory>
@Multibinds fun viewFactories() : Set<Ui.Factory> } -
DI @Provides fun provideCircuit( presenterFactories: Set<Presenter.Factory>, uiFactories: Set<Ui.Factory>, ) :
CircuitConf i g { return CircuitConf i g.Builder() .addPresenterFactories(presenterFactories) .addUiFactories(uiFactories) .build() } -
DI (w/ Anvil) @CircuitInject(CounterScreen : : class, AppScope : :
class) class CounterPresenter @Inject constructor( private val repository: CounterRespository ) : Presenter<State> { / / . . . } @CircuitInject(CounterScreen : : class, AppScope : : class) @Composable fun Counter(state: State, modif i er: Modif i er) { / / . . . } -
Multiplatform • Goal is multiplatform presentation logic, not multiplatform UI
• Current focus is JVM and Desktop use cases -
Advanced Use Cases • Navigate to legacy Activity or Fragment
using Interceptors • Tracing using EventListener and CircuitContext tags • Extract value from running Circuit environment using • Taking control of con fi g changes -
Future • Improved KMP/KMM support • Inter-Screen transitions API •
Lint checks • ???