Slide 1

Slide 1 text

Architecture at cale

Slide 2

Slide 2 text

Activities, fragments, view models, navigation, layouts… Android APIs

Slide 3

Slide 3 text

« It is probably better to call the core Android APIs a "system framework." For the most part, the platform APIs we provide are there to de fi ne how an application interacts with the operating system; but for anything going on purely within the app, these APIs are often just not relevant. » by Dianne Hackborn Android APIs

Slide 4

Slide 4 text

Screen

Slide 5

Slide 5 text

class CardView( private val screen: CardViewScreen, private val presenterFactory: CardPresenter.Factory, context: Context, ) : ContourLayout(context) { private val events = PublishRelay.create() override fun onAttachedToWindow() { super.onAttachedToWindow() events .compose(presenterFactory.create(screen)) .takeUntil(detaches()) .subscribe(this::setModel) } } NO-NO

Slide 6

Slide 6 text

Presenter UiModel UiEvent Ui

Slide 7

Slide 7 text

Presenter UiModel UiEvent Ui Ui

Slide 8

Slide 8 text

No content

Slide 9

Slide 9 text

data class DependentWelcomeViewModel( val toolbarTitle: String, val title: String, val subTitle: String, val ctaLabel: String, ) sealed interface DependentWelcomeViewEvent { object Close : DependentWelcomeViewEvent object CtaClicked : DependentWelcomeViewEvent }

Slide 10

Slide 10 text

Ui

Slide 11

Slide 11 text

interface Ui { }

Slide 12

Slide 12 text

interface Ui { interface EventReceiver { fun sendEvent(event: UiEvent) } fun setEventReceiver(receiver: EventReceiver) }

Slide 13

Slide 13 text

interface Ui { interface EventReceiver { fun sendEvent(event: UiEvent) } fun setEventReceiver(receiver: EventReceiver) fun setModel(model: UiModel) }

Slide 14

Slide 14 text

class DependentWelcomeView( context: Context, attrs: AttributeSet? = null, ) : ContourLayout(context, attrs), Ui { } ContourLayout https://github.com/cashapp/contour

Slide 15

Slide 15 text

class DependentWelcomeView( context: Context, attrs: AttributeSet? = null, ) : ContourLayout(context, attrs), Ui { override fun setEventReceiver(receiver: EventReceiver) { } override fun setModel(model: DependentWelcomeViewModel) { } } ContourLayout https://github.com/cashapp/contour

Slide 16

Slide 16 text

class DependentWelcomeView( context: Context, attrs: AttributeSet? = null, ) : ContourLayout(context, attrs), Ui { private val palette = themeInfo().colorPalette private val toolbar = MooncakeToolbar(context) private val image = AppCompatImageView(context).apply { setImageResource(R.drawable.investing_components_dependent_welcome) } private val titleView = AppCompatTextView(context).apply { textSize = 32f ... } private val subtitleView = AppCompatTextView(context) private val button = MooncakePillButton(context) init { setBackgroundColor(palette.background) } override fun setEventReceiver(receiver: EventReceiver) { } override fun setModel(model: DependentWelcomeViewModel) { } } ContourLayout https://github.com/cashapp/contour

Slide 17

Slide 17 text

private val titleView = AppCompatTextView(context).apply { textSize = 32f ... } private val subtitleView = AppCompatTextView(context) private val button = MooncakePillButton(context) init { setBackgroundColor(palette.background) toolbar.layoutBy( x = matchParentX(), y = topTo { parent.top() } ) image.layoutBy( x = matchParentX(36.dip, 36.dip), y = bottomTo { titleView.top() }.topTo { toolbar.bottom() } ) titleView.layoutBy(...) subtitleView.layoutBy(...) button.layoutBy(...) } override fun setEventReceiver(receiver: EventReceiver) { } override fun setModel(model: DependentWelcomeViewModel) { } } ContourLayout https://github.com/cashapp/contour

Slide 18

Slide 18 text

