.NET MAUI Shell Navigation: Пълно ръководство за маршрутизация, дълбоки връзки и параметри (2026)

Пълно ръководство за .NET MAUI Shell навигация в .NET 10: маршрути, GoToAsync, IQueryAttributable, deep links на iOS/Android и как да избегнете течове на памет.

MAUI Shell Navigation: Пълно ръководство 2026

Актуализирано: 11 август 2026

.NET MAUI Shell е контейнер за навигация, който Microsoft препоръчва за повечето мобилни приложения от .NET 8 насам и остава актуален избор в .NET 10. Той обединява йерархията на страниците, TabBar-а, Flyout менюто и URI-базираната маршрутизация в един декларативен XAML модел. В това ръководство разглеждам как да настроите Shell правилно, как да предавате параметри между страници без да пробивате AOT trimming, как да реализирате дълбоки връзки на iOS и Android и, което е също толкова важно, как да избегнете капаните с паметта, за които официалната документация мълчи.

  • Shell не е остарял в .NET 10. Той си остава препоръчителният начин за навигация в .NET MAUI за проекти с бутонни ленти или Flyout меню.
  • Всяка страница, до която достъпвате чрез GoToAsync и която не е част от визуалното дърво на Shell, трябва да е регистрирана чрез Routing.RegisterRoute, иначе рискувате изтичане на памет.
  • Предпочитайте IQueryAttributable пред [QueryProperty], защото първото не използва reflection и работи с NativeAOT и full trimming.
  • За сложни обекти между страниците използвайте ShellNavigationQueryParameters, не query string. Примитивите се сериализират, обектите не.
  • App Links (Android) и Universal Links (iOS) се закачат директно към Shell маршрутите ви, ако имената им съвпадат с URL пътищата на сайта ви.
  • В .NET 10 Shell handler-архитектурата вече е налична като preview на Android. Това решава дълга история от regressions с бутонните ленти.

Какво представлява .NET MAUI Shell и защо да го използвам

Shell е приложна обвивка, която декларира цялата навигационна структура на приложението в един XAML файл, обикновено AppShell.xaml. Тя внедрява няколко доста скучни, но пестящи седмици проблеми:

  • URI маршрутизация. Всяка страница има път (напр. //home/products/detail), а не се задава като инстанция.
  • Flyout меню и TabBar от кутията. Не е нужно да пишете MasterDetailPage с ръчно управление на състоянието, както при Xamarin.Forms.
  • Търсене в горната лента чрез SearchHandler. Платформено-нативни резултати без да пишете CollectionView.
  • Дълбоки връзки. Shell маршрутите са URL-и, така че App Links и Universal Links се закачат директно.

В моя опит, единственият случай, в който не избирам Shell, е когато приложението има напълно нестандартна навигация: модален wizard от пет стъпки без TabBar, без Flyout, без Back стек. За всичко останало Shell пести код. Ако мигрирате от Xamarin.Forms, вижте отделното ръководство за миграция от Xamarin.Forms към .NET MAUI, където разглеждам как да преобразувате TabbedPage и NavigationPage структури в Shell.

Ако очаквате, че Shell ще ви ограничи в дизайна, не е така. TabBar-ът и Flyout менюто са напълно стилизуеми чрез Shell.TabBarBackgroundColor, Shell.FlyoutBackground, ItemTemplate и MenuItemTemplate, а горната лента може да се скрие изцяло с Shell.NavBarIsVisible="False" за отделни страници.

Йерархия: Shell, TabBar, FlyoutItem и ShellContent

Йерархията изглежда странно на пръв поглед. Прочетете я отдолу нагоре: ShellContent е обвивка около една ContentPage, Tab е логическа група от ShellContent елементи (за под-табове), а FlyoutItem или TabBar е коренният контейнер, който определя как потребителят вижда навигацията:

  • FlyoutItem показва входа в страничното (Flyout) меню.
  • TabBar показва входовете само като долни табове, без Flyout меню.
  • Комбинация. Можете да имате едновременно FlyoutItems (в менюто) и TabBar (за най-често използвани секции).

Ето минимален работещ пример за приложение с три таба и Flyout меню:

<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       xmlns:views="clr-namespace:MyApp.Views"
       x:Class="MyApp.AppShell"
       FlyoutBehavior="Flyout">

    <FlyoutItem Title="Начало" Icon="home.png">
        <Tab Title="Начало">
            <ShellContent Route="home" ContentTemplate="{DataTemplate views:HomePage}" />
        </Tab>
        <Tab Title="Продукти">
            <ShellContent Route="products" ContentTemplate="{DataTemplate views:ProductsPage}" />
        </Tab>
    </FlyoutItem>

    <FlyoutItem Title="Настройки" Icon="settings.png">
        <ShellContent Route="settings" ContentTemplate="{DataTemplate views:SettingsPage}" />
    </FlyoutItem>
</Shell>

Забележете, че използвам ContentTemplate, а не ContentPage инстанция. Това е важно. Shell създава страницата лениво при първо посещение и не задържа излишни визуални обекти в паметта. Ако запишете <ShellContent><views:HomePage /></ShellContent>, Shell създава всички страници наведнъж при стартиране. Дребна разлика в XAML, огромна разлика в времето за студен старт.

Регистрация на маршрути с Routing.RegisterRoute

Страници като ProductDetailPage, до които се стига само чрез GoToAsync, не са част от визуалното дърво на Shell. Тях трябва да ги регистрирате изрично в конструктора на AppShell:

public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();

        Routing.RegisterRoute("productDetail", typeof(ProductDetailPage));
        Routing.RegisterRoute("editProfile", typeof(EditProfilePage));
        Routing.RegisterRoute("checkout", typeof(CheckoutPage));
    }
}

