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

    View Slide

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

    View Slide

  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

    View Slide

  4. Screen

    View Slide

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

    View Slide

  6. Presenter
    UiModel
    UiEvent
    Ui

    View Slide

  7. Presenter
    UiModel
    UiEvent
    Ui Ui

    View Slide

  8. View Slide

  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


    }

    View Slide

  10. Ui

    View Slide

  11. interface Ui {


    }

    View Slide

  12. interface Ui {


    interface EventReceiver {


    fun sendEvent(event: UiEvent)


    }


    fun setEventReceiver(receiver: EventReceiver)


    }

    View Slide

  13. interface Ui {


    interface EventReceiver {


    fun sendEvent(event: UiEvent)


    }


    fun setEventReceiver(receiver: EventReceiver)


    fun setModel(model: UiModel)


    }

    View Slide

  14. class DependentWelcomeView(


    context: Context,


    attrs: AttributeSet? = null,


    ) : ContourLayout(context, attrs),


    Ui {


    }


    ContourLayout


    https://github.com/cashapp/contour

    View Slide

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

    View Slide

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

    View Slide



  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) {


    }
    override fun setModel(model: DependentWelcomeViewModel) {


    }


    }


    ContourLayout


    https://github.com/cashapp/contour

    View Slide



  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) {


    button.setOnClickListener { receiver.sendEvent(CtaClicked) }


    toolbar.setNavigationOnClickListener { receiver.sendEvent(Close) }


    }
    override fun setModel(model: DependentWelcomeViewModel) {


    }


    }


    ContourLayout


    https://github.com/cashapp/contour

    View Slide



  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) {


    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

    View Slide

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


    }

    View Slide

  21. Ui tests

    View Slide

  22. View Slide

  23. Paparazzi

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  27. Paparazzi: Reports

    View Slide

  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

    View Slide

  29. Paparazzi: CI work
    fl
    ow


    https://github.com/cashapp/paparazzi

    View Slide

  30. Paparazzi: CI work
    fl
    ow


    https://github.com/cashapp/paparazzi

    View Slide

  31. Paparazzi: CI work
    fl
    ow


    https://github.com/cashapp/paparazzi

    View Slide

  32. runs on
    JVM
    Paparazzi


    https://github.com/cashapp/paparazzi

    View Slide

  33. View Slide

  34. Presenter

    View Slide

  35. interface Presenter {


    }

    View Slide

  36. interface Presenter {


    fun start(scope: CoroutineScope): Binding


    }

    View Slide

  37. interface Presenter {


    fun start(scope: CoroutineScope): Binding


    interface Binding {


    val models: Flow


    fun sendEvent(event: UiEvent)


    }


    }

    View Slide

  38. interface Presenter {


    fun start(scope: CoroutineScope): Binding


    interface Binding {


    // TODO: Change to StateFlow.


    val models: Flow


    fun sendEvent(event: UiEvent)


    }


    }

    View Slide

  39. Presenter
    UiModel
    UiEvent
    Ui Ui

    View Slide

  40. fun map(event): model

    View Slide

  41. interface ObservableTransformer {


    fun apply(@NonNull Observable upstream): ObservableSource


    }


    View Slide

  42. interface ObservableTransformer {


    fun apply(@NonNull Observable upstream): ObservableSource


    }


    interface CoroutinePresenter {


    suspend fun produceModels(


    events: Flow,


    emit: FlowCollector,


    )


    }

    View Slide

  43. class DependentWelcomePresenterOT(


    private val stringManager: StringManager,


    private val database: CashDatabase,


    @Io private val ioScheduler: Scheduler,


    ) : ObservableTransformer {


    override fun apply(


    events: Observable


    ): ObservableSource {


    }


    }
    Observable Transformer

    View Slide

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

    View Slide



  45. ): 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

    View Slide

  46. @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

    View Slide

  47. Molecule

    View Slide

  48. @Composable


    fun Content() {


    // Create views and stuff.


    }
    Molecule


    https://github.com/cashapp/molecule

    View Slide

  49. @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

    View Slide

  50. /**


    * 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

    View Slide

  51. /**


    * 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

    View Slide

  52. val flow: Flow = moleculeFlow(Immediate) {


    profilePresenter(userFlow, balanceFlow)


    }


    val stateFlow: StateFlow = scope.launchMolecule(ContextClock) {


    profilePresenter(userFlow, balanceFlow)


    }
    Molecule


    https://github.com/cashapp/molecule

    View Slide

  53. interface ObservableTransformer {


    fun apply(@NonNull Observable upstream): ObservableSource


    }


    interface CoroutinePresenter {


    suspend fun produceModels(


    events: Flow,


    emit: FlowCollector,


    )


    }

    View Slide

  54. interface CoroutinePresenter {


    suspend fun produceModels(


    events: Flow,


    emit: FlowCollector,


    )


    }


    interface MoleculePresenter {


    @Composable


    fun models(


    events: Flow


    ): UiModel


    }

    View Slide

  55. class DependentWelcomePresenter(


    database: CashDatabase,


    private val stringManager: StringManager,


    @Io private val ioContext: CoroutineContext,


    ) : MoleculePresenter {


    @Composable


    override fun models(events: Flow): DependentWelcomeViewModel {


    }


    }
    Molecule Presenter

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

  59. class SomethingStateManager(


    ) {


    @Composable


    fun somethingStates(): SomethingState {


    }


    }
    Composable Producer

    View Slide

  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

    View Slide

  61. View Slide

  62. Navigation

    View Slide

  63. interface Screen : Parcelable
    @Parcelize


    data class StockDetails(


    val investmentEntityToken: InvestmentEntityToken,


    ) : Screen

    View Slide

  64. interface Navigator {


    fun goTo(screen: Screen)


    }

    View Slide

  65. Broadway

    View Slide

  66. Factory

    View Slide

  67. interface ViewFactory {


    fun createView(


    screen: Screen,


    parent: ViewGroup,


    ): Ui?


    }
    interface PresenterFactory {


    fun create(


    screen: Screen,


    navigator: Navigator,


    ): Presenter?


    }

    View Slide

  68. Presenter Factory
    fun ObservableTransformer.asPresenter()


    : Presenter {


    return object : Presenter {


    override fun start(


    scope: CoroutineScope,


    ): Presenter.Binding {


    // Stuff.


    }


    }


    }

    View Slide

  69. fun CoroutinePresenter.asPresenter()


    : Presenter {


    return object : Presenter {


    override fun start(


    scope: CoroutineScope,


    ): Presenter.Binding {


    // Stuff.


    }


    }


    }
    Presenter Factory

    View Slide

  70. fun MoleculePresenter.asPresenter()


    : Presenter {


    return object : Presenter {


    override fun start(


    scope: CoroutineScope,


    ): Presenter.Binding {


    // Stuff.


    }


    }


    }
    Presenter Factory

    View Slide

  71. fun ObservableTransformer.asPresenter(): Presenter


    fun CoroutinePresenter.asPresenter(): Presenter


    fun MoleculePresenter.asPresenter(): Presenter
    Presenter Factory

    View Slide

  72. interface ViewFactory {


    fun createView(


    screen: Screen,


    parent: ViewGroup,


    ): Ui?


    }
    interface PresenterFactory {


    fun create(


    screen: Screen,


    navigator: Navigator,


    ): Presenter?


    }

    View Slide

  73. Glue

    View Slide

  74. @Provides


    fun provideBroadway(


    viewFactories: Set,


    presenterFactories: Set,


    ): Broadway {


    return Broadway(


    viewFactories = viewFactories.toList(),


    presenterFactories = presenterFactories.toList()


    )


    }
    Glue

    View Slide

  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

    View Slide

  76. - Hook up your factories into Broadway.


    - Draw the rest of the owl.
    Glue: try it at home!

    View Slide

  77. - View swapping.


    - Animation.


    - Overlay.


    - Backstack.


    - Con
    fi
    g changes.


    - Stu
    ff
    .
    Glue

    View Slide

  78. FIN

    View Slide

  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

    View Slide