Deep Linking و Universal Links در .NET MAUI: راهنمای کامل پیاده‌سازی در ۲۰۲۶

در این راهنما هر سه مدل Deep Linking در .NET MAUI 9 یعنی Custom URL Scheme، Universal Links در iOS و Android App Links را با کد قابل‌اجرا و تست عملی پیاده‌سازی می‌کنیم.

.NET MAUI Deep Linking Guide 2026

به‌روزرسانی: ۲۸ ژوئن ۲۰۲۶

Deep Linking در .NET MAUI به اپلیکیشن شما اجازه می‌دهد تا با کلیک روی یک URL (چه از مرورگر، چه از پیامک یا ایمیل) مستقیماً به یک صفحه‌ی مشخص داخل اپ منتقل شود، بدون اینکه کاربر مجبور باشد از Home Screen مسیر را دستی طی کند. در iOS این کار با Universal Links و در اندروید با Android App Links انجام می‌شود، و هر دو نیازمند یک فایل تأیید دامنه (AASA یا assetlinks.json) روی سرور شما هستند. خب، در این راهنما هر سه مدل، یعنی Custom URL Scheme قدیمی، Universal Links و App Links مدرن، را با کد قابل‌اجرا برای .NET MAUI 9 پیاده‌سازی می‌کنیم.

  • Universal Links (iOS) و App Links (Android) دو استاندارد رسمی برای Deep Linking مبتنی بر HTTPS هستند که بر خلاف Custom URL Scheme قابل جعل نیستند.
  • برای فعال‌سازی Universal Links به فایل apple-app-site-association در مسیر /.well-known/ دامنه‌تان نیاز دارید که با Content-Type=application/json سرو شود.
  • برای App Links اندروید فایل assetlinks.json + اعلان autoVerify="true" در Intent Filter لازم است؛ از Android 12 به بعد تأیید دامنه سخت‌گیرانه‌تر شده است.
  • در .NET MAUI لینک ورودی را در App.xaml.cs از طریق Shell.Current.GoToAsync به مسیر مناسب route می‌کنیم.
  • تست Deep Link روی شبیه‌ساز با xcrun simctl openurl برای iOS و adb shell am start -a android.intent.action.VIEW -d برای اندروید انجام می‌شود.
  • سرویس‌های جانبی مثل Firebase Dynamic Links (که در ۲۰۲۵ منسوخ شد) و Branch.io فقط برای Deferred Deep Linking منطقی هستند نه Deep Linking ساده.

Deep Link یک اصطلاح چتر است برای هر URL که کاربر را به یک محتوای مشخص داخل اپلیکیشن می‌برد. اما در عمل سه نوع کاملاً متفاوت داریم که اغلب توسعه‌دهنده‌ها آن‌ها را با هم اشتباه می‌گیرند. درک این تفاوت قبل از نوشتن حتی یک خط کد ضروری است؛ چون انتخاب اشتباه می‌تواند منجر به این شود که لینک‌های شما در Safari باز شوند، در WhatsApp بلاک شوند یا توسط بدافزارها هایجک گردند.

Custom URL Scheme قدیمی‌ترین مدل است: لینکی مثل myapp://product/42. هر اپ می‌تواند هر scheme دلخواهی ثبت کند و اگر دو اپ scheme یکسانی ثبت کنند، نتیجه روی iOS غیرقابل پیش‌بینی است. این مدل هیچ ضمانتی برای امنیت ندارد و در ۲۰۲۶ فقط برای OAuth Callback یا ارتباط بین اپ‌های یک تیم منطقی است.

Universal Links (iOS) و App Links (Android) روی پروتکل HTTPS کار می‌کنند. لینکی مثل https://shop.example.com/product/42 اگر اپ نصب باشد در اپ باز می‌شود و در غیر این صورت به وب‌سایت می‌رود. این مدل امن است چون سیستم‌عامل قبل از باز کردن لینک در اپ، فایل تأیید دامنه را روی سرور شما چک می‌کند؛ بنابراین هیچ اپ دیگری نمی‌تواند ادعا کند که مالک shop.example.com است.