x = matchParentX(), y = topTo { parent.top() } ) image.layoutBy( x = matchParentX(36.dip, 36.dip), y = bottomTo { titleView.top() }.topTo { toolbar.bottom() } ) titleView.layoutBy(...) subtitleView.layoutBy(...) button.layoutBy(...) } override fun setEventReceiver(receiver: EventReceiver) { button.setOnClickListener { receiver.sendEvent(CtaClicked) } toolbar.setNavigationOnClickListener { receiver.sendEvent(Close) } } override fun setModel(model: DependentWelcomeViewModel) { } } ContourLayout https://github.com/cashapp/contour

Slide 19

Slide 19 text

image.layoutBy( x = matchParentX(36.dip, 36.dip), y = bottomTo { titleView.top() }.topTo { toolbar.bottom() } ) titleView.layoutBy(...) subtitleView.layoutBy(...) button.layoutBy(...) } override fun setEventReceiver(receiver: EventReceiver) { button.setOnClickListener { receiver.sendEvent(CtaClicked) } toolbar.setNavigationOnClickListener { receiver.sendEvent(Close) } } override fun setModel(model: DependentWelcomeViewModel) { toolbar.title = model.toolbarTitle titleView.text = model.title subtitleView.text = model.subTitle button.text = model.ctaLabel } } ContourLayout https://github.com/cashapp/contour

Slide 20

Slide 20 text

