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

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

Введение: зачем вашему .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-сборки заметно улучшили по сравнению с предыдущими версиями.

Об авторе Editorial Team

Our team of expert writers and editors.