原文地址:https://medium.com/airbnb-engineering/a-deep-dive-into-airbnbs-server-driven-ui-system-842244c5f5
原文标题:A Deep Dive into Airbnb’s Server-Driven UI System
今年工作太忙了,公众号也搁置了很久。今天偶然一撇,看到了这篇文章。之前正好在公司也听了前facebook员工关于facebook服务端UI渲染的分享,于是仔细阅读了一下这篇AirBnb的服务端驱动的UI系统。
AirBnb是如何使用名为Ghost Platform的服务端驱动UI系统,快速在Web、iOS、Android上快速发布功能的呢?
背景:服务端驱动的UI
在深入讨论Airbnb服务器驱动UI(SDUI)的实现之前,了解SDUI的总体概念以及它如何提供优于传统客户端驱动UI的优势非常重要。
在传统做法中,数据由后端驱动,UI由每个客户端(web、iOS和Android)驱动。以Airbnb的列表页面为例。为了向用户显示列表,我们可以从后端请求列表数据。收到此列表数据后,客户端将该数据转换为UI。
这带来了一些问题。首先,在每个客户端上都构建了特定于列表的逻辑,用于转换和呈现列表数据。如果我们对列表的显示方式进行更改,那么这种逻辑很快就会变得复杂且不灵活。
第二,每个客户端都必须彼此保持一致。如前所述,当显示逻辑变得复杂时,每个客户端都有自己的复杂之处和处理状态、显示UI等的具体实现。客户端之间很容易产生分歧。
最后,移动端存在版本控制问题。每次我们需要在登录页面添加新功能时,我们都需要发布新版本的移动应用程序,以便用户获得最新体验。在用户更新之前,我们几乎没有办法确定用户是否在使用这些新功能或对这些新功能做出良好的响应。
服务端驱动(SDUI)的情形
如果客户端甚至不需要知道他们正在显示一个列表呢?如果我们可以将UI直接传递给客户端,而完全不考虑列出数据,那会怎么样?这本质上就是SDUI所做的——我们将UI和数据一起传递,客户端显示它,而不知道它包含的数据。
Airbnb特定的SDUI实现使我们的后端能够同时控制数据以及数据在所有客户端上的显示方式。从屏幕布局、版面如何排列、每个版面中显示的数据,甚至用户与版面交互时采取的行动,都由我们的web、iOS和Android应用程序的单一后端响应控制。
Airbnb的SDUI系统:Ghost Platform
Ghost Platform(以下简称GP)是一个统一的、自定义的、服务器驱动的UI系统,它使我们能够快速迭代并安全地跨web、iOS和Android启动功能。之所以称之为Ghost,是因为我们主要关注“顾客”和“房主”功能,这是Airbnb应用程序的两个方面。
GP以每个客户端的本地语言(分别是Typescript、Swift和Kotlin)提供web、iOS和Android框架,使开发人员能够以最少的设置创建服务器驱动的功能。
GP的核心点是,特性可以共享一个通用部分、布局和操作库,其中许多是向后兼容的,使团队能够更快地发布,并将复杂的业务逻辑移动到后端的中心位置。
标准化Schema
GP的主干是一个标准化的数据模型,客户端可以使用它来呈现UI。为了实现这一点,GP使用统一的数据服务mesh(Viaduct.)跨后端服务利用共享数据层。
为了使服务器驱动的UI系统具有可扩展性,GP为Web、iOS和Android应用程序使用了单一的共享GraphQL模式——即,我们在所有平台上使用相同的模式来处理响应和生成强类型数据模型。
我们花时间概括了不同功能的共享方面,并以一致、深思熟虑的方式考虑了每个页面的特性。结果是一个通用Schema,能够在Airbnb上渲染所有功能。此Schema功能强大,足以考虑可重用的部分、动态布局、子页面、操作等,客户机应用程序中相应的GP框架利用此通用Schema来标准化UI呈现。
GP的相应
GP的第一个基本方面是总体响应的结构。有两个主要概念用于描述GP响应中的UI:section 和 screens。
Sections:section是GP最原始的构建块。一个section描述了一组具有内聚性的UI组件的数据,其中包含要显示的确切数据—已经翻译、本地化和格式化。每个client获取sectio数据并将其直接转换为UI。
Screens:任何GP响应都可以有任意数量的screens。每个screen都描述了屏幕的布局,进而描述了sections数组中的section将出现的位置(称为placements)。它还定义了其他元数据,例如如何呈现部分(例如,作为popover、modal或全屏)和日志数据。
interface GPResponse { sections: [SectionContainer] screens: [ScreenContainer] # ... Other metadata, logging data or feature-specific logic}
使用GP构建的特性后端将实现此GPResponse,并根据其用例填充屏幕和部分。web、iOS和Android上的GP客户端框架为开发人员提供了获取GPResponse实现并将其转换为UI的标准处理,而他们只需做很少的工作。
Sections
Section是GP最基本的构建块。GP sectioin的关键特性是它们完全独立于其他部分和显示它们的屏幕。
通过将section与它们周围的上下文分离,我们获得了重用和重新调整section用途的能力,而不必担心业务逻辑与任何特定功能产生紧密耦合。
Section Schema
在GraphQL模式中,GP section是所有可能的section类型的并集。每个section类型指定它们提供的要呈现的字段。section在GPResponse实现中接收,带有一些元数据,并通过SectionContainer包装器提供,该包装器包含有关section状态、日志数据和实际section数据模型的详细信息。
# Example sectionstype HeroSection { # Image urls images: [String]!} type TitleSection { title: String!, titleStyle: TextStyle! # Optional subtitle subtitle: String subtitleStyle: TextStyle # Action to be taken when tapping the optional subtitle onSubtitleClickAction: IAction} enum SectionComponentType { HERO, TITLE, PLUS_TITLE, # ... There's alot of these :)} union Section = HeroSection | TitleSection | # ... More section data models # The wrapper that wraps each section. Responsible for metadata, logging data and SectionComponentTypetype SectionContainer { id: String! # The key that determines how to render the section data model sectionComponentType: SectionComponentType # The data for this specific section section: Section # ... Metadata, logging data & more}
需要涉及的一个重要概念是SectionComponentType。SectionComponentType控制如何呈现section的数据模型。这使得一个数据模型可以在需要时以多种不同的方式呈现。
例如,两个SectionComponentTypes TITLE和PLUS_TITLE可能使用相同的TitleSection数据模型,但PLUS_TITLE实现将使用Airbnb的PLUS特定徽标和标题样式来渲染TitleSection。这为使用GP的特性提供了灵活性,同时仍然促进了模式和数据的可重用性。
Section Components
Section数据通过“section component”转换为UI。每个section组件负责将数据模型和SectionComponentType转换为UI组件。抽象section component由GP在每个平台上以其本地语言(即Typescript、Swift、Kotlin)提供,开发人员可以对其进行扩展以创建新的section。
section component将section数据模型映射到一个唯一的渲染,因此仅适用于一个SectionComponentType。如前所述,section在呈现时没有依赖其所在屏幕或其周围部分的任何上下文,因此每个section component都没有提供特定于功能的业务逻辑。
我是一名Android开发人员,所以让我们以Android为例. 为了构建一个标题section,我们有下面的代码片段。Web和iOS有类似的实现——分别是Typescript和Swift——用于构建section组件。
// This annotation builds a Map<SectionComponentType, SectionComponent> that GP uses to render sections @SectionComponentType(SectionComponentType.TITLE)class TitleSectionComponent : SectionComponent<TitleSection>() { // Developers override this method and build UI from TitleSection corresponding to TITLE override fun buildSectionUI(section: TitleSection) { // Text() Turns our title into a styled TextView Text( text = section.title, style = section.titleStyle ) // Optionally build a subtitle if present in the TitleSection data model if (!section.subtitle.isNullOrEmpty() { Text( text = section.subtitle, style = section.subtitleStyle ) } }}
GP提供了许多“核心”部分组件,例如上面的示例TitleSectionComponent,意味着可以从后端进行配置、设置样式和向后兼容,因此我们可以适应任何功能的用例。当然,在GP上构建新特性的开发人员可以根据需要添加新的section component。
Screens
Screen是GP的另一个构建块,但与section不同,screen主要由GP客户端框架处理,并且在使用上更加独特。GP screen负责各section的布局和组织。
screen schema
Screen作为ScreenContainer类型接收。根据screenProperties字段中包含的值,可以在modal(弹出窗口)、底部工作表或全屏中启动Screen。
Screen允许屏幕布局的动态配置,并通过LayoutsPerFormFactor类型依次排列section。LayoutsPerFormFactor 使用一个名为 ILayout 的接口指定紧凑和宽断点的布局,下面将详细介绍该接口。然后,每个客户端上的GP框架使用屏幕密度、旋转和其他因素来确定要渲染LayoutsPerFormFactor的哪一个 ILayout。
type ScreenContainer { id: String # Properties such as how to launch this screen (popup, sheet, etc.) screenProperties: ScreenProperties layout: LayoutsPerFormFactor}# Specifies the ILayout type depending on rotation, client screen density, etc.type LayoutsPerFormFactor { # Compact is usually used for portrait breakpoints (i.e. mobile phones) compact: ILayout # Wide is usually used for landscape breakpoints (i.e. web browsers, tablets) wide: ILayout}
ILayouts
ILayouts允许屏幕根据响应更改布局。在schema中,ILayout是一个接口,每个ILayout实现指定不同的位置。ILayout包含一个或多个SectionDetail类型,这些类型指向响应最外层的sections数组中的section。我们指向section数据模型,而不是内联包含它们。这通过跨布局配置重用section(前文中的LayoutsPerFormFactor)来缩小响应大小。
interface ILayout {}type SectionDetail { # References a SectionContainer in the GPResponse.sections array sectionId: String # Styling data topPadding: Int bottomPadding: Int # ... Other styling data (margins, borders, etc)}# A placement meat to display a single GP sectiontype SingleSectionPlacement { sectionDetail: SectionDetail!}# A placement meat to display multiple GP sections in the order they appear in the sectionDetails arraytype MultipleSectionsPlacement { sectionDetails: [SectionDetail]!}# A layout implementation defines the placements that sections are inserted into.type SingleColumnLayout implements ILayout { nav: SingleSectionPlacement main: MultipleSectionsPlacement floatingFooter: SingleSectionPlacement}
GP客户端框架为开发人员提供了ILayout,因为ILayout类型比section更特殊。每个客户端的GP框架中的每一个ILayout 都有一个唯一的渲染器。布局渲染器从每个位置获取每个SectionDetail,找到适当的section组件来渲染该部分,使用该部分组件构建该部分的UI,最后将构建的UI放置到布局中。
Actions
GP的最后一个概念是我们的操作和事件处理基础架构。GP最能改变游戏规则的一个方面是,除了从网络响应中定义屏幕的部分和布局外,我们还可以定义用户在屏幕上与UI交互时所采取的操作,如点击按钮或刷卡。我们通过schema中的IAction接口来实现这一点。
interface IAction {}# A simple action that will navigate the user to the screen matching the screenId when invokedtype NavigateToScreen implements IAction { screenId: String}# A sample TitleSection using an IAction type to handle the click of the subtitletype TitleSection { ... subtitle: String # Action to be taken when tapping the subtitle onSubtitleClickAction: IAction}
回想一下前面的内容,section component 将我们的标题部分转换为每个客户端上的UI。让我们看一看同一个Android示例,其中的TitleSectionComponent在点击字幕文本时会触发一个动态IAction。
@SectionComponentType(SectionComponentType.TITLE)class TitleSectionComponent : SectionComponent<TitleSection>() { override fun buildSectionUI(section: TitleSection) { // Build title UI elements if (!section.subtitle.isNullOrEmpty() { Text( ... onClick = { GPActionHandler.handleIAction(section.onSubtitleClickAction) } ) } }}
当用户点击本节中的字幕时,它将触发为TitleSection中的onSubtitleClickAction字段传递的IAction。GP负责将此操作路由到为该功能定义的事件处理程序,该事件处理程序将处理触发的IAction。
有一组标准的通用操作是GP统一处理的,例如导航到屏幕或滚动到某个部分。功能可以添加自己的IAction type,并使用这些type来处理其功能的独特操作。由于特定于功能的事件处理程序的作用域是该功能,因此它们可以包含任意多的特定于功能的业务逻辑,从而在出现特定用例时可以自由地使用自定义操作和业务逻辑。
所有步骤合起来
我们已经讨论了几个概念,所以让我们看一个完整的GP响应,看看它是如何呈现的,以将所有内容联系在一起的。下面是一个GP相应的例子:
{ "screens": [ { "id": "ROOT", "screenProperties": { }, "layout": { "wide": { }, "compact": { "type": "SingleColumnLayout", "main": { "type": "MultipleSectionsPlacement", "sectionDetails": [ { "sectionId": "hero_section" }, { "sectionId": "title_section" } ] }, "nav": { "type": "SingleSectionPlacement", "sectionDetail": { "sectionId": "toolbar_section" } }, "footer": { "type": "SingleSectionPlacement", "sectionDetail": { "sectionId": "book_bar_footer" } } } } } ], "sections": [ { "id": "toolbar_section", "sectionComponentType": "TOOLBAR", "section": { "type": "ToolbarSection", "nav_button": { "onClickAction": { "type": "NavigateBack", "screenId": "previous_screen_id" } } } }, { "id": "hero_section", "sectionComponentType": "HERO", "section": { "type": "HeroSection", "images": [ "api.airbnb.com/..." ] } }, { "id": "title_section", "sectionComponentType": "TITLE", "section": { "type": "TitleSection", "title": "Seamist Beach Cottage, Private Beach & Ocean Views", "titleStyle": { } } }, { "id": "book_bar_footer", "sectionComponentType": "BOOK_BAR_FOOTER", "section": { "type": "ButtonSection", "title": "$450/night", "button": { "text": "Check Availability", "onClickAction": { "type": "NavigateToScreen", "screenId": "next_screen_id" } } } } ]}
创建section component
使用GP的特性需要获取实现上述GPResponse的响应。收到GPResponse后,GP infra 将解析该响应并为开发人员构建section。
回想一下,sections数组中的每个section 都有一个 SectionComponentType 和一个section数据模型。处理GP的开发人员添加section component,使用SectionComponentType作为如何呈现section数据模型的key。
GP查找每个section component并将其传递给相应的数据模型。每个section componet组件都为该section创建UI组件,GP会将其插入到下面布局中的适当位置。
处理action
现在已经设置了每个section component的UI元素,我们需要处理用户与section的交互。例如,如果他们点击一个按钮,我们需要处理点击触发的action。
回想一下,GP将事件路由到其适当的处理程序。上面的示例响应包含两个可以触发操作的部分,即toolbar_section和book_bar_footer。用于构建这两个部分的section component只需获取IAction并指定何时启动它,在这两种情况下,都是在单击按钮时启动。
我们可以通过每个客户端上的点击处理程序来实现这一点,它将使用GP infra在点击事件上路由事件。
button( onClickListener = { GPActionHandler.handleIAction(section.button.onClickAction) })
设置屏幕和布局
为了给我们的用户提供一个完全交互的screen,GP通过screen数组查找具有“ROOT”id(GP的默认screen id)的屏幕。然后,GP将根据断点和与用户正在使用的特定设备相关的其他因素找到合适的ILayout类型。为了保持简单,我们将使用compact字段中的布局,即SingleColumnLayout。
在这里,GP将给SingleColumnLayout找到合适的布局渲染器。布局包括了顶部的容器(nav部分)、可滚动的列表(main部分)和一个浮动的底部(footer部分)。
此布局渲染器将采用每一部分的模型,其中包含SectionDetail对象。这些SectionDetails包含一些样式信息以及要填入的section的sectionId。GP将迭代这些SectionDetail对象,并使用前面构建的section component将section填充到各自的位置。
GP的下一步
GP只存在了大约一年,但大多数Airbnb最常用的功能(如搜索、列表页面、签出)都是基于GP构建的。尽管已经有一定的使用规模,GP仍处于初级阶段,还有很多工作要做。
我们计划通过“嵌套部分”实现更具可组合性的UI,通过我们的设计工具(如Figma)和所见即所得(WYSIWYG)编辑部分和位置,提高已存在元素的可发现性,从而实现无代码功能更改。