Пропускането на тази регистрация е причина №1 за изтичане на памет в Shell приложения. Ако извикате GoToAsync("productDetail") без предварителна регистрация, ще получите ArgumentException: Route productDetail not found. Но по-коварното е, когато страницата изглежда, че работи, защото сте я подали чрез абсолютен маршрут към ShellContent. В такъв случай Shell я държи като част от Tab структурата и никога не освобождава handler-ите ѝ. Точно на този капан се хванах преди няколко месеца в реално приложение. Паметта растеше с всяко посещение на детайлна страница, докато Android процесът се убие.

Регистрирайте маршрутите като строково-константи на едно място. Не разпръсквайте "productDetail" буквали из целия проект. Ще ми благодарите, когато преименувате маршрут след половин година.

Shell разграничава три вида пътища и разликата между тях определя дали новата страница се добавя към стека, или го изчиства:

ФормаДействиеКога да я използвам
//routeАбсолютен път. Изчиства навигационния стек и позиционира приложението в даден маршрут от корена.Логин към Home, изход, deep link entry point.
routeОтносителен път. Добавя страницата към текущия стек (push).Стандартна навигация напред от списък към детайл.
..Връщане назад с една стъпка (pop).Ръчно връщане след записване на форма.
../..Връщане назад с две стъпки.След многостъпков wizard, за да излезете направо на списъка.
../detail?id=5Pop + push. Връща и веднага бута нова страница с параметър.Замяна на текущата страница без отваряне на междинна.

Ето стандартните извиквания:

// Абсолютно, след логин
await Shell.Current.GoToAsync("//home");

// Относително, от списък към детайл
await Shell.Current.GoToAsync($"productDetail?id={product.Id}");

// Назад
await Shell.Current.GoToAsync("..");

// Назад с параметър обратно (напр. резултат от избор)
await Shell.Current.GoToAsync("..", new Dictionary<string, object>
{
    ["selectedColor"] = "red"
});

Тук е моментът, в който бих ви посъветвал да не смесвате Shell навигацията с Navigation.PushAsync. Технически двете работят паралелно, но държат отделни стекове и лесно се стига до състояние, в което Back бутонът се държи непредвидимо. Изберете едното.

Как да предам параметри между страници в MAUI Shell

Има три работещи начина, всеки със свои компромиси. Query string е най-простият, но поддържа само примитиви (string, int, bool, Guid):

await Shell.Current.GoToAsync($"productDetail?id={product.Id}&highlight=true");

За сложни обекти използвайте ShellNavigationQueryParameters. Това е дизайнерски вариант, добавен в .NET 7 специално за случая, когато query string просто не е достатъчен:

