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

Architecture at Scale (droidconNYC 2022)

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.

Benoît Quenaudon

September 02, 2022
Tweet

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