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

Sweet Architecture

Sweet Architecture

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

April 28, 2023
Tweet

More Decks by Benoît Quenaudon

Other Decks in Programming

Transcript

  1. Dianne Hackborn: « 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. » Android APIs
  2. Ui

  3. 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() } }
  4. 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)) .subscribe(this::setModel) } }
  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. data class DependentWelcomeViewModel( val toolbarTitle: String, val title: String, val

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

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

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

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

    View(context, attrs), Ui<DependentWelcomeViewModel, DependentWelcomeViewEvent> { }
  11. class DependentWelcomeView( context: Context, attrs: AttributeSet? = null, ) :

    View(context, attrs), Ui<DependentWelcomeViewModel, DependentWelcomeViewEvent> { // views and buttons setup… }
  12. class DependentWelcomeView( context: Context, attrs: AttributeSet? = null, ) :

    View(context, attrs), Ui<DependentWelcomeViewModel, DependentWelcomeViewEvent> { // views and buttons setup… override fun setEventReceiver(receiver: EventReceiver<DependentWelcomeViewEvent>) { } override fun setModel(model: DependentWelcomeViewModel) { } }
  13. class DependentWelcomeView( context: Context, attrs: AttributeSet? = null, ) :

    View(context, attrs), Ui<DependentWelcomeViewModel, DependentWelcomeViewEvent> { // views and buttons setup… override fun setEventReceiver(receiver: EventReceiver<DependentWelcomeViewEvent>) { button.setOnClickListener { receiver.sendEvent(CtaClicked) } toolbar.setNavigationOnClickListener { receiver.sendEvent(Close) } } override fun setModel(model: DependentWelcomeViewModel) { } }
  14. class DependentWelcomeView( context: Context, attrs: AttributeSet? = null, ) :

    View(context, attrs), Ui<DependentWelcomeViewModel, DependentWelcomeViewEvent> { // views and buttons setup… 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 } }
  15. 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. } }
  16. class DependentWelcomeViewTest( ) { @get:Rule val paparazzi = Paparazzi( theme

    = "Theme.Cash.Default", ) @Test fun tests() { } } Paparazzi: Legacy UI Test https://github.com/cashapp/paparazzi
  17. class DependentWelcomeViewTest( ) { @get:Rule val paparazzi = Paparazzi( theme

    = "Theme.Cash.Default", ) @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
  18. @RunWith(TestParameterInjector::class) class DependentWelcomeViewTest( @TestParameter private val design: DesignTheme, ) {

    @get:Rule val paparazzi = Paparazzi( theme = "Theme.Cash.Default", ) @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
  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
  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
  21. - Write tests. - Record snapshots: ./gradlew module:recordPaparazziDebug - Check

    snapshots into the repository. - Run verify task on CI: ./gradlew module:verifyPaparazziDebug Paparazzi: CI work fl ow https://github.com/cashapp/paparazzi
  22. interface Presenter<UiModel : Any, UiEvent : Any> { val models:

    Flow<UiModel> fun sendEvent(event: UiEvent) }
  23. interface Presenter<UiModel : Any, UiEvent : Any> { interface Binding<UiModel

    : Any, UiEvent : Any> { val models: Flow<UiModel> fun sendEvent(event: UiEvent) } }
  24. 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) } }
  25. 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) } }
  26. interface ObservableTransformer<UiModel : Any, UiEvent : Any> { fun apply(@NonNull

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

    Observable<UiEvent> events): ObservableSource<UiModel> } interface CoroutinePresenter<UiModel : Any, UiEvent : Any> { suspend fun produceModels( events: Flow<UiEvent>, emit: FlowCollector<UiModel>, ) }
  28. 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
  29. 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
  30. ): 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
  31. @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], ) ) } Observable Transformer
  32. @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() // Default model emitted. events.accept(CtaClicked) assertThat(navigator.takeNextScreen()).isEqualTo(InvestingHome()) } Observable Transformer
  33. @Composable fun Content(message: Message) { Column { Text(text = message.author)

    Text(text = message.body) } } Molecule https://github.com/cashapp/molecule
  34. @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) { ProfileModel.Loading } else { ProfileModel.Data(user.name, balance) } } Molecule https://github.com/cashapp/molecule
  35. /** * 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. } Molecule https://github.com/cashapp/molecule enum class RecompositionClock { ContextClock, Immediate, }
  36. /** * 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 enum class RecompositionClock { ContextClock, Immediate, }
  37. 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
  38. 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>, ) }
  39. 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 }
  40. 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
  41. 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 { event -> when (event) { CtaClicked -> { // Start flow. } Close -> { // Navigate back. } } } } } } Molecule Presenter
  42. 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 { event -> when (event) { 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
  43. @Test fun models() = runTest { 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`() = runTest { presenter.test { awaitItem() // Default model emitted. sendEvent(CtaClicked) assertThat(navigator.awaitNextScreen()).isEqualTo(InvestingHome()) } } Molecule Presenter Test: Turbine https://github.com/cashapp/turbine
  44. class SomethingStateManager( database: CashDatabase, private val ioContext: CoroutineContext, ) {

    private val somethingQueries = database.somethingStateQueries @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
  45. interface Navigator { fun goTo(screen: Screen) } LaunchedE f fect(events)

    { events.collect { event -> when (event) { CtaClicked -> { // Start flow. } Close -> { // Navigate. } } } }
  46. interface Navigator { fun goTo(screen: Screen) } LaunchedE f fect(events)

    { events.collect { event -> when (event) { CtaClicked -> { // Start flow. } Close -> { navigator.goTo(SomeScreen()) } } } }
  47. interface ViewFactory { fun createView( screen: Screen, parent: ViewGroup, ):

    Ui<*, *>? } interface PresenterFactory { fun create( screen: Screen, navigator: Navigator, ): Presenter<*, *>? }
  48. 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. } } }
  49. 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
  50. 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
  51. interface ViewFactory { fun createView( screen: Screen, parent: ViewGroup, ):

    Ui<*, *>? } interface PresenterFactory { fun create( screen: Screen, navigator: Navigator, ): Presenter<*, *>? }
  52. interface ViewFactory { fun createView( screen: Screen, parent: ViewGroup, ):

    Ui<*, *>? } interface PresenterFactory { fun create( screen: Screen, navigator: Navigator, ): Presenter<*, *>? } interface TransitionFactory { fun createTransition( fromScreen: Screen, fromView: View, toScreen: Screen, toView: View, parent: ViewGroup, back: Boolean, isTab: (Screen) -> Boolean, ): Animator? }
  53. class InvestingViewFactory @Inject internal constructor( private val customOrder: InvestingCustomOrderView.Factory, private

    val featureFlag: FeatureFlagManager, ) : BroadwayViewFactory { override fun createView(screen: Screen, context: Context, parent: ViewGroup): View? { val view = when (screen) { is DependentWelcomeScreen -> DependentWelcomeView(context) is CustomOrderScreen -> customOrder.build(context) is NotificationSettings -> if (featureFlag.something()) { InvestingNotificationSettingsNew(context) } else { InvestingNotificationSettingsLegacy(context) } else -> XmlFactory.inflate( context = context, layoutResId = when (screen) { is InvestingHome -> R.layout.investing_home else -> return null }, parent = parent, ) } return view } }
  54. class InvestingPresenterFactory @Inject internal constructor( private val notificationSettingsPresenter: InvestingNotificationSettingsPresenter.Factory, private

    val customOrderPresenter: InvestingCustomOrderPresenter.Factory, private val investingHomePresenter: InvestingHomePresenter.Factory, private val dependentWelcomePresenter: DependentWelcomePresenter.Factory, ) : PresenterFactory { override fun create( screen: Screen, navigator: Navigator, ): Presenter<*, *>? { return when (screen) { is DependentWelcomeScreen -> dependentWelcomePresenter.construct(navigator).asPresenter() is CustomOrderScreen -> customOrderPresenter.create(screen, navigator).asPresenter() is InvestingHome -> investingHomePresenter.create(navigator, screen).asPresenter() is NotificationSettings -> notificationSettingsPresenter.create(screen, navigator).asPresenter() else -> null } } }
  55. @Provides fun provideBroadway( viewFactories: Set<ViewFactory>, presenterFactories: Set<PresenterFactory>, ): Broadway {

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

    rest of the owl. Glue: try it at home!
  58. Glue: try it at Cash App! - navigator.goTo(SomeScreen) - Determine

    the screen type: full, overlay, meta. - Back stack management - Tear down old presenter - currentPresenterScope.cancel() - Save UI state - Spin up new presenter - val newPresenter = presenterFactories.asSequence() 
 .mapNotNull { it.create(screen, navigator) } 
 .firstOrNull() - newPresenter.start(scope) - Create UI - val newUi = uiFactories.asSequence() 
 .mapNotNull { it.createUi(screen, themedContext, parent) } 
 .firstOrNull() - Restore UI state if adequate - Bind UI to Presenter - newPresenterScope.launch { 
 ui.setEventReceiver(presenter::sendEvent) 
 presenter.models.collect(ui::setModel) 
 } - Deliver results - Attach UI and runs transition - Remove old UI
  59. FIN

  60. References • Molecule • https://github.com/cashapp/molecule • Turbine • https://github.com/cashapp/turbine •

    Paparazzi • https://github.com/cashapp/paparazzi • Android Jetpack Compose • https://developer.android.com/jetpack/compose • Kotlin Coroutines • https://kotlinlang.org/docs/coroutines-overview.html • TestParameterInjector • https://github.com/google/TestParameterInjector • RxJava • https://github.com/ReactiveX/RxJava • Circuit • https://github.com/slackhq/circuit FIN