Yelp has many teams of mobile developers who collectively maintain two different mobile apps on iOS and Android: Yelp (hereinafter “Consumer App”) and Yelp for Business (hereinafter “Biz App”). We’re always looking for ways to ship features more quickly and consistently on all these platforms! We adopt vendor and open-source libraries when possible, and we develop our own shared libraries when necessary. While many teams were already independently adopting server-driven UI (SDUI) to build their features faster and cheaper, we felt something was missing – a foundation that tied all our libraries together into a shared, server-driven, end-to-end solution for mobile features.

In this blog post, we’ll cover the Biz Native Foundation (BNF), which provides a foundation for building, testing, deploying, and monitoring server-driven features in our Biz App. At the end, we’ll share future plans for extending this foundation to our Consumer App.

What is the Yelp for Business mobile app?

Launched in December 2014 for both iOS and Android, our Yelp for Business mobile app enables businesses to manage their presence on Yelp and connect with customers from their phone.

The app has several core screens, or tabs, with highly personalized content for each business and app user. For example, a restaurant that opened during COVID-19 will have different needs than a firmly established plumber, and a business owner will have different needs than a manager or employee. The core screens link to secondary screens for updating business information, adding photos, responding to reviews, and finding new customers.

Developing both iOS and Android versions of a complex, personalized app has been a major challenge. The level of effort to ship a new feature can be high, and the time-to-market can range from a minimum of one week (we release our apps weekly) to several months or quarters. Once released, a feature must be supported in a range of app versions while undergoing continuous maintenance and improvements with each new release.

Server-driven UI (SDUI) was an obvious way to address these challenges, and many product teams had already adopted SDUI or were planning to adopt SDUI in 2019 when we began developing the BNF to standardize and simplify mobile development. The COVID-19 pandemic accelerated our efforts as we added features to help businesses navigate an extremely dynamic, challenging time. We realized we needed to make a significant investment in order to adopt SDUI across our entire app.

Business and Technical Requirements

We defined a handful of important business and technical goals for our foundation:

  1. Ship Biz App features more quickly and consistently on iOS and Android
  2. Reduce the level-of-effort required to build, test, deploy, and monitor new Biz App features
  3. Support dynamic, highly-personalized content
  4. Give our marketing and product teams more direct control over the content in the Biz App

Alternatives Considered

Before we began building our own foundation, we reviewed a couple alternatives:

Webviews

The Biz App was already using webviews to share content with our Yelp for Business web app (biz.yelp.com). That said, we’d been slowly migrating away from webviews for the past two years for several reasons:

  • Webviews require careful handshaking between native and web apps
  • Most mobile app engineers don’t have experience debugging web apps, and most front-end engineers don’t have experience debugging mobile apps
  • Native screens offer superior user experience (UX) over webviews, e.g. they are faster and more tightly integrated with the platform

React Native

React Native would allow us to ship mobile app features more quickly and consistently, and our front-end developers could contribute to our mobile app more easily. React Native would be faster than webviews and more tightly integrated with the platform. However, React Native had some significant downsides for our existing Biz App and developer community:

  • We didn’t already use React Native at Yelp, and most of our mobile developers didn’t have professional React Native experience
  • We couldn’t reuse our existing code or native libraries without extensive bridging, which feels counter to building a foundation

Design Principles

Once we decided to build our own foundation, we established some design principles to guide our efforts.

Adopt Best Practices

We would adopt Yelp-specific or industry-standard best practices when possible. Yelp already has consistent vendor, open-source, and internal libraries for mobile development. We use the latest features in UIKit (iOS) and Material Design / Jetpack (Android). On Android, we use our open-sourced Bento framework to build modularized UIs. On iOS, we have a similar internal framework. We wanted our foundation to build on these existing solutions rather than replace them.

Support Server-Driven UI (SDUI)

We would give our backend more control over screen content through server-driven UI. This would enable us to make changes more quickly and consistently on all clients. It would also enable dynamic, personalized content and give our marketing and product teams more direct control. Fortunately, SDUI wasn’t new to Yelp or the Biz App, where several product teams had already adopted SDUI for their features. We would learn from these efforts to create a shared SDUI framework for our foundation.

Enable Customization

We would enable customization. Though we wanted to encourage reuse and consistency, we didn’t want to restrict product teams from writing custom code where it makes sense. Otherwise, they would simply build their features without the foundation.