تجربه‌ی من از سه اپ تولیدی این است: اگر اپ شما کوچک است و فقط نیاز به OAuth Redirect دارد، Custom Scheme کافی است. اما برای هر سناریوی واقعی (اشتراک‌گذاری محصول، دعوت دوستان، باز کردن سفارش از ایمیل) حتماً به سراغ Universal Links / App Links بروید. هزینه‌ی تنظیم اولیه‌اش چند ساعت است، ولی صادقانه بگویم هیچ‌وقت بابتش پشیمان نمی‌شوید.

سه استراتژی Deep Linking در MAUI

قبل از پیاده‌سازی، بهتر است سه گزینه را در یک جدول مقایسه‌ای کنار هم ببینیم تا تصمیم آگاهانه‌تری بگیرید. هر سه مدل در .NET MAUI قابل پیاده‌سازی هستند، اما هزینه‌ی نگه‌داری و درجه‌ی امنیت‌شان متفاوت است.

ویژگیCustom URL SchemeUniversal Links (iOS)App Links (Android)
فرمت URLmyapp://...https://domain/...https://domain/...
تأیید مالکیت دامنهنداردAASA fileassetlinks.json
Fallback به وبخطای 404 یا alertخودکار در Safariخودکار در Chrome
قابل جعل توسط اپ دیگربلهخیرخیر (با autoVerify)
کار در iMessage / Mailمحدودکاملندارد
زمان راه‌اندازی۱۰ دقیقه۱–۲ ساعت۱–۲ ساعت
توصیه برای ۲۰۲۶فقط OAuthاجباریاجباری

برای فعال‌سازی Universal Links سه قطعه‌ی پازل لازم است: فایل apple-app-site-association روی سرور، Entitlement مناسب در پروژه‌ی MAUI و کد دریافت‌کننده در AppDelegate. مرحله‌به‌مرحله جلو می‌رویم تا هیچ نکته‌ای از قلم نیفتد.

گام ۱: ساخت فایل AASA

فایلی به نام apple-app-site-association (بدون پسوند) بسازید و دقیقاً در مسیر https://yourdomain.com/.well-known/apple-app-site-association قرار دهید. این فایل باید با Content-Type: application/json و بدون redirect سرو شود؛ در غیر این صورت Apple آن را نمی‌پذیرد.

{
  "applinks": {
    "details": [
      {
        "appIDs": ["ABCDE12345.com.yourcompany.yourapp"],
        "components": [
          {
            "/": "/product/*",
            "comment": "صفحه محصول"
          },
          {
            "/": "/order/*",
            "comment": "جزئیات سفارش"
          }
        ]
      }
    ]
  }
}

ABCDE12345 همان Team ID شما در Apple Developer Portal است و com.yourcompany.yourapp برابر Bundle ID اپ. صحت فایل را با ابزار رسمی Apple بررسی کنید: AASA Validator یا با دستور curl -I https://yourdomain.com/.well-known/apple-app-site-association.

گام ۲: تنظیم Entitlements در پروژه MAUI

در پوشه‌ی Platforms/iOS فایلی به نام Entitlements.plist ایجاد یا ویرایش کنید:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>com.apple.developer.associated-domains</key>
  <array>
    <string>applinks:yourdomain.com</string>
    <string>applinks:www.yourdomain.com</string>
  </array>
</dict>
</plist>

سپس در YourApp.csproj این Entitlements را برای پروفایل‌های Debug و Release متصل کنید:

<PropertyGroup Condition="$(TargetFramework.Contains('-ios'))">
  <CodesignEntitlements>Platforms/iOS/Entitlements.plist</CodesignEntitlements>
</PropertyGroup>

گام ۳: دریافت لینک در AppDelegate

در Platforms/iOS/AppDelegate.cs متد ContinueUserActivity را override کنید:

using Foundation;
using UIKit;

namespace YourApp;

[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)
        {
            // ارسال به منطق مرکزی روتینگ که در App.xaml.cs تعریف شده
            MainThread.BeginInvokeOnMainThread(async () =>
            {
                await DeepLinkRouter.HandleAsync(url.AbsoluteString);
            });
            return true;
        }
        return false;
    }
}

