Share
Embed
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
Transcript
107 slides
-
Life is too short to develop only for Android A
journey to Server-driven UI Zhenlei Ji @zhenleiji -
🗓 Outline 1. The problem 2. Server driven UI 3.
Our journey -
Insurance Perks The Month of N Detail Explore We are
building similar screen over and over again -
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": […] } … } -
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": […] } … } -
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": […] } … } -
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": […] } … } -
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) -
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": [] } ] } -
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 -
Api Response (SDUI) Render UI components Polymorphic JSON HeaderDto SmallBodyTextDto
ButtonDto SimpleCardDto CarouselDto Map to data models -
Api Response (SDUI) Render UI components Polymorphic JSON HeaderDto SmallBodyTextDto
ButtonDto SimpleCardDto CarouselDto Map to data models -
Api Response (SDUI) Render UI components Polymorphic JSON HeaderDto SmallBodyTextDto
ButtonDto SimpleCardDto Map to data models GridDto -
Atomic design Atoms Spot Icon Body Text Small Body Text
Molecules Three Item Container -
Atomic design Atoms Organisms Spot Icon Body Text Small Body
Text Molecules Three Item Container Product Spotlight -
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 -
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 -
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 -
Foundation Kick-o f • Stateless Backend • Backend without versioning
😢 • Static screen • All clickable components should have deeplink and tracking -
Foundation Polymorphic JSON HeaderDto Data Models 1 ButtonDto BodyTextDto CarouselDto
SectionDto GridDto … 3 mappings -
Foundation Polymorphic JSON Data Models 1 HeaderDto ButtonDto BodyTextDto CarouselDto
SectionDto GridDto … HeaderViewEntity View Entities ButtonViewEntity BodyTextViewEntity CarouselViewEntity SectionViewEntity GridViewEntity … 2 3 mappings -
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 -
1. Polymorphic JSON → Data Models Polymorphic JSON HeaderDto Data
Models ButtonDto BodyTextDto CarouselDto SectionDto GridDto … • Jackson • Moshi • kotlinx.serialization Libraries -
1. Polymorphic JSON → Data Models • Jackson • Moshi
• kotlinx.serialization Libraries Polymorphic JSON HeaderDto Data Models ButtonDto BodyTextDto CarouselDto SectionDto GridDto … -
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 -
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 💣 -
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 😎 -
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) -
Moshi Setup • Create SduiType enum internal enum class SduiType(val
kclass: KClass<out Item>) { Header(), BodyText(), Button(), // more items Carousel(), Section(), Grid() } -
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) } -
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? } -
2. Data Models → View Entities @JsonClass(generateAdapter = true) data
class ButtonDto( val text: String, val operation: Operation ){ }} -
2. Data Models → View Entities @JsonClass(generateAdapter = true) data
class ButtonDto( val text: String, val operation: Operation ) : Item<ButtonViewEntity> { }} -
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? }} -
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? }} -
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) }} -
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) } ) } }} -
3. View Entities → View HeaderViewEntity View Entities ButtonViewEntity BodyTextViewEntity
CarouselViewEntity SectionViewEntity GridViewEntity … HeaderView Views ButtonView BodyTextView CarouselView SectionView GridView … • ViewFactory • ViewFactoryMap • RecyclerViewAdapter -
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 }} -
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 ) } } }} -
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 ) } } }} -
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()) // ... } } }} -
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)) } } }} -
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>) } -
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>) } -
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)) } }} 🤩 -
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 }} -
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 }} -
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 }} -
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) } }} -
Test strategy Polymorphic JSON Data models 1 2 3 View
Entites Microservice -
Test strategy Polymorphic JSON Data models View Entites Microservice Contract
test Unit test 1 2 3 -
Test strategy Polymorphic JSON Data models View Entites Microservice Contract
test Unit test Screenshot test* 1 2 3 -
Membership Squad Not so hypothetical situation Spaces squad Membership squad
Membership's Devs Spaces' Devs -
“Vaccine” class SduiVaccineView @JvmOverloads constructor( context: Context, attrs: AttributeSet? =
null, defStyleAttr: Int = 0 ) : Bindable<SduiVaccineViewEntity> { // more code }} View -
“Vaccine” class SduiVaccineView @JvmOverloads constructor( context: Context, attrs: AttributeSet? =
null, defStyleAttr: Int = 0 ) : ISduiVaccineView, Bindable<SduiVaccineViewEntity> { // more code }} View -
“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 -
“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 -
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 🤔 -
🤔 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 -
“Android-less” The Month of N Detail Story Perks 🤯 Browser
🌐 n26://sdui?path=/api/membership/perks 👆