Create a Supporting Toolchain

We would create tools that simplify or automate common tasks, such as debugging, testing, logging, monitoring, and documentation.

Core Concepts

The BNF has only four core concepts to keep the system simple and intuitive. Since mobile screens are the heart of every application, the BNF provides a generic screen that hosts generic components. When the user interacts with generic components, we trigger generic actions to update the UI or applications state, using generic properties to provide a way for generic components to observe application state without strong coupling.

We’ll go through these concepts and show how they all work together to support mobile feature development.

Generic Screen

A generic screen is a flexible template that can support any screen in the Biz App. Before the BNF, adding a new screen required boilerplate code, such as a custom view controller (iOS) or activity/fragment (Android). Fortunately, mobile screens are constrained by the geometry of mobile devices, so we created one highly configurable screen.

A generic screen consists of a number of sections containing one or more generic components. Each section represents a part of the screen, such as the top/bottom navigation bar or scroll view.

A generic screen must be configured to display content. The BNF supports both remotely and locally configured screens.

Remotely configuring a generic screen (SDUI)

A remotely configured generic screen uses an endpoint on our REST API to load a JSON screen configuration resource:

/ui/{business_id}/screens/{name}/configuration/v1

The endpoint has path arguments that specify the target business ID and the logical name of the screen, e.g. home.

We use Swagger 2.0 to document our REST APIs and auto-generate client networking libraries. Let’s look at some definitions for our screen configuration.

The screen configuration object (ScreenConfigurationV1) has properties for each section on the screen, e.g. components is the main scroll view. We version the screen configuration object and the screen configuration endpoint whenever we add new properties to this object, such as a new section.

ScreenConfigurationV1:
  properties:
    header:
      $ref: #/definitions/GenericComponent
    components:
      type: array
      items:
        $ref: #/definitions/GenericComponent
    sticky_bottom_components:
      type: array
      items:
        $ref: #/definitions/GenericComponent
    data:
      $ref: #definitions/GenericScreenData
  required:
  - components
  - data
  type: object

Each section can be configured with one or more generic components. The GenericComponent object only contains an ID (learn-more-button) and a type (generic_button_v1).

GenericComponent:
  properties:
    id:
      type: string
    type:
      type: string
  required:
  - id
  - type
  type: object

The data for each component is stored in a separate GenericScreenData object that maps a component ID to a GenericComponentData object, which was our best approximation for a Swagger union type that worked with our Swagger codegen pipeline, which automatically generates networking code for iOS and Android clients. GenericActionData plays a similar role for generic actions.

GenericScreenData:
  properties:
    id_to_component_data:
      description: Map component ID to its GenericComponentData
      $ref: #/definitions/IdToGenericComponentData
    id_to_action_data:
      description: Map action ID to its GenericActionData
      $ref: #definitions/IdToGenericActionData
  required:
  - id_to_component_map
  - id_to_action_map
  type: object

GenericComponentData:
  properties:
    generic_button_v1:
      $ref: #definitions/GenericButtonDataV1
    generic_text_v1:
      $ref: #definitions/GenericTextDataV1
    ...
  type: object

GenericActionData:
  properties:
    generic_open_url_v1:
      $ref: #definitions/GenericShowOpenUrlV1
    generic_close_screen_v1:
      $ref: #definitions/GenericCloseScreenV1
    ...
  type: object

There are some benefits to storing configuration data separately from references:

  • We can reuse the same data across multiple references, reducing the size of the screen configuration
  • We can debug the screen configuration more easily with all configuration data in a flat map

Locally configuring a generic screen

A locally configured generic screen uses either a Kotlin or Swift domain-specific language (DSL).

screenConfiguration {
    header {
        navBar(title = "Welcome!")
    }
    components {
        text("Yelp is working on some cool things!", style = HEADER1)
        button(
            "Learn more on our blog",
            tappedActions = actions {
               openUrl("https://engineeringblog.yelp.com")
            }
        )
    }
}

Though locally configured screens can’t be updated without a client release, they still satisfy many of our requirements, such as shipping features more quickly and reducing the level-of-effort. Not every screen has dynamic, personalized content that benefits from being server-driven, and some screens are simply hard to make server-driven.

Generic Components

A generic component is a basic building block for a generic screen. The BNF supports a rich, extensible library of components.