در اندروید فرآیند مشابه است اما با ابزارهای متفاوت. فایل assetlinks.json باید روی دامنه باشد، Intent Filter در MainActivity با autoVerify="true" اعلام شود و کد دریافت‌کننده در OnNewIntent پیاده‌سازی گردد. برای پروژه‌های بزرگ پیشنهاد می‌کنم همراه با این مقاله، راهنمای ما درباره‌ی معماری MVVM و Shell Navigation در .NET MAUI را هم مرور کنید تا منطق روتینگ را تمیزتر سازماندهی کنید.

گام ۱: تولید assetlinks.json

قبل از هر چیز SHA-256 fingerprint کلید امضای اپ خود را به دست آورید. اگر از Play App Signing استفاده می‌کنید، این مقدار را از کنسول Google Play، بخش Setup → App Integrity کپی کنید. برای Debug Keystore محلی:

keytool -list -v -keystore ~/.android/debug.keystore \
  -alias androiddebugkey -storepass android -keypass android

سپس فایل را در https://yourdomain.com/.well-known/assetlinks.json قرار دهید:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.yourcompany.yourapp",
      "sha256_cert_fingerprints": [
        "AA:BB:CC:DD:EE:FF:11:22:33:44:..."
      ]
    }
  }
]

گام ۲: اعلام Intent Filter در MainActivity

در Platforms/Android/MainActivity.cs یک یا چند IntentFilter اضافه کنید:

using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.OS;

namespace YourApp;

[Activity(Theme = "@style/Maui.SplashTheme",
    MainLauncher = true,
    LaunchMode = LaunchMode.SingleTop,
    ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation)]
[IntentFilter(
    new[] { Intent.ActionView },
    Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
    DataScheme = "https",
    DataHost = "yourdomain.com",
    DataPathPrefix = "/product",
    AutoVerify = true)]
[IntentFilter(
    new[] { Intent.ActionView },
    Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
    DataScheme = "https",
    DataHost = "yourdomain.com",
    DataPathPrefix = "/order",
    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 { } uri && intent.Action == Intent.ActionView)
        {
            MainThread.BeginInvokeOnMainThread(async () =>
            {
                await DeepLinkRouter.HandleAsync(uri.ToString()!);
            });
        }
    }
}

نکته‌ی مهم (و من خودم اولین بار با همین گیر کردم): از Android 12 (API 31) به بعد اگر autoVerify="true" باشد ولی فایل assetlinks.json در دسترس نباشد یا fingerprint مطابقت نکند، سیستم به‌جای باز کردن اپ، لینک را در مرورگر باز می‌کند، آن هم بدون هیچ هشدار به کاربر. برای دیباگ از دستور زیر کمک بگیرید:

adb shell pm get-app-links com.yourcompany.yourapp

اگر verified نباشد، با این دستور دستی به‌روزرسانی کنید:

adb shell pm verify-app-links --re-verify com.yourcompany.yourapp

مسیریابی Deep Link به صفحات Shell

تا اینجا لینک ورودی را دریافت کرده‌ایم اما هنوز کاربر را به صفحه‌ی درست نبرده‌ایم. در .NET MAUI رایج‌ترین الگو استفاده از Shell.Current.GoToAsync با Route Parameter است. یک سرویس مرکزی به نام DeepLinkRouter طراحی می‌کنیم که از هر دو پلتفرم فراخوانی شود و تمام منطق routing را در یک‌جا متمرکز کند.

public static class DeepLinkRouter
{
    public static string? PendingLink { get; set; }

    public static async Task HandleAsync(string url)
    {
        if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
            return;

        // اگر Shell هنوز آماده نیست، لینک را در صف نگه می‌داریم
        if (Shell.Current is null)
        {
            PendingLink = url;
            return;
        }

        var segments = uri.AbsolutePath.Trim('/').Split('/');

        switch (segments)
        {
            case ["product", var id]:
                await Shell.Current.GoToAsync(
                    $"//product?id={Uri.EscapeDataString(id)}");
                break;

            case ["order", var id]:
                await Shell.Current.GoToAsync(
                    $"//order?id={Uri.EscapeDataString(id)}");
                break;

            case ["invite", var code]:
                await Shell.Current.GoToAsync(
                    $"//signup?invite={Uri.EscapeDataString(code)}");
                break;

            default:
                // لینک ناشناخته: به صفحه اصلی fallback می‌کنیم
                await Shell.Current.GoToAsync("//home");
                break;
        }
    }
}

در صفحه‌ی مقصد پارامتر را با QueryProperty دریافت کنید:

