Slide 1

Slide 1 text

U+2019 @egorand Modern Android Architecture

Slide 2

Slide 2 text

U+2020

Slide 3

Slide 3 text

No content

Slide 4

Slide 4 text

No content

Slide 5

Slide 5 text

Stored Balance

Slide 6

Slide 6 text

Stored Balance P2P Payments

Slide 7

Slide 7 text

Stored Balance P2P Payments Cash Card

Slide 8

Slide 8 text

Stored Balance P2P Payments Cash Card In-App Receipts

Slide 9

Slide 9 text

Stored Balance P2P Payments Cash Card In-App Receipts Cash-Ins

Slide 10

Slide 10 text

Stored Balance P2P Payments Cash Card In-App Receipts Instant Cash-Outs* Cash-Ins

Slide 11

Slide 11 text

Stored Balance P2P Payments Cash Card In-App Receipts Instant Cash-Outs* Boosts Cash-Ins

Slide 12

Slide 12 text

Stored Balance P2P Payments Cash Card In-App Receipts Instant Cash-Outs* Boosts Bitcoin Cash-Ins

Slide 13

Slide 13 text

Challenges

Slide 14

Slide 14 text

Release Model • Release trains • Go-live every 2 weeks • Only hot-fixes allowed in between • Daily server deploys

Slide 15

Slide 15 text

Backwards Compat • ~24% of customers on older versions • Mission-critical • Frequent server deploys • Moving fast

Slide 16

Slide 16 text

Workstreams • Features developed in parallel • Shared codebase • Merges into master • Android/iOS parity

Slide 17

Slide 17 text

Migration to Kotlin • Better developer experience • Faster builds • Large codebase • Complex code

Slide 18

Slide 18 text

Architectural debt • Multiple presenter implementations • Navigation framework • Testability • Large modules

Slide 19

Slide 19 text

Architecture Overview

Slide 20

Slide 20 text

app presenters backend viewmodels api db protos

Slide 21

Slide 21 text

protos Protocol Buffer messages Generated code Gradle configs for pulling and compiling protos

Slide 22

Slide 22 text

Protocol Buffers Schema Compilers for many platforms Binary on the wire Backwards compatible

Slide 23

Slide 23 text

Workflow .proto files uploaded to Artifactory .zip with protos gets pulled down Protos compiled into .java/.kt Generated code checked into git

Slide 24

Slide 24 text

Wire .proto compiler built at Square Android-aware Java + Kotlin CLI + Gradle

Slide 25

Slide 25 text