Every component has an unique ID and type. We use a naming convention to distinguish generic, reusable component types (generic_button_v1) from components that are customized for one feature (feature_ad_preview_v1). However, the BNF doesn’t handle generic or feature-specific component types differently, so we refer to all components as generic components.

Configuring components

Generic components must be configured with data. In remote screen configurations, each component type has an associated data object. When adding new features to the component, we always version the component type and data object.

definitions:
  GenericButtonDataV1:
    properties:
      text:
        type: string
      style:
        type: string
        enum:
        - primary
        - secondary
        - tertiary
      size:
        type: string
        enum:
        - standard
        - large
        - small
      viewed_actions:
        description: Actions to fire when the app user views the button
        type: array
        items:
          $ref: #/definitions/GenericAction
      tapped_actions:
        description: Actions to fire when the app user taps the button
        type: array
        items:
          $ref:#/definitions/GenericAction
    required:
    - text
    - style
    - size
    - tapped_actions
    type: object

In a local screen configuration, the generic component can be configured with our DSL:

button(
   text = "Learn more on our blog",
   style = PRIMARY,
   tappedActions = actions {
      openUrl("https://engineeringblog.yelp.com")
  }
)

Both produce the same result on the client:

Composing components

The BNF has several ways to build larger components from smaller pieces. First, many mobile features can be broken into a vertical stack of simpler components, such as buttons, text, icons, and images. Second, many features can be built by composing components with a container component.

For example, the Biz App has cards to promote the products and services Yelp offers to businesses. The promotional cards are built from a stack of simpler components and a bordered container (generic_bordered_container_v1), which contains a feature-specific component for each product (feature_call_to_action_v1).

iOS and Android provide mechanisms to recycle views when scrolling, so using vertical stacks of simpler, reusable components improves scroll performance.

Initially, we were worried that containers would impact scroll performance and memory consumption, especially with high levels of nesting. But we kept finding designs that benefited from containers. In practice, we don’t nest more than one or two levels. On Android, containers are nested RecyclerViews that share a common RecycledViewPool, allowing re-use of simpler components such as text, buttons, and images.

Rendering components on clients

On the client, components are rendered with a factory associated with the component type. The same factory handles multiple versions of the same component. We typically have one implementation of each component (GenericButtonComponent) on the client, and the factory maps the server-driven component data to an internal configuration.

class GenericButtonComponentFactory: GenericComponentFactory {

    // Used by the BNF infrastructure to build a catalog of
    // available & deprecated types
    override val availableTypes = listOf(V1, V2)
    override val deprecatedTypes = listOf(V1)

    override fun create(
        component: GenericComponent,
        data: GenericComponentData
    ) = when (component.type) {
        V1 -> createV1(component.id, data.generic_button_v1)
        V2 -> createV2(component.id, data.generic_button_v2)
        else -> throw IllegalStateException("Unexpected component ${component.type}")
    }

    fun createV1(id: String, data: GenericButtonDataV1): GenericButtonComponent {
        // Convert GenericButtonDataV1 to an internal state
        // Construct & return the GenericButtonComponent
    }

    fun createV2(id: String, data: GenericButtonDataV2): GenericButtonComponent {
        // Convert GenericButtonDataV2 to an internal state
        // Construct & return the GenericButtonComponent
    }

    companion object {
        const val V1 = "generic_button_v1"
        const val V2 = "generic_button_v2"
    }
}

Generic Actions

A generic action is a side effect that occurs when the user interacts with a screen or component. A generic screen or component can trigger actions under any number of conditions, such as when the user views the screen or taps the component.

Like generic components, every generic action has a unique ID (open-blog-url) and type (generic_open_url_v1), and we use naming conventions to distinguish between generic and feature-specific actions (feature_close_business_v1).

As with generic components, the BNF was designed to support a rich, extensible library of actions. Here’s a sampling of actions:

Generic Action Description
generic_open_url_v1 Opens a deep link, which supports “https”, “tel”, “yelp”, and “yelp-biz” schemes
generic_close_screen_v1 Closes the current screen and opens an optional URL to navigate to the next screen
generic_show_screen_v1 Opens another screen using a nested screen configuration
generic_reconfigure_screen_v1 Reconfigures the current screen with a new screen configuration
generic_update_property_v1 Updates the value of a generic property, which represents a piece of application state
generic_scroll_to_component_v1 Scrolls the screen to a specified component
feature_close_business_v1 Marks the current business as closed, which has a lot of feature-specific side-effects

