🏆

Web版しかなかったサービスがGoogle Playのアプリ大賞を受賞するまで

2023/12/08に公開

先日、症状検索エンジン「ユビー」のAndroidアプリが、Google Play ベスト オブ 2023 優れたAI部門で大賞を受賞しました。

https://prtimes.jp/main/html/rd/p/000000064.000048083.html

リリースから約2年半、みんなで育ててここまで来ることができましたが、実は最初はWeb版のおまけで、1週間で突貫リリースしたアプリでした。そこからの成長を振り返り、技術的におもしろそうなトピックをいくつか紹介します。

Web版をWebViewで動かすだけ

モバイルアプリ(以下アプリ)のリリース当時、Web版はすでに数百万MAUまでグロースしているプロダクトでした。そのため、ある程度PMFした体験がベースとしてあった上で、アプリを入れてもらえるのか、アプリ特有の体験(通知等)が刺さって継続的に使ってもらえるのか、といった点が主な不確実性でした。

そこを最速で検証するために Capacitor を採用しました。Capacitor は Ionic Framework の裏で使われているライブラリで、WebView上のJavaScriptからネイティブのAPIを呼べるようにブリッジすることで、Web技術でのクロスプラットフォームアプリ開発を実現します。

JavaScriptからプッシュ通知を送る例
import { LocalNotifications } from "@capacitor/local-notifications";

LocalNotifications.schedule({
  notifications: [
    {
      id: "foo",
      title: "メッセージが届きました!",
      body: "今すぐ確認しましょう",
      schedule: { at: new Date() },
    },
  ],
});

Capacitor は本来、HTMLやJavaScriptをアプリ本体にバンドルして、ローカルサーバーをWebViewから参照するアーキテクチャで動作します。しかし、Web版はNext.jsで開発していて、当時は Static Exports 機能がリッチでなかった[1]こともあり、そのまま動かすのは困難でした。

そこで、Capacitor からリモートサーバーを参照するように設定することで、Web版でホストされているNext.jsをそのまま表示するようにしました。これはローカル開発でホットリロードするための機能で、"This is not intended for use in production." と明記されています。しかし、その仕組みを理解した上でハックする選択をしました。

これにより、開発期間1週間の突貫リリースでも、Web版で提供している機能をそのままフル提供できました。また、Webアプリのデプロイと同じ要領でOTAアップデートも実現でき、高速に開発と価値検証をすることができました。

局所的なネイティブ実装

上述したハックによる問題を解決するために、部分的にAndroid/iOSそれぞれにネイティブ実装を入れています。Android/iOSのプロジェクトを隠蔽せずにそのままコード生成してくれるため、自由にネイティブ部分にも手を入れられるのがCapacitorのいいところです。

まずはオフライン対応です。WebViewはリモートサーバーを参照しているため、オフラインでは動作しません[2]。そのため、最低限のフォールバックをネイティブ側で実装しています。

また、強制アップデートもネイティブで実装しています。もちろんアプリケーションは基本的に後方互換を保って実装していますが、Capacitor自体のバージョンを更新する際に困ってしまうからです。リモートサーバーから配信しているWeb側のCapacitorライブラリは単一のデプロイで更新されますが、ネイティブ側は端末によってインストールしているバージョンが異なるため、Web側とネイティブ側でCapacitorバージョンの差異を避けられません。これによりWebViewがクラッシュしてしまうことがあるため、ネイティブ側で強制アップデート機能を実装し、円滑に更新できるようにしています。

Web版をアプリ向けにビルドする

リリース当初はWeb版をそのまま動かしていましたが、その後の開発で、アプリ向けに処理を分岐することが増えました。例えば、アプリでだけストアレビューに関するセクションを出すなどがあります。

Capacitor.getPlatform() !== "web" ? <AppReviewSection /> : null

Capacitor.getPlatform() はランタイムで実行されますから、当然AppReviewSectionコンポーネントはバンドルに含まれてしまいます。これらが積み重なり、Web版のバンドルサイズが不必要に大きくなってしまいました。

これは、コードベースは同じままビルドを分けることで解決しました。まず、Capacitor.getPlatform() の代わりに環境変数で分岐するようにします。

