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

Life is too short to develop only for Android - A journey to Server-driven UI

Life is too short to develop only for Android - A journey to Server-driven UI

Talk given at the following conferences/events:
- Droidcon New York (September / 2022)
- Droidcon Berlin (July / 2022)
- Android Makers (April / 2022)
- Droidcon Lisbon (April / 2022)
- Glovo & Friends Tech Conference (Nov / 2021)

Have you ever had to implement a last minute feature? Did your requirements change? Is your build slow?

Server-driven UI might be the solution to your problems. In this talk, I will discuss how we designed a multi-platform system to build new screens via a simple backend change without having to release new versions of the native apps.

After this talk, you will be able to ship features faster on all platforms simultaneously, you will notice that life is too short to develop only for Android.

Zhenlei Ji

April 22, 2022
Tweet

More Decks by Zhenlei Ji

Other Decks in Programming

Transcript

  1. Life is too short to develop only for Android A

    journey to Server-driven UI Zhenlei Ji @zhenleiji
  2. Insurance Perks The Month of N Detail Explore We are

    building similar screen over and over again
  3. Api Response (not SDUI) { "insurance": { "title": "Premium Insurances",

    "icon": "https://n26.com/icon.png", "description": "You need at least €16,90…", "button": "Top up to unlock", }, "account_detail": { "title": "N26 You", "subtitle": "See my account details", "icon": "https://n26.com/card.png" }, "card_o ff ers": { "title": "Get more from N26", "items": […] } … }
  4. Api Response (not SDUI) { "insurance": { "title": "Premium Insurances",

    "icon": "https://n26.com/icon.png", "description": "You need at least €16,90…", "button": "Top up to unlock", }, "account_detail": { "title": "N26 You", "subtitle": "See my account details", "icon": "https://n26.com/card.png" }, "card_o ff ers": { "title": "Get more from N26", "items": […] } … }
  5. Api Response (not SDUI) { "insurance": { "title": "Premium Insurances",

    "icon": "https://n26.com/icon.png", "description": "You need at least €16,90…", "button": "Top up to unlock", }, "account_detail": { "title": "N26 You", "subtitle": "See my account details", "icon": "https://n26.com/card.png" }, "card_o ff ers": { "title": "Get more from N26", "items": […] } … }
  6. Api Response (not SDUI) { "insurance": { "title": "Premium Insurances",

    "icon": "https://n26.com/icon.png", "description": "You need at least €16,90…", "button": "Top up to unlock", }, "account_detail": { "title": "N26 You", "subtitle": "See my account details", "icon": "https://n26.com/card.png" }, "card_o ff ers": { "title": "Get more from N26", "items": […] } … }
  7. Api Response (not SDUI) { "insurance": { "title": "Premium Insurances",

    "icon": "https://n26.com/icon.png", "description": "You need at least €16,90…", "button": "Top up to unlock", }, "account_detail": { "title": "N26 You", "subtitle": "See my account details", "icon": "https://n26.com/card.png" }, "card_o ff ers": { "title": "Get more from N26", "items": […] } … } Backend for Frontend (BFF)
  8. Api Response (SDUI) { "items": [ { "icon": "https://n26.com/icon.png", "text":

    "Premium Insurance", }, { "text": "You need at least €16,90…", }, { "text": "Top Up to unlock", "operation": {tracking, deeplink}, }, { "icon": "https://n26.com/card.png", "title": "N26 Metal", "subtitle": "See my account details" }, { "text": "Insurance", "items": [] } ] }
  9. Api Response (SDUI) { "items": [ { "type": "Header", "icon":

    "https://n26.com/icon.png", "text": "Premium Insurance", }, { "type": "SmallBodyText", "text": "You need at least €16,90…", }, { "type": "Button", "text": "Top Up to unlock", "operation": {tracking, deeplink}, }, { "type": "SimpleCard", "icon": "https://n26.com/card.png", "title": "N26 Metal", "subtitle": "See my account details" }, { "type": "Carousel", "text": "Insurance", "items": [] } ] } Polymorphic JSON
  10. Api Response (SDUI) Render UI components Polymorphic JSON HeaderDto SmallBodyTextDto

    ButtonDto SimpleCardDto CarouselDto Map to data models
  11. Api Response (SDUI) Render UI components Polymorphic JSON HeaderDto SmallBodyTextDto

    ButtonDto SimpleCardDto CarouselDto Map to data models
  12. Atomic design Atoms Organisms Spot Icon Body Text Small Body

    Text Molecules Three Item Container Product Spotlight
  13. Atomic design (code) data class ThreeItemContainerViewEntity( val title: BodyTextViewEntity, val

    subtitle: SmallBodyTextViewEntity, val spotIconViewEntity: SpotIconViewEntity ) : ViewEntity View Entity class ThreeItemContainerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ConstraintLayout(context, attrs, defStyleAttr) { // more code }} Custom View Molecules Three Item Container
  14. Atomic design (code) data class ThreeItemContainerViewEntity( val title: BodyTextViewEntity, val

    subtitle: SmallBodyTextViewEntity, val spotIconViewEntity: SpotIconViewEntity ) : ViewEntity View Entity class ThreeItemContainerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ConstraintLayout(context, attrs, defStyleAttr), Bindable<ThreeItemContainerViewEntity> { // more code }} Custom View Molecules Three Item Container
  15. Atomic design (code) data class ThreeItemContainerViewEntity( val title: BodyTextViewEntity, val

    subtitle: SmallBodyTextViewEntity, val spotIconViewEntity: SpotIconViewEntity ) : ViewEntity View Entity class ThreeItemContainerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ConstraintLayout(context, attrs, defStyleAttr), Bindable<ThreeItemContainerViewEntity> { override fun bind(viewEntity: ThreeItemContainerViewEntity) { // bind code } // more code }} Custom View Molecules Three Item Container
  16. Foundation Kick-o f • Stateless Backend • Backend without versioning

    😢 • Static screen • All clickable components should have deeplink and tracking
  17. Foundation Polymorphic JSON Data Models 1 HeaderDto ButtonDto BodyTextDto CarouselDto

    SectionDto GridDto … HeaderViewEntity View Entities ButtonViewEntity BodyTextViewEntity CarouselViewEntity SectionViewEntity GridViewEntity … 2 3 mappings
  18. Foundation Polymorphic JSON Data Models 1 HeaderDto ButtonDto BodyTextDto CarouselDto

    SectionDto GridDto … HeaderViewEntity View Entities ButtonViewEntity BodyTextViewEntity CarouselViewEntity SectionViewEntity GridViewEntity … 2 HeaderView Views ButtonView BodyTextView CarouselView GridView … 3 SectionView 3 mappings
  19. 1. Polymorphic JSON → Data Models Polymorphic JSON HeaderDto Data

    Models ButtonDto BodyTextDto CarouselDto SectionDto GridDto … • Jackson • Moshi • kotlinx.serialization Libraries
  20. 1. Polymorphic JSON → Data Models • Jackson • Moshi

    • kotlinx.serialization Libraries Polymorphic JSON HeaderDto Data Models ButtonDto BodyTextDto CarouselDto SectionDto GridDto …
  21. Moshi Setup • Create EnumJsonAdapter with default fallback • Create

    a custom JsonAdapterFactory to handle polymorphic json and ignore the invalid json Polymorphic JSON { "type": "SimpleCard", "icon": "https://n26.com/card.png", "title": "N26 Metal", "subtitle": "See my account details" } @JsonClass(generateAdapter = true) data class SimpleCardDto( val icon: String, val title: String, val subtitle: String ) : Item<BodyTextViewEntity> Data model
  22. Moshi Setup • Create EnumJsonAdapter with default fallback • Create

    a custom JsonAdapterFactory to handle polymorphic json and ignore the invalid json Polymorphic JSON { "type": "SimpleCard", "title": "N26 Metal", "subtitle": "See my account details" } @JsonClass(generateAdapter = true) data class SimpleCardDto( val icon: String, val title: String, val subtitle: String ) : Item<BodyTextViewEntity> Data model 💣
  23. Moshi Setup • Create EnumJsonAdapter with default fallback • Create

    a custom JsonAdapterFactory to handle polymorphic json and ignore the invalid json Polymorphic JSON { "type": "SimpleCard", "title": "N26 Metal", "subtitle": "See my account details" } @JsonClass(generateAdapter = true) data class UnknownDto( val cause: Throwable ) : Item<ViewEntity> Data model 😎
  24. Moshi Setup • Create Operation, a wrapper of deeplink and

    tracking { "type": "Button", "text": "Top Up to unlock", "operation": { "uri": “n26://deeplink“, "tracking": {}, } } @JsonClass(generateAdapter = true) data class Operation(val uri: String, val tracking: Tracking)
  25. Moshi Setup • Create SduiType enum internal enum class SduiType(val

    kclass: KClass<out Item>) { Header(), BodyText(), Button(), // more items Carousel(), Section(), Grid() }
  26. Moshi Setup • Create SduiType enum internal enum class SduiType(val

    kclass: KClass<out Item>) { Header(HeaderDto::class), BodyText(BodyTextDto::class), Button(ButtonDto::class), // more items Carousel(CarouselDto::class), Section(SectionDto::class), Grid(GridDto::class) }
  27. 2. Data Models → View Entities HeaderDto Data Models ButtonDto

    BodyTextDto CarouselDto SectionDto GridDto … HeaderViewEntity View Entities ButtonViewEntity BodyTextViewEntity CarouselViewEntity SectionViewEntity GridViewEntity … @JsonClass(generateAdapter = false) interface Item<out T : ViewEntity> { fun toViewEntity( actionDelegate: ActionDelegate ): T? }
  28. 2. Data Models → View Entities @JsonClass(generateAdapter = true) data

    class ButtonDto( val text: String, val operation: Operation ){ }}
  29. 2. Data Models → View Entities @JsonClass(generateAdapter = true) data

    class ButtonDto( val text: String, val operation: Operation ) : Item<ButtonViewEntity> { }}
  30. 2. Data Models → View Entities @JsonClass(generateAdapter = true) data

    class ButtonDto( val text: String, val operation: Operation ) : Item<ButtonViewEntity> { override fun toViewEntity(actionDelegate: ActionDelegate): ButtonViewEntity? }}
  31. 2. Data Models → View Entities @JsonClass(generateAdapter = true) data

    class ButtonDto( val text: String, val operation: Operation ) : Item<ButtonViewEntity> { override fun toViewEntity(actionDelegate: ActionDelegate): ButtonViewEntity? }}
  32. 2. Data Models → View Entities @JsonClass(generateAdapter = true) data

    class ButtonDto( val text: String, val operation: Operation ) : Item<ButtonViewEntity> { override fun toViewEntity(actionDelegate: ActionDelegate): ButtonViewEntity? = operation.toActionEntity(actionDelegate) }}
  33. 2. Data Models → View Entities @JsonClass(generateAdapter = true) data

    class ButtonDto( val text: String, val operation: Operation ) : Item<ButtonViewEntity> { override fun toViewEntity(actionDelegate: ActionDelegate): ButtonViewEntity? = operation.toActionEntity(actionDelegate) ?.let { actionEntity -> ButtonViewEntity( text = text, clickAction = ClickAction { actionDelegate.performAction(actionEntity) } ) } }}
  34. 3. View Entities → View HeaderViewEntity View Entities ButtonViewEntity BodyTextViewEntity

    CarouselViewEntity SectionViewEntity GridViewEntity … HeaderView Views ButtonView BodyTextView CarouselView SectionView GridView … • ViewFactory • ViewFactoryMap • RecyclerViewAdapter
  35. 3. View Entities → View (ViewFactory) class ButtonView @JvmOverloads constructor(

    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : Button(context, attrs, defStyleAttr), Bindable<ButtonViewEntity> { override fun bind(viewEntity: ButtonViewEntity): Unit = with(viewEntity) { // Bind code } // more code }}
  36. 3. View Entities → View (ViewFactory) class ButtonView @JvmOverloads constructor(

    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : Button(context, attrs, defStyleAttr), Bindable<ButtonViewEntity> { override fun bind(viewEntity: ButtonViewEntity): Unit = with(viewEntity) { // Bind code } // more code class ViewFactory : BindableViewFactory<ButtonViewEntity> { override fun inflateView(parent: ViewGroup): View = ButtonView(parent.context).apply { rootView.layoutParams = RecyclerView.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) } } }}
  37. 3. View Entities → View (ViewFactory) class ButtonView @JvmOverloads constructor(

    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : Button(context, attrs, defStyleAttr), Bindable<ButtonViewEntity> { override fun bind(viewEntity: ButtonViewEntity): Unit = with(viewEntity) { // Bind code } // more code class ViewFactory : BindableViewFactory<ButtonViewEntity> { override fun inflateView(parent: ViewGroup): View = ButtonView(parent.context).apply { rootView.layoutParams = RecyclerView.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) } } }}
  38. 3. View Entities → View (ViewFactoryMap) object SduiViewFactoryMap { fun

    create() { val viewFactoryMap = mutableOf<KClass<out ViewEntity>, ViewFactory<in ViewEntity>>() viewFactoryMap.apply { // Simple tile put(HeaderViewEntity::class, HeaderView.ViewFactory()) put(BodyTextViewEntity::class, BodyTextView.ViewFactory()) put(ButtonViewEntity::class, ButtonView.ViewFactory()) // ... } } }}
  39. 3. View Entities → View (ViewFactoryMap) object SduiViewFactoryMap { fun

    create() { val viewFactoryMap = mutableOf<KClass<out ViewEntity>, ViewFactory<in ViewEntity>>() viewFactoryMap.apply { // Simple tile put(HeaderViewEntity::class, HeaderView.ViewFactory()) put(BodyTextViewEntity::class, BodyTextView.ViewFactory()) put(ButtonViewEntity::class, ButtonView.ViewFactory()) // ... // Complex tile put(CarouselViewEntity::class, CarouselView.ViewFactory(viewFactoryMap = this)) put(SectionViewEntity::class, SectionView.ViewFactory(viewFactoryMap = this)) put(GridViewEntity::class, GridView.ViewFactory(viewFactory = this)) } } }}
  40. 3. View Entities → View (ViewFactoryMap) class ViewFactoryMap : LinkedHashMap<KClass<out

    ViewEntity>, ViewFactory<in ViewEntity>>() { inline fun <reified Entity : ViewEntity> register( viewFactory: ViewFactory<in Entity> ): ViewFactory<in ViewEntity>? = put(Entity::class, viewController as ViewController<ViewEntity>) }
  41. 3. View Entities → View (ViewFactoryMap) class ViewFactoryMap : LinkedHashMap<KClass<out

    ViewEntity>, ViewFactory<in ViewEntity>>() { inline fun <reified Entity : ViewEntity> register( viewFactory: ViewFactory<in Entity> ): ViewFactory<in ViewEntity>? = put(Entity::class, viewFactory as ViewFactory<ViewEntity>) }
  42. 3. View Entities → View (ViewFactoryMap) class ViewFactoryMap : LinkedHashMap<KClass<out

    ViewEntity>, ViewFactory<in ViewEntity>>() { inline fun <reified Entity : ViewEntity> register( viewFactory: ViewFactory<in Entity> ): ViewFactory<in ViewEntity>? = put(Entity::class, viewFactory as ViewFactory<ViewEntity>) } object SduiViewFactoryMap { fun create() = ViewFactoryMap().apply { // Simple tile register(HeaderView.ViewFactory()) register(BodyTextView.ViewFactory()) register(ButtonView.ViewFactory()) // ... // Complex tile register(CarouselView.ViewFactory(viewFactoryMap = this)) register(SectionView.ViewFactory(viewFactoryMap = this)) register(GridView.ViewFactory(viewFactoryMap = this)) } }} 🤩
  43. 3. View Entities → View (RecyclerViewAdapter) @ContributesMultibinding(SessionScope::class) @FragmentKey(SduiFragment::class) class SduiFragment

    @Inject constructor( val viewModelFactory: SduiViewModelFactory, ) : Fragment(R.layout.fragment_sdui) { private val recyclerViewAdapter = RecyclerViewAdapter() // more code }}
  44. 3. View Entities → View (RecyclerViewAdapter) @ContributesMultibinding(SessionScope::class) @FragmentKey(SduiFragment::class) class SduiFragment

    @Inject constructor( val viewModelFactory: SduiViewModelFactory, ) : Fragment(R.layout.fragment_sdui) { private val recyclerViewAdapter = RecyclerViewAdapter() // more code }}
  45. 3. View Entities → View (RecyclerViewAdapter) @ContributesMultibinding(SessionScope::class) @FragmentKey(SduiFragment::class) class SduiFragment

    @Inject constructor( val viewModelFactory: SduiViewModelFactory, ) : Fragment(R.layout.fragment_sdui) { private val recyclerViewAdapter = RecyclerViewAdapter(SduiViewFactoryMap.create()) // more code }}
  46. 3. View Entities → View (RecyclerViewAdapter) @ContributesMultibinding(SessionScope::class) @FragmentKey(SduiFragment::class) class SduiFragment

    @Inject constructor( val viewModelFactory: SduiViewModelFactory, ) : Fragment(R.layout.fragment_sdui) { private val recyclerViewAdapter = RecyclerViewAdapter(SduiViewFactoryMap.create()) // more code private fun showContent(content: SduiViewState.Content) { setViewVisibility(isContentVisible = true) recyclerViewAdapter.submitList(content.viewEntities) } }}
  47. “Vaccine” class SduiVaccineView @JvmOverloads constructor( context: Context, attrs: AttributeSet? =

    null, defStyleAttr: Int = 0 ) : Bindable<SduiVaccineViewEntity> { // more code }} View
  48. “Vaccine” class SduiVaccineView @JvmOverloads constructor( context: Context, attrs: AttributeSet? =

    null, defStyleAttr: Int = 0 ) : ISduiVaccineView, Bindable<SduiVaccineViewEntity> { // more code }} View
  49. “Vaccine” class SduiVaccineView @JvmOverloads constructor( context: Context, attrs: AttributeSet? =

    null, defStyleAttr: Int = 0 ) : ISduiVaccineView, Bindable<SduiVaccineViewEntity> { // more code }} View data class SduiVaccineViewEntity( ) : ViewEntity View Entity
  50. “Vaccine” class SduiVaccineView @JvmOverloads constructor( context: Context, attrs: AttributeSet? =

    null, defStyleAttr: Int = 0 ) : ISduiVaccineView, Bindable<SduiVaccineViewEntity> { // more code }} View data class SduiVaccineViewEntity( val webserviceRelativePath: String ) : ViewEntity View Entity 💉 Membership Microservice
  51. 1. New module 2. Set up DI 3. Create retrofit

    service 4. Set up SDUI library 5. Create the ViewModel 6. Create an Activity/Fragment 7. Tests 😭 Not so hypothetical situation Build a new screen 🤔
  52. 🤔 Not so hypothetical situation Build a new screen 1.

    New module 2. Set up DI 3. Create retrofit service 4. Set up SDUI library 5. Create the ViewModel 6. Create an Activity/Fragment 7. Tests deeplink
  53. “Android-less” The Month of N Detail Story Perks 🤯 Browser

    🌐 n26://sdui?path=/api/membership/perks 👆