NativeAOT в .NET MAUI: ускорение запуска до 2x и уменьшение размера приложений на 50%

NativeAOT в .NET MAUI позволяет ускорить запуск iOS-приложений до 2 раз и уменьшить размер на 50%. Разбираем настройку NativeAOT, переход на скомпилированные привязки, тримминг и типичные ошибки — с примерами кода для .NET 10.

NativeAOT .NET MAUI 10: 2x старт, Гайд 2026

Введение: зачем вашему .NET MAUI-приложению NativeAOT

Когда пользователь нажимает на иконку вашего приложения, у вас есть примерно 2–3 секунды, чтобы показать ему что-то осмысленное. Если запуск затягивается — пользователь уходит. И это не просто ощущение: по данным Google, 53% мобильных пользователей покидают приложение, которое загружается дольше 3 секунд.

Для тех, кто работает с .NET MAUI, это всегда было больным местом. JIT-компиляция при старте добавляла заметные задержки, особенно на iOS. Честно говоря, наблюдать за «задумчивым» сплэш-скрином на тестовом iPhone — удовольствие сомнительное.

NativeAOT (Native Ahead-of-Time compilation) меняет ситуацию кардинально. Вместо того чтобы компилировать IL-код в нативный прямо на устройстве, NativeAOT делает это заранее — на этапе сборки. Результат? Запуск до 2 раз быстрее и размер приложения до 50% меньше.

Но бесплатного обеда не бывает — придётся адаптировать код, перейти на скомпилированные привязки и разобраться с триммингом. Давайте пройдём весь путь: от понимания принципов работы до практического внедрения в существующий проект. С реальными примерами кода, бенчмарками и разбором типичных ошибок.

Как работает NativeAOT и чем отличается от JIT

JIT-компиляция: проблема холодного старта

По умолчанию .NET MAUI работает на рантайме Mono, который компилирует IL-код в нативный «на лету» — при первом вызове каждого метода. Это и есть JIT-компиляция (Just-in-Time).

Проблема в том, что при запуске приложения нужно скомпилировать десятки, а то и сотни методов. И каждая такая компиляция занимает время:

// При JIT-компиляции каждый метод компилируется при первом вызове:
// 1. Загрузка IL-кода метода
// 2. Компиляция в нативный код
// 3. Выполнение нативного кода
//
// На холодном старте это происходит для сотен методов одновременно

На флагманских устройствах это почти незаметно. А вот на бюджетных Android-смартфонах или старых iPhone задержка при запуске может спокойно достигать 4–6 секунд. Многовато, правда?

NativeAOT: компиляция заранее

NativeAOT подходит к задаче иначе — компилирует всё приложение в нативный машинный код ещё на этапе публикации. На выходе получается полностью нативный бинарник, который не нуждается ни в JIT-компиляторе, ни в рантайме Mono:

  • Статический анализ программы — компилятор анализирует весь граф вызовов и определяет, какой код реально используется
  • Полный тримминг — неиспользуемый код агрессивно удаляется из итоговой сборки
  • AOT-компиляция — весь оставшийся код компилируется в нативный машинный код целевой платформы

По сути, вы получаете приложение, которое стартует почти мгновенно, потому что ему не нужно ничего компилировать в рантайме.

Бенчмарки: реальные цифры производительности

Теория — это хорошо, но давайте посмотрим на конкретные цифры. Вот результаты бенчмарков Microsoft для .NET MAUI приложений:

МетрикаMono (по умолчанию)NativeAOTУлучшение
Время запуска iOS~820 мс~410 мсдо 2x быстрее
Время запуска Mac Catalyst~650 мс~540 мс1.2x быстрее
Размер приложения iOS~38 МБ~19 МБдо 50% меньше
Размер .NET iOS~25 МБ~16 МБ~35% меньше
Потребление памятибазовоесниженноеощутимо меньше

Это не теоретические расчёты — реальные результаты измерений команды .NET на типичных MAUI-приложениях. Разница между 820 и 410 мс запуска кажется небольшой на бумаге, но на практике это разница между «нормально» и «мгновенно» в восприятии пользователя. Особенно если ваше приложение открывают по несколько раз в день.

