🙆

Flutter/GraphQLで審査なしでUI変更を実現するServer-Driven UI (SDUI)を実装してみる

3行概要

  • 審査なしでアプリのUI変更を実現するServer-Driven UI (SDUI) について学ぶ
  • FlutterでSDUIを実現するためのGraphQLの型定義の設計を提供
  • A/BテストやパーソナライズなどSDUIが光るユースケースを考える

Server-Driven UI (SDUI) ってなに?

Server-Driven UI とは、UIコンポーネントとその構成がサーバーで定義され、実行時にそのデータを用いてアプリ側でUIを構築する手法です。

SDUIの本質的な考え方として、クライアントはサーバー側で構築されたUIをそのまま表示します。この際、クライアントはListやGridなどを全く知らなくても良いのが特徴です。

https://medium.com/airbnb-engineering/a-deep-dive-into-airbnbs-server-driven-ui-system-842244c5f5

SDUIが広まるトリガになったのは、2021年のAirbnbのテックブログです。

それからUberなどでも採用され、日本ではTappleやUbie, Gaudy などの企業で採用されているようです。

発想としてはそんなに新しい概念ではないのですが、GraphQLなどが一定レベルで普及し、かつプロダクト開発の速度は上昇しているため、SDUIについて改めて整理しておくことが重要と考え記事を書くことにしました。

Server-Driven UI (SDUI) のKey Benefits

モバイルで特筆すべきSDUIのメリットは、審査が不要であることです。

UI変更についてSDUIが吸収してくれるため、審査不要で好きなタイミングで好きなようにUIを変更になるのが一番のメリットと言えます。

さらに、この副作用としてプロダクトの改善スピードがグッと上昇します。

A/Bテストやユーザーごとの条件出し分け、キャンペーンやパーソナライズなどすべてをサーバーで吸収できることが大きなSDUIの強みです。

Server-Driven UI (SDUI) を実現するためにはGraphQLの型定義を利用すると簡単

Airbnbの元記事でもある通り、SDUIを実現するにはGraphqlの型定義を利用するのが非常に便利です。

サーバーで構築したデータをFlutterで表示するために、データをWidgetへ変換する WidgetParser を定義するのですが、この際にGraphQLの型定義があると非常に楽に組むことが出来ます。

SDUIの実装をみるとたまにRESTでやっている人もいるのですが、型安全にそれぞれのWidgetにパースできる体験はかなり気持ちいいので、ぜひGraphQLを使うことをオススメします。

もちろんgRPCなどを使っても構いません。

Server-Driven UI (SDUI)を実現するGraphQLスキーマ

では早速、GraphQLのスキーマを書いて全体像を理解してみましょう。

UIを構築するためのWidgetContainer

type TitleWidget {
    title: String!
    subtitle: String!
    imgUrl: String
}

"""
GridでEcSiteの商品を表現するWidgetのための型
"""
type GridWidget {
    title: String!
    items: [EcItem!]!
}

"""
ListでEcSiteの商品を表現するWidgetのための型
"""
type ListWidget {
    title: String!
    items: [EcItem!]!
}

"""
スペーシングを追加するWidgetのための型
"""
type SpacerWidget {
    vertical: Int!
    horizontal: Int!
}

"""
GridWidgetとListWidgetが扱うitemを表現する型
"""
type EcItem {
    name: String!
    price: Int!
    imgUrl: String!
}

"""
各Widgetをまとめるための型
"""
union Widget = TitleWidget 
    | GridWidget
    | ListWidget
    | SpacerWidget

"""
Widgetのメタデータを保持するためのWrapper
"""
type WidgetContainer {
    id: String!
    widget: Widget!
}

一番わかりやすいのは Widget 型かと思います。

Widget 型はFlutterのWidgetの描画に必要な各データを保持するための型です。

このWidgetをそのまま返すとキャッシュ管理のために必要なidフィールドがなかったりして不便なので、WidgetContainer としてメタデータを一緒に返却するように設計しています。簡便のため省略しますが、variation フィールドを用意したりすることで、細かいWidgetのバリエーションを出し分けすることも可能になります。

サーバー側の resolver で実際に返却するのは WidgetContainer となり、クライアントで受け取ってFlutterのWidgetにparseするのも WidgetContainer となります。

ふるまいを構築するためのAction

SDUIの面白い部分はUI要素を変更するだけにとどまりません。

ボタンのタップやスワイプ時など、ユーザー起点のトリガによるWidgetのふるまいもサーバードリブンで変更することもできるのです。

interface IAction {
    id: String!
}

type NavigateToScreen implements IAction {
    id: String!
    screenId: String!
}
"""
IActionを自由に配置することが出来る
"""
type ListWidget {
    title: String!
    items: [EcItem!]!
    onTap: IAction!
}

このように IAction を定義し、 Widget に配置することでふるまいを変更することが出来ます。