process.env.NEXT_PUBLIC_IS_NATIVE ? <AppReviewSection /> : null

そして、NEXT_PUBLIC_IS_NATIVE を true と false に設定してそれぞれ別にビルドすることで Dead Code Elimination や Tree Shaking が効き、Web/アプリそれぞれに不要なコードが含まれないようになりました。アプリ向けのビルドはWeb版とは別のホストで配信しています。

Web版からのデータ引き継ぎ

一般的なアプリでは、Web版とモバイルアプリで同じアカウントにログインすることで、データを共有できることが多いと思います。

しかしユビーでは、会員登録していないユーザーでも体調に関する履歴を残せるようにしています。ユーザーを最適な医療に導くために、時系列での体調の変化をとても大事にしているからです。Web版でユビーを気に入ってくれたユーザーがせっかくアプリを入れてくれたのに、この履歴が断絶されてしまうことは避けたいです。

そこで、Dynamic Links を用いたデータ引き継ぎを実装しています。ユーザーがWeb版からモバイルアプリ導線を踏んだタイミングで、期限付きのデータ引き継ぎトークンを発行し、Dynamic Links に載せて開きます。Dynamic Links は、アプリをインストール済みかどうか等をチェックして、適切にストアやアプリに飛ばしてくれます。ストアを経由する場合も、Deferred Deep Link という仕組みによって Dynamic Links のパラメータを復元することができるため、アプリ側でトークンを用いてデータを引き継ぎます。

Dynamic Links を用いたデータ引き継ぎフローを図示したもの

ヘルスコネクト対応

ユビーは、Androidのヘルスコネクト日本国内のローンチパートナーとして連携しています。具体的には、ヘルスコネクトから血糖値データを取得しています。これにより、特に一部の疾患に関する質問において、大幅な精度向上を実現できました。

当時、ヘルスコネクトはまだ始まったばかりで、Capacitorからの利用事例は全くありませんでした。そこで、JavaScriptからヘルスコネクトのAPIを呼ぶためのCapacitorプラグインを作成し、OSSとして公開しています。

https://github.com/ubie-oss/capacitor-health-connect

この事例については、Google I/O Extended Japan 2023 でも紹介させていただきました[3]

下タブの実装

ユビーには様々な機能があり、ナビゲーションを下タブとして置いています。

この下タブもWebView上で描画されています。WebViewはコンテキストとしてはブラウザのタブ1つと同等ですから、履歴のスタックも1つしか持てません。しかしユーザーは、下タブそれぞれがブラウザタブのように状態を持ち、切り替えても維持されることを期待します。

そのため、JavaScriptのレイヤで独自にタブごとの履歴スタック等を実装し、できるだけ状態が維持されるようにしています。もっとも、到底なめらかな体験とはいえず、ユーザー体験には限界が見えているのが実情です。

これからの展望

ユーザー体験等の面で妥協していることは多くありますが、結果として短期間でリリースし、多くのユーザーに価値を届けることができていて、ここまでの選択は正解だったと考えています。

ここからは体験を磨き、価値を最大化していくフェーズです。そのためにWebView一本足ではなく、ネイティブとWebViewのハイブリッドアプリにシフトしていきます。下タブのようなベースとなるUIや一部機能はネイティブ[4]で実装して体験を担保しつつ、引き続きWeb版の資産も活かして高速に価値提供していく体制です。

この分野ではメルカリさんLINEさんなどが先駆者としていますが、Ubieでもおもしろいことを構想中です。一緒につくっていくことに少しでも興味を持ってくださる方は、以下の採用サイトか Twitter DM か何かでご連絡いただけると嬉しいです!

https://recruit.ubie.life/

脚注
  1. 例えば Image コンポーネントに対応していないなど ↩︎

  2. ServiceWorkerで対応可能ですが、サービスの性質上、オフラインで提供できる価値があまりないため、今のところ対応していません ↩︎

  3. https://www.youtube.com/watch?v=9GpWvL-PZtw&t=1376s ↩︎

  4. 便宜上ネイティブと呼んでいますが、FlutterやReact Nativeも視野に入れて検証中です ↩︎

Ubie テックブログ

Discussion