Поддерживаемые платформы в .NET 10

Важный момент, который стоит учитывать сразу: NativeAOT в .NET MAUI поддерживается не на всех платформах.

  • iOS — полная поддержка (и именно тут максимальный эффект)
  • Mac Catalyst — полная поддержка
  • Windows — поддержка через NativeAOT для .NET
  • Android — NativeAOT не поддерживается, но используется профилированный AOT (Profiled AOT)

На Android вместо NativeAOT применяется другой механизм — профилированная AOT-компиляция. Она компилирует не весь код, а только наиболее часто используемые методы, определённые с помощью профилей. Получается разумный баланс между размером приложения и скоростью запуска. Не идеально, но работает.

Пошаговая настройка NativeAOT в проекте

Итак, переходим к практике. Настройка состоит из четырёх шагов — ничего запредельно сложного, но каждый важен.

Шаг 1: включение анализаторов совместимости

Начните с включения анализаторов тримминга и AOT. Это ещё не включает NativeAOT — только показывает предупреждения о несовместимом коде:

<PropertyGroup>
  <!-- Включаем анализаторы на всех платформах -->
  <IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

Соберите проект и внимательно посмотрите на все предупреждения. Они подскажут, где в коде используется рефлексия, динамическая загрузка типов или другие несовместимые с AOT паттерны. На этом этапе лучше не торопиться — исправьте всё, что найдёте.

Шаг 2: включение NativeAOT для iOS и Mac Catalyst

Теперь добавляем условное включение NativeAOT для поддерживаемых платформ:

<PropertyGroup>
  <IsAotCompatible>true</IsAotCompatible>

  <!-- NativeAOT для iOS -->
  <PublishAot Condition="$([MSBuild]::GetTargetPlatformIdentifier(
    '$(TargetFramework)')) == 'ios'">true</PublishAot>

  <!-- NativeAOT для Mac Catalyst -->
  <PublishAot Condition="$([MSBuild]::GetTargetPlatformIdentifier(
    '$(TargetFramework)')) == 'maccatalyst'">true</PublishAot>
</PropertyGroup>

Шаг 3: включение полного тримминга

NativeAOT работает в связке с полным триммингом — без него полноценного эффекта не будет:

<PropertyGroup>
  <TrimMode>Full</TrimMode>
</PropertyGroup>

Шаг 4: включение строгой компиляции XAML

Последний шаг — убедиться, что все XAML-файлы компилируются строго:

<PropertyGroup>
  <MauiStrictXamlCompilation>true</MauiStrictXamlCompilation>
  <MauiEnableXamlCBindingWithSourceCompilation>true</MauiEnableXamlCBindingWithSourceCompilation>
</PropertyGroup>

Когда все четыре шага пройдены, полная конфигурация .csproj выглядит примерно так:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
    <OutputType>Exe</OutputType>
    <UseMaui>true</UseMaui>
    <SingleProject>true</SingleProject>
    <ImplicitUsings>enable</ImplicitUsings>

    <!-- NativeAOT и тримминг -->
    <IsAotCompatible>true</IsAotCompatible>
    <TrimMode>Full</TrimMode>
    <MauiStrictXamlCompilation>true</MauiStrictXamlCompilation>
    <MauiEnableXamlCBindingWithSourceCompilation>true</MauiEnableXamlCBindingWithSourceCompilation>

    <PublishAot Condition="$([MSBuild]::GetTargetPlatformIdentifier(
      '$(TargetFramework)')) == 'ios'">true</PublishAot>
    <PublishAot Condition="$([MSBuild]::GetTargetPlatformIdentifier(
      '$(TargetFramework)')) == 'maccatalyst'">true</PublishAot>
  </PropertyGroup>
</Project>

Скомпилированные привязки: обязательное условие для NativeAOT

Почему строковые привязки несовместимы с NativeAOT