var navigationParams = new ShellNavigationQueryParameters
{
    ["product"] = product,
    ["source"] = "search"
};

await Shell.Current.GoToAsync("productDetail", navigationParams);

Внимание: тези параметри се използват еднократно. При pop и повторен push обектът вече не е там. Ако получаващата страница живее по-дълго (заради навигация напред и назад), запишете обекта в собствения ѝ ViewModel или в singleton сервиз.

Третият вариант, passing чрез Messenger, е за случаи, когато две страници трябва да общуват, но не са в директна навигационна релация. От .NET 9 MessagingCenter е маркиран като internal; използвайте WeakReferenceMessenger от CommunityToolkit.Mvvm. За пълна архитектурна картина вижте ръководството за MVVM в .NET MAUI с CommunityToolkit.Mvvm, където разглеждам как WeakReferenceMessenger се вплита в ViewModel-ите.

IQueryAttributable срещу [QueryProperty] и защо предпочитам първия

За приемане на параметри в получаващата страница или ViewModel имате две опции. Атрибутът [QueryProperty] е декларативен и удобен:

[QueryProperty(nameof(ProductId), "id")]
[QueryProperty(nameof(Highlight), "highlight")]
public partial class ProductDetailPage : ContentPage
{
    public string ProductId { get; set; }
    public bool Highlight { get; set; }
}

Работи, но зад кулисите Shell използва reflection, за да намери property-тата. Това означава, че при NativeAOT или пълен trimming (което ще стане default в .NET 11 за много sample приложения) кодът се чупи мълчаливо. Стойностите просто не пристигат. Затова в новите си проекти използвам IQueryAttributable:

public partial class ProductDetailViewModel : ObservableObject, IQueryAttributable
{
    [ObservableProperty] private string productId;
    [ObservableProperty] private bool highlight;
    [ObservableProperty] private Product product;

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("id", out var idValue))
            ProductId = idValue?.ToString();

        if (query.TryGetValue("highlight", out var h) && bool.TryParse(h?.ToString(), out var hb))
            Highlight = hb;

        if (query.TryGetValue("product", out var p) && p is Product prod)
            Product = prod;
    }
}

Няколко важни детайла. ApplyQueryAttributes се извиква преди OnAppearing и преди BindingContext да е напълно готов при първо посещение. Затова не разчитайте на UI да е рендериран вътре в него, само задавайте property-та. Ако трябва да заредите данни от API, стартирайте задачата тук, но чакайте резултата в OnAppearing или в отделен InitializeAsync метод.

Модална навигация с Shell

За модална страница (напр. диалог за филтриране, който заема целия екран, но не е част от нормалния стек) добавете presentation=modal към query string-а или задайте атрибута на самата страница:

// Опция 1: чрез query параметър
await Shell.Current.GoToAsync("filters?presentation=modal");

// Опция 2: чрез XAML атрибут на страницата
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             Shell.PresentationMode="Modal"
             x:Class="MyApp.Views.FiltersPage">
    ...
</ContentPage>

За разлика от Navigation.PushModalAsync, модалната Shell страница все още има достъп до Shell.Current и GoToAsync("..") за затваряне. Това е много по-чисто от смесването на две API-та. На iOS модалната страница се появява със стандартната card анимация от iOS 13+, а на Android с fade-slide. Ако искате различни презентационни режими (NotAnimated, ModalNotAnimated), задайте ги през XAML. Не всички се задават през query string.

Как да обработя Back бутона в MAUI Shell

Тук трябва да сте наясно с две отделни концепции. BackButtonBehavior контролира визуалния Back бутон в навигационната лента, стрелката горе вляво:

<ContentPage.BindingContext>
    <vm:EditProductViewModel />
</ContentPage.BindingContext>

<Shell.BackButtonBehavior>
    <BackButtonBehavior Command="{Binding ConfirmExitCommand}"
                        IconOverride="close.png"
                        TextOverride="Отказ" />
</Shell.BackButtonBehavior>

Използвам това, когато потребителят има незапазени промени. Командата отваря диалог "Наистина ли искате да излезете?" и решава дали да извика GoToAsync(".."). Не забравяйте, че на iOS няма хардуерен Back бутон, така че това е единственият начин потребителят да се върне.

