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. Architecture at cale

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

  3. « 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
  4. Screen

  5. 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
  6. Presenter UiModel UiEvent Ui

  7. Presenter UiModel UiEvent Ui Ui

  8. None
  9. data class DependentWelcomeViewModel( val toolbarTitle: String, val title: String, val

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

  11. interface Ui<UiModel : Any, UiEvent : Any> { }

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

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

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

    ContourLayout(context, attrs), Ui<DependentWelcomeViewModel, DependentWelcomeViewEvent> { } ContourLayout https://github.com/cashapp/contour
  15. 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
  16. 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
  17. 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
  18. 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
  19. 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
  20. 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. }
  21. Ui tests

  22. None
  23. Paparazzi

  24. @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
  25. @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
  26. 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
  27. Paparazzi: Reports

  28. - 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
  29. Paparazzi: CI work fl ow https://github.com/cashapp/paparazzi

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

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

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

  33. None
  34. Presenter

  35. interface Presenter<UiModel : Any, UiEvent : Any> { }

  36. interface Presenter<UiModel : Any, UiEvent : Any> { fun start(scope:

    CoroutineScope): Binding<UiModel, UiEvent> }
  37. 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) } }
  38. 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) } }
  39. Presenter UiModel UiEvent Ui Ui

  40. fun map(event): model

  41. interface ObservableTransformer<UiModel : Any, UiEvent : Any> { fun apply(@NonNull

    Observable<UiEvent> upstream): ObservableSource<UiModel> }
  42. 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>, ) }
  43. 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
  44. 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
  45. ): 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
  46. @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
  47. Molecule

  48. @Composable fun Content() { // Create views and stuff. }

    Molecule https://github.com/cashapp/molecule
  49. @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
  50. /** * 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
  51. /** * 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
  52. 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
  53. 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>, ) }
  54. 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 }
  55. 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
  56. 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
  57. 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
  58. @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
  59. class SomethingStateManager( ) { @Composable fun somethingStates(): SomethingState { }

    } Composable Producer
  60. 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
  61. None
  62. Navigation

  63. interface Screen : Parcelable @Parcelize data class StockDetails( val investmentEntityToken:

    InvestmentEntityToken, ) : Screen
  64. interface Navigator { fun goTo(screen: Screen) }

  65. Broadway

  66. Factory

  67. interface ViewFactory { fun createView( screen: Screen, parent: ViewGroup, ):

    Ui<*, *>? } interface PresenterFactory { fun create( screen: Screen, navigator: Navigator, ): Presenter<*, *>? }
  68. 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. } } }
  69. 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
  70. 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
  71. fun ObservableTransformer.asPresenter(): Presenter<UiModel, UiEvent> fun CoroutinePresenter.asPresenter(): Presenter<UiModel, UiEvent> fun MoleculePresenter.asPresenter():

    Presenter<UiModel, UiEvent> Presenter Factory
  72. interface ViewFactory { fun createView( screen: Screen, parent: ViewGroup, ):

    Ui<*, *>? } interface PresenterFactory { fun create( screen: Screen, navigator: Navigator, ): Presenter<*, *>? }
  73. Glue

  74. @Provides fun provideBroadway( viewFactories: Set<ViewFactory>, presenterFactories: Set<PresenterFactory>, ): Broadway {

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

    rest of the owl. Glue: try it at home!
  77. - View swapping. - Animation. - Overlay. - Backstack. -

    Con fi g changes. - Stu ff . Glue
  78. FIN

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