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

Sweet Architecture

Sponsored · Ship Features Fearlessly Turn features on and off without deploys. Used by thousands of Ruby developers.

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.

Avatar for Benoît Quenaudon

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