На Android има отделен хардуерен Back бутон, който не минава през BackButtonBehavior. За него override-нете OnBackButtonPressed в ContentPage:

protected override bool OnBackButtonPressed()
{
    if (viewModel.HasUnsavedChanges)
    {
        _ = ConfirmExitAsync();
        return true; // блокираме default поведението
    }
    return base.OnBackButtonPressed();
}

Върнатата true означава "аз се погрижих, не пипай", а false оставя Shell да направи pop. Тестирайте и двата пътя. Единият е дословно недостижим на iOS.

Ако Shell маршрутите ви са URL-и, реалните URL-и на сайта ви могат да ги активират директно от Safari или Chrome. Това е поддържано от Apple и Google от години, но има доста плочки, които трябва да наредите.

Android (App Links): в MainActivity.cs добавете [IntentFilter] с AutoVerify=true:

[Activity(...)]
[IntentFilter(new[] { Android.Content.Intent.ActionView },
    Categories = new[] { Android.Content.Intent.CategoryDefault, Android.Content.Intent.CategoryBrowsable },
    DataScheme = "https",
    DataHost = "example.com",
    DataPathPrefix = "/product",
    AutoVerify = true)]
public class MainActivity : MauiAppCompatActivity { }

После публикувайте assetlinks.json в https://example.com/.well-known/assetlinks.json с SHA256 fingerprint-а на подписващия ключ. Пълните стъпки са в Microsoft Learn, Android app links.

iOS (Universal Links): добавете Associated Domains capability в Entitlements.plist и качете apple-app-site-association (без разширение) в https://example.com/.well-known/. Съдържанието трябва да е JSON, а Apple е педантичен, така че не оставяйте trailing запетаи. Детайлите са в Microsoft Learn, Apple universal links.

След като двете платформи проверят връзката, отваряне на https://example.com/product/42 ще стартира приложението и ще извика OnAppLinkRequestReceived. Оттам просто пренасочете към Shell маршрута:

protected override void OnAppLinkRequestReceived(Uri uri)
{
    base.OnAppLinkRequestReceived(uri);
    // uri.AbsolutePath = "/product/42"
    var id = uri.Segments.Last();
    Shell.Current.Dispatcher.Dispatch(async () =>
        await Shell.Current.GoToAsync($"//home/products/productDetail?id={id}"));
}

Защо страницата ми не се освобождава от паметта