Вот тут начинается, пожалуй, самая трудоёмкая часть миграции. Обычные привязки данных в .NET MAUI работают через рефлексию: путь к свойству задаётся строкой, и рантайм ищет нужное свойство через System.Reflection. При NativeAOT и полном тримминге рефлексия ограничена, и триммер просто не может определить, какие свойства нужны для привязки.

Результат? Привязки молча перестают работать. Никаких исключений — просто пустые поля на экране.

Скомпилированные привязки (Compiled Bindings) решают эту проблему: они генерируют строго типизированный код на этапе компиляции, и триммер точно видит, какие свойства используются.

Переход на скомпилированные привязки в XAML

Ключевой шаг — добавить атрибут x:DataType ко всем XAML-страницам и элементам с привязками. На первый взгляд изменение минимальное, но разница огромная.

Было (строковая привязка):

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
    <StackLayout>
        <!-- Строковая привязка — НЕ работает с NativeAOT -->
        <Label Text="{Binding UserName}" />
        <Label Text="{Binding Email}" />
        <Button Text="Сохранить" Command="{Binding SaveCommand}" />
    </StackLayout>
</ContentPage>

Стало (скомпилированная привязка):

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:vm="clr-namespace:MyApp.ViewModels"
             x:DataType="vm:ProfileViewModel">
    <StackLayout>
        <!-- Скомпилированная привязка — работает с NativeAOT -->
        <Label Text="{Binding UserName}" />
        <Label Text="{Binding Email}" />
        <Button Text="Сохранить" Command="{Binding SaveCommand}" />
    </StackLayout>
</ContentPage>

Обратите внимание: единственное изменение — добавление xmlns:vm и x:DataType. Сами привязки остаются теми же, но теперь компилятор знает конкретный тип и генерирует строго типизированный код. Приятный бонус — вы получите ошибки компиляции вместо тихих сбоев в рантайме, если допустите опечатку в имени свойства.

Скомпилированные привязки в коде (Code-behind)

Если вы создаёте привязки в C#-коде, замените строковые пути на лямбда-выражения:

// Было — строковая привязка (рефлексия):
myLabel.SetBinding(Label.TextProperty, "Customer.Name");

// Стало — скомпилированная привязка (лямбда):
myLabel.SetBinding(
    Label.TextProperty,
    static (OrderViewModel vm) => vm.Customer.Name);

Ключевое слово static перед лямбдой гарантирует, что она не захватывает контекст — это важно для корректного тримминга. Без static можно получить неожиданные проблемы, так что лучше добавлять его всегда.

Работа с DataTemplate и CollectionView

Отдельного внимания заслуживают шаблоны данных. Здесь x:DataType должен указывать на тип элемента коллекции, а не на ViewModel страницы:

<CollectionView ItemsSource="{Binding Orders}"
                x:DataType="vm:MainViewModel">
    <CollectionView.ItemTemplate>
        <DataTemplate x:DataType="models:Order">
            <Grid Padding="10">
                <Label Text="{Binding OrderNumber}" />
                <Label Text="{Binding TotalAmount,
                       StringFormat='{0:C}'}"
                       Grid.Column="1" />
            </Grid>
        </DataTemplate>
    </CollectionView.ItemTemplate>
</CollectionView>

Внутри DataTemplate контекст привязки меняется на элемент коллекции, поэтому x:DataType переопределяется на тип модели. Эту деталь легко пропустить, и она часто становится источником ошибок при миграции.

Работа с триммингом: типичные проблемы и решения

Тримминг — это, пожалуй, то место, где большинство разработчиков спотыкаются. Давайте разберём самые частые проблемы.

Проблема 1: сериализация JSON через рефлексию

Если вы используете System.Text.Json с рефлексионным сериализатором (а скорее всего используете), он сломается при тримминге. Решение — source-generated сериализация:

// Было — рефлексия (НЕ совместимо с триммингом):
var json = JsonSerializer.Serialize(user);
var user = JsonSerializer.Deserialize<User>(json);