message Person { // The customer's full name. required string name = 1; // The customer's ID number. required int32 id = 2; // Email address for the customer. optional string email = 3; enum PhoneType { MOBILE = 0; HOME = 1; /** Could be phone or fax. */ WORK = 2; } message PhoneNumber { // The customer's phone number. required string number = 1; // The type of phone stored here. optional PhoneType type = 2 [default = HOME]; } // A list of the customer's phone numbers. repeated PhoneNumber phone = 4; } .proto public final class Person extends Message { public final String name; public final Integer id; public final String email; public final List phone; // constructors, equals, hashCode, toString public static final class Builder {} public enum PhoneType implements WireEnum { MOBILE(0), HOME(1), WORK(2); } public static final class PhoneNumber extends Message { public final String number; public final PhoneType type; // constructors, equals, hashCode, toString public static final class Builder {} } .java (458 lines in original) Wire 2

Slide 26

Slide 26 text

message Person { // The customer's full name. required string name = 1; // The customer's ID number. required int32 id = 2; // Email address for the customer. optional string email = 3; enum PhoneType { MOBILE = 0; HOME = 1; /** Could be phone or fax. */ WORK = 2; } message PhoneNumber { // The customer's phone number. required string number = 1; // The type of phone stored here. optional PhoneType type = 2 [default = HOME]; } // A list of the customer's phone numbers. repeated PhoneNumber phone = 4; } .proto data class Person( val name: String, val id: Int, val email: String? = null, val phone: List = emptyList(), val unknownFields: ByteString = ByteString.EMPTY ) : Message { enum class PhoneType(private val value: Int) : WireEnum { MOBILE(0), HOME(1), WORK(2); } data class PhoneNumber( val number: String, val type: PhoneType? = PhoneType.HOME, val unknownFields: ByteString = ByteString.EMPTY ) : Message } .kt (206 lines in original) Wire 3

Slide 27

Slide 27 text

Wire 2 task generateProtos(type: JavaExec) { classpath = configurations.wire main = 'com.squareup.wire.WireCompiler' args = [ '--proto_path=protos/src/test/proto', '--java_out=protos/src/test/proto-java', '--android', 'squareup.cash.Person' ] } apply plugin: 'com.squareup.wire' wire { sourcePath 'com.squareup.cash:protos:+' roots 'squareup.cash.Person' kotlin { out "${buildDir}/generated/custom" } } Wire 3

Slide 28

Slide 28 text

b viewmodels api p Retrofit interface OkHttp interceptors Networking helpers

Slide 29

Slide 29 text

interface AppService { @POST("/2.0/cash/initiate-payment") fun initiatePayment( @Body request: InitiatePaymentRequest ): Observable @POST("/2.0/cash/get-profile") fun getProfile( @Body request: GetProfileRequest ): Observable // other methods } AppService RxJava Observables Request/response are protos

Slide 30

Slide 30 text

Blockers InitiatePaymentResponse responseContext: ResponseContext ResponseContext blockers: List SetNameBlocker PasscodeBlocker SetAddressBlocker

Slide 31

Slide 31 text

Client 1. InitiatePaymentRequest Server 1. Passcode verification required 2. Send a passcode verification blocker

Slide 32

Slide 32 text

Client 3. Display PasscodeVerification screen 4. Prompt for passcode 5. Send passcode hash to server

Slide 33

Slide 33 text

Client Server 6. IDV required 7. Send IDV blocker etc.

Slide 34

Slide 34 text

ackend protos db DB schema in .sq files Generated SQLDelight code Migrations in .sqm files

Slide 35

Slide 35 text

ORM Room SQLDelight Annotate entities Use custom DSL Annotate entities Write SQL Bring your own DSL Write SQL Use generated code

Slide 36

Slide 36 text

Why?

Slide 37

Slide 37 text

Everyone understands SQL SQL is standardized SQL has tooling

Slide 38

Slide 38 text

import com.example.sqldelight.hockey.data.PlayerVals; import com.example.sqldelight.hockey.data.Date; CREATE TABLE player ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, first_name TEXT NOT NULL, last_name TEXT NOT NULL, number INTEGER AS Integer NOT NULL, team INTEGER, age INTEGER AS Integer NOT NULL, birth_date INTEGER AS Date NOT NULL, weight REAL AS Float NOT NULL, shoots TEXT AS PlayerVals.Shoots NOT NULL, position TEXT AS PlayerVals.Position NOT NULL, FOREIGN KEY (team) REFERENCES team(id) ); insertPlayer: INSERT INTO player (first_name, last_name, number, team) VALUES (?, ?, ?, (SELECT id FROM team WHERE name = ?)); selectAll: SELECT * FROM player JOIN team ON player.team = team.id; forTeam: SELECT first_name, last_name, number, team.name AS teamName FROM player JOIN team ON player.team = team.id WHERE team.id = :team_id OR :team_id = -1; SQLDelight IDE plugin Gradle plugin Migrations verifier Multiplatform!

Slide 39

Slide 39 text

Workflow Add SQL to .sq files Run ./gradlew :db:assemble Use generated code

Slide 40

Slide 40 text

Workflow: Migrations Add SQL to a new .sqm file Run ./gradlew :db:verifyMigration

Slide 41

Slide 41 text

app enters backend Facades for :db and :api Models for upper layers based on protos Mostly interfaces

Slide 42

Slide 42 text

class AppMessagePresenter( cashDatabase: CashDatabase ) { private val appMessageQueries = cashDatabase.appMessageQueries }

Slide 43

Slide 43 text

class AppMessagePresenter( cashDatabase: CashDatabase ) { private val appMessageQueries = cashDatabase.appMessageQueries override fun subscribe() { val activityInlineAppMessage = appMessageQueries .foregroundVideo(ACTIVITY_INLINE) .asObservable(ioScheduler) .mapToKOptional() } }

Slide 44

Slide 44 text

class AppMessagePresenter( cashDatabase: CashDatabase, profileManager: ProfileManager ) { private val appMessageQueries = cashDatabase.appMessageQueries override fun subscribe() { val activityInlineAppMessage = appMessageQueries .foregroundVideo(ACTIVITY_INLINE) .asObservable(ioScheduler) .mapToKOptional() } }

Slide 45

Slide 45 text

class AppMessagePresenter( cashDatabase: CashDatabase, profileManager: ProfileManager ) { private val appMessageQueries = cashDatabase.appMessageQueries override fun subscribe() { val activityInlineAppMessage = combineLatest( appMessageQueries .foregroundVideo(ACTIVITY_INLINE) .asObservable(ioScheduler) .mapToKOptional(), profileManager.profile() ) { appMessage, profile -> // a lot of code } } }

Slide 46

Slide 46 text

AppMessageManager activityInlineAppMessage(): Observable RealAppMessageManager FakeAppMessageManager

Slide 47

Slide 47 text

class RealAppMessageManager( cashDatabase: CashDatabase, profileManager: ProfileManager ) { private val appMessageQueries = cashDatabase.appMessageQueries override fun activityInlineAppMessage(): Observable { return combineLatest( appMessageQueries .foregroundVideo(ACTIVITY_INLINE) .asObservable(ioScheduler) .mapToKOptional(), profileManager.profile() ) { appMessage, profile -> // a lot of code } } }

Slide 48

Slide 48 text

class AppMessagePresenter( appMessageManager: AppMessageManager ) { override fun subscribe() { val activityInlineAppMessage = appMessageManager .activityInlineAppMessage .subscribe() } }

Slide 49

Slide 49 text

class FakeAppMessageManager : AppMessageManager { val activityInlineAppMessages = BehaviorRelay.create() override fun activityInlineAppMessage() = activityInlineAppMessages.hide() }

Slide 50

Slide 50 text

class AppMessagePresenterTest { private val appMessageManager = FakeAppMessageManager() }

Slide 51

Slide 51 text

class AppMessagePresenterTest { private val appMessageManager = FakeAppMessageManager() private fun makePresenter() { return AppMessagePresenter(appMessageManager) } }

Slide 52

Slide 52 text

class AppMessagePresenterTest { private val appMessageManager = FakeAppMessageManager() private fun makePresenter() { return AppMessagePresenter(appMessageManager) } @Test fun `appMessage is included in viewmodel`() { val viewModel = Observable.wrap(makePresenter()).test() val expectedAppMessage = AppMessage() appMessageManager.activityInlineAppMessages.accept(appMessage) } }

Slide 53

Slide 53 text

class AppMessagePresenterTest { private val appMessageManager = FakeAppMessageManager() private fun makePresenter() { return AppMessagePresenter(appMessageManager) } @Test fun `appMessage is included in viewmodel`() { val viewModel = Observable.wrap(makePresenter()).test() val expectedAppMessage = AppMessage() appMessageManager.activityInlineAppMessages.accept(appMessage) with(viewModel.latest()) { assertThat(appMessage).isEqualTo(expectedAppMessage) } } }

Slide 54

Slide 54 text

a pres viewmodels ViewModels: presenter → view ViewEvents: view → presenter Data classes

Slide 55

Slide 55 text

ViewModel ViewEvent View Presenter

Slide 56

Slide 56 text

ViewModel ViewEvent data class SendPaymentViewModel( val toolbarActionText: String, val toolbarAmount: String, val selectedInstrument: String? = null ) sealed class SendPaymentViewEvent { object SendPayment : SendPaymentViewEvent() class UpdateNote( val note: String = "" ) : SendPaymentViewEvent() }

Slide 57

Slide 57 text

app presenters Subscribe to ViewEvents Emit ViewModels Handle navigation

Slide 58

Slide 58 text

Stateless

Slide 59

Slide 59 text

class FileBlockerPresenter : ObservableTransformer

Slide 60

Slide 60 text

class FileBlockerPresenter : ObservableTransformer { override fun apply( viewEvents: Observable ): ObservableSource { return viewEvents.publish { events -> Observable.merge( listOf( events.filterIsInstance().compose(navigationLogic), events.filterIsInstance().compose(goToSupport), events.filterIsInstance().compose(uploadBitmaps) ) ) } } }

Slide 61

Slide 61 text

class FileBlockerPresenter : ObservableTransformer { override fun apply( viewEvents: Observable ): ObservableSource { return viewEvents.publish { events -> Observable.merge( listOf( events.filterIsInstance().compose(navigationLogic), events.filterIsInstance().compose(goToSupport), events.filterIsInstance().compose(uploadBitmaps) ) ) } } private val navigationLogic = ObservableTransformer { events -> events.doOnNext { event -> navigator.goTo( when (event) { // convert an event into a navigation target } } } }

Slide 62

Slide 62 text

class FileBlockerPresenterTest

Slide 63

Slide 63 text

class FileBlockerPresenterTest { private lateinit var events: PublishSubject private lateinit var viewModels: TestObserver private val navigator = FakeNavigator() }

Slide 64

Slide 64 text

class FileBlockerPresenterTest { private lateinit var events: PublishSubject private lateinit var viewModels: TestObserver private val navigator = FakeNavigator() private fun setupPresenter() { events = PublishSubject.create() val presenter = FileBlockerPresenter(navigator) viewModels = events .compose(presenter) .test() } }

Slide 65

Slide 65 text

class FileBlockerPresenterTest { private lateinit var events: PublishSubject private lateinit var viewModels: TestObserver private val navigator = FakeNavigator() private fun setupPresenter() { events = PublishSubject.create() val presenter = FileBlockerPresenter(navigator) viewModels = events .compose(presenter) .test() } @Test fun captureCompleted_noDialogMessage() { } }

Slide 66

Slide 66 text

class FileBlockerPresenterTest { private lateinit var events: PublishSubject private lateinit var viewModels: TestObserver private val navigator = FakeNavigator() private fun setupPresenter() { events = PublishSubject.create() val presenter = FileBlockerPresenter(navigator) viewModels = events .compose(presenter) .test() } @Test fun captureCompleted_noDialogMessage() { navigator.nextScreen = Profile } }

Slide 67

Slide 67 text

class FileBlockerPresenterTest { private lateinit var events: PublishSubject private lateinit var viewModels: TestObserver private val navigator = FakeNavigator() private fun setupPresenter() { events = PublishSubject.create() val presenter = FileBlockerPresenter(navigator) viewModels = events .compose(presenter) .test() } @Test fun captureCompleted_noDialogMessage() { navigator.nextScreen = Profile events.onNext(CaptureCompleted()) } }

Slide 68

Slide 68 text

class FileBlockerPresenterTest { private lateinit var events: PublishSubject private lateinit var viewModels: TestObserver private val navigator = FakeNavigator() private fun setupPresenter() { events = PublishSubject.create() val presenter = FileBlockerPresenter(navigator) viewModels = events .compose(presenter) .test() } @Test fun captureCompleted_noDialogMessage() { navigator.nextScreen = Profile events.onNext(CaptureCompleted()) viewModels.assertValueAt(0, Uploading) viewModels.assertValueCount(1) } }

Slide 69

Slide 69 text

class FileBlockerPresenterTest { private lateinit var events: PublishSubject private lateinit var viewModels: TestObserver private val navigator = FakeNavigator() private fun setupPresenter() { events = PublishSubject.create() val presenter = FileBlockerPresenter(navigator) viewModels = events .compose(presenter) .test() } @Test fun captureCompleted_noDialogMessage() { navigator.nextScreen = Profile events.onNext(CaptureCompleted()) viewModels.assertValueAt(0, Uploading) viewModels.assertValueCount(1) assertThat(navigator.takeNextScreen()).isEqualTo(Profile) } }

Slide 70

Slide 70 text

app Emit ViewEvents Render ViewModels Only talk to presenters

Slide 71

Slide 71 text

class FileBlockerView( context: Context, attrs: AttributeSet? ) : RelativeLayout(context, attrs)

Slide 72

Slide 72 text

class FileBlockerView( context: Context, attrs: AttributeSet? ) : RelativeLayout(context, attrs) { private lateinit var presenter: FileBlockerPresenter }

Slide 73

Slide 73 text

class FileBlockerView( context: Context, attrs: AttributeSet? ) : RelativeLayout(context, attrs) { private lateinit var presenter: FileBlockerPresenter private val viewEvents = PublishSubject.create() }

Slide 74

Slide 74 text

class FileBlockerView( context: Context, attrs: AttributeSet? ) : RelativeLayout(context, attrs) { private lateinit var presenter: FileBlockerPresenter private val viewEvents = PublishSubject.create() private lateinit var disposables: CompositeDisposable }

Slide 75

Slide 75 text

class FileBlockerView( context: Context, attrs: AttributeSet? ) : RelativeLayout(context, attrs) { private lateinit var presenter: FileBlockerPresenter private val viewEvents = PublishSubject.create() private lateinit var disposables: CompositeDisposable override fun onAttachedToWindow() { super.onAttachedToWindow() disposables = CompositeDisposable() } }

Slide 76

Slide 76 text

class FileBlockerView( context: Context, attrs: AttributeSet? ) : RelativeLayout(context, attrs) { private lateinit var presenter: FileBlockerPresenter private val viewEvents = PublishSubject.create() private lateinit var disposables: CompositeDisposable override fun onAttachedToWindow() { super.onAttachedToWindow() disposables = CompositeDisposable() disposables += viewEvents .compose(presenter) .observeOn(AndroidSchedulers.mainThread()) .errorHandlingSubscribe(::renderViewModel) } }

Slide 77

Slide 77 text

class FileBlockerView( context: Context, attrs: AttributeSet? ) : RelativeLayout(context, attrs) { private lateinit var presenter: FileBlockerPresenter private val viewEvents = PublishSubject.create() private lateinit var disposables: CompositeDisposable override fun onAttachedToWindow() { super.onAttachedToWindow() disposables = CompositeDisposable() disposables += viewEvents .compose(presenter) .observeOn(AndroidSchedulers.mainThread()) .errorHandlingSubscribe(::renderViewModel) } override fun onDetachedFromWindow() { super.onDetachedFromWindow() disposables.dispose() } }

Slide 78

Slide 78 text

What’s next?

Slide 79

Slide 79 text

Better modularization • Key to faster builds • Shard by feature • Rely on common modules • Move Android dependencies up

Slide 80

Slide 80 text

presenters backend viewmodels api db protos app

Slide 81

Slide 81 text

app P2P Support Card

Slide 82

Slide 82 text

More Kotlin • Finish migration to Kotlin • Remove javac build step • More libraries converted to Kotlin • Multiplatform!

Slide 83

Slide 83 text

Sharing code with iOS • Kotlin, yes, kudos • SQLDelight • Retrofit + OkHttp + Wire + Okio • Entire stack except for UI

Slide 84

Slide 84 text

Thanks! @egorand