Введение: зачем вашему .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 пройдитесь по этому чек-листу. Лучше потратить час на проверку, чем потом часами искать причину неработающих привязок:
- Удалите все директивы
<?xaml-comp compile="false" ?>— весь XAML должен компилироваться - Удалите все вызовы
LoadFromXaml— динамическая загрузка XAML несовместима с NativeAOT - Добавьте
x:DataTypeко всем страницам и шаблонам данных в XAML - Замените строковые привязки в коде на лямбда-выражения
- Замените
DependencyServiceна встроенный DI-контейнер - Перейдите на source-generated сериализацию JSON
- Замените XAML-расширения
OnPlatform/OnIdiomна классы - Проверьте совместимость всех NuGet-пакетов через
dotnet publish - Убедитесь, что сборка проходит без предупреждений
IL2xxx/IL3xxx - Протестируйте опубликованное приложение на реальном устройстве
Часто задаваемые вопросы
Обязательно ли использовать 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-сборки заметно улучшили по сравнению с предыдущими версиями.