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

Architecture at Scale (droidconNYC 2022)

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest. →

Architecture at Scale (droidconNYC 2022)

How about unidirectional data flow in an architecture where views and presenters don’t know about each other? While growing up to 40+ engineers and 600+ modules, Cash App managed to control the complexity of its product. Views can be written in either Kotlin or XML; presenters with either Rx, Coroutines, or Compose; no problem. We test views on the JVM and we don’t need to define fake presenters either.
Writing new screens is delightful and we’ll see how we made it possible by:
- Looking at the foundations of the architecture: our internal navigation library which allows clear modularity,
- Checking how it can adapt to presenters using different technologies,
- Explaining how views are defined and tested,
- Seeing how everything is glued together from a bird’s-eye view of the app. Growing your app and team doesn’t imply more pain nor more complexity. Attendees will gain a sound understanding about how we achieved it.

Avatar for Benoît Quenaudon

Benoît Quenaudon

September 02, 2022

More Decks by Benoît Quenaudon

Other Decks in Programming

Transcript

  1. « 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
  2. class CardView( private val screen: CardViewScreen, private val presenterFactory: CardPresenter.Factory,

    context: Context, ) : ContourLayout(context) { private val events = PublishRelay.create<CardViewEvent>() override fun onAttachedToWindow() { super.onAttachedToWindow() events .compose(presenterFactory.create(screen)) .takeUntil(detaches()) .subscribe(this::setModel) } } NO-NO
  3. data class DependentWelcomeViewModel( val toolbarTitle: String, val title: String, val

    subTitle: String, val ctaLabel: String, ) sealed interface DependentWelcomeViewEvent { object Close : DependentWelcomeViewEvent object CtaClicked : DependentWelcomeViewEvent }
  4. Ui

  5. interface Ui<UiModel : Any, UiEvent : Any> { interface EventReceiver<UiEvent>

    { fun sendEvent(event: UiEvent) } fun setEventReceiver(receiver: EventReceiver<UiEvent>) }
  6. interface Ui<UiModel : Any, UiEvent : Any> { interface EventReceiver<UiEvent>

    { fun sendEvent(event: UiEvent) } fun setEventReceiver(receiver: EventReceiver<UiEvent>) fun setModel(model: UiModel) }
  7. class DependentWelcomeView( context: Context, attrs: AttributeSet? = null, ) :

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

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

    ContourLayout(context, attrs), Ui<DependentWelcomeViewModel, DependentWelcomeViewEvent> { 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<DependentWelcomeViewEvent>) { } override fun setModel(model: DependentWelcomeViewModel) { } } ContourLayout https://github.com/cashapp/contour
  10. 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<DependentWelcomeViewEvent>) { } override fun setModel(model: DependentWelcomeViewModel) { } } ContourLayout https://github.com/cashapp/contour
  11. 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<DependentWelcomeViewEvent>) { button.setOnClickListener { receiver.sendEvent(CtaClicked) } toolbar.setNavigationOnClickListener { receiver.sendEvent(Close) } } override fun setModel(model: DependentWelcomeViewModel) { } } ContourLayout https://github.com/cashapp/contour
  12. 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<DependentWelcomeViewEvent>) { 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
  13. abstract class ComposeUiView<UiModel : Any, UiEvent : Any>( context: Context,

    attrs: AttributeSet? = null, ) : AbstractComposeView(context, attrs), Ui<UiModel, UiEvent> { @Composable abstract fun Content( model: UiModel?, onEvent: (UiEvent) -> Unit, ) // Deal with setModel/eventReceiver stuff. }
  14. @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
  15. @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
  16. 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
  17. - 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
  18. interface Presenter<UiModel : Any, UiEvent : Any> { fun start(scope:

    CoroutineScope): Binding<UiModel, UiEvent> }
  19. interface Presenter<UiModel : Any, UiEvent : Any> { fun start(scope:

    CoroutineScope): Binding<UiModel, UiEvent> interface Binding<UiModel : Any, UiEvent : Any> { val models: Flow<UiModel> fun sendEvent(event: UiEvent) } }
  20. interface Presenter<UiModel : Any, UiEvent : Any> { fun start(scope:

    CoroutineScope): Binding<UiModel, UiEvent> interface Binding<UiModel : Any, UiEvent : Any> { // TODO: Change to StateFlow. val models: Flow<UiModel> fun sendEvent(event: UiEvent) } }
  21. interface ObservableTransformer<UiModel : Any, UiEvent : Any> { fun apply(@NonNull

    Observable<UiEvent> upstream): ObservableSource<UiModel> }
  22. interface ObservableTransformer<UiModel : Any, UiEvent : Any> { fun apply(@NonNull

    Observable<UiEvent> upstream): ObservableSource<UiModel> } interface CoroutinePresenter<UiModel : Any, UiEvent : Any> { suspend fun produceModels( events: Flow<UiEvent>, emit: FlowCollector<UiModel>, ) }
  23. class DependentWelcomePresenterOT( private val stringManager: StringManager, private val database: CashDatabase,

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

    @Io private val ioScheduler: Scheduler, ) : ObservableTransformer<DependentWelcomeViewEvent, DependentWelcomeViewModel> { override fun apply( events: Observable<DependentWelcomeViewEvent> ): ObservableSource<DependentWelcomeViewModel> { return events.publish { events -> Observable.merge( events.filterIsInstance<CtaClicked>().startFlow(), events.filterIsInstance<Close>().goBack(), ) } } private fun Observable<CtaClicked>.startFlow(): Observable<DependentWelcomeViewModel> { return consumeOnNext { // Start f l ow. } } private fun Observable<Close>.goBack(): Observable<DependentWelcomeViewModel> { return consumeOnNext { // Navigate back. } } } Observable Transformer
  25. ): ObservableSource<DependentWelcomeViewModel> { return events.publish { events -> Observable.merge( events.filterIsInstance<CtaClicked>().startFlow(),

    events.filterIsInstance<Close>().goBack(), models(), ) } } private fun models(): Observable<DependentWelcomeViewModel> { 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<CtaClicked>.startFlow(): Observable<DependentWelcomeViewModel> { return consumeOnNext { // Start f l ow. } } private fun Observable<Close>.goBack(): Observable<DependentWelcomeViewModel> { return consumeOnNext { // Navigate back. } Observable Transformer
  26. @Test fun models() { val events = PublishRelay.create<DependentWelcomeViewEvent>() 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<DependentWelcomeViewEvent>() val models = events.compose(presenter).test(rxRule) models.assertAnyValue() events.accept(CtaClicked) assertThat(navigator.takeNextScreen()).isEqualTo(InvestingHome()) } Observable Transformer
  27. @Composable fun Content() { // Create views and stuff. }

    Molecule https://github.com/cashapp/molecule
  28. @Composable fun profilePresenter( userFlow: Flow<User>, balanceFlow: Flow<Long>, ): 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
  29. /** * Create a [Flow] which will continually recompose `body`

    to produce * a stream of [T] values when collected. */ fun <T> moleculeFlow( clock: RecompositionClock, body: @Composable () -> T, ): Flow<T> { // Stuff. } enum class RecompositionClock { ContextClock, Immediate, } Molecule https://github.com/cashapp/molecule
  30. /** * Launch a coroutine into this [CoroutineScope] which will

    * continually recompose `body` to produce a [StateFlow] stream * of [T] values. */ fun <T> CoroutineScope.launchMolecule( clock: RecompositionClock, body: @Composable () -> T, ): StateFlow<T> { // Stuff. } Molecule https://github.com/cashapp/molecule
  31. val flow: Flow<ProfileModel> = moleculeFlow(Immediate) { profilePresenter(userFlow, balanceFlow) } val

    stateFlow: StateFlow<ProfileModel> = scope.launchMolecule(ContextClock) { profilePresenter(userFlow, balanceFlow) } Molecule https://github.com/cashapp/molecule
  32. interface ObservableTransformer<UiModel : Any, UiEvent : Any> { fun apply(@NonNull

    Observable<UiEvent> upstream): ObservableSource<UiModel> } interface CoroutinePresenter<UiModel : Any, UiEvent : Any> { suspend fun produceModels( events: Flow<UiEvent>, emit: FlowCollector<UiModel>, ) }
  33. interface CoroutinePresenter<UiModel : Any, UiEvent : Any> { suspend fun

    produceModels( events: Flow<UiEvent>, emit: FlowCollector<UiModel>, ) } interface MoleculePresenter<UiModel : Any, UiEvent : Any> { @Composable fun models( events: Flow<UiEvent> ): UiModel }
  34. class DependentWelcomePresenter( database: CashDatabase, private val stringManager: StringManager, @Io private

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

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

    val ioContext: CoroutineContext, ) : MoleculePresenter<DependentWelcomeViewModel, DependentWelcomeViewEvent> { @Composable override fun models(events: Flow<DependentWelcomeViewEvent>): 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
  37. @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
  38. 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
  39. interface ViewFactory { fun createView( screen: Screen, parent: ViewGroup, ):

    Ui<*, *>? } interface PresenterFactory { fun create( screen: Screen, navigator: Navigator, ): Presenter<*, *>? }
  40. Presenter Factory fun ObservableTransformer<UiEvent, UiModel>.asPresenter() : Presenter<UiModel, UiEvent> { return

    object : Presenter<UiModel, UiEvent> { override fun start( scope: CoroutineScope, ): Presenter.Binding<UiModel, UiEvent> { // Stuff. } } }
  41. fun CoroutinePresenter<UiEvent, UiModel>.asPresenter() : Presenter<UiModel, UiEvent> { return object :

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

    Presenter<UiModel, UiEvent> { override fun start( scope: CoroutineScope, ): Presenter.Binding<UiModel, UiEvent> { // Stuff. } } } Presenter Factory
  43. interface ViewFactory { fun createView( screen: Screen, parent: ViewGroup, ):

    Ui<*, *>? } interface PresenterFactory { fun create( screen: Screen, navigator: Navigator, ): Presenter<*, *>? }
  44. @Provides fun provideBroadway( viewFactories: Set<ViewFactory>, presenterFactories: Set<PresenterFactory>, ): Broadway {

    return Broadway( viewFactories = viewFactories.toList(), presenterFactories = presenterFactories.toList() ) } Glue
  45. 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
  46. - Hook up your factories into Broadway. - Draw the

    rest of the owl. Glue: try it at home!
  47. FIN

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