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 full-size slide

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

    View full-size 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 full-size slide

  4. 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 full-size slide

  5. Presenter
    UiModel
    UiEvent
    Ui

    View full-size slide

  6. Presenter
    UiModel
    UiEvent
    Ui Ui

    View full-size slide

  7. 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 full-size slide

  8. interface Ui {


    }

    View full-size slide

  9. interface Ui {


    interface EventReceiver {


    fun sendEvent(event: UiEvent)


    }


    fun setEventReceiver(receiver: EventReceiver)


    }

    View full-size slide

  10. interface Ui {


    interface EventReceiver {


    fun sendEvent(event: UiEvent)


    }


    fun setEventReceiver(receiver: EventReceiver)


    fun setModel(model: UiModel)


    }

    View full-size slide

  11. class DependentWelcomeView(


    context: Context,


    attrs: AttributeSet? = null,


    ) : ContourLayout(context, attrs),


    Ui {


    }


    ContourLayout


    https://github.com/cashapp/contour

    View full-size slide

  12. 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 full-size slide

  13. 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 full-size slide



  14. 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 full-size slide



  15. 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 full-size slide



  16. 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 full-size slide

  17. 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 full-size slide

  18. @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 full-size slide

  19. @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 full-size slide

  20. 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 full-size slide

  21. Paparazzi: Reports

    View full-size slide

  22. - 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 full-size slide

  23. Paparazzi: CI work
    fl
    ow


    https://github.com/cashapp/paparazzi

    View full-size slide

  24. Paparazzi: CI work
    fl
    ow


    https://github.com/cashapp/paparazzi

    View full-size slide

  25. Paparazzi: CI work
    fl
    ow


    https://github.com/cashapp/paparazzi

    View full-size slide

  26. runs on
    JVM
    Paparazzi


    https://github.com/cashapp/paparazzi

    View full-size slide

  27. interface Presenter {


    }

    View full-size slide

  28. interface Presenter {


    fun start(scope: CoroutineScope): Binding


    }

    View full-size slide

  29. interface Presenter {


    fun start(scope: CoroutineScope): Binding


    interface Binding {


    val models: Flow


    fun sendEvent(event: UiEvent)


    }


    }

    View full-size slide

  30. interface Presenter {


    fun start(scope: CoroutineScope): Binding


    interface Binding {


    // TODO: Change to StateFlow.


    val models: Flow


    fun sendEvent(event: UiEvent)


    }


    }

    View full-size slide

  31. Presenter
    UiModel
    UiEvent
    Ui Ui

    View full-size slide

  32. fun map(event): model

    View full-size slide

  33. interface ObservableTransformer {


    fun apply(@NonNull Observable upstream): ObservableSource


    }


    View full-size slide

  34. interface ObservableTransformer {


    fun apply(@NonNull Observable upstream): ObservableSource


    }


    interface CoroutinePresenter {


    suspend fun produceModels(


    events: Flow,


    emit: FlowCollector,


    )


    }

    View full-size slide

  35. 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 full-size slide

  36. 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 full-size slide



  37. ): 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 full-size slide

  38. @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 full-size slide

  39. @Composable


    fun Content() {


    // Create views and stuff.


    }
    Molecule


    https://github.com/cashapp/molecule

    View full-size slide

  40. @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 full-size slide

  41. /**


    * 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 full-size slide

  42. /**


    * 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 full-size slide

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


    profilePresenter(userFlow, balanceFlow)


    }


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


    profilePresenter(userFlow, balanceFlow)


    }
    Molecule


    https://github.com/cashapp/molecule

    View full-size slide

  44. interface ObservableTransformer {


    fun apply(@NonNull Observable upstream): ObservableSource


    }


    interface CoroutinePresenter {


    suspend fun produceModels(


    events: Flow,


    emit: FlowCollector,


    )


    }

    View full-size slide

  45. interface CoroutinePresenter {


    suspend fun produceModels(


    events: Flow,


    emit: FlowCollector,


    )


    }


    interface MoleculePresenter {


    @Composable


    fun models(


    events: Flow


    ): UiModel


    }

    View full-size slide

  46. class DependentWelcomePresenter(


    database: CashDatabase,


    private val stringManager: StringManager,


    @Io private val ioContext: CoroutineContext,


    ) : MoleculePresenter {


    @Composable


    override fun models(events: Flow): DependentWelcomeViewModel {


    }


    }
    Molecule Presenter

    View full-size slide

  47. 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 full-size slide

  48. 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 full-size slide

  49. @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 full-size slide

  50. class SomethingStateManager(


    ) {


    @Composable


    fun somethingStates(): SomethingState {


    }


    }
    Composable Producer

    View full-size slide

  51. 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 full-size slide

  52. interface Screen : Parcelable
    @Parcelize


    data class StockDetails(


    val investmentEntityToken: InvestmentEntityToken,


    ) : Screen

    View full-size slide

  53. interface Navigator {


    fun goTo(screen: Screen)


    }

    View full-size slide

  54. interface ViewFactory {


    fun createView(


    screen: Screen,


    parent: ViewGroup,


    ): Ui<*, *>?


    }
    interface PresenterFactory {


    fun create(


    screen: Screen,


    navigator: Navigator,


    ): Presenter<*, *>?


    }

    View full-size slide

  55. Presenter Factory
    fun ObservableTransformer.asPresenter()


    : Presenter {


    return object : Presenter {


    override fun start(


    scope: CoroutineScope,


    ): Presenter.Binding {


    // Stuff.


    }


    }


    }

    View full-size slide

  56. fun CoroutinePresenter.asPresenter()


    : Presenter {


    return object : Presenter {


    override fun start(


    scope: CoroutineScope,


    ): Presenter.Binding {


    // Stuff.


    }


    }


    }
    Presenter Factory

    View full-size slide

  57. fun MoleculePresenter.asPresenter()


    : Presenter {


    return object : Presenter {


    override fun start(


    scope: CoroutineScope,


    ): Presenter.Binding {


    // Stuff.


    }


    }


    }
    Presenter Factory

    View full-size slide

  58. fun ObservableTransformer.asPresenter(): Presenter


    fun CoroutinePresenter.asPresenter(): Presenter


    fun MoleculePresenter.asPresenter(): Presenter
    Presenter Factory

    View full-size slide

  59. interface ViewFactory {


    fun createView(


    screen: Screen,


    parent: ViewGroup,


    ): Ui<*, *>?


    }
    interface PresenterFactory {


    fun create(


    screen: Screen,


    navigator: Navigator,


    ): Presenter<*, *>?


    }

    View full-size slide

  60. @Provides


    fun provideBroadway(


    viewFactories: Set,


    presenterFactories: Set,


    ): Broadway {


    return Broadway(


    viewFactories = viewFactories.toList(),


    presenterFactories = presenterFactories.toList()


    )


    }
    Glue

    View full-size slide

  61. 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 full-size slide

  62. - Hook up your factories into Broadway.


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

    View full-size slide

  63. - View swapping.


    - Animation.


    - Overlay.


    - Backstack.


    - Con
    fi
    g changes.


    - Stu
    ff
    .
    Glue

    View full-size slide

  64. 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 full-size slide