もちろん、Flutter側で事前のマッピングは必要です。

しかし、例えばカードをタップしたときに画面Aに遷移するかBに遷移するか、もしく開くURLなどをリリース後に変更したくなることも結構ありませんか?それをリリースサイクルの中でハンドリングしたり効果測定を仕込んだり….という面倒なふるまいの変更をすべてサーバー側で担えるのはSDUIの大きなメリットです。

FlutterでWidgetParserを実装し、フロント側実装を一切変えずにアプリの画面を出し分ける

ここまでのスキーマを使って、サーバー側から画面を構成する以下のようなレスポンスが得られたと仮定します。

{
   "data": {
     "homeScreen": [
       {
         "id": "title",
         "variation": null,
         "widget": {
           "title": "Server-Driven UI ",
           "subtitle": "Flutter/GraphQLでServer-Driven UI (SDUI)をやってみる",
           "imgUrl": "https://storage.googleapis.com/zenn-user-upload/avatar/f2d7b3783d.jpeg",
           "__typename": "TitleWidget"
         },
         "__typename": "WidgetContainer"
       },
       {
         "id": "spacer_20",
         "variation": null,
         "widget": {
           "vertical": 20,
           "horizontal": 0,
           "__typename": "SpacerWidget"
         },
         "__typename": "WidgetContainer"
       },
       {
         "id": "ListWidget",
         "variation": null,
         "widget": {
           "title": "商品一覧",
           "items": [
             {
               "id": "item1",
               "name": "ビール",
               "price": 600,
               "imgUrl": "https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Food/Beer%20Mug.png",
               "__typename": "EcItem"
             },
             {
               "id": "item2",
               "name": "ラーメン",
               "price": 1200,
               "imgUrl": "https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Food/Steaming%20Bowl.png",
               "__typename": "EcItem"
             },
             {
               "id": "item3",
               "name": "プリン",
               "price": 800,
               "imgUrl": "https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Food/Custard.png",
               "__typename": "EcItem"
             }
           ],
           "onTap": {
             "id": "onTapDetail",
             "screenId": "DetailScreen",
             "__typename": "NavigateToScreen"
           },
           "__typename": "ListWidget"
         },
         "__typename": "WidgetContainer"
       }
     ],
     "__typename": "Query"
   }
 }

最低限、画面を構成する要素が得られていることがわかります。

これをFlutterのWidgetとして扱うために、 widgetParser を書いてみましょう。

https://pub.dev/packages/graphql_codegen

Flutterの graphql_codegenは優秀なので、生成してくれるコードを利用して、GraphQLのレスポンスをWidgetにしていきます。

Widget widgetParser(Query$sduScreen$homeScreen query) {
  return query.widget.when(
    titleWidget: (title) {
      return TitleWidget(
        title: title.title,
        description: title.subtitle,
        imgUrl: title.imgUrl,
      );
    },
    gridWidget: (grid) {
      return GridCardWidget(grid: grid);
    },
    listWidget: (list) {
      // IActionとして定義されたふるまいも、whenで取り出すことができる
      // final onTap = list.onTap;
      // onTap.when(navigateToScreen: , orElse: );

      return ListCardWidget(list: list);
    },
    spacerWidget: (size) {
      return SizedBox(
        height: size.vertical.toDouble(),
        width: size.horizontal.toDouble(),
      );
    },
    orElse: () => const SizedBox(),
  );
}

便利なのは言わずもがな、 when メソッドです。

このメソッドを利用すれば、レスポンスに合わせたWidgetの出し分けが非常に簡単になります。

後はおもむろにqueryして、それをWidgetParserに渡すだけです。

 final widgets = res.map((query) => widgetParser(query)).toList();
  return CustomScrollView(
            slivers: [
              SliverList.list(
                children: widgets,
              ),
            ],
          );

CustomScrollViewSliverList.list を使えば List<Widget> を簡単に並べることが出来ます。

実際の画面の実装はほぼこれだけで、あくまでサーバー側のレスポンスをもとにWidgetを構築することが出来ます。

フロント側でどのようなデータが来ても出せるようにWidgetParserを作っておけば、後はサーバー側で好きなようにデータを構築してアプリのUIを変更することが出来ます。

従って、上記の図のように、サーバー側の変更のみでListViewやGridViewの出し分けが可能になります。

ListやGridくらいなら、状態を管理してフロント側のみで出し分けすれば良いですが、SDUIを利用するメリットはもっと別のユースケースにあります。

Server-Driven UI (SDUI)が活躍しそうなユースケース

SDUIは審査なしで、サーバー側の変更のみで即時アプリ内のUIを変更可能であることが何よりの強みです。

利用したい条件によって非常に柔軟にUIを出し分けることができます。

ナビゲーションなどの簡単なふるまいもサーバーサイドで柔軟に変更可能なことから、アプリのかなり大きな範囲をリリース後に自由に変更できるようになります。