// Стало — source-generated (совместимо):
[JsonSerializable(typeof(User))]
[JsonSerializable(typeof(List<Order>))]
public partial class AppJsonContext : JsonSerializerContext { }

// Использование:
var json = JsonSerializer.Serialize(user, AppJsonContext.Default.User);
var user = JsonSerializer.Deserialize(json, AppJsonContext.Default.User);

Немного больше кода? Да. Зато работает надёжно и, как бонус, source-generated сериализация ещё и быстрее рефлексионной.

Проблема 2: DependencyService и рефлексия

Если в проекте ещё остался устаревший DependencyService — самое время от него избавиться. Он использует рефлексию для поиска реализаций, что несовместимо с триммингом. Замените его на встроенный DI-контейнер:

// Было — DependencyService (рефлексия):
var service = DependencyService.Get<ILocationService>();

// Стало — внедрение зависимостей через конструктор:
public class MapViewModel
{
    private readonly ILocationService _locationService;

    public MapViewModel(ILocationService locationService)
    {
        _locationService = locationService;
    }
}

// Регистрация в MauiProgram.cs:
builder.Services.AddSingleton<ILocationService, LocationService>();
builder.Services.AddTransient<MapViewModel>();

Кстати, переход на DI — это хорошая практика в любом случае, даже без NativeAOT. Код становится чище и тестируемее.

Проблема 3: XAML-разметки OnPlatform и OnIdiom

XAML-расширения разметки OnPlatform и OnIdiom несовместимы с полным триммингом. Их нужно заменить на соответствующие классы — синтаксис чуть более громоздкий, но работает:

<!-- Было — XAML markup extension (несовместимо): -->
<Label FontSize="{OnPlatform iOS=16, Android=14}" />

<!-- Стало — класс OnPlatform (совместимо): -->
<Label>
    <Label.FontSize>
        <OnPlatform x:TypeArguments="x:Double">
            <On Platform="iOS" Value="16" />
            <On Platform="Android" Value="14" />
        </OnPlatform>
    </Label.FontSize>
</Label>

Да, это выглядит более многословно. Но, по моему опыту, таких мест в проекте обычно не так уж много.

Проблема 4: несовместимые сторонние библиотеки

Не все NuGet-пакеты совместимы с триммингом и NativeAOT. Единственный надёжный способ это проверить — собрать приложение с NativeAOT и внимательно посмотреть на предупреждения:

# Публикация с NativeAOT для проверки предупреждений
dotnet publish -f net10.0-ios -c Release

# Все предупреждения IL2xxx и IL3xxx — потенциальные проблемы
# IL2026: Member requires unreferenced code
# IL2046: Trim analysis incompatibility
# IL3050: Generic type requires runtime code generation

Если библиотека выдаёт предупреждения — свяжитесь с автором или ищите альтернативу. Хорошая новость: в .NET 10 большинство популярных библиотек (CommunityToolkit.Mvvm, Refit, Polly) уже поддерживают тримминг.

Профилированный AOT для Android

Хотя NativeAOT на Android не поддерживается, для этой платформы есть свой механизм оптимизации — профилированный AOT. Он компилирует в нативный код только наиболее критичные методы, определённые с помощью профилей запуска:

<PropertyGroup Condition="'$(Configuration)' == 'Release'">
  <!-- Профилированный AOT для Android (включён по умолчанию в Release) -->
  <AndroidEnableProfiledAot>true</AndroidEnableProfiledAot>

  <!-- Опционально: удаление IL после AOT для уменьшения размера -->
  <AndroidStripILAfterAOT>true</AndroidStripILAfterAOT>
</PropertyGroup>

Свойство AndroidStripILAfterAOT (появилось в .NET 8) позволяет дополнительно уменьшить размер APK, удаляя IL-код для уже скомпилированных методов. Если размер APK для вас критичен — обязательно попробуйте.

Отладка NativeAOT-приложений