[QueryProperty(nameof(ProductId), "id")]
public partial class ProductPage : ContentPage
{
    public string ProductId
    {
        set => ((ProductViewModel)BindingContext).LoadProduct(value);
    }

    public ProductPage(ProductViewModel vm)
    {
        InitializeComponent();
        BindingContext = vm;
    }
}

اگر اپ شما حالت آفلاین دارد و می‌خواهید Deep Linkها حتی بدون اتصال شبکه کار کنند، الگوهای ذکر‌شده در معماری Offline-First در .NET MAUI به شما کمک می‌کنند تا داده‌های لازم را قبل از اینکه کاربر متوجه قطع شبکه شود از کش محلی بارگذاری کنید.

یکی از پرتکرارترین سؤالاتی که در پروژه‌ها می‌بینم این است: «چرا لینکم وقتی روی Notes می‌چسبانم و کلیک می‌کنم در Safari باز می‌شود؟». پاسخ معمولاً این است: یا AASA file در دسترس نیست، یا Apple آن را هنوز کش نکرده، یا کاربر یک‌بار «Open in Safari» را انتخاب کرده و iOS ترجیح را به یاد سپرده است. تست منظم پیش از انتشار، ۹۰٪ این مشکلات را حذف می‌کند.

تست iOS با شبیه‌ساز

# باز کردن یک URL در شبیه‌ساز بوت‌شده
xcrun simctl openurl booted "https://yourdomain.com/product/42"

# لیست شبیه‌سازها
xcrun simctl list devices

# بررسی اینکه AASA file توسط iOS دانلود شده
xcrun simctl spawn booted log stream --predicate \
  'subsystem == "com.apple.swcd"' --info

تست اندروید با ADB

# شبیه‌سازی کلیک روی Deep Link
adb shell am start -a android.intent.action.VIEW \
  -d "https://yourdomain.com/product/42" \
  com.yourcompany.yourapp

# بررسی وضعیت تأیید App Links
adb shell pm get-app-links com.yourcompany.yourapp

# پاک کردن ترجیح کاربر (اگر یک‌بار "Always open in browser" را زده)
adb shell pm reset-app-links com.yourcompany.yourapp

برای تست end-to-end توصیه می‌کنم لینک‌ها را در یک پیام Slack یا Telegram به خودتان بفرستید و از همان‌جا کلیک کنید. این محیط بسیار به سناریوی واقعی کاربر نزدیک‌تر از کپی‌پیست در Safari است، چون iOS رفتار متفاوتی برای لینک‌های داخل اپ‌های پیام‌رسان دارد. برای تست‌های اتومیشن، یکپارچه‌سازی این سناریوها در سوییت Appium شما (همان‌طور که در راهنمای تست‌نویسی در .NET MAUI توضیح داده‌ام) کاملاً منطقی است.

اشتباهات رایج و راهکار رفع آن‌ها

من این الگو را در سه اپ تولیدی پیاده کرده‌ام و در هر سه پروژه با اشتباهات تقریباً یکسانی روبه‌رو شدم. فهرست زیر بر اساس فراوانی و میزان تأثیر مرتب شده است تا بتوانید مشکلات مشابه را سریع‌تر تشخیص دهید.

۱. سرو کردن AASA file با Content-Type اشتباه

اگر سرور شما فایل را با text/plain یا application/octet-stream سرو کند، iOS آن را به سکوت نادیده می‌گیرد. در Nginx این تنظیم را اضافه کنید:

location = /.well-known/apple-app-site-association {
    default_type application/json;
    add_header Cache-Control "no-cache";
}

۲. فراموش کردن Path Prefixهای متعدد

هر مسیری که می‌خواهید deep link شود باید یا در AASA components یا در Intent Filter ذکر شود. اگر کاربر روی /blog/123 کلیک کند اما فقط /product و /order را ثبت کرده باشید، لینک در مرورگر باز خواهد شد. یا همه‌ی paths را با "/": "*" ثبت کنید (اگر دامنه فقط برای اپ است)، یا فهرست paths را به‌روز نگه دارید.

۳. کلید امضای اشتباه در assetlinks.json

