ディープリンクは「カスタムスキーム(myapp://)」「Android App Links(HTTPS + assetlinks.json)」「iOS Universal Links(HTTPS + AASA)」の3種類があり、2026年の本番アプリは HTTPS ベースの App Links / Universal Links を主軸に据えるのが標準です。
.NET MAUI 10 の AppShell は URI ベースのルーティングを持ち、Routing.RegisterRoute と [QueryProperty] を組み合わせれば //products?id=42 のような URL からそのまま画面を開けます。
Android 12 以降は intent-filter の android:autoVerify="true" と Digital Asset Links による自動検証がないと、リンクがブラウザに落ちます。SHA-256 は Play Console の App Signing 画面から取得します。
iOS では apple-app-site-association を Content-Type: application/json、拡張子なしで /.well-known/ に配信し、Xcode の Signing & Capabilities で Associated Domains を applinks:example.com の形で追加します。
Firebase Dynamic Links が停止したため、アプリ未インストール状態からのリンク保持(遅延ディープリンク)は Adjust、Branch、Kochava、あるいは自前実装への移行が必須です。
本番投入前に Apple 公式の Diagnostics(Settings → Developer)と Google の Statement List Tester、そして adb shell pm verify-app-links で必ず検証すること。私たちはこれを CI に組み込んでいます。
目次
.NET MAUI におけるディープリンクとは何か
カスタムスキームと App Links / Universal Links の違い
Shell ナビゲーションでルートを登録する
Android App Links を設定する手順
iOS Universal Links を設定する手順
QueryProperty でリンクパラメータを受け取る
Firebase Dynamic Links 廃止後の遅延ディープリンク戦略
ディープリンクの検証とデバッグ
よくある質問
.NET MAUI におけるディープリンクとは何か
ディープリンク(Deep Link)は、URL をタップしたときに Web ブラウザではなくインストール済みのモバイルアプリを直接起動し、URL に紐づく特定の画面まで遷移させるための仕組みです。私たちが「アプリを開かせるだけ」の実装から先に進む必要があるのは、たいてい次の3つのユースケースが持ち込まれてきたときです。マーケティングメールのボタンから商品詳細を開きたい、SMS の予約確認リンクから予約詳細を開きたい、Push 通知のペイロードに含まれる URL から特定のチャットを開きたい――どれも「アプリ内のどの画面か」まで含めた URL でなければ意味がありません。
.NET MAUI 10 では、この動線は大きく2層で構成されます。プラットフォーム側(AndroidManifest.xml と iOS の Entitlements + AASA)で「この URL は自分のアプリで処理する」ことを OS に宣言する層と、その先で AppShell の URI ルーティングによって「URL のパスとクエリから該当の ContentPage を組み立てる」層です。私たちのチームでは、この2層を別モジュールとして扱い、プラットフォームレイヤの実装とテストは iOS/Android の担当が、Shell ルーティングは MVVM 側の担当がそれぞれオーナーシップを持って進めています。この分担が曖昧だと、AASA の1文字ミスと QueryProperty の名前ミスのどちらが原因でリンクが動かないのか、誰も特定できずに丸1日消えます。
カスタムスキームと App Links / Universal Links の違い
.NET MAUI で選べるディープリンク方式は、大きく分けてカスタム URI スキーム方式と、HTTPS ベースの App Links / Universal Links 方式の2種類です。歴史的にはカスタムスキーム(myapp://order/42)が古参で、サーバー側の設定が要らないぶん手軽ですが、2026年時点で本番投入するなら Android App Links と iOS Universal Links を主軸に、レガシー互換のためにカスタムスキームを併記する構成をおすすめします。理由は単純で、iOS 14 以降 SMS やメールに書かれたカスタムスキームはユーザーに「App Store を開こうとしています」と確認を出す挙動になり、コンバージョンが目に見えて落ちるからです。
下の比較表は、私たちが技術選定レビューで実際に説明のたたきに使っている整理です。
比較項目 カスタム URI スキーム Android App Links / iOS Universal Links
URL 形式 myapp://pathhttps://example.com/path
サーバー要件 不要 HTTPS 配信の検証ファイルが必須
未インストール時の挙動 エラー or ストア誘導なし そのまま Web ページが表示されフォールバック可能
アプリ選択ダイアログ 同じスキームを名乗る他アプリと衝突しやすい ドメイン所有権で検証されるため衝突しない
推奨用途 OAuth リダイレクト、SSO コールバック マーケティングリンク、メール、SMS、Push
2026年の実務での位置付け 補助的に併用 主軸
補足: OAuth / OpenID Connect のリダイレクト先はセキュリティ要件上カスタムスキームを使うのが今でも一般的です(RFC 8252)。.NET MAUI セキュリティ実装ガイド で扱っている OAuth の章と合わせて設計してください。
Shell ナビゲーションでルートを登録する
プラットフォーム側の設定に入る前に、まず「開くべき画面」を Shell に理解させておく必要があります。AppShell.xaml のトップレベルタブに置いてある画面はデフォルトで //routename の URI で到達できますが、詳細画面など動的にプッシュされる画面は Routing.RegisterRoute で明示的にルートを宣言しなければなりません。私たちの構成では、DI 初期化の直後、App.xaml.cs ではなく専用の AppRoutes 静的クラスにルート登録をまとめています。理由は、ルート名の typo をコンパイル時に潰しやすくするためです。
// AppRoutes.cs — すべてのルート名を一箇所に集約する
public static class AppRoutes
{
public const string ProductDetail = "products/detail";
public const string OrderDetail = "orders/detail";
public const string ChatRoom = "chat/room";
public static void RegisterAll()
{
Routing.RegisterRoute(ProductDetail, typeof(ProductDetailPage));
Routing.RegisterRoute(OrderDetail, typeof(OrderDetailPage));
Routing.RegisterRoute(ChatRoom, typeof(ChatRoomPage));
}
}
// App.xaml.cs
public partial class App : Application
{
public App(IServiceProvider services)
{
InitializeComponent();
AppRoutes.RegisterAll(); // ← Shell 生成前に必ず一度だけ呼ぶ
MainPage = services.GetRequiredService<AppShell>();
}
}
Shell ルーティングそのものについては .NET MAUI 10 クリーンアーキテクチャ実践ガイド で MVVM / DI と組み合わせた設計を詳しく書いていますので、そちらも参照してください。ここではディープリンクを扱うために最低限必要な部分だけに絞ります。ルート登録が終わっていれば、コードからは await Shell.Current.GoToAsync("//products/detail?id=42") で、URL からは後述のプラットフォーム連携経由で同じ画面に到達できるはずです。
Android App Links を設定する手順
Android 側で HTTPS の URL をアプリで受け取るためには、(1)該当 Activity に DataScheme="https" と DataHost="example.com" を含む intent-filter を追加し、AutoVerify=true を指定する、(2)ドメインのルートに https://example.com/.well-known/assetlinks.json を配信する、の2つが両輪です。Android 12 以降は autoVerify なしの HTTPS リンクはブラウザに落ちるようになったので、この設定が本番動作の前提です。
// Platforms/Android/MainActivity.cs
[Activity(Theme = "@style/Maui.SplashTheme",
MainLauncher = true,
LaunchMode = LaunchMode.SingleTop,
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation |
ConfigChanges.UiMode | ConfigChanges.ScreenLayout |
ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
[IntentFilter(new[] { Android.Content.Intent.ActionView },
Categories = new[]
{
Android.Content.Intent.CategoryDefault,
Android.Content.Intent.CategoryBrowsable
},
DataScheme = "https",
DataHost = "example.com",
AutoVerify = true)]
public class MainActivity : MauiAppCompatActivity
{
protected override void OnCreate(Bundle? savedInstanceState)
{
base.OnCreate(savedInstanceState);
HandleIntent(Intent);
}
protected override void OnNewIntent(Intent? intent)
{
base.OnNewIntent(intent);
HandleIntent(intent);
}
private static void HandleIntent(Intent? intent)
{
if (intent?.Data is not { } uri) return;
// https://example.com/products/42?ref=email
// → Shell の "//products/detail?id=42&ref=email" に変換
var path = uri.Path ?? string.Empty;
var query = uri.Query is { Length: > 0 } q ? q : string.Empty;
var shellRoute = path switch
{
var p when p.StartsWith("/products/") =>
$"//{AppRoutes.ProductDetail}?id={p["/products/".Length..]}{query.Replace('?', '&')}",
var p when p.StartsWith("/orders/") =>
$"//{AppRoutes.OrderDetail}?id={p["/orders/".Length..]}{query.Replace('?', '&')}",
_ => "//"
};
MainThread.BeginInvokeOnMainThread(async () =>
await Shell.Current.GoToAsync(shellRoute));
}
}
assetlinks.json は、Play Console でリリースに使われている App Signing 用の SHA-256 フィンガープリントを埋め込むのが本番運用の鉄則です。デバッグ用キーの SHA-256 を貼り付けたまま出荷して、Play からのアップデート後に App Links が全部止まる――これは私たちが過去にやった典型的な事故で、リリース後4時間で切り戻しました。以下がテンプレートです。
// https://example.com/.well-known/assetlinks.json
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.myapp",
"sha256_cert_fingerprints": [
"14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"
]
}
}]
注意: Play App Signing を有効にしている場合、SHA-256 はローカルの keystore ではなく Play Console → 設定 → アプリの署名 に表示される「アプリの署名鍵証明書」の SHA-256 を使ってください。上の値を間違えると adb shell pm verify-app-links が verified=false を返します。
詳細は Microsoft Learn の Android app links (.NET MAUI 10) にある公式手順が最も正確です。私たちも新しい Android バージョンに合わせるたびにここを一度読み直しています。
iOS Universal Links を設定する手順
iOS 側は Android より作業がひとつ多く、(1)Apple Developer サイトで App ID に Associated Domains を有効化する、(2)Xcode の Signing & Capabilities で applinks:example.com をエンタイトルメントに追加する、(3)ドメインに apple-app-site-association ファイルを拡張子なしで配信する、(4)AppDelegate の ContinueUserActivity を実装して NSUserActivityTypeBrowsingWeb を受ける、の4ステップです。.NET MAUI では MauiProgram.cs か AppDelegate 相当のプラットフォームコードにフックを書きます。
// Platforms/iOS/AppDelegate.cs
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
public override bool ContinueUserActivity(UIApplication application,
NSUserActivity userActivity,
UIApplicationRestorationHandler completionHandler)
{
if (userActivity.ActivityType == "NSUserActivityTypeBrowsingWeb"
&& userActivity.WebPageUrl is { } url)
{
HandleUrl(url);
return true;
}
return false;
}
private static void HandleUrl(NSUrl url)
{
// https://example.com/products/42?ref=email
var path = url.Path ?? string.Empty;
var query = url.Query is { Length: > 0 } q ? "?" + q : string.Empty;
var shellRoute = path switch
{
var p when p.StartsWith("/products/") =>
$"//{AppRoutes.ProductDetail}?id={p["/products/".Length..]}",
var p when p.StartsWith("/orders/") =>
$"//{AppRoutes.OrderDetail}?id={p["/orders/".Length..]}",
_ => "//"
};
MainThread.BeginInvokeOnMainThread(async () =>
await Shell.Current.GoToAsync(shellRoute + query.Replace('?', '&').Insert(0, "?")));
}
}
Entitlements の宣言は Platforms/iOS/Entitlements.plist に追記します。ここを忘れると App Store の Provisioning が通っていても Universal Links は動きません。
<!-- Platforms/iOS/Entitlements.plist -->
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:example.com</string>
<string>applinks:www.example.com</string>
</array>
そして肝心の apple-app-site-association は次のような JSON を、拡張子なし・Content-Type: application/json で配信します。appIDs は <TeamID>.<BundleID> の形式です。iOS 側はこのファイルを Apple の CDN 経由でキャッシュするため、変更が全端末に伝播するまで 24〜48 時間見ておいてください。
// https://example.com/.well-known/apple-app-site-association
{
"applinks": {
"details": [
{
"appIDs": ["ABCDE12345.com.example.myapp"],
"components": [
{ "/": "/products/*" },
{ "/": "/orders/*" }
]
}
]
}
}
公式仕様と Xcode 側の設定手順は Microsoft Learn の Apple universal links (.NET MAUI) を一次情報として参照してください。実装サンプルは Redth/MAUI.AppLinks.Sample の GitHub リポジトリ が両プラットフォームの最小構成をきれいに示していて、私たちも社内の勉強会で毎年これを参照点にしています。
QueryProperty でリンクパラメータを受け取る
URL に含まれるクエリ文字列(?id=42&ref=email)を画面側で受け取るには、[QueryProperty] 属性を ContentPage ないし ViewModel に付けます。プロパティ名とクエリキーの2引数で対応関係を宣言する形式です。私たちの流儀では、ページ側ではなく ViewModel 側にクエリを受けさせて、BindingContext 経由で UI に反映しています。理由は、テスト容易性と、Push 通知など Shell 以外の経路から同じ ViewModel を組み立てるときに再利用できるからです。
[QueryProperty(nameof(ProductId), "id")]
[QueryProperty(nameof(Referrer), "ref")]
public partial class ProductDetailViewModel : ObservableObject
{
[ObservableProperty] private string? productId;
[ObservableProperty] private string? referrer;
partial void OnProductIdChanged(string? value)
{
if (!string.IsNullOrEmpty(value))
_ = LoadProductAsync(value);
}
private async Task LoadProductAsync(string id)
{
// …API 呼び出しで詳細を取得
}
}
ここで一点、罠があります。OnAppearing のタイミングで ProductId がまだ null になることがあり、これは Shell が QueryProperty のセッターを呼ぶ順序と、ページのライフサイクルイベントの発火順序が微妙にずれるためです。私たちは初回ロードを OnAppearing ではなく OnProductIdChanged 側で走らせることで安定させています。CommunityToolkit.Mvvm × .NET MAUI 実践ガイド で紹介している ObservableProperty の partial void OnXChanged フックがちょうどここに嵌まります。
Firebase Dynamic Links 廃止後の遅延ディープリンク戦略
ここまでの App Links / Universal Links は「アプリがすでにインストール済み」という前提で動きます。ユーザーが未インストールの状態で URL をタップした場合、ストアに誘導したあと、インストールが終わってからも 本来開くはずだった画面 に飛ばしたい――これが「遅延ディープリンク(deferred deep linking)」で、OS 標準の App Links / Universal Links だけでは実現できません。2025年8月に Firebase Dynamic Links がシャットダウンしたため、この用途で FDL を使っていたチームは全員移行を強いられました。
2026年時点で私たちが評価している選択肢は次の4つです。Adjust と Branch は SDK 完成度とアトリビューション連携で頭ひとつ抜けており、マーケティング部門との連携が前提のプロダクトなら最有力候補です。Kochava は広告計測寄り、AppsFlyer はグローバル展開の実績が長い、というのが2026年の勢力図です。SDK を入れずに済ませたい場合は、Play Referrer API と iOS の Clipboard 経由アプローチを組み合わせた自前実装もできますが、Apple の Pasteboard 通知に関するプライバシー制約(iOS 14 以降でユーザーに通知が飛ぶ)を踏まえると、UX 上の摩擦は無視できません。
アプリ側の設計としては、ディープリンクハンドラーを SDK に依存しない薄いインタフェースにしておくのがコツです。私たちのプロジェクトでは IDeepLinkResolver というインタフェースを切り、実体を Adjust の SDK 実装と、テスト用のインメモリ実装で差し替えできるようにしています。SDK ベンダーロックインを避けるうえでも、この抽象化は最低限やっておくと将来の乗り換えコストが下がります。
ディープリンクの検証とデバッグ
ディープリンクは「動かない理由が10通りある」の代表格です。ここは端折らないでください。私たちが CI とローカル両方で回している検証手順を、Android と iOS それぞれで並べます。
Android での検証
まず assetlinks.json が正しく配信されているかを、Google の Digital Asset Links Statement List Tester で確認します。次に、実機に APK を入れた状態で以下のコマンドを流すと、autoVerify の結果を OS 側の視点から確認できます。
# assetlinks.json 単体の配信確認
curl -I https://example.com/.well-known/assetlinks.json
# Content-Type が application/json、200 OK であること
# App Links の検証状態確認
adb shell pm verify-app-links --re-verify com.example.myapp
adb shell pm get-app-links com.example.myapp
# → "verified" と表示されれば OK
# 実際にディープリンクを投げてみる
adb shell am start -a android.intent.action.VIEW \
-d "https://example.com/products/42" com.example.myapp
iOS での検証
iOS では実機の 設定 → デベロッパ → Universal Links → 診断 に URL を貼り付けて、「インストール済みアプリを開きます」と緑のチェックが出るかを見るのが一番確実です。AASA の配信そのものは Apple のバリデータ(https://app-site-association.cdn-apple.com/a/v1/example.com にアクセスして返る内容)で確認できます。ここが 404 だと、Apple の CDN がまだあなたのドメインをフェッチしていない可能性があるので、24 時間置いて再度試してください。
# AASA の配信確認
curl -I https://example.com/.well-known/apple-app-site-association
# Content-Type: application/json、拡張子なしで 200 OK
# Apple CDN 側にキャッシュされた AASA を確認
curl https://app-site-association.cdn-apple.com/a/v1/example.com
# シミュレータで URL を叩く
xcrun simctl openurl booted "https://example.com/products/42"
Tip: Push 通知経由でディープリンクを開くフローも忘れずにテストしてください。.NET MAUI 10 プッシュ通知実装ガイド で扱っている FCM / APNs のペイロード設計と、ここで作った URL ハンドラーを連結すると、Push タップから特定画面への遷移が自然につながります。
そして最終確認として、リリース署名で作った本番ビルドを Play Console の内部テストと TestFlight に上げて、ストア経由でインストールしたビルドが正しく Verified 扱いになることを確認します。この最終工程は .NET MAUI 10 アプリストア公開ガイド で解説しているリリースワークフローの一部として組み込んでおくのがおすすめです。
よくある質問
ディープリンクとカスタム URL スキームの違いは何ですか?
広義にはどちらも「URL でアプリの特定画面を開く」仕組みですが、カスタムスキーム(myapp://)はドメイン検証がなく他アプリと衝突しやすい一方、Android App Links / iOS Universal Links は HTTPS で assetlinks.json / AASA によるドメイン所有権検証があり、ダイアログなしに直接アプリが開きます。2026年の本番運用ではまず後者を選び、OAuth リダイレクトなど必要な場面だけカスタムスキームを併用する構成が定番です。
.NET MAUI で Shell ルートを登録する最短手順は?
App.xaml.cs で MainPage に AppShell を設定する前に、Routing.RegisterRoute("products/detail", typeof(ProductDetailPage)); を呼びます。以後は await Shell.Current.GoToAsync("//products/detail?id=42") でどこからでも遷移できます。ルート名は文字列 typo の温床なので定数クラスに集約するのが安全です。
assetlinks.json に書く SHA-256 はどこで取得しますか?
Play App Signing を利用している場合、Play Console → 設定 → アプリの署名 に表示される「アプリの署名鍵証明書」の SHA-256 を使います。ローカル keystore の SHA-256 やアップロード鍵の SHA-256 を貼ってしまうと本番配信された APK では autoVerify が失敗するので注意してください。
apple-app-site-association の変更が反映されないのはなぜですか?
iOS は AASA を端末側でキャッシュし、Apple の CDN 経由でも取得します。両方のキャッシュが切れるまで 24〜48 時間かかることがあります。実機テストではアプリを再インストールするか、iOS 側の 設定 → デベロッパ → Universal Links → 診断 の再検証を実行すると強制的に最新の AASA を取り直せます。
Firebase Dynamic Links の代替として何を使えばよいですか?
2025年8月に FDL がシャットダウンしたため、遅延ディープリンクが必要なプロダクトは Adjust、Branch、AppsFlyer、Kochava といったサードパーティ SDK への移行が現実解です。単純な「インストール後に対象画面へ遷移」だけなら Play Referrer API と自前サーバーの組み合わせで作れますが、iOS のプライバシー制約と保守コストを考えると、規模のあるアプリでは SDK 採用のほうが結果的に安上がりです。