CapacitorでWeb・Android・iOSのディープリンクを統一的に扱う
Capacitorアプリでディープリンク処理に苦戦した経験から、Native側でURLを受け取りSPAルーターに委託する設計パターンを共有します
Capacitorでネイティブアプリを作っていると、ディープリンク処理が厄介だと感じていました。Web、Android、iOSでそれぞれ挙動が違うし、アプリが起動していない状態(コールドスタート)と起動済みの状態(ウォームスタート)で処理を分ける必要がある。さらにOAuth認証のコールバックも絡んでくると、どこで何を処理すべきか混乱しがちです。
この記事では、試行錯誤の末にたどり着いた設計パターンを共有します。
最初の失敗:OAuthコールバックをNative側で処理しようとした
OAuth認証でよく使われるPKCEフローでは、認証プロバイダーからのコールバックがカスタムURLスキーム(com.example.app://auth/callback)でアプリに戻ってきます。これを最初に受け取るのはNative側(iOS/Android)です。
最初はこのコールバック処理もNative側で完結させようとしていました。「どうせNative側で受け取るんだから、そのままNativeコードでトークン処理しよう」と考えたんです。トークンを受け取って、セッションを確立して、適切な画面に遷移する…という処理をSwift/Javaで書こうとしました。
結果、コードがどんどん複雑になっていきました。一番辛かったのはロジックの二重管理です。
- 認証状態を判定するロジックがNative側とSPA側の両方に存在してしまう
- トークンの保存先をNativeとSPAで共有する仕組みが必要
- エラーハンドリングもNativeとSPAで二重になる
- iOSとAndroidで同じロジックを別々に実装・メンテする必要がある
「Native側で受け取るからNative側で処理する」という発想自体が間違いだったと気づいてから、設計を見直しました。
たどり着いた設計思想
Native側でディープリンクを受け取り、SPA側のルーティングに委託する。
この設計のポイントは:
-
Native側の責務を最小限に
- ディープリンクURLを受け取る
- URLをパースしてパス・クエリ・フラグメントを抽出
- SPAルーターに処理を委託
-
判断はすべてSPA側で
- 認証状態のチェック
- リダイレクト先の決定
- エラーハンドリング
-
ページリロードを避ける
history.pushState+popstateイベントでSPAルーターに認識させる
こうすると、認証周りのロジックはTypeScript/Reactで一元管理できて、iOSとAndroidで同じ挙動を保証しやすくなりました。
全体像
+-----------------------------------------------------------+
| OS |
| (Deep Link tap / OAuth callback) |
+-----------------------------+-----------------------------+
|
v
+-----------------------------------------------------------+
| Native Layer |
| - Receive URL |
| - Parse (scheme, host, path, query, fragment) |
| - Cold start: save to pendingPath |
| - Warm start: delegate to SPA immediately |
+-----------------------------+-----------------------------+
|
v
+-----------------------------------------------------------+
| WebView |
| history.pushState() + dispatch popstate event |
+-----------------------------+-----------------------------+
|
v
+-----------------------------------------------------------+
| SPA Router |
| - Route matching |
| - Auth check |
| - Render |
+-----------------------------------------------------------+
設定ファイル
Android(AndroidManifest.xml)
<activity
android:name=".MainActivity"
android:launchMode="singleTask">
<!-- カスタムURLスキーム -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="${applicationId}" />
</intent-filter>
<!-- App Links(HTTPS) -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="my.example.com" />
</intent-filter>
</activity>
設定のポイント:
android:launchMode="singleTask": アプリが既に起動している場合、新しいActivityを作らず既存のものを再利用する。これがないとディープリンクのたびに新しいインスタンスが作られてしまうandroid.intent.action.VIEW: このActivityがURLを「見る」ためのものであることを宣言android.intent.category.BROWSABLE: ブラウザからこのActivityを起動できるようにする。これがないとディープリンクが動作しないandroid.intent.category.DEFAULT: 暗黙的Intentのデフォルトターゲットとして機能させるandroid:scheme="${applicationId}": GradleのapplicationIdを参照する。ビルドバリアント(prod/stg/local)ごとに異なるスキームを自動で設定できるandroid:autoVerify="true": App Links用。サーバー側の.well-known/assetlinks.jsonと照合して、このアプリが正当なハンドラーであることを検証する
iOS(Info.plist)
<!-- カスタムURLスキーム -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>$(APP_URL_SCHEME)</string>
</array>
</dict>
</array>
設定のポイント:
CFBundleURLTypes: アプリが処理できるURLスキームの一覧を定義する配列CFBundleURLSchemes: 実際のスキーム文字列。com.example.app://のcom.example.app部分$(APP_URL_SCHEME): Xcodeのビルド設定(Build Settings)から注入される変数。Xcodeで「User-Defined」設定を追加するか、xcconfig ファイルで定義する。環境別にcom.example.app、com.example.app.stgなどを切り替えられる
Universal Links(HTTPSスキームのディープリンク)を使う場合は、追加で以下が必要です:
- Xcodeの設定: Signing & Capabilities → Associated Domains に
applinks:my.example.comを追加 - サーバー側:
https://my.example.com/.well-known/apple-app-site-associationにJSON ファイルを配置
ウォームスタート対応
まずは基本となるウォームスタート(アプリ起動済み)の処理から。アプリが起動済みの場合は、SPAルーターに直接委託します。ページリロードを避けるために、JavaScriptのhistory.pushStateを使います。
Android
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
Uri data = intent.getData();
if (data != null) {
handleDeepLink(data);
}
}
private void handleDeepLink(Uri uri) {
String path = extractPath(uri);
String query = uri.getQuery();
navigateWithSpaRouter(path, query);
}
private void navigateWithSpaRouter(String path, String query) {
String fullPath = (query == null || query.isEmpty())
? path
: path + "?" + query;
String script = String.format(
"(function() {" +
" window.history.pushState({}, '', '%s');" +
" window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));" +
"})();",
escapeForJs(fullPath)
);
getBridge().getWebView().evaluateJavascript(script, null);
}
onNewIntentは、singleTaskモードのActivityが既に存在する状態で新しいIntentを受け取ったときに呼ばれます。
iOS
private func navigateWithSpaRouter(path: String, query: String?) {
let fullPath = query.map { "\(path)?\($0)" } ?? path
let script = """
(function() {
window.history.pushState({}, '', '\(fullPath.escapedForJs)');
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
})();
"""
webView?.evaluateJavaScript(script)
}
popstateイベントを発火させることで、React RouterやTanStack Routerなどのクライアントサイドルーターがルート変更を検知してくれます。
コールドスタート対応
ここが一番ハマったところでした。アプリが起動していない状態でディープリンクを踏むと、WebViewの準備ができていない状態でURLを処理しようとしてしまいます。
Android
public class MainActivity extends BridgeActivity {
private String pendingDeepLinkPath = null;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// getIntent()はActivityの標準メソッド。
// このActivityを起動したIntentを取得する
Intent intent = getIntent();
if (intent != null && intent.getData() != null) {
Uri uri = intent.getData();
// パスとクエリを抽出して保存
pendingDeepLinkPath = extractFullPath(uri);
}
}
}
WebViewの準備完了を検知するために、WebViewClientをオーバーライドします。
@Override
public void onPageStarted(WebView view, String url, Bitmap favicon) {
super.onPageStarted(view, url, favicon);
// 初回ロード時にpendingパスがあればリダイレクト
if (pendingDeepLinkPath != null && isInitialLoad(url)) {
String targetPath = pendingDeepLinkPath;
pendingDeepLinkPath = null;
// Capacitorのベースパスに置き換え
view.stopLoading();
view.loadUrl("https://localhost" + targetPath);
}
}
private boolean isInitialLoad(String url) {
// Capacitorの初期ロードURLかどうか判定
return url.startsWith("https://localhost") &&
(url.equals("https://localhost/") || url.endsWith("?init=1"));
}
iOS
最初はDispatchQueue.main.asyncAfterで遅延させていましたが、タイミングが安定しませんでした。WebViewの準備完了を確実に検知するために、WKNavigationDelegateを使います。
class CustomViewController: CAPBridgeViewController {
private var pendingDeepLinkPath: String?
private var initialLoadHandled = false
override func viewDidLoad() {
super.viewDidLoad()
webView?.navigationDelegate = self
}
}
extension CustomViewController: WKNavigationDelegate {
func webView(_ webView: WKWebView,
didStartProvisionalNavigation navigation: WKNavigation!) {
guard !initialLoadHandled,
let targetPath = pendingDeepLinkPath,
let url = webView.url else { return }
// Capacitorの初期ロードかどうか判定
if url.scheme == "capacitor" && url.host == "localhost" &&
(url.path == "/" || url.path.isEmpty) {
initialLoadHandled = true
pendingDeepLinkPath = nil
let targetUrl = URL(string: "capacitor://localhost\(targetPath)")!
webView.stopLoading()
webView.load(URLRequest(url: targetUrl))
}
}
}
didStartProvisionalNavigationは、WebViewがURLのロードを開始した直後に呼ばれます。このタイミングならWebViewは確実に準備できています。
OAuth認証との連携
OAuth認証のコールバックも、ディープリンクでアプリに戻ってくるという点では同じ仕組みです。認証プロバイダー(Google、Appleなど)での認証が完了すると、カスタムURLスキームでコールバックが返ってきます。
com.example.app://auth/callback#access_token=xxx&refresh_token=yyy
フラグメントの扱い
Supabase AuthなどのOAuthプロバイダーは、トークンをURLフラグメント(#以降)で返すことがあります。
ただし、ネイティブ側でフラグメントを扱うのは少し面倒です。理由としては:
- フラグメントはサーバーに送信されないため、WebViewのURLロードでは
#以降が無視されることがある - JavaScriptの
window.location.hashで取得する方法もあるが、タイミングによっては空になる
そこで、Native側でフラグメントを受け取った時点でクエリパラメータに変換してしまうのが楽でした。?区切りならWebViewでも確実にパースできます。
// Android
private String buildFullPath(Uri uri) {
String path = extractPath(uri);
String query = uri.getQuery();
String fragment = uri.getFragment();
// フラグメントをクエリに結合
if (fragment != null && !fragment.isEmpty()) {
if (query == null || query.isEmpty()) {
query = fragment;
} else {
query = query + "&" + fragment;
}
}
return query == null ? path : path + "?" + query;
}
SPA側のコールバック処理
SPA側では、クエリパラメータからトークンを取得してセッションを確立します。このあたりは一般的なOAuthコールバックの実装と同じで、特別なことはしていません。Supabaseを使っている場合はsetSessionでトークンを渡すだけです。
認証ロジックがSPA側に集約されているので、エラーハンドリングやリダイレクト処理も一箇所で管理できます。
ハマったポイント
Universal Links vs URL Scheme
iOSではUniversal Links(https://)とカスタムURLスキーム(com.example.app://)で、コールバックメソッドが異なります。
- URL Scheme:
application(_:open:options:) - Universal Links:
application(_:continue:restorationHandler:)
両方に対応する場合は、それぞれのメソッドを実装する必要があります。
// URL Scheme
func application(_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
handleDeepLink(url: url)
return true
}
// Universal Links
func application(_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else {
return false
}
handleUniversalLink(url: url)
return true
}
カスタムURLスキームのhost/path解釈
カスタムURLスキーム(com.example.app://calendars/123)の場合、://の直後がhostとして扱われます。
com.example.app://calendars/123
scheme = "com.example.app"
host = "calendars"
path = "/123"
つまり/calendars/123というパスを期待していると、hostがcalendars、pathが/123に分かれてしまいます。hostとpathを結合してSPAに渡すヘルパー関数を用意しておくと安心です。
// Android
private String extractPathFromCustomScheme(Uri uri) {
String host = uri.getHost();
String path = uri.getPath();
StringBuilder result = new StringBuilder("/");
if (host != null && !host.isEmpty()) {
result.append(host);
}
if (path != null && !path.isEmpty()) {
result.append(path);
}
return result.toString();
}
おわりに
この設計にしてから、ディープリンク周りのバグがだいぶ減りました。Native側のコードもシンプルになって、メンテナンスしやすくなった気がします。
ポイントをまとめると:
- Native側はURLの受け渡しに徹する(判断はしない)
- ウォームスタートは
history.pushState+popstateイベント - コールドスタートは
didStartProvisionalNavigation(iOS)/onPageStarted(Android)で検知 - OAuth含め、認証ロジックはSPA側に集約
同じような悩みを持っている方の参考になれば。