اگر شما لوکال build می‌کنید با Debug keystore اما assetlinks.json را با fingerprint نسخه‌ی Play Store پر کرده‌اید، روی دستگاه دولوپر کار نمی‌کند. راه‌حل: هر دو fingerprint را در آرایه‌ی sha256_cert_fingerprints قرار دهید تا هر دو build شناسایی شوند.

۴. Race condition هنگام Cold Start

وقتی اپ از حالت کاملاً بسته باز می‌شود، Shell هنوز ساخته نشده ولی Deep Link قبلاً رسیده است. اگر مستقیم Shell.Current.GoToAsync صدا بزنید، NullReferenceException می‌گیرید. راه‌حل: لینک را در PendingLink ذخیره کنید و در رویداد Shell.Current.Navigated یا در سازنده‌ی AppShell پردازش کنید.

۵. Universal Link روی همان دامنه‌ی صفحه‌ی فعلی

یک رفتار عجیب iOS این است: اگر کاربر داخل Safari باشد و روی لینکی از همان دامنه‌ی صفحه‌ی فعلی کلیک کند، iOS اپ را باز نمی‌کند. این یک محدودیت طراحی است نه باگ. برای دور زدن آن، از یک دامنه‌ی جداگانه برای deep linkها استفاده کنید (مثلاً link.yourdomain.com).

برای جزئیات رسمی پیاده‌سازی، مستندات Universal Links در .NET MAUI روی iOS و Android App Links در .NET MAUI را مطالعه کنید. همچنین راهنمای رسمی Android در مورد تأیید App Links به‌خصوص برای دیباگ مسائل verification بسیار مفید است.

پرسش‌های متداول

آیا برای Universal Links حتماً به دامنه‌ی اختصاصی نیاز دارم؟

بله، Universal Links فقط روی HTTPS کار می‌کند و نیازمند فایل apple-app-site-association روی دامنه‌ای است که مالک آن هستید. می‌توانید از یک ساب‌دامین (مثلاً link.example.com) استفاده کنید اما نمی‌توانید از دامنه‌های اشتراکی مثل GitHub Pages برای پروژه‌ی تولیدی استفاده کنید چون TLS certificate و کنترل .well-known اهمیت دارد.

چرا App Link من بعد از نصب اپ کار نمی‌کند ولی بعد از verify-app-links کار می‌کند؟

این مشکل از Android 12 شایع شده است. سیستم در زمان نصب فقط یک‌بار assetlinks.json را چک می‌کند و اگر آن لحظه فایل در دسترس نبود یا تأخیر شبکه داشت، verification fail می‌شود و دیگر تلاش نمی‌کند. راه‌حل پایدار، انتشار اپ با autoVerify=true از همان روز اول و اطمینان از پاسخ سریع CDN است.

تفاوت Deferred Deep Linking با Deep Linking معمولی چیست؟

Deep Linking معمولی فرض می‌کند اپ نصب است. Deferred Deep Linking یعنی کاربر روی لینکی کلیک می‌کند، اپ نصب نیست، به استور می‌رود، نصب می‌کند و اولین بار که اپ را باز می‌کند مستقیم به همان محتوای اصلی می‌رود. این قابلیت در iOS و Android بومی پشتیبانی نمی‌شود و نیاز به سرویس‌های جانبی مثل Branch.io یا Adjust دارد.

آیا می‌توانم Custom URL Scheme و Universal Links را همزمان داشته باشم؟

بله و در عمل توصیه می‌کنم همین کار را بکنید. Custom Scheme برای OAuth callback (مثلاً Google Sign-In) و Universal Links برای محتوای قابل‌اشتراک‌گذاری. این دو در تعارض نیستند و هر کدام در سناریوی خود بهتر عمل می‌کنند.

چطور بفهمم کاربر از چه لینکی وارد اپ شده برای تحلیل آماری؟

قبل از فراخوانی Shell.Current.GoToAsync در DeepLinkRouter، URL کامل را به سرویس تحلیلی خود (App Insights، Mixpanel، Firebase Analytics) ارسال کنید با یک event مثل deep_link_opened و پارامتر source_url. سپس می‌توانید UTM parameters را پارس کنید و کمپین‌ها را track کنید.

Marcus Chen
درباره نویسنده Marcus Chen

Senior mobile architect with a decade of cross-platform experience. Spent the last five years going deep on .NET MAUI in production.