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

U+2019: Modern Android Architecture

U+2019: Modern Android Architecture

There's plenty of ways to design an Android application, most of them are correct and some of them scale well. The Cash Android app is growing in size and complexity, which constantly prompts us to re-evaluate our design decisions. This talk will provide a thorough overview of the state of our app's architecture, the challenges we're facing and how we're planning to address them. The topics will include:

* Modularization
* Build tooling
* Navigation
* RxJava
* Testing
* and more

Square is widely known for building great open source software, and this talk will go over our open source stack and ways in which your project can benefit from the tools we built!

Egor Andreevich

March 30, 2019
Tweet

Transcript

  1. Release Model • Release trains • Go-live every 2 weeks

    • Only hot-fixes allowed in between • Daily server deploys
  2. Backwards Compat • ~24% of customers on older versions •

    Mission-critical • Frequent server deploys • Moving fast
  3. Workflow .proto files uploaded to Artifactory .zip with protos gets

    pulled down Protos compiled into .java/.kt Generated code checked into git
  4. 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<PhoneNumber> 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<PhoneNumber, PhoneNumber.Builder> { public final String number; public final PhoneType type; // constructors, equals, hashCode, toString public static final class Builder {} } .java (458 lines in original) Wire 2
  5. 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<PhoneNumber> = 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
  6. 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
  7. interface AppService { @POST("/2.0/cash/initiate-payment") fun initiatePayment( @Body request: InitiatePaymentRequest ):

    Observable<InitiatePaymentResponse> @POST("/2.0/cash/get-profile") fun getProfile( @Body request: GetProfileRequest ): Observable<GetProfileResponse> // other methods } AppService RxJava Observables Request/response are protos
  8. ORM Room SQLDelight Annotate entities Use custom DSL Annotate entities

    Write SQL Bring your own DSL Write SQL Use generated code
  9. 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!
  10. app enters backend Facades for :db and :api Models for

    upper layers based on protos Mostly interfaces
  11. class AppMessagePresenter( cashDatabase: CashDatabase ) { private val appMessageQueries =

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

    appMessageQueries = cashDatabase.appMessageQueries override fun subscribe() { val activityInlineAppMessage = appMessageQueries .foregroundVideo(ACTIVITY_INLINE) .asObservable(ioScheduler) .mapToKOptional() } }
  13. 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 } } }
  14. class RealAppMessageManager( cashDatabase: CashDatabase, profileManager: ProfileManager ) { private val

    appMessageQueries = cashDatabase.appMessageQueries override fun activityInlineAppMessage(): Observable<AppMessage> { return combineLatest( appMessageQueries .foregroundVideo(ACTIVITY_INLINE) .asObservable(ioScheduler) .mapToKOptional(), profileManager.profile() ) { appMessage, profile -> // a lot of code } } }
  15. class AppMessagePresenter( appMessageManager: AppMessageManager ) { override fun subscribe() {

    val activityInlineAppMessage = appMessageManager .activityInlineAppMessage .subscribe() } }
  16. class AppMessagePresenterTest { private val appMessageManager = FakeAppMessageManager() private fun

    makePresenter() { return AppMessagePresenter(appMessageManager) } }
  17. 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) } }
  18. 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) } } }
  19. 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() }
  20. class FileBlockerPresenter : ObservableTransformer<FileBlockerViewEvent, FileBlockerViewModel> { override fun apply( viewEvents:

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

    Observable<FileBlockerViewEvent> ): ObservableSource<FileBlockerViewModel> { return viewEvents.publish { events -> Observable.merge( listOf( events.filterIsInstance<NavigationAction>().compose(navigationLogic), events.filterIsInstance<SupportClick>().compose(goToSupport), events.filterIsInstance<CaptureCompleted>().compose(uploadBitmaps) ) ) } } private val navigationLogic = ObservableTransformer<NavigationAction, FileBlockerViewModel> { events -> events.doOnNext { event -> navigator.goTo( when (event) { // convert an event into a navigation target } } } }
  22. class FileBlockerPresenterTest { private lateinit var events: PublishSubject<FileBlockerViewEvent> private lateinit

    var viewModels: TestObserver<FileBlockerViewModel> private val navigator = FakeNavigator() }
  23. class FileBlockerPresenterTest { private lateinit var events: PublishSubject<FileBlockerViewEvent> private lateinit

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

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

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

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

    var viewModels: TestObserver<FileBlockerViewModel> private val navigator = FakeNavigator() private fun setupPresenter() { events = PublishSubject.create<FileBlockerViewEvent>() 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) } }
  28. class FileBlockerPresenterTest { private lateinit var events: PublishSubject<FileBlockerViewEvent> private lateinit

    var viewModels: TestObserver<FileBlockerViewModel> private val navigator = FakeNavigator() private fun setupPresenter() { events = PublishSubject.create<FileBlockerViewEvent>() 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) } }
  29. class FileBlockerView( context: Context, attrs: AttributeSet? ) : RelativeLayout(context, attrs)

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

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

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

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

    { private lateinit var presenter: FileBlockerPresenter private val viewEvents = PublishSubject.create<FileBlockerViewEvent>() 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() } }
  34. Better modularization • Key to faster builds • Shard by

    feature • Rely on common modules • Move Android dependencies up
  35. More Kotlin • Finish migration to Kotlin • Remove javac

    build step • More libraries converted to Kotlin • Multiplatform!
  36. Sharing code with iOS • Kotlin, yes, kudos • SQLDelight

    • Retrofit + OkHttp + Wire + Okio • Entire stack except for UI