Presented as a joint talk with @marcoGomier at Advanced Kotlin Dev Day, Amsterdam, 2022.11.24.
@marcoGomier @stewemetalMarco GomieroAdoptingJetpack ComposeSafely👨💻 Senior Android Engineer @ TIER Google Developer Expert for Kotlin👨💻 Senior Android Engineer @ TIER Co-organizer of Kotlin BudapestIstván Juhos
View Slide
@marcoGomier @stewemetalCompose in the wild
@marcoGomier @stewemetalWHY?
@marcoGomier @stewemetalNo more RecyclerView Adapters🥳
@marcoGomier @stewemetalWhy Compose?🤓 Less code😎 More fun😍 Kotlin
@marcoGomier @stewemetalStart using Compose
@marcoGomier @stewemetal🚧 ⛔ Start using Compose
@marcoGomier @stewemetalPre-ComposeArchitecture
@marcoGomier @stewemetalinternal class ArticleDetailsViewModel(...) : BaseViewModel() {@get:Bindable var title: String by binding("", BR.title)@get:Bindable var price: String by binding("", BR.price)...interface Input {val articleId: PublishRelay}ㅤ interface Output {val articleNotFound: PublishRelayval articleDetailsUIState: BehaviorRelay}ㅤ val input = object : Input {override val articleId = PublishRelay.create()}val output = object : Output {override val articleNotFound = PublishRelay.create()override val state = BehaviorRelay.create()}override fun onViewResumed() {super.onViewResumed()input.articleId.flatMap { /* Get stuff from network */ }.observeOn(schedulerProvider.mainThread()).subscribeOn(schedulerProvider.io()).subscribe({ data ->// do thingstitle = "Title"output.state.accept(aState)},{ error ->output.hideTopBanner.accept(Unit)},).disposeOnPause()}ㅤ }Pre-Compose ViewModel
@marcoGomier @stewemetalinternal class ArticleDetailsViewModel(...) : BaseViewModel() {@get:Bindable var title: String by binding("", BR.title)@get:Bindable var price: String by binding("", BR.price)...interface Input {val articleId: PublishRelay}ㅤ interface Output {val articleNotFound: PublishRelayval articleDetailsUIState: BehaviorRelay}ㅤ val input = object : Input {override val articleId = PublishRelay.create()}val output = object : Output {override val articleNotFound = PublishRelay.create()override val state = BehaviorRelay.create()}override fun onViewResumed() {super.onViewResumed()input.articleId.flatMap { /* Get stuff from network */ }.observeOn(schedulerProvider.mainThread()).subscribeOn(schedulerProvider.io()).subscribe({ data ->// do thingstitle = "Title"output.state.accept(aState)},{ error ->output.hideTopBanner.accept(Unit)},).disposeOnPause()}}
@marcoGomier @stewemetalinternal class ArticleDetailsViewModel(...) : BaseViewModel() {@get:Bindable var title: String by binding("", BR.title)@get:Bindable var price: String by binding("", BR.price)...interface Input {val articleId: PublishRelay}ㅤ interface Output {val articleNotFound: PublishRelayval articleDetailsUIState: BehaviorRelay}ㅤ val input = object : Input {override val articleId = PublishRelay.create()}val output = object : Output {override val articleNotFound = PublishRelay.create()override val state = BehaviorRelay.create()}override fun onViewResumed() {super.onViewResumed()input.articleId.flatMap { /* Get stuff from network */ }.observeOn(schedulerProvider.mainThread()).subscribeOn(schedulerProvider.io()).subscribe({ data ->// do thingstitle = "Title"output.state.accept(aState)},{ error ->output.hideTopBanner.accept(Unit)},).disposeOnPause()}}Data Binding 💀● UI State in bindable properties● Custom binding adapters● Business Logic in the XML● Hard to test● Random tooling issues● KAPT@marcoGomier @stewemetal
@marcoGomier @stewemetalinternal class ArticleDetailsViewModel(...) : BaseViewModel() {@get:Bindable var title: String by binding("", BR.title)@get:Bindable var price: String by binding("", BR.price)... interface Input {val articleId: PublishRelay}ㅤ interface Output {val articleNotFound: PublishRelayval articleDetailsUIState: BehaviorRelay}ㅤ val input = object : Input {override val articleId = PublishRelay.create()}val output = object : Output {override val articleNotFound = PublishRelay.create()override val state = BehaviorRelay.create()}override fun onViewResumed() {super.onViewResumed()input.articleId.flatMap { /* Get stuff from network */ }.observeOn(schedulerProvider.mainThread()).subscribeOn(schedulerProvider.io()).subscribe({ data ->// do things
@marcoGomier @stewemetalinternal class ArticleDetailsViewModel(...) : BaseViewModel() {@get:Bindable var title: String by binding("", BR.title)@get:Bindable var price: String by binding("", BR.price)...interface Input {val articleId: PublishRelay}ㅤ interface Output {val articleNotFound: PublishRelayval articleDetailsUIState: BehaviorRelay}ㅤ val input = object : Input {override val articleId = PublishRelay.create()}val output = object : Output {override val articleNotFound = PublishRelay.create()override val state = BehaviorRelay.create()}override fun onViewResumed() {super.onViewResumed()input.articleId.flatMap { /* Get stuff from network */ }.observeOn(schedulerProvider.mainThread()).subscribeOn(schedulerProvider.io()).subscribe({ data ->// do thingstitle = "Title"output.state.accept(aState)},{ error ->output.hideTopBanner.accept(Unit)},).disposeOnPause()}ㅤ }Legacy ViewModelMultiple Rx Relays● Data Input● Events, Navigationand (some) state@marcoGomier @stewemetal
@marcoGomier @stewemetal@get:Bindable var title: String by binding("", BR.title)@get:Bindable var price: String by binding("", BR.price)...interface Input {val articleId: PublishRelay}ㅤ interface Output {val articleNotFound: PublishRelayval articleDetailsUIState: BehaviorRelay}ㅤ val input = object : Input {override val articleId = PublishRelay.create()}val output = object : Output {override val articleNotFound = PublishRelay.create()override val state = BehaviorRelay.create()}override fun onViewResumed() {super.onViewResumed()input.articleId.flatMap { /* Get stuff from network */ }.observeOn(schedulerProvider.mainThread()).subscribeOn(schedulerProvider.io()).subscribe({ data ->// do thingstitle = "Title"output.state.accept(aState)},{ error ->output.hideTopBanner.accept(Unit)},).disposeOnPause()}ㅤ }● UI State split into multipleplaces● Rx specific boilerplate allover the place@marcoGomier @stewemetal
@marcoGomier @stewemetalJust switching toCompose is impossible😞
@marcoGomier @stewemetalUntangling theSpaghetti 🍝
@marcoGomier @stewemetalUntangling the spaghetti● Start with a clean slate● Make adoption as easy as possible● Consider MAD Architecture suggestions
@marcoGomier @stewemetalUntangling the spaghettihttps://goo.gle/MAD● Start with a clean slate● Make adoption as easy as possible● Consider MAD Architecture suggestions
@marcoGomier @stewemetalUntangling the spaghetti - UI state, UI eventsinternal class ArticleDetailsViewModel(...) : BaseViewModel() {@get:Bindable var title: String by binding("", BR.title)@get:Bindable var price: String by binding("", BR.price)val input = object : Input {override val articleId = PublishRelay.create()}val output = object : Output {override val articleNotFound = PublishRelay.create()override val state = BehaviorRelay.create()}}
@marcoGomier @stewemetalinternal class ArticleDetailsViewModel(...) : BaseViewModel() {@get:Bindable var title: String by binding("", BR.title)@get:Bindable var price: String by binding("", BR.price)val input = object : Input {override val articleId = PublishRelay.create()}val output = object : Output {override val articleNotFound = PublishRelay.create()override val state = BehaviorRelay.create()}}Untangling the spaghetti - UI state, UI events
@marcoGomier @stewemetalinternal class ArticleDetailsViewModel(...) : BaseViewModel(){ @get:Bindable var title: String by binding("", BR.title)@get:Bindable var price: String by binding("", BR.price)val input = object : Input {override val articleId = PublishRelay.create()}val output = object : Output {override val articleNotFound = PublishRelay.create()override val state = BehaviorRelay.create()}} Untangling the spaghetti - UI state, UI events
@marcoGomier @stewemetalUntangling the spaghetti - UI state, UI eventsinternal class ArticleDetailsViewModel(...) : BaseViewModel(){ ...}
@marcoGomier @stewemetalUntangling the spaghetti - UI state, UI eventsinternal class ArticleDetailsViewModel(...) : BaseViewModel(){ ...} https://goo.gle/architecture-state-holders
@marcoGomier @stewemetalUntangling the spaghetti - UI state, UI eventsabstract class BaseTierViewModel(val initialState: State,) : ViewModel() {...}
@marcoGomier @stewemetalUntangling the spaghetti - UI state, UI eventsabstract class BaseTierViewModel<State>(val initialState: State,) : ViewModel() {private val state = BehaviorSubject.createDefault(initialState)...}
@marcoGomier @stewemetalUntangling the spaghetti - UI state, UI eventsabstract class BaseTierViewModel<State>(val initialState: State,) : ViewModel() {private val state = BehaviorSubject.createDefault(initialState)fun state(): Observable = state.hide()...}
@marcoGomier @stewemetalUntangling the spaghetti - UI state, UI eventsabstract class BaseTierViewModel(val initialState: State,) : ViewModel() {private val state = BehaviorSubject.createDefault(initialState)fun state(): Observable = state.hide()...} sealed class ArticleDetailsUIState {object Loading: ArticleDetailsUIState()data class Content(val title: String,val price: String,val articleDetailsEvent: ArticleDetailsEvent? = null,): ArticleDetailsUIState()}
@marcoGomier @stewemetalUntangling the spaghetti - UI state, UI eventsabstract class BaseTierViewModel(val initialState: State,) : ViewModel() {private val state = BehaviorSubject.createDefault(initialState)fun state(): Observable = state.hide()...} data class ArticleDetailsUIState(val isLoading: Boolean = true,val title: String,val price: String,val articleDetailsEvent: ArticleDetailsEvent? = null,)
@marcoGomier @stewemetalUntangling the spaghetti - UI state, UI eventsabstract class BaseTierViewModel(val initialState: State,) : ViewModel() {private val state = BehaviorSubject.createDefault(initialState)fun state(): Observable = state.hide()...}
@marcoGomier @stewemetalUntangling the spaghetti - User eventsabstract class BaseTierViewModel(val initialState: State,) : ViewModel() {...}
@marcoGomier @stewemetalUntangling the spaghetti - User eventsabstract class BaseTierViewModel(val initialState: State,) : ViewModel() {...} https:/goo.gle/architecture-ui-events
@marcoGomier @stewemetalUntangling the spaghetti - User eventsabstract class BaseTierViewModel<ViewEvent, State>(val initialState: State,) : ViewModel() {...private val events = PublishSubject.create().toSerialized()...}
@marcoGomier @stewemetalUntangling the spaghetti - User eventsabstract class BaseTierViewModel<ViewEvent, State>(val initialState: State,) : ViewModel() {...private val events = PublishSubject.create().toSerialized()protected abstract fun onViewEvent(event: ViewEvent,)...}
@marcoGomier @stewemetalUntangling the spaghetti - User eventsabstract class BaseTierViewModel<ViewEvent, State>(val initialState: State,) : ViewModel() {...private val events = PublishSubject.create().toSerialized()protected abstract fun onViewEvent(event: ViewEvent,)funtriggerViewEvent(event: ViewEvent) {events.onNext(event)}}
@marcoGomier @stewemetalUntangling the spaghetti - User eventsval input = object : Input {override val articleId = PublishRelay.create()} android:onClick="@{ () -> vm.onBuyClicked() }”/>💀
@marcoGomier @stewemetalUntangling the spaghetti - User eventsval input = object : Input {override val articleId = PublishRelay.create()} android:onClick="@{ () -> vm.onBuyClicked() }”/>sealed interface ArticleDetailsViewEvent {data class RenderArticle(val articleId: String) : ArticleDetailsViewEventobject BuyClicked: ArticleDetailsViewEvent}
@marcoGomier @stewemetalUntangling the spaghetti - ViewModel testing@Testfun `ArticleDetailsViewModel Test`(){ val testObserver = viewModel.state().test()every { shopUseCase.getArticle(articleId)} returns Single.just(defaultArticle)viewModel.onViewCreated()viewModel.triggerViewEvent(RenderArticle(articleId))viewModel.onViewResumed()testObserver.assertValueAt(2) { state ->// …} }
@marcoGomier @stewemetal@Testfun `ArticleDetailsViewModel Test`(){ val testObserver = viewModel.state().test()every { shopUseCase.getArticle(articleId)} returns Single.just(defaultArticle)viewModel.onViewCreated()viewModel.triggerViewEvent(RenderArticle(articleId))viewModel.onViewResumed()testObserver.assertValueAt(2) { state ->// …} } Untangling the spaghetti - ViewModel testing
@marcoGomier @stewemetalUntangling the spaghetti - ViewModel testing@Testfun `ArticleDetailsViewModel Test`(){ val testObserver = viewModel.state().test()every { shopUseCase.getArticle(articleId)} returns Single.just(defaultArticle)viewModel.onViewCreated()viewModel.triggerViewEvent(RenderArticle(articleId))viewModel.onViewResumed()testObserver.assertValueAt(2) { state ->// …}}
@marcoGomier @stewemetalUntangling the spaghetti - Compose UIabstract class BaseComposeActivity : AppCompatActivity() {abstract val viewModel: TierViewModelabstract val content: @Composable (State) -> Unitoverride fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContent {val state by viewModel.state().subscribeAsState(initial = viewModel.initialState)content(state)}viewModel.onViewCreated()}}
@marcoGomier @stewemetalUntangling the spaghetti - Compose UIinternal class ArticleDetailsActivity :BaseComposeActivity() {...override val content = @Composable { state: ArticleDetailsState ->ArticleDetailsScreen(state = state,onBuyButtonClick ={ viewModel.triggerViewEvent(BuyClicked) },)}override fun onCreate(savedInstanceState: Bundle?) {…viewModel.triggerViewEvent(RenderArticle(val articleId: String))}
@marcoGomier @stewemetalUntangling the spaghetti - Compose UIinternal class ArticleDetailsActivity :BaseComposeActivity() {...override val content = @Composable { state: ArticleDetailsState ->ArticleDetailsScreen(state = state,onBuyButtonClick ={ viewModel.triggerViewEvent(BuyClicked) },)}override fun onCreate(savedInstanceState: Bundle?) {…viewModel.triggerViewEvent(RenderArticle(articleId))}
@marcoGomier @stewemetalGradleConfiguration
@marcoGomier @stewemetalGradle Configuration● Enable Compose only in some modules● Enable and add Compose libraries with one line
@marcoGomier @stewemetalModule Configuration → buildSrcfun configureAndroidModule(project: Project,isDataBindingEnabled: Boolean,isComposeEnabled: Boolean = false,) = project.libraryExtension.run {...}
@marcoGomier @stewemetalModule Configuration → buildSrcfun configureAndroidModule(project: Project,isDataBindingEnabled: Boolean,isComposeEnabled: Boolean = false,) = project.libraryExtension.run {...if (isComposeEnabled) {configureCompose(project)}}
@marcoGomier @stewemetalModule Configuration → buildSrcprivate fun BaseExtension.configureCompose(project: Project) {composeOptions.kotlinCompilerExtensionVersion = project.COMPOSE_COMPILERbuildFeatures.compose = trueproject.dependencies {addProvider("implementation", project.COMPOSE_BUNDLE)addProvider("androidTestImplementation", project.COMPOSE_TESTING_BUNDLE)addProvider("debugImplementation", project.COMPOSE_TESTING_MANIFEST)}}
@marcoGomier @stewemetalGradle Version Catalogue[versions]androidx-compose = "1.3.0"androidx-compose-compiler = "1.3.2"[libraries]androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "androidx-compose" }androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "androidx-compose" }[bundles]compose = ["androidx-compose-foundation", "androidx-compose-material", "androidx-compose-runtime-rxjava2", "androidx-compose-ui", "androidx-compose-ui-tooling", "androidx-customlayout-poolingcontainer"]compose-testing = ["androidx-compose-ui-test-junit4"]@marcoGomier @stewemetalgradle/libs.versions.toml● Gradle Version Catalog for managing dependencies● Bundles are awesome to make life easier
@marcoGomier @stewemetalModule Configuration → build.gradleplugins {id 'com.android.library'id 'kotlin-android'}ModuleConfig.configureAndroidModuleWithCompose(project)dependencies {...}
@marcoGomier @stewemetalFuture: Migrate to convention pluginsplugins {id 'com.tier.android.library'id 'kotlin-android'}enabledFeatures {compose()}dependencies {...}plugins {id 'com.tier.android.library'id 'com.tier.android.compose'id 'kotlin-android'}dependencies {...}
@marcoGomier @stewemetalFuture: Use Compose BOM
@marcoGomier @stewemetalDesign System
@marcoGomier @stewemetalOctopus Design System● 100% Views with Data Binding and @BindingAdapters● Good component APIs & docs● Easy usage with View-based UI
@marcoGomier @stewemetalOctopus Design System● 100% Views with Data Binding and @BindingAdapters● Good component APIs & docs● Easy usage with View-based UI● Not fun to maintain 😢OctopusButtonPrimary.ktR.layout.__internal_view_octopus_buttonR.styleable.OctopusButtonR.drawable.__internal_octopus_button_background_primary@marcoGomier @stewemetal
@marcoGomier @stewemetalOctopus Design System● 100% Views with Data Binding and @BindingAdapters● Good component APIs & docs● Easy usage with View-based UI● Not fun to maintain 😢// We have to disable specific setters to prevent misuse of the viewprivate var areSettersEnabled = false@marcoGomier @stewemetal
@marcoGomier @stewemetalOctopus Design SystemWhat about Compose?🤔
@marcoGomier @stewemetalOctopus Design System● Possible approaches for us@marcoGomier @stewemetal
@marcoGomier @stewemetalOctopus Design System● Possible approaches for usViews + AndroidView@Composablefun OctopusButtonPrimary(text: String,onClick: () -> Unit,) {AndroidView(factory = { context ->OctopusButtonPrimary(context).apply {setText(text)setOnClickListener {onClick()}...}},update = { view ->...},)}@marcoGomier @stewemetal
@marcoGomier @stewemetalOctopus Design System● Possible approaches for us@marcoGomier @stewemetalViews + AndroidView@Composablefun OctopusButtonPrimary(text: String,onClick: () -> Unit,) {AndroidView(factory = { context ->OctopusButtonPrimary(context).apply {setText(text)setOnClickListener {onClick()}...}},update = { view ->...},)}
@marcoGomier @stewemetalOctopus Design System● Possible approaches for us@Composablepublic fun OctopusButtonPrimary(text: String,modifier: Modifier = Modifier,buttonSize: ButtonSize = NORMAL,enabled: Boolean = true,loading: Boolean = false,onClick: () -> Unit,) {Box() {OctopusRippleTheme() {Button() {Text()}if (loading) {OctopusButtonLoader()}}}}@marcoGomier @stewemetalReimplement components in Compose
@marcoGomier @stewemetal● The benefits of pure Compose● Easier to maintain● No attachments to the legacy Views● Easy for teams to adoptOctopus Design System🤩
@marcoGomier @stewemetal● Downsides of our approach● A second implementation to maintain● Needs parity with the View components● Some APIs are still experimentalOctopus Design System🧐
@marcoGomier @stewemetalOctopus Design System● The future - going Compose-first● Compose implementation behind the Views - ComposeView● Life with minimal XML, and more Kotlin ❤⏩
@marcoGomier @stewemetalIn-house Composeonboarding
@marcoGomier @stewemetal● Architecture● Screens● Octopus theme & componentsExperiments
@marcoGomier @stewemetal● Regular 1-hour knowledge sharing sessionsThe Android Guild
@marcoGomier @stewemetal● Regular 1-hour knowledge sharing sessions● A good place to start the onboardingThe Android Guild
@marcoGomier @stewemetal● Regular 1-hour knowledge sharing sessions● A good place to start the onboardingThe Android Guildhttps://www.marcogomiero.com/talks/2022/imperative-vs-declarative-appdevcon/
@marcoGomier @stewemetalThe Android Guild🐙 Composing an Octopus
@marcoGomier @stewemetalThe Android Guild🐙 Composing an OctopusCompose Shorts
@marcoGomier @stewemetalThe Android Guild● Materials in the main repo● Sessions are recorded@marcoGomier @stewemetal
@marcoGomier @stewemetalConclusions
@marcoGomier @stewemetalIt dependsTM
@marcoGomier @stewemetalConclusions● You can’t just start writing Composable functions● Take time to revisit your architecture● A design system will help● Jumping on the Compose Ship Scooter is not a race● Zero ➡ Hero takes timeWe’re still here 😄
Resources● https://developer.android.com/modern-android-development● https://developer.android.com/topic/architecture/ui-layer/stateholders● https://developer.android.com/topic/architecture/ui-layer/events● https://twitter.com/Lojanda/status/1584589111670673409● https://github.com/android/nowinandroid/tree/main/build-logic● https://www.droidcon.com/2022/06/28/branching-out-to-jetpack-compose/● https://github.com/androidx/androidx/blob/androidx-main/compose/docs/compose-api-guidelines.md● https://github.com/twitter/compose-rules
@marcoGomier @stewemetalIstván Juhos👨💻 Senior Android Engineer @ TIER Co-organizer of Kotlin Budapest Twitter: @stewemetal Github: stewemetal Mastodon: androiddev.social/@stewemetal Thank you!👨💻 Senior Android Engineer @ TIER Google Developer Expert for KotlinTwitter: @marcoGomier Github: prof18 Website: marcogomiero.com Mastodon: androiddev.social/@marcogomMarco Gomiero