A/Bテスト

どのようなコンポーネントがコンバージョン率を高めるのか、テストしながら最適解を探すようなケースではSDUIはかなりワークするのではないでしょうか?

効果測定と連携してワークフローを組めば、ABテストから最適なコンバージョンを得られるUIを算出し、一定期間後にはベストなもののみを提供するようなことも可能になります。

一連のワークフローはサーバーで完結し、「審査不要」で即時実行可能なことは特筆すべき内容です。

複雑な条件によるUIの出し分け

ユーザーのStatusや属性によりパーソナライズしたい場合や、時刻や地域による出し分けや、商品一覧の中に広告案件を入れ込みたい場合など、複雑なUIの出し分けが必要になるような際にもSDUIは非常に便利です。

フロント側でこれらを出し分ける場合、ロジックが複雑になるうえ更新頻度が予想以上に早くなったりして運用コストが高くなりがちです。

結局のところ、サーバー側のロジックが複雑になることは間違いないのですが、複雑性をサーバー側に寄せることで、変更をアプリに反映させるまでのリードタイムはグッと短くすることが出来ます

すぐに試したい変更を入れたときほど審査が長時間かかる…といったバッドな開発体験を減らすのに寄与することでしょう。

Server-Driven UI (SDUI)のデメリット

ここまで、SDUIの簡単な実装アイデアと利便性を紹介してきましたが、デメリットも明確に存在しているため記載しておきます。

まず、SDUIを行う場合、サーバーサイドの人間がアプリ側のUI設計について一定以上知見を持っていることが前提になります。

私はFlutter開発者なのでサーバーサイドでUIコンポーネントを組み合わせて画面を作成することに抵抗感がありませんが、UI設計を一切行ったことのないサーバーサイドエンジニアがこれらのレスポンスを組み立てることは抵抗感があるでしょう。

もちろん、Flutter Webなどで管理画面を作成して、特定のレスポンスに対してどのようにレンダリングされるかなどテストすることも可能ですが、やはり一定の手間がかかることは認識しておくべきです。

したがって、BFFを扱った経験や、UI開発を行ったことのあるサーバーサイドエンジニアがいるチームでは導入が推奨されるが、そうでないチームにおいては意思決定の余地ありです。

また、モバイルにおいてネットワークエラーはつきものです。よってエラーが発生した時のフォールバックをどうするかといった点について考えておく必要もあります。

Server-Driven UI (SDUI) をどこまで利用するか

Airbnbの実装例では、複数画面に及ぶアプリ全体をSDUIで実現しているようでしたが、実際の運用を考えるとそれは厳しいように思えます。

サーバーがすべてのUIを構築することは過度な負荷を担わせることにもなりかねませんし、ネットワーク経由で取得したデータをWidgetにパースすることもコストがかかります。

したがって、SDUIを適用するのは、変更可能性が高く、変更によってビジネスインパクトが大きそうな箇所に限定することが重要なのではないでしょうか。

さらに、当該箇所の前後のコンポーネントもSDUIによる構築部分として捉えておくと、比較的大きなUI変更についてもサーバーサイドで構築することができるため、検討の価値ありと考えています。

また、これはまだ試せていないのですが、サーバーで構築するレスポンスはあくまでスキーマを踏襲する形をとっていればいいことから、LLMに構築させるようなことも可能なのではないかと考えています。

まとめ

実は、Server-Driven UI(SDUI)の歴史は思ったより古く、その基礎はWWDC 2010からあったようです。今回メインで参考にしたAirBnbの記事も2021年のもの。

フロント側が持っている知識をサーバーサイドに寄せる考え方なので、直感的ではない考え方ですし、スタンダードなものではないように思えます。

一方、アプリの開発→検証サイクルはどんどん加速している手ごたえもあり、審査の時間はいつまで経っても開発検証サイクルのボトルネックとなっています。

SDUIは審査を必要とせずにアプリの見た目や機能を爆速で変更できるため、適切な粒度で導入出来ればアプリの成長をグッと高めることが出来るマジックバレットになるかもしれません。

さらに、LLM時代に突入し、UIもLLMに組み立てさせた方が良いという論調がメインストリームに上がるのも時間の問題でしょう。

SDUIで要求されるサーバー側のレスポンスのデータは、今回のようにGraphQLでスキーマ定義を行っていれば、LLMにとっても出力しやすいものです。LLMによってパーソナライズされたUIが提供されるような未来がSDUIとの組み合わせによって実装される試みは近い将来実現されることでしょう。

この流れの中で、Flutterエンジニアは各Widgetにより集中し、UXの高いコンポーネントを構築されることが求められるのではないでしょうか。私には、それは正しい専門家の姿であるように思えます。

まだSDUIを試したことがない方は、ぜひ一度おためしください!

Xもよろしくお願いします~!
https://x.com/hagakun_yakuzai

株式会社マインディア テックブログ

Discussion