Configuring actions

In a remote screen configuration, each action type has a corresponding data model:

GenericOpenUrlDataV1:
  properties:
    url:
      description: Link to be opened when the action is triggered
      type: string
  required:
  - url
  type: object

In a local screen configuration, actions can be configured with the DSL in an actions block:

button(
   text = "Learn more on our blog",
   style = PRIMARY,
   tappedActions = actions {
      openUrl("https://engineeringblog.yelp.com")
  }
)

Handling actions

We use an event-based architecture on both iOS and Android to handle user interactions. Generic actions are events, which are either Swift structs or Kotlin data classes. For example, on Android, we have an OpenUrlEvent to model generic_open_url_v1 in remote screen configurations.

data class OpenUrlEvent(val url: String): GenericScreenEvent()

Android uses a Model-View-Intent (MVI) architecture where components publish events (intents) to a shared event bus. When the user taps a component, the component will publish its tappedActions.

class GenericButtonComponentViewHolder :
    GenericComponentViewHolder<GenericButtonComponentState>(
        R.layout.view_generic_button_component
    )
{
    lateinit var tappedActions: List<GenericScreenEvent>

    private val button by clickView<GenericButton>(R.id.button) {
        eventBus.sendEvents(tappedActions)
    }

    override fun bind(state: GenericButtonComponentState) {
        button.configure(state)
        tappedActions = state.tappedActions
    }
}

The event will be delivered to a matching intent handler that knows how to process the user’s intent and update the UI state.

class NavigationIntentHandler: GenericScreenIntentHandler() {

    @Event(OpenUrlEvent::class)
    fun handleOpenUrl(event: OpenUrlEvent){
        with(event.url) {
            when {
                startsWith("tel:") -> openTelLink(this)
                startsWith("https:") -> openSecureHttpLink(this)
                startsWith("http:") -> openUnsecureHttpLink(this)
                startsWith("yelp-biz:") -> openCustomLink(this)
                else -> reportUnsupportedLinkError(this)
            }
        }
    }
}

Generic Properties

Most UIs are dynamic; they need to respond to user interactions and changes in application state. For example, businesses can exchange messages with their customers, and we want to show the number of unread messages as a badge component.

Modeling generic properties

We represent a generic property using a dot-separated hierarchical path and an associated data type:

businesses.{business_id}.inbox.messages.unread.count<integer>

A generic property can have path parameters that provide additional context. For example, each business has a separate inbox, so the {business_id} parameter corresponds to the unique business ID.

To a generic component, a generic property is just a strongly-typed variable that it can read or write. The generic component doesn’t know the meaning of the data (the number of unread messages) or how the data is stored or updated.

In a remote screen configuration, we use the GenericProperty object to model properties.

GenericProperty:
    properties:
      path:
        description: A dot-separated hierarchical path for the property
        type: string
      type:
        description: Represents the property type
        type: string
    required:
    - name
    - type
    type: object

Supporting generic properties

Each generic property has a generic property manager that handles reads and writes.

On Android, we resolve a generic property into an RxJava Observable backed by a BehaviorSubject, which remembers the latest value. A generic component subscribes to the Observable to receive new values and update its view.

class BusinessInboxPropertyManager: GenericPropertyManager<Int> {

    val inboxPropertyDefinition =
        GenericPropertyDefinition(
            "businesses.{business_id}.inbox.messages.unread.count"
        )

    override val properties = listOf(inboxPropertyDefinition)

    private val subjectMap = mutableMapOf<String, BehaviorSubject<Int>>()

    override fun get(path: String): Observable<Int> {
        return getOrCreateSubject(path).hide()
    }

    override fun set(path: String, value: Int) {
        getOrCreateSubject(path).onNext(value)
    }

    private fun getOrCreateSubject(path: String): BehaviorSubject<Int> {
        return subjectMap[path] ?: BehaviorSubject.create<Int>().also {
            subjectMap[path] = it
        }
    }
}

Building dynamic components with generic properties

The BNF supports a generic_badge_v1 component that represents a basic badge with a dynamic count using a generic property.