abstract class ComposeUiView( context: Context, attrs: AttributeSet? = null, ) : AbstractComposeView(context, attrs), Ui { @Composable abstract fun Content( model: UiModel?, onEvent: (UiEvent) -> Unit, ) // Deal with setModel/eventReceiver stuff. }

Slide 21

Slide 21 text

Ui tests

Slide 22

Slide 22 text

No content

Slide 23

Slide 23 text

Paparazzi

Slide 24

Slide 24 text

@RunWith(TestParameterInjector::class) class DependentWelcomeViewTest( @TestParameter private val design: DesignTheme, ) { @get:Rule val paparazzi = Paparazzi( theme = "Theme.Cash.Default", ) private val context: Context by lazy { paparazzi.context .wrapWithTheme { design.provide(paparazzi.context) } } @Test fun tests() { } } Paparazzi: Legacy UI Test https://github.com/cashapp/paparazzi

Slide 25

Slide 25 text

@RunWith(TestParameterInjector::class) class DependentWelcomeViewTest( @TestParameter private val design: DesignTheme, ) { @get:Rule val paparazzi = Paparazzi( theme = "Theme.Cash.Default", ) private val context: Context by lazy { paparazzi.context .wrapWithTheme { design.provide(paparazzi.context) } } @Test fun tests() { val view = DependentWelcomeView(context = context) view.setModel( DependentWelcomeViewModel( toolbarTitle = "Stocks", title = "Stocks are now available for anyone 13+", subTitle = "Request approval from your account sponsor to...”, ctaLabel = "Continue", ) ) paparazzi.snapshot(view) } } Paparazzi: Legacy UI Test https://github.com/cashapp/paparazzi

Slide 26

Slide 26 text

class HelloComposeTest { @get:Rule val paparazzi = Paparazzi() @Test fun compose() { paparazzi.snapshot { HelloPaparazzi() } } } @Composable fun HelloPaparazzi() { Column( Modifier .background(Color.White) .fillMaxSize() .wrapContentSize() ) { // Stuff. } Paparazzi: Compose UI Test https://github.com/cashapp/paparazzi

Slide 27

Slide 27 text

Paparazzi: Reports

Slide 28

Slide 28 text

- Write tests. - Record snapshots: ./gradle module:recordPaparazziDebug - Check snapshots into the repository. - Run verify task on CI: ./gradle module:verifyPaparazziDebug Paparazzi: CI work fl ow https://github.com/cashapp/paparazzi

Slide 29

Slide 29 text

Paparazzi: CI work fl ow https://github.com/cashapp/paparazzi

Slide 30

Slide 30 text

Paparazzi: CI work fl ow https://github.com/cashapp/paparazzi

Slide 31

Slide 31 text

Paparazzi: CI work fl ow https://github.com/cashapp/paparazzi

Slide 32

Slide 32 text

runs on JVM Paparazzi https://github.com/cashapp/paparazzi

Slide 33

Slide 33 text

No content

Slide 34

Slide 34 text

Presenter

Slide 35

Slide 35 text

interface Presenter { }

Slide 36

Slide 36 text

interface Presenter { fun start(scope: CoroutineScope): Binding }

Slide 37

Slide 37 text

interface Presenter { fun start(scope: CoroutineScope): Binding interface Binding { val models: Flow fun sendEvent(event: UiEvent) } }

Slide 38

Slide 38 text

interface Presenter { fun start(scope: CoroutineScope): Binding interface Binding { // TODO: Change to StateFlow. val models: Flow fun sendEvent(event: UiEvent) } }

Slide 39

Slide 39 text

Presenter UiModel UiEvent Ui Ui

Slide 40

Slide 40 text

fun map(event): model

Slide 41

Slide 41 text

interface ObservableTransformer { fun apply(@NonNull Observable upstream): ObservableSource }

Slide 42

Slide 42 text

interface ObservableTransformer { fun apply(@NonNull Observable upstream): ObservableSource } interface CoroutinePresenter { suspend fun produceModels( events: Flow, emit: FlowCollector, ) }

Slide 43

Slide 43 text

class DependentWelcomePresenterOT( private val stringManager: StringManager, private val database: CashDatabase, @Io private val ioScheduler: Scheduler, ) : ObservableTransformer { override fun apply( events: Observable ): ObservableSource { } } Observable Transformer

Slide 44

Slide 44 text

class DependentWelcomePresenterOT( private val stringManager: StringManager, private val database: CashDatabase, @Io private val ioScheduler: Scheduler, ) : ObservableTransformer { override fun apply( events: Observable ): ObservableSource { return events.publish { events -> Observable.merge( events.filterIsInstance().startFlow(), events.filterIsInstance().goBack(), ) } } private fun Observable.startFlow(): Observable { return consumeOnNext { // Start f l ow. } } private fun Observable.goBack(): Observable { return consumeOnNext { // Navigate back. } } } Observable Transformer

Slide 45

Slide 45 text

): ObservableSource { return events.publish { events -> Observable.merge( events.filterIsInstance().startFlow(), events.filterIsInstance().goBack(), models(), ) } } private fun models(): Observable { return database.stateQueries.select().asObservable(ioScheduler).mapToOne() .map { it.toolbar_title } .startWith(stringManager[R.string.investing_tab_title]) .map { DependentWelcomeViewModel( toolbarTitle = it, title = stringManager[R.string.dependent_welcome_title], subTitle = stringManager[R.string.dependent_welcome_subtitle], ctaLabel = stringManager[R.string.dependent_welcome_cta_label], ) } } private fun Observable.startFlow(): Observable { return consumeOnNext { // Start f l ow. } } private fun Observable.goBack(): Observable { return consumeOnNext { // Navigate back. } Observable Transformer

Slide 46

Slide 46 text

@Test fun models() { val events = PublishRelay.create() val models = events.compose(presenter).test(rxRule) models.assertValue( DependentWelcomeViewModel( toolbarTitle = stringManager[R.string.investing_tab_title], title = stringManager[R.string.dependent_welcome_title], subTitle = stringManager[R.string.dependent_welcome_subtitle], ctaLabel = stringManager[R.string.dependent_welcome_cta_label], ) ) } @Test fun `cta clicks navigates starts invest teen request authorization flow`() { val events = PublishRelay.create() val models = events.compose(presenter).test(rxRule) models.assertAnyValue() events.accept(CtaClicked) assertThat(navigator.takeNextScreen()).isEqualTo(InvestingHome()) } Observable Transformer

Slide 47

Slide 47 text

Molecule

Slide 48

Slide 48 text

@Composable fun Content() { // Create views and stuff. } Molecule https://github.com/cashapp/molecule

Slide 49

Slide 49 text

@Composable fun profilePresenter( userFlow: Flow, balanceFlow: Flow, ): ProfileModel { val user: User? by userFlow.collectAsState(null) val balance: Long by balanceFlow.collectAsState(0L) return if (user == null) { Loading } else { Data(user.name, balance) } } Molecule https://github.com/cashapp/molecule

Slide 50

Slide 50 text

/** * Create a [Flow] which will continually recompose `body` to produce * a stream of [T] values when collected. */ fun moleculeFlow( clock: RecompositionClock, body: @Composable () -> T, ): Flow { // Stuff. } enum class RecompositionClock { ContextClock, Immediate, } Molecule https://github.com/cashapp/molecule

Slide 51

Slide 51 text

/** * Launch a coroutine into this [CoroutineScope] which will * continually recompose `body` to produce a [StateFlow] stream * of [T] values. */ fun CoroutineScope.launchMolecule( clock: RecompositionClock, body: @Composable () -> T, ): StateFlow { // Stuff. } Molecule https://github.com/cashapp/molecule

Slide 52

Slide 52 text

val flow: Flow = moleculeFlow(Immediate) { profilePresenter(userFlow, balanceFlow) } val stateFlow: StateFlow = scope.launchMolecule(ContextClock) { profilePresenter(userFlow, balanceFlow) } Molecule https://github.com/cashapp/molecule

Slide 53

Slide 53 text

interface ObservableTransformer { fun apply(@NonNull Observable upstream): ObservableSource } interface CoroutinePresenter { suspend fun produceModels( events: Flow, emit: FlowCollector, ) }

Slide 54

Slide 54 text

interface CoroutinePresenter { suspend fun produceModels( events: Flow, emit: FlowCollector, ) } interface MoleculePresenter { @Composable fun models( events: Flow ): UiModel }

Slide 55

Slide 55 text

class DependentWelcomePresenter( database: CashDatabase, private val stringManager: StringManager, @Io private val ioContext: CoroutineContext, ) : MoleculePresenter { @Composable override fun models(events: Flow): DependentWelcomeViewModel { } } Molecule Presenter

Slide 56

Slide 56 text

class DependentWelcomePresenter( database: CashDatabase, private val stringManager: StringManager, @Io private val ioContext: CoroutineContext, ) : MoleculePresenter { @Composable override fun models(events: Flow): DependentWelcomeViewModel { LaunchedE f fect(events) { events.collect { item -> when (item) { CtaClicked -> { // Start flow. } Close -> { // Navigate back. } } } } } } Molecule Presenter

Slide 57

Slide 57 text

class DependentWelcomePresenter( database: CashDatabase, private val stringManager: StringManager, @Io private val ioContext: CoroutineContext, ) : MoleculePresenter { @Composable override fun models(events: Flow): DependentWelcomeViewModel { LaunchedE f fect(events) { events.collect { item -> when (item) { CtaClicked -> { // Start flow. } Close -> { // Navigate back. } } } } val toolbarTitle by remember { database.stateQueries.select().asFlow().mapToOne(ioContext) .map { it.toolbar_title } }.collectAsState(stringManager[R.string.investing_tab_title]) return DependentWelcomeViewModel( toolbarTitle = toolbarTitle, title = stringManager[R.string.dependent_welcome_title], subTitle = stringManager[R.string.dependent_welcome_subtitle], ctaLabel = stringManager[R.string.dependent_welcome_cta_label], ) } } Molecule Presenter

Slide 58

Slide 58 text

@Test fun models() = runBlocking { presenter.test { assertThat(awaitItem()).isEqualTo( DependentWelcomeViewModel( toolbarTitle = stringManager[R.string.investing_tab_title], title = stringManager[R.string.dependent_welcome_title], subTitle = stringManager[R.string.dependent_welcome_subtitle], ctaLabel = stringManager[R.string.dependent_welcome_cta_label], ) ) } } @Test fun `cta clicks navigates starts invest teen request authorization flow`() = runBlocking { presenter.test { awaitItem() sendEvent(CtaClicked) assertThat(navigator.awaitNextScreen()).isEqualTo(InvestingHome()) } } Molecule Presenter Test: Turbine https://github.com/cashapp/turbine

Slide 59

Slide 59 text

class SomethingStateManager( ) { @Composable fun somethingStates(): SomethingState { } } Composable Producer

Slide 60

Slide 60 text

class SomethingStateManager( database: CashDatabase, private val ioContext: CoroutineContext, ) { private val somethingQueries = database.investingStateQueries @Composable fun somethingStates(): SomethingState { val dbSomethingState: Investing_state? by remember { somethingQueries.select().asFlow().mapToOne(ioContext) }.collectAsState(null) if (dbSomethingState == null) return Loading return Content( hasOneThing = dbSomethingState!!.has_one_thing, ) } } Composable Producer

Slide 61

Slide 61 text

No content

Slide 62

Slide 62 text

Navigation

Slide 63

Slide 63 text

interface Screen : Parcelable @Parcelize data class StockDetails( val investmentEntityToken: InvestmentEntityToken, ) : Screen

Slide 64

Slide 64 text

interface Navigator { fun goTo(screen: Screen) }

Slide 65

Slide 65 text

Broadway

Slide 66

Slide 66 text

Factory

Slide 67

Slide 67 text

interface ViewFactory { fun createView( screen: Screen, parent: ViewGroup, ): Ui<*, *>? } interface PresenterFactory { fun create( screen: Screen, navigator: Navigator, ): Presenter<*, *>? }

Slide 68

Slide 68 text

Presenter Factory fun ObservableTransformer.asPresenter() : Presenter { return object : Presenter { override fun start( scope: CoroutineScope, ): Presenter.Binding { // Stuff. } } }

Slide 69

Slide 69 text

fun CoroutinePresenter.asPresenter() : Presenter { return object : Presenter { override fun start( scope: CoroutineScope, ): Presenter.Binding { // Stuff. } } } Presenter Factory

Slide 70

Slide 70 text

fun MoleculePresenter.asPresenter() : Presenter { return object : Presenter { override fun start( scope: CoroutineScope, ): Presenter.Binding { // Stuff. } } } Presenter Factory

Slide 71

Slide 71 text

fun ObservableTransformer.asPresenter(): Presenter fun CoroutinePresenter.asPresenter(): Presenter fun MoleculePresenter.asPresenter(): Presenter Presenter Factory

Slide 72

Slide 72 text

interface ViewFactory { fun createView( screen: Screen, parent: ViewGroup, ): Ui<*, *>? } interface PresenterFactory { fun create( screen: Screen, navigator: Navigator, ): Presenter<*, *>? }

Slide 73

Slide 73 text

Glue

Slide 74

Slide 74 text

@Provides fun provideBroadway( viewFactories: Set, presenterFactories: Set, ): Broadway { return Broadway( viewFactories = viewFactories.toList(), presenterFactories = presenterFactories.toList() ) } Glue

Slide 75

Slide 75 text

class Container( private val broadway: Broadway, ) : FrameLayout(context), Navigator { fun goTo(screen: Screen) { val presenter = broadway.createPresenter(screen, navigator = this) val ui = broadway.createUi(screen, context) bindAndAttachAndSwapAndStuff(ui, presenter) } } Glue

Slide 76

Slide 76 text

- Hook up your factories into Broadway. - Draw the rest of the owl. Glue: try it at home!

Slide 77

Slide 77 text

- View swapping. - Animation. - Overlay. - Backstack. - Con fi g changes. - Stu ff . Glue

Slide 78

Slide 78 text

FIN

Slide 79

Slide 79 text

References • Molecule • https://github.com/cashapp/molecule • Turbine • https://github.com/cashapp/turbine • Paparazzi • https://github.com/cashapp/paparazzi • Contour • https://github.com/cashapp/contour • Broadway • https://github.com/cashapp/broadway coming soon™ • Android Jetpack Compose • https://developer.android.com/jetpack/compose • Kotlin Coroutines • https://kotlinlang.org/docs/coroutines-overview.html • RxJava • https://github.com/ReactiveX/RxJava FIN