Тут есть приятный момент: при разработке приложение по умолчанию запускается на рантайме Mono, даже если NativeAOT включён в проекте. NativeAOT применяется только при публикации через dotnet publish. Что это значит на практике:

  • В режиме Debug — привычная отладка через Visual Studio или VS Code, ничего не меняется
  • При публикации получаете нативный бинарник, который можно отладить через lldb
  • Поведение Mono и NativeAOT должно совпадать — при условии, что ваш код не выдаёт предупреждений тримминга

Золотое правило здесь простое: если приложение собирается без предупреждений IL2xxx / IL3xxx, оно будет корректно работать и в NativeAOT-режиме. Не игнорируйте эти предупреждения — они существуют не просто так.

Чек-лист миграции на NativeAOT

Перед включением NativeAOT пройдитесь по этому чек-листу. Лучше потратить час на проверку, чем потом часами искать причину неработающих привязок:

  1. Удалите все директивы <?xaml-comp compile="false" ?> — весь XAML должен компилироваться
  2. Удалите все вызовы LoadFromXaml — динамическая загрузка XAML несовместима с NativeAOT
  3. Добавьте x:DataType ко всем страницам и шаблонам данных в XAML
  4. Замените строковые привязки в коде на лямбда-выражения
  5. Замените DependencyService на встроенный DI-контейнер
  6. Перейдите на source-generated сериализацию JSON
  7. Замените XAML-расширения OnPlatform / OnIdiom на классы
  8. Проверьте совместимость всех NuGet-пакетов через dotnet publish
  9. Убедитесь, что сборка проходит без предупреждений IL2xxx / IL3xxx
  10. Протестируйте опубликованное приложение на реальном устройстве

Часто задаваемые вопросы

Обязательно ли использовать NativeAOT в .NET MAUI?

Нет, NativeAOT — опциональная оптимизация. Если приложение запускается достаточно быстро, а размер IPA/APK вас устраивает, можно спокойно продолжать на Mono-рантайме. NativeAOT имеет смысл, когда время запуска и размер приложения критичны — например, в коммерческих продуктах с большой базой пользователей.

Работает ли NativeAOT на Android?

На данный момент — нет. Для Android в .NET MAUI используется профилированная AOT-компиляция, которая включена по умолчанию в Release-конфигурации. Она компилирует наиболее часто вызываемые методы, обеспечивая хороший баланс между размером и скоростью.

Можно ли использовать рефлексию в NativeAOT-приложениях?

Ограниченно. NativeAOT выполняет статический анализ и удаляет код, на который нет статических ссылок. Если ваш код обращается к типам или методам через рефлексию, компилятор может их удалить. Используйте атрибуты [DynamicallyAccessedMembers] и [DynamicDependency] для явного указания, какие типы нужно сохранить.

Как скомпилированные привязки влияют на производительность без NativeAOT?

Скомпилированные привязки в 8–20 раз быстрее рефлексионных даже без NativeAOT. Так что переход на них — это, пожалуй, самая простая и эффективная оптимизация, которую можно сделать в .NET MAUI-приложении прямо сейчас. Независимо от ваших планов по NativeAOT.

Увеличивается ли время сборки при использовании NativeAOT?

Да, время публикации увеличивается — компилятору нужно выполнить полный статический анализ и AOT-компиляцию. Но это влияет только на dotnet publish. Обычная сборка для разработки (dotnet build) работает на Mono и не замедляется. В .NET 10, к слову, время NativeAOT-сборки заметно улучшили по сравнению с предыдущими версиями.

Об авторе Caleb Whitford

Caleb has shipped seven mobile apps to the App Store and Play Store over the last nine years, four of them in Xamarin and three in .NET MAUI. He spent five years at a healthcare-tech company in Boston building a clinician-facing iPad app used by roughly 14,000 nurses, then moved to a freelance practice in 2024 focused on enterprise MAUI rollouts for regulated industries. His writing tends toward the practical: CI pipelines on App Center successors, MSAL token caching across Android lifecycle resets, MAUI Blazor Hybrid in production, and the awkward seams between MAUI and native SDKs that vendors haven't gotten around to wrapping. He runs a small Discord for enterprise MAUI engineers and writes a Friday newsletter from his home in Portland, Maine.