Web版しかなかったサービスがGoogle Playのアプリ大賞を受賞するまで
先日、症状検索エンジン「ユビー」のAndroidアプリが、Google Play ベスト オブ 2023 優れたAI部門で大賞を受賞しました。
リリースから約2年半、みんなで育ててここまで来ることができましたが、実は最初はWeb版のおまけで、1週間で突貫リリースしたアプリでした。そこからの成長を振り返り、技術的におもしろそうなトピックをいくつか紹介します。
Web版をWebViewで動かすだけ
モバイルアプリ(以下アプリ)のリリース当時、Web版はすでに数百万MAUまでグロースしているプロダクトでした。そのため、ある程度PMFした体験がベースとしてあった上で、アプリを入れてもらえるのか、アプリ特有の体験(通知等)が刺さって継続的に使ってもらえるのか、といった点が主な不確実性でした。
そこを最速で検証するために Capacitor を採用しました。Capacitor は Ionic Framework の裏で使われているライブラリで、WebView上のJavaScriptからネイティブのAPIを呼べるようにブリッジすることで、Web技術でのクロスプラットフォームアプリ開発を実現します。
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 のパラメータを復元することができるため、アプリ側でトークンを用いてデータを引き継ぎます。
ヘルスコネクト対応
ユビーは、Androidのヘルスコネクトに日本国内のローンチパートナーとして連携しています。具体的には、ヘルスコネクトから血糖値データを取得しています。これにより、特に一部の疾患に関する質問において、大幅な精度向上を実現できました。
当時、ヘルスコネクトはまだ始まったばかりで、Capacitorからの利用事例は全くありませんでした。そこで、JavaScriptからヘルスコネクトのAPIを呼ぶためのCapacitorプラグインを作成し、OSSとして公開しています。
この事例については、Google I/O Extended Japan 2023 でも紹介させていただきました[3]。
下タブの実装
ユビーには様々な機能があり、ナビゲーションを下タブとして置いています。
この下タブもWebView上で描画されています。WebViewはコンテキストとしてはブラウザのタブ1つと同等ですから、履歴のスタックも1つしか持てません。しかしユーザーは、下タブそれぞれがブラウザタブのように状態を持ち、切り替えても維持されることを期待します。
そのため、JavaScriptのレイヤで独自にタブごとの履歴スタック等を実装し、できるだけ状態が維持されるようにしています。もっとも、到底なめらかな体験とはいえず、ユーザー体験には限界が見えているのが実情です。
これからの展望
ユーザー体験等の面で妥協していることは多くありますが、結果として短期間でリリースし、多くのユーザーに価値を届けることができていて、ここまでの選択は正解だったと考えています。
ここからは体験を磨き、価値を最大化していくフェーズです。そのためにWebView一本足ではなく、ネイティブとWebViewのハイブリッドアプリにシフトしていきます。下タブのようなベースとなるUIや一部機能はネイティブ[4]で実装して体験を担保しつつ、引き続きWeb版の資産も活かして高速に価値提供していく体制です。
この分野ではメルカリさんやLINEさんなどが先駆者としていますが、Ubieでもおもしろいことを構想中です。一緒につくっていくことに少しでも興味を持ってくださる方は、以下の採用サイトか Twitter DM か何かでご連絡いただけると嬉しいです!
Discussion