GenericBadgeDataV1:
    properties:
      dynamic_count:
        $ref: #/definitions/GenericProperty
    required:
    - dynamic_count
    type: object

On Android, we map the generic property to an Observable<Int> in the component’s MVI state.

// The BadgeComponent’s MVI state stores an Observable
data class BadgeComponentState(
   val dynamicCount: Observable<Int>,
   @ColorRes val color: Int = R.color.red
)

// The BadgeComponenFactory resolves a generic property into
// the Observable required by the MVI state using the
// GenericProperties registry.
fun createBadgeComponentState(data: GenericBadgeDataV1)
   = BadgeComponentState(
         dynamicCount = GenericProperties.get(data.dynamicCount.path)
     )

The component subscribes to the Observable<Int> and updates the badge to reflect the current code.

// The BadgeComponent subscribes to the Observable
state.dynamicCount
  .doOnSubscribe {
      // Keep the badge invisible until we have the first count
      badgeView.isVisible = false
  }
  .doOnSuccess {
      // Update the value of the badge!
      badgeView.value = it
      // Don’t show the badge unless there’s a non-zero count
      badgeView.isVisible = (it > 0)
  }
  .doOnError {
      // If there’s an error, hide the badge
      badgeView.isVisible = false
  }
  .subscribe()
  .autodispose()

We’re still experimenting with generic properties and refining the use cases. We believe they are a necessary concept to unlock dynamic, server-driven UIs.

Current Use Cases

We’re using the BNF to power the Home, Yelp Ads, and Business Info, and More tabs. These tabs are remotely configured screens because they host dynamic, personalized content. The Yelp Ads tab hosts the Ads Dashboard screen, which was the first screen built entirely from scratch using the BNF. We’ll share more about this in a future blog post; stay tuned!

We’re also using the BNF to power several in-product marketing screens. These screens are usually remotely configured to give our marketing and product teams more direct control, but sometimes we build them locally first using our screen configuration DSL.

Finally, we’re using the BNF to build debug screens to prototype new designs or test individual generic components, actions, or properties. These screens are locally configured with our screen configuration DSL.

Future Directions

Building a Better Backend

SDUI pushes more of the business and presentation logic into the backend. Most backend engineers aren’t familiar with mobile app development or building mobile UIs. Consequently, they need better infrastructure for making, testing, and deploying their changes. We also need better tooling for our marketing and product teams to make changes, too.

Adopting SwiftUI and Jetpack Compose

One of our design principles is “Adopting Best Practices.” We’ve therefore watched the evolution of SwiftUI and Jetpack Compose with great interest. Both frameworks support building composable, dynamic UIs with a simple declarative syntax. We hope to adopt these new frameworks in the near future.

Adopting GraphQL

Yelp is currently migrating our web and mobile apps from individual REST APIs to a unified GraphQL schema. We’re planning to migrate the BNF to GraphQL, which offers better support than REST for making changes without breaking backwards compatibility. Mobile clients must write explicit GraphQL queries that describe the types and fields they support. With our REST API, we are frequently creating new versions of entire objects (GenericButtonDataV7) or APIs just to add one field safely. With GraphQL, we can evolve our schema incrementally.

Building a Yelp Native Foundation

Our Consumer App and Biz App handle separate sides of the same transaction – connecting consumers to great local businesses. In many cases, building a new feature requires changes in both apps. For example, when the Biz App added features for businesses to provide COVID-related updates to consumers, the Consumer App added corresponding features for consumers to see those updates.

When we started the BNF in 2019, product teams working on the Consumer App were also starting a shared server-driven foundation for similar reasons. Unfortunately, the Biz App and Consumer App had different REST APIs and separate Git repositories. We made the practical decision to share ideas and techniques but not code. Now we’re slowly moving towards a common Yelp Native Foundation by migrating to a unified GraphQL schema and adopting monorepos.

We’re very excited about the future of SDUI at Yelp and in the industry as a whole. Many companies, such as Airbnb and Doordash, have recently published the details of their own shared, server-driven foundations, and there are open-source efforts, such as Beagle. We’ve noticed many similarities between our work and these projects, which suggests there are some natural design patterns for implementing SDUI. We hope this blog post contributes to the growing SDUI community. Keep an eye on this blog for updates on our progress!

Become a Mobile Software Engineer at Yelp

Want to help us grow our mobile foundation on iOS?

View Job

Back to blog