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

Android UIs at Scale: UI Architecture in the Compose world

Android UIs at Scale: UI Architecture in the Compose world

These are the slides from the 360AnDev 2021 version of the talk "Android UIs at Scale: UI Architecture in the Compose world".

Android UIs are experiencing a paradigm shift with the advent of Jetpack Compose, and it's necessary for us to prepare for it.

As we navigate these weird times for UI development in Android, we need to have a strategy in place to adopt Compose, but how can we embrace Compose, while maintaining our current UI in a good state? What does an architecture that plays nice with Views and Composables look like?

In this session I will describe the mechanisms that we use at Twitter to create UI components. I will explain how we manage their reusability and encapsulation, how our stateful UI components look like, how they work with Android Views and Composables and how the migration path between them.

Nacho L.

July 23, 2021
Tweet

More Decks by Nacho L.

Other Decks in Technology

Transcript

  1. A long time ago in a galaxy far, far away

    … Well, actually… it was in 2017
  2. 19 interface ViewModel interface Binder { fun bind(viewDelegate: ViewDelegate, viewModel:

    ViewModel): Disposable } interface ViewDelegate<out V: View> { protected val rootView: V }
  3. 20 interface ViewModel interface Binder { fun bind(viewDelegate: ViewDelegate, viewModel:

    ViewModel): Disposable } interface ViewDelegate<out V: View> { protected val rootView: V } But it still was too unopinionated
  4. ViewModel interface Binder { fun bind(viewDelegate: ViewDelegate, viewModel: ViewModel): Disposable

    } interface ViewDelegate<out V: View> { protected val rootView: V } interface
  5. ViewModel interface Binder { fun bind(viewDelegate: ViewDelegate, viewModel: ViewModel): Disposable

    } interface ViewDelegate<out V: View> { protected val rootView: V } interface
  6. ViewModel class MyViewModel : { internal fun textChanged(): Observable<String> =

    ... internal fun respondToButtonClick() { ... } fun updateText(text: String) { ... } }
  7. ViewModel class MyViewModel : { internal fun textChanged(): Observable<String> =

    ... } internal fun onButtonClicked(): Observable<View> = button.clicks() }
  8. interface ViewModel<VS: ViewState, I: UserIntent> { fun stateObservable(): Observable<VS> fun

    processUserIntent(intent: I) } class WeaverViewBinder : Binder { fun bind(viewDelegate: ViewDelegate, viewModel: ViewModel): Disposable { val disposables = CompositeDisposable() disposables += viewModel.stateObservable() .subscribe { state >
  9. interface ViewModel<VS: ViewState, I: UserIntent> { fun stateObservable(): Observable<VS> fun

    processUserIntent(intent: I) } interface ViewDelegate<out V: View, VS: ViewState, I: UserIntent> { protected val rootView: V fun render(state: VS) fun userIntentObservable(): Observable<I> = Observable.never() } class WeaverViewBinder : Binder { fun bind(viewDelegate: ViewDelegate, viewModel: ViewModel): Disposable { val disposables = CompositeDisposable() disposables += viewModel.stateObservable() .subscribe { state >
  10. interface ViewDelegate<out V: View, VS: ViewState, I: UserIntent> { protected

    val rootView: V fun render(state: VS) fun userIntentObservable(): Observable<I> = Observable.never() } class WeaverViewBinder : Binder { fun bind(viewDelegate: ViewDelegate, viewModel: ViewModel): Disposable { val disposables = CompositeDisposable() disposables += viewModel.stateObservable() .subscribe { state -> viewDelegate.render(state) } disposables += viewDelegate.userIntentObservable() .subscribe { intent -> viewModel.processUserIntent(intent) } return disposables } }
  11. interface ViewDelegate<out V: View, VS: ViewState, I: UserIntent> { protected

    val rootView: V fun render(state: VS) fun userIntentObservable(): Observable<I> = Observable.never() } class WeaverViewBinder : Binder { fun bind(viewDelegate: ViewDelegate, viewModel: ViewModel): Disposable { val disposables = CompositeDisposable() disposables += viewModel.stateObservable() .subscribe { state >
  12. interface ViewDelegate<out V: View, VS: ViewState, I: UserIntent> { protected

    val rootView: V fun render(state: VS) fun userIntentObservable(): Observable<I> = Observable.never() } class WeaverViewBinder : Binder { fun bind(viewDelegate: ViewDelegate, viewModel: ViewModel): Disposable { val disposables = CompositeDisposable() disposables += viewModel.stateObservable() .subscribe { state >
  13. What we use: Weaver [CHIRPBIRDI CO N] ’s MVI +

    Data binding UI architecture framework
  14. 1. Data binding What is Weaver? <FrameLayout android:id="@+id/my_layout" ... >

    <tag android:id="@id/weaverComponent" android:value="MyComponent" /> </ FrameLayout>
  15. 1. Data binding What is Weaver? <FrameLayout android:id="@+id/my_layout" ... >

    <tag android:id="@id/weaverComponent" android:value="MyComponent" /> </ FrameLayout>
  16. 1. Data binding What is Weaver? <FrameLayout android:id="@+id/my_layout" ... >

    <tag android:id="@id/weaverComponent" android:value="MyComponent" /> </ FrameLayout>
  17. 1. Data binding What is Weaver? <FrameLayout android:id="@+id/my_layout" ... >

    <tag android:id="@id/weaverComponent" android:value="MyComponent" /> </ FrameLayout>
  18. 1. Data binding What is Weaver? <FrameLayout android:id="@+id/my_layout" ... >

    <tag android:id="@id/weaverComponent" android:value="MyComponent" /> </ FrameLayout> 2. MVI
  19. 2. MVI What is Weaver? setState { ... } onSuccess

    { value -> ... } onFailure { throwable -> ... } onSuccessOrFailure { result -> ... } onComplete { ... } } yieldEffect( .
  20. What is Weaver? 4. Standalone components class MyFeatureActivity : InjectedFragmentActivity()

    <FrameLayout android:id="@+id/my_layout" ... > <tag android:id="@id/weaverComponent" android:value="MyComponent" /> </ FrameLayout>
  21. What is Weaver? 4. Standalone components class MyFeatureActivity : InjectedFragmentActivity()

    <FrameLayout android:id="@+id/my_layout" ... > <tag android:id="@id/weaverComponent" android:value="MyComponent" /> </ FrameLayout>
  22. What is Weaver? 4. Standalone components class MyFeatureActivity : InjectedFragmentActivity()

    <FrameLayout android:id="@+id/my_layout" ... > <tag android:id="@id/weaverComponent" android:value="MyComponent" /> </ FrameLayout>
  23. What is Weaver? 4. Standalone components class MyFeatureActivity : InjectedFragmentActivity()

    <FrameLayout android:id="@+id/my_layout" ... > <tag android:id="@id/weaverComponent" android:value="MyComponent" /> </ FrameLayout> 5. Easy threading
  24. What is Weaver? 5. 6. 7. 8. Painless subscriptions Easy

    threading Testing framework IDE support
  25. &

  26. & Mental model is the same Instead of having 3

    building blocks, we only need 1 in Compose: the ViewModel.
  27. & Mental model is the same Binders, Views unnecessary To

    provide everything necessary we will need some configuration via CompositionLocal Cohesive entrypoints
  28. & val viewModel = weaverViewModel<MyViewModel>() val values by viewModel.watchAsState(MyViewState ::

    value1, MyViewState :: value2, …) val (value1, value2, ... ) = values val value by viewModel.watchAsState(MyViewState :
  29. &

  30. & @Composable fun FollowButton() { val viewModel = weaverViewModel<FollowViewModel>() val

    isFollowing by viewModel.watchAsState(FollowViewState :: following) FollowButton(isFollowing) { viewModel.toggleFollow() } }
  31. & @Composable fun FollowButton() { val viewModel = weaverViewModel<FollowViewModel>() val

    isFollowing by viewModel.watchAsState(FollowViewState :: following) FollowButton(isFollowing) { viewModel.toggleFollow() } } @Composable fun FollowButton( isFollowing: Boolean, onClick: () -> Unit ) { ... }
  32. & @Composable fun FollowButton() { val viewModel = weaverViewModel<FollowViewModel>() val

    isFollowing by viewModel.watchAsState(FollowViewState :: following) FollowButton(isFollowing) { viewModel.toggleFollow() } } @Composable fun FollowButton( isFollowing: Boolean, onClick: () -> Unit ) { ... }
  33. & @Composable fun FollowButton() { val viewModel = weaverViewModel<FollowViewModel>() val

    isFollowing by viewModel.watchAsState(FollowViewState :: following) FollowButton(isFollowing) { viewModel.toggleFollow() } } @Composable fun FollowButton( isFollowing: Boolean, onClick: () -> Unit ) { ... }
  34. & @Composable fun FollowButton() { val viewModel = weaverViewModel<FollowViewModel>() val

    isFollowing by viewModel.watchAsState(FollowViewState :: following) FollowButton(isFollowing) { viewModel.toggleFollow() } } @Composable fun FollowButton( isFollowing: Boolean, onClick: () -> Unit ) { ... }
  35. & @Composable fun FollowButton() { val viewModel = weaverViewModel<FollowViewModel>() val

    isFollowing by viewModel.watchAsState(FollowViewState :: following) FollowButton(isFollowing) { viewModel.toggleFollow() } } @Composable fun FollowButton( isFollowing: Boolean, onClick: () -> Unit ) { ... }
  36. &

  37. & strategy 1. Proof of concept 2. Prepare the build

    to make sure that Compose or the new IR backend properly built on CI.
  38. & strategy 2. Adding to the build 3. Design system

    Robust theming and ways to customize for special cases.
  39. & strategy 3. Design system 4. Early Access Program Partner

    up with select feature teams of different needs and allow them to early adopt Compose. Provide hands on support to them. They will find all the possible ways your infra is broken and how your APIs suck. Fix, document.
  40. & strategy 4. Early Access Program 5. Hype train Education

    is king on such a big change. Make sure to communicate internally about Compose, with comprehensive documentation and education sessions / talks / codelabs.
  41. & strategy 5. Hype train 6. Broad adoption Open the

    floodgates for more feature teams to use it.
  42. & strategy 6. Broad adoption 7. Long term support Team

    ownership on upgrading the Compose dependencies. Integration and instrumentation testing are key to make sure the core infra doesn’t break between updates. [CHIRPBIRDI CO N] feature devs
  43. @Composable fun TopicsHeader() { val viewModel = weaverViewModel<TopicHeaderViewModel>() val state

    by viewModel.watchAsState() Title(state.title) Subtitle(state.subtitle) Row { Facepile(state.users) FollowButton(state.following) { viewModel.toggleFollow() } } Description(state.description) Timeline(state.timeline) }
  44. @Composable fun TopicsHeader() { val viewModel = weaverViewModel<TopicHeaderViewModel>() val state

    by viewModel.watchAsState() Title(state.title) Subtitle(state.subtitle) Row { Facepile(state.users) FollowButton(state.following) { viewModel.toggleFollow() } } Description(state.description) Timeline(state.timeline) }
  45. @Composable fun TopicsHeader() { val viewModel = weaverViewModel<TopicHeaderViewModel>() val state

    by viewModel.watchAsState() Title(state.title) Subtitle(state.subtitle) Row { Facepile(state.users) FollowButton(state.following) { viewModel.toggleFollow() } } Description(state.description) Timeline(state.timeline) } Business logic is reused
  46. @Composable fun TopicsHeader() { val viewModel = weaverViewModel<TopicHeaderViewModel>() val state

    by viewModel.watchAsState() Title(state.title) Subtitle(state.subtitle) Row { Facepile(state.users) FollowButton(state.following) { viewModel.toggleFollow() } } Description(state.description) Timeline(state.timeline) }
  47. @Composable fun TopicsHeader() { val viewModel = weaverViewModel<TopicHeaderViewModel>() val state

    by viewModel.watchAsState() Title(state.title) Subtitle(state.subtitle) Row { Facepile(state.users) FollowButton(state.following) { viewModel.toggleFollow() } } Description(state.description) Timeline(state.timeline) }
  48. @Composable fun Facepile(twitterUsers: List<TwitterUser>, membersClicked: () -> Unit) { val

    borderColor = HorizonTheme.colors.primary.toArgb() AndroidView( :: FacePileView) { facePileView: FacePileView -> facePileView.avatarBorderColor = borderColor facePileView.initFaces(twitterUsers.take(3).reversed()) facePileView.setOnClickListener { membersClicked() } } }
  49. @Composable fun Facepile(twitterUsers: List<TwitterUser>, membersClicked: () -> Unit) { val

    borderColor = HorizonTheme.colors.primary.toArgb() AndroidView( :: FacePileView) { facePileView: FacePileView -> facePileView.avatarBorderColor = borderColor facePileView.initFaces(twitterUsers.take(3).reversed()) facePileView.setOnClickListener { membersClicked() } } }
  50. class @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr:

    Int = 0 ) : AbstractComposeView(context, attrs, defStyleAttr), HorizonButton { HorizonComposeButton }
  51. class @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr:

    Int = 0 ) : AbstractComposeView(context, attrs, defStyleAttr), HorizonButton { HorizonComposeButton }
  52. class @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr:

    Int = 0 ) : AbstractComposeView(context, attrs, defStyleAttr), HorizonButton { HorizonComposeButton } private var onClick by mutableStateOf({}) private var buttonText: CharSequence? by mutableStateOf("") override fun getText(): CharSequence? = buttonText override fun setText(value: CharSequence?) { buttonText = value } // ...
  53. class @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr:

    Int = 0 ) : AbstractComposeView(context, attrs, defStyleAttr), HorizonButton { HorizonComposeButton } @Composable override fun Content() { HorizonTheme { HorizonButton(_text, onClick, ... ) } } // ...
  54. private val styleButtonAppearanceMap by lazy { fun bold(size: ButtonSize) =

    ButtonStyle(size, ButtonType.Outlined, ButtonColors.Legacy.Primary) fun heavy(size: ButtonSize) = ButtonStyle(size, ButtonType.Filled, ButtonColors.Legacy.Primary) fun regular(size: ButtonSize) = ButtonStyle(size, ButtonType.Outlined, ButtonColors.Legacy.Regular) // ... mapOf( R.style.TwitterButtonLargeBold to bold(ButtonSize.Legacy.Large()), R.style.TwitterButtonSmallBold to bold(ButtonSize.Legacy.Small()), // ... ) } internal fun styleToButtonAppearance(@StyleRes styleResId: Int): ButtonStyle = styleButtonAppearanceMap[styleResId] ?: error("There is no mapping for this legacy style.")
  55. private val styleButtonAppearanceMap by lazy { fun bold(size: ButtonSize) =

    ButtonStyle(size, ButtonType.Outlined, ButtonColors.Legacy.Primary) fun heavy(size: ButtonSize) = ButtonStyle(size, ButtonType.Filled, ButtonColors.Legacy.Primary) fun regular(size: ButtonSize) = ButtonStyle(size, ButtonType.Outlined, ButtonColors.Legacy.Regular) // ... mapOf( R.style.TwitterButtonLargeBold to bold(ButtonSize.Legacy.Large()), R.style.TwitterButtonSmallBold to bold(ButtonSize.Legacy.Small()), // ... ) } internal fun styleToButtonAppearance(@StyleRes styleResId: Int): ButtonStyle = styleButtonAppearanceMap[styleResId] ?: error("There is no mapping for this legacy style.")
  56. <style name="SharedTheme" parent="ThemeBase"> <item name="viewInflaterClass">com.twitter.ui.CustomViewInflater </ item> ... </ style>

    class CustomViewInflater : AppCompatViewInflater() { override fun createView(context: Context, name: String, attrs: AttributeSet): View { if ( name == TwitterButton :: class.java.canonicalName && HorizonComposeButtonUtils.isComposeButtonEnabled() ) { HorizonComposeButton(context, name, attrs) } else { super.createView(context, name, attrs) } } }
  57. <style name="SharedTheme" parent="ThemeBase"> <item name="viewInflaterClass">com.twitter.ui.CustomViewInflater </ item> ... </ style>

    class CustomViewInflater : AppCompatViewInflater() { override fun createView(context: Context, name: String, attrs: AttributeSet): View { if ( name == TwitterButton :: class.java.canonicalName && HorizonComposeButtonUtils.isComposeButtonEnabled() ) { HorizonComposeButton(context, name, attrs) } else { super.createView(context, name, attrs) } } }
  58. class CustomViewInflater : AppCompatViewInflater() { override fun createView(context: Context, name:

    String, attrs: AttributeSet): View { if ( name == TwitterButton :: class.java.canonicalName && HorizonComposeButtonUtils.isComposeButtonEnabled() ) { HorizonComposeButton(context, name, attrs) } else { super.createView(context, name, attrs) } } } <style name="SharedTheme" parent="ThemeBase"> <item name="viewInflaterClass">com.twitter.ui.CustomViewInflater </ item> ... </ style>
  59. class CustomViewInflater : AppCompatViewInflater() { override fun createView(context: Context, name:

    String, attrs: AttributeSet): View { if ( name == TwitterButton :: class.java.canonicalName && HorizonComposeButtonUtils.isComposeButtonEnabled() ) { HorizonComposeButton(context, name, attrs) } else { super.createView(context, name, attrs) } } } But what if we want to be selective?
  60. class CustomViewInflater : AppCompatViewInflater() { override fun createView(context: Context, name:

    String, attrs: AttributeSet): View { if ( name == TwitterButton :: class.java.canonicalName && HorizonComposeButtonUtils.isComposeButtonEnabled() ) { HorizonComposeButton(context, name, attrs) } else { super.createView(context, name, attrs) } } } But what if we want to be selective?
  61. class CustomViewInflater : AppCompatViewInflater() { override fun createView(context: Context, name:

    String, attrs: AttributeSet): View { if ( name == && HorizonComposeButtonUtils.isComposeButtonEnabled() ) { HorizonComposeButton(context, name, attrs) } else { super.createView(context, name, attrs) } } } But what if we want to be selective? MigrationTwitterButton :: class.java.canonicalName
  62. if ( name == && HorizonComposeButtonUtils.isComposeButtonEnabled() ) { HorizonComposeButton(context, name,

    attrs) } else { super.createView(context, name, attrs) } } } But what if we want to be selective? MigrationTwitterButton :: class.java.canonicalName class MigrationTwitterButton @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : TwitterButton(context, attrs, defStyleAttr)
  63. But what if we want to be selective? <com.twitter.ui.components.button.TwitterButton <com.twitter.ui.components.button.MigrationTwitterButton

    class MigrationTwitterButton @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : TwitterButton(context, attrs, defStyleAttr)
  64. val button = context.findViewById< button.setText(R.string.my_new_fancy_text) HorizonButton>() class HorizonComposeButton @JvmOverloads constructor(

    ... ) : AbstractComposeView(context, attrs, defStyleAttr), HorizonButton class TwitterButton @JvmOverloads constructor( ... ) : AppCompatButton(context, attrs, defStyleAttr), HorizonButton
  65. val button = context.findViewById< button.setText(R.string.my_new_fancy_text) HorizonButton>() class HorizonComposeButton @JvmOverloads constructor(

    ... ) : AbstractComposeView(context, attrs, defStyleAttr), HorizonButton class TwitterButton @JvmOverloads constructor( ... ) : AppCompatButton(context, attrs, defStyleAttr), HorizonButton
  66. public interface { // Omit nullity annotation to maintain compatibility

    with TextView's getText() // that returns platform type CharSequence getText(); void setText(@Nullable CharSequence charSequence); void setIcon(@DrawableRes int iconDrawable); boolean isEnabled(); void setEnabled(boolean enabled); int getVisibility(); void setVisibility(int visibility); void setOnClickListener(@Nullable View.OnClickListener listener); void setTag(@Nullable Object action); void setContentDescription(@Nullable CharSequence contentDescription); boolean callOnClick(); HorizonButton }
  67. class HorizonComposeButton( ... ) : AbstractComposeView( ... ), HorizonButton {

    var text = mutableStateOf("") init { context.withStyledAttributes(attrs, R.styleable.HorizonComposeButton, defAttrs) { text = getString(R.styleable.HorizonComposeButton_android_text) ?: "" } } Text(text) } } @Composable override fun Content() {
  68. Text(text) } } class HorizonComposeButton( ... ) : AbstractComposeView( ...

    ), HorizonButton { var text = mutableStateOf("") init { context.withStyledAttributes(attrs, R.styleable.HorizonComposeButton, defAttrs) { text = getString(R.styleable.HorizonComposeButton_android_text) ?: "" } } @Composable override fun Content() {
  69. Text(text) } } class HorizonComposeButton( ... ) : AbstractComposeView( ...

    ), HorizonButton { @Composable override fun Content() { val viewModel = weaverViewModel<HorizonButtonViewModel>(id) val text by viewModel.watchAsState(HorizonButtonState :: text)
  70. } } class HorizonComposeButton( ... ) : AbstractComposeView( ... ),

    HorizonButton { var text = mutableStateOf("") init { context.withStyledAttributes(attrs, R.styleable.HorizonComposeButton, defAttrs) { text = getString(R.styleable.HorizonComposeButton_android_text) ?: "" } } @Composable override fun Content() { val internalText = rememberSaveable { text } Text(internalText)
  71. } } class HorizonComposeButton( ... ) : AbstractComposeView( ... ),

    HorizonButton { var text = mutableStateOf("") init { context.withStyledAttributes(attrs, R.styleable.HorizonComposeButton, defAttrs) { text = getString(R.styleable.HorizonComposeButton_android_text) ?: "" } } @Composable override fun Content() { val internalText = rememberSaveable { text } Text(internalText)
  72. } } class HorizonComposeButton( ... ) : AbstractComposeView( ... ),

    HorizonButton { var text = mutableStateOf("") init { context.withStyledAttributes(attrs, R.styleable.HorizonComposeButton, defAttrs) { text = getString(R.styleable.HorizonComposeButton_android_text) ?: "" } } @Composable override fun Content() { val internalText = rememberSaveable { text } Text(internalText) SideEffect { if (textInternal != text) { text = textInternal } }