Това е най-често задаваният ми въпрос от разработчици, мигриращи от Xamarin.Forms. В .NET 9 Microsoft добави автоматично disconnect-ване на handler-ите при връщане назад, което оправи повечето случаи (issue #22313). Но не всички. Ето какво все още причинява течове:

  1. Нерегистрирани маршрути. Ако страницата не е в RegisterRoute, но я достигате чрез трик с абсолютни пътища, Shell я държи. Регистрирайте.
  2. Event handler-и, които не се откачат. Ако ViewModel-ът се абонира за event на singleton (напр. NetworkStatusChanged), но не се отписва в OnDisappearing, той държи страницата жива.
  3. MessagingCenter регистрации. Стар код с MessagingCenter.Subscribe, който никога не извиква Unsubscribe. Мигрирайте към WeakReferenceMessenger. Той използва слаби референции и решава проблема сам.
  4. Асинхронни задачи, задържащи this. Task.Run(() => DoWork(this)) без CancellationToken, който да прекрати задачата при излизане от страницата.

Диагностиката е сравнително проста: използвайте dotnet-gcdump след няколко pop операции и потърсете инстанции на страницата или ViewModel-а, които все още съществуват. Не се доверявайте на визуалната проверка. Android може да задържи страницата в паметта минути, преди GC да я почисти. Ако очаквате едно копие, а виждате пет, имате теч.

Какво е ново за Shell в .NET 10

Няколко неща в .NET 10 засягат директно как пиша Shell код:

  • Handler-базирана Shell архитектура на Android (preview). Досега Shell на Android рендираше през legacy renderers, което носеше цяла история от regressions с бутонните ленти. В .NET 10 това е налично като opt-in preview и се очаква да стане default в .NET 11. Ако видите странности с TabBar цветовете в custom темите ви, това е причината.
  • Shell.NavBarVisibilityAnimationEnabled е нов attached property, който изключва fade-in анимацията на навигационната лента. Малка промяна, но полезна за приложения, които често показват/скриват NavBar-а.
  • iOS 26 rendering bug. Ако тествате на iOS 26 beta и виждате празни бели ленти под NavBar-а, обновете до .NET 10 SR11 или по-нов. По-стари версии имат bug в iOS 26 layout API-то.
  • MessagingCenter е окончателно internal. Ако вашият проект все още го използва, компилаторът ще започне да ругае. Мигрирайте.

Пълният changelog е в What's new in .NET MAUI for .NET 10 (Microsoft Learn). За data слой промените в .NET 10 (нови EF Core източници за MAUI, промени в SqliteAsyncConnection) съм разгледал отделно в ръководството за SQLite + EF Core в .NET MAUI.

Често срещани грешки и как да ги отстраня

"Route ... not found", най-често липсваща регистрация или разлика в главни/малки букви. Shell маршрутите са case-sensitive. ProductDetail и productDetail са различни маршрути.

Query параметри не пристигат. Проверете три неща: (1) използвате ли reflection-базирания [QueryProperty] с NativeAOT? (2) паснали ли сте името на ключа точно (case-sensitive)? (3) навигирате ли през междинна страница с DI-конструирана ContentPage, вижте bug #35107.

Back стекът се държи странно. Почти винаги защото сте смесили Shell.Current.GoToAsync с Navigation.PushAsync. Използвайте само едното.

TabBar изчезва след GoToAsync. Абсолютните маршрути (//route) към страница извън TabBar-а излизат от структурата му. Използвайте относителен push или задайте Shell.TabBarIsVisible="True" изрично на детайлната страница.

App Link работи в браузър, но приложението не се отваря. На Android, липсва AutoVerify=true или SHA256 fingerprint не съвпада; проверете с adb shell pm get-app-links com.company.app. На iOS, проверете, че apple-app-site-association се сервира с Content-Type: application/json и без redirect. Apple отказва да ги следва.

Често задавани въпроси

Каква е разликата между Shell.GoToAsync и Navigation.PushAsync?

GoToAsync използва URI маршрути и работи с регистрираните Shell routes, докато PushAsync добавя конкретна инстанция на страница към класическия navigation стек. Двете държат отделни стекове, а смесването им води до непредвидимо поведение на Back бутона. В Shell приложение винаги използвайте GoToAsync.

Мога ли да използвам Shell без Flyout меню?

Да. Заменете коренния <FlyoutItem> с <TabBar> и премахнете FlyoutBehavior, и ще получите приложение само с долни табове. Ако искате един-единствен screen без табове, използвайте <TabBar> с един <ShellContent>, а после скрийте лентата с Shell.TabBarIsVisible="False" на страницата.

Как да предам сложен обект между страници в MAUI Shell?

Използвайте ShellNavigationQueryParameters вместо query string, защото приема object стойности. Приемащата страна имплементира IQueryAttributable и чете обекта от IDictionary<string, object>. Query параметрите се използват еднократно, така че за дълготрайно състояние запишете обекта в singleton сервиз или ViewModel.

Как поддържам навигационното състояние при заспиване на приложението?

Android може да прекрати процеса, докато приложението е в background. Shell не сериализира стека автоматично, така че запишете критичните параметри (напр. ID на текущата страница) в Preferences или SecureStorage при OnDisappearing, и при следващо стартиране извикайте GoToAsync с абсолютен маршрут, за да възстановите състоянието.

Защо страницата ми не се освобождава от паметта след Back?

Най-често защото ViewModel-ът задържа референция чрез event handler, MessagingCenter subscription или незавършена Task. Регистрирайте маршрута правилно чрез Routing.RegisterRoute, отписвайте event-и в OnDisappearing и мигрирайте от MessagingCenter към WeakReferenceMessenger от CommunityToolkit.Mvvm.

David O'Reilly
За Автора David O'Reilly

Native iOS/Android specialist turned MAUI advocate. Writes about the gritty platform details most cross-platform tutorials skip.