.NET MAUI Handlers в 2026: кастомизация Entry, Button и создание собственных нативных контролов
Разбираем устройство Handler в .NET MAUI 9: PropertyMapper, три способа кастомизации через AppendToMapping, создание своего Handler с нуля и как избежать утечек памяти в ConnectHandler/DisconnectHandler.
Handlers в .NET MAUI: это тонкий слой, который сопоставляет кросс-платформенный виртуальный контрол (например, Entry) с нативным UITextField на iOS и AppCompatEditText на Android через словарь-Mapper. В отличие от Xamarin.Forms Renderers, Handler не создаёт лишний контейнер вокруг нативной вьюхи, а обрабатывает изменение каждого свойства отдельным Action'ом. Ниже я разберу устройство Handler'ов в .NET MAUI 9, три способа модифицировать существующий Handler, создание собственных нативных контролов и, честно говоря, главную ловушку: утечки памяти в ConnectHandler и DisconnectHandler.
Handler: это partial-класс с PropertyMapper и опциональным CommandMapper, который связывает виртуальный контрол MAUI с нативным UIView или Android.Views.View.
Модификация чужого Handler'а делается через AppendToMapping, PrependToMapping и ModifyMapping. Такое изменение глобально влияет на все контролы данного типа в приложении.
Свой Handler требует четырёх шагов: интерфейс, партиал-класс с mapper, CreatePlatformView на каждой платформе и регистрация через ConfigureMauiHandlers.
Утечки памяти чаще всего появляются, когда в ConnectHandler подписываются на нативные события, но не отписываются в DisconnectHandler.
Handlers работают в тандеме с NativeAOT в .NET 9/10 и не требуют рефлексии, если mapper строго типизирован.
Что такое Handlers в .NET MAUI
Handler: это специализированный класс, реализующий интерфейс IViewHandler (или IElementHandler для не-визуальных элементов), который отвечает за две вещи: создание нативной вьюхи и сопоставление свойств кросс-платформенного контрола с этой вьюхой. Каждый стандартный контрол MAUI имеет свой Handler: ButtonHandler, EntryHandler, LabelHandler, SliderHandler и так далее. В Microsoft Learn эти кросс-платформенные контролы называются virtual views, а нативные platform views.
Практически это выглядит так. Когда вы объявляете в XAML <Entry Text="Hello" />, MAUI создаёт экземпляр Microsoft.Maui.Controls.Entry, а тот при первом рендере запрашивает у MauiContext зарегистрированный Handler. EntryHandler инстанцирует UITextField на iOS или AppCompatEditText на Android и подписывает набор mapper Actions, которые срабатывают при изменении каждого свойства. Никакой рефлексии, никакого поиска через INotifyPropertyChanged: только словарь строк-ключей и делегатов.
Именно поэтому команда MAUI утверждает, что Handler'ы быстрее Renderers: меньше выделений памяти, меньше объектов в визуальной иерархии, статичные mapping'и и предсказуемый lifecycle. Если вы мигрируете с Xamarin.Forms на .NET MAUI, готовьтесь: переписать Renderers в Handler'ы это самый трудоёмкий шаг, но и самый выигрышный в плане производительности.
Чем Handlers отличаются от Renderers
Если коротко: Renderers наследовали ViewRenderer<TElement, TNativeView> и оборачивали нативную вьюху в дополнительный контейнер. На Android это был ViewGroup, на iOS чаще всего лишний UIView. Handler'ы этого не делают: ViewHandler.CreatePlatformView возвращает саму нативную вьюху, без обёртки. В приложении из ~500 элементов это даёт заметное уменьшение visual tree и ускоряет layout pass на десятки миллисекунд, что особенно чувствуется на бюджетных Android-устройствах.
Второе принципиальное отличие: обработка изменений свойств. В Renderer'ах вы писали один OnElementPropertyChanged и внутри разбирали e.PropertyName через if/else. В Handler'е каждое свойство имеет собственный статический метод, который вызывается напрямую из mapper'а по строковому ключу (обычно nameof(IEntry.IsPassword)). Это ускоряет диспетчеризацию и упрощает тестирование.
Третье отличие, расцепление платформы и фреймворка. Handler инвертирует отношения: раньше платформенный контрол знал о фреймворке, теперь фреймворк знает о минимальном интерфейсе контрола (IEntry, IButton). Это позволяет переиспользовать Handler'ы в других фреймворках вроде Comet или Fabulous. Официальный гайд по миграции Renderer в Handler детально описывает, как переносить каждый паттерн.
PropertyMapper и CommandMapper: сердце Handler'а
PropertyMapper, это типизированный Dictionary<string, Action<THandler, TVirtualView>>, где ключ это имя свойства виртуального контрола, а значение это действие, которое применяет это свойство к нативной вьюхе. Например, у ButtonHandler в mapper есть запись [nameof(IText.Text)] = MapText, и когда пользователь меняет button.Text, MAUI дёргает ButtonHandler.MapText(handler, button), а тот вызывает platformView.SetTitle(...) на iOS или platformView.Text = ... на Android.
CommandMapper отличается тем, что срабатывает не на изменение свойства, а на команду от виртуального контрола к нативному. Например, «прокрути ScrollView до элемента» или «сфокусируйся». Это Dictionary<string, Action<THandler, TVirtualView, object?>>. Практический пример, EntryHandler: команда nameof(IEntry.Focus) вызывает platformView.BecomeFirstResponder() на iOS и platformView.RequestFocus() на Android.
Оба mapper'а объявлены как public static поля Handler'а, что даёт вам прямой доступ на модификацию из любой точки приложения. Именно эта архитектура открывает три способа кастомизации: AppendToMapping, PrependToMapping и ModifyMapping. Каждый из них не заменяет исходное отображение, а дописывает свою логику: до, после или вместо оригинала.
Как модифицировать существующий Handler
Это самый частый сценарий. Допустим, вы хотите убрать подчёркивание у всех Entry на Android и убрать borderStyle на iOS. В Xamarin.Forms эту задачу решали через Effect или Renderer, а в MAUI хватает четырёх строк. Три метода расширения работают так: PrependToMapping выполняет ваш код до стандартного mapping'а, AppendToMapping запускается после, а ModifyMapping идёт вместо оригинала, но с доступом к нему.
// В MauiProgram.cs или в статическом конструкторе App.xaml.cs
using Microsoft.Maui.Handlers;
Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping(
nameof(IEntry.Background),
(handler, view) =>
{
#if ANDROID
// Убираем стандартный подчёркивающий drawable у AppCompatEditText
handler.PlatformView.BackgroundTintList =
Android.Content.Res.ColorStateList.ValueOf(
Android.Graphics.Color.Transparent);
handler.PlatformView.SetBackground(null);
#elif IOS || MACCATALYST
// Убираем рамку у UITextField
handler.PlatformView.BorderStyle = UIKit.UITextBorderStyle.None;
#endif
});
Ключ nameof(IEntry.Background), это важный момент. Ключи, которые использует сам MAUI, привязаны к именам свойств интерфейсов из Microsoft.Maui, а не к именам свойств контрола Microsoft.Maui.Controls.Entry. Если ошибётесь с ключом, MAUI просто добавит новое mapping-действие с этим ключом, но оно никогда не сработает, потому что фреймворк дёргает mapping только по «своим» ключам. Я на это сам как-то потратил полдня, честно говоря.
Когда выбрать Prepend, Append или Modify
PrependToMapping, это редкий случай. Применяйте, когда нужно подготовить нативную вьюху до того, как MAUI применит свои настройки. Например, задать ClipToBounds перед тем, как MAUI выставит фон. AppendToMapping это 90% случаев: ваши изменения последними накатываются поверх стандартного поведения. ModifyMapping подходит, когда вы хотите полностью перехватить обработку свойства и опционально вызвать оригинал: сигнатура даёт вам ссылку на оригинальный delegate третьим параметром.
Как создать кастомный Handler с нуля
Модификация чужого mapper'а не всегда достаточна. Классический случай, свой видео-плеер, кастомная карта или сложный чарт, где нужна полная работа с нативным API. По документации Microsoft Learn создание Handler'а состоит из пяти шагов: интерфейс контрола, кросс-платформенный класс, партиал-Handler с mapper, платформенные реализации CreatePlatformView и регистрация. Разберём на примере простого «StepEntry»: это Entry, у которого есть кнопки «+»/«−» и максимальное значение.
Шаг 1: кросс-платформенный интерфейс
// StepEntry/IStepEntry.cs
using Microsoft.Maui;
public interface IStepEntry : IView
{
int Value { get; set; }
int Step { get; }
int Maximum { get; }
void ValueChanged(int newValue);
}
Интерфейс наследует IView, и этого достаточно для любого визуального контрола. Метод ValueChanged будет вызываться из платформенного кода при взаимодействии с нативной вьюхой (наш способ передать событие обратно во view-модель).
Шаг 2: кросс-платформенный класс
// StepEntry/StepEntry.cs
public class StepEntry : View, IStepEntry
{
public static readonly BindableProperty ValueProperty =
BindableProperty.Create(nameof(Value), typeof(int), typeof(StepEntry), 0,
BindingMode.TwoWay);
public int Value
{
get => (int)GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
public int Step { get; set; } = 1;
public int Maximum { get; set; } = 100;
public event EventHandler<int>? ValueChangedEvent;
public void ValueChanged(int newValue)
{
Value = newValue;
ValueChangedEvent?.Invoke(this, newValue);
}
}
Шаг 3: partial Handler и mapper
// Handlers/StepEntryHandler.cs (общий код)
using Microsoft.Maui.Handlers;
public partial class StepEntryHandler
{
public static IPropertyMapper<IStepEntry, StepEntryHandler> PropertyMapper =
new PropertyMapper<IStepEntry, StepEntryHandler>(ViewHandler.ViewMapper)
{
[nameof(IStepEntry.Value)] = MapValue,
[nameof(IStepEntry.Maximum)] = MapMaximum,
};
public StepEntryHandler() : base(PropertyMapper) { }
}
Ключевой момент, PropertyMapper строится поверхViewHandler.ViewMapper. Это даёт вам весь стандартный набор свойств (Background, Opacity, IsVisible и так далее) бесплатно; вы дописываете только специфичные для контрола ключи.
Шаг 4a: реализация для iOS
// Handlers/StepEntryHandler.iOS.cs
#if IOS || MACCATALYST
using Microsoft.Maui.Handlers;
using UIKit;
public partial class StepEntryHandler : ViewHandler<IStepEntry, UIStepper>
{
protected override UIStepper CreatePlatformView() => new UIStepper
{
MinimumValue = 0,
MaximumValue = VirtualView?.Maximum ?? 100,
StepValue = VirtualView?.Step ?? 1,
};
protected override void ConnectHandler(UIStepper platformView)
{
base.ConnectHandler(platformView);
platformView.ValueChanged += OnStepperChanged;
}
protected override void DisconnectHandler(UIStepper platformView)
{
// ВАЖНО: отписываемся вручную, MAUI сам этого не сделает
platformView.ValueChanged -= OnStepperChanged;
base.DisconnectHandler(platformView);
}
void OnStepperChanged(object? sender, EventArgs e)
{
if (sender is UIStepper stepper && VirtualView is IStepEntry entry)
entry.ValueChanged((int)stepper.Value);
}
static void MapValue(StepEntryHandler h, IStepEntry v)
{
if (Math.Abs(h.PlatformView.Value - v.Value) > 0.5)
h.PlatformView.Value = v.Value;
}
static void MapMaximum(StepEntryHandler h, IStepEntry v) =>
h.PlatformView.MaximumValue = v.Maximum;
}
#endif
Обратите внимание: mapping-функции MapValue и MapMaximum объявлены в обоих партиалах, потому что реализация зависит от типа PlatformView. Это нормальная и рекомендованная схема. Если бы обе платформы имели общий интерфейс (что редкость), можно было бы вынести их в общий партиал.
Регистрация Handler'а в MauiProgram.cs
Handler не сработает, пока вы не зарегистрируете его в ConfigureMauiHandlers. Это делается один раз в MauiProgram.CreateMauiApp():
AddHandler<TCrossPlatformType, THandlerType>() это то место, где MAUI связывает виртуальный контрол с его Handler'ом. Порядок регистрации имеет значение только если один и тот же тип регистрируется дважды: побеждает последний. Это, кстати, удобно для тестирования: можно временно подменить Handler на mock.
Жизненный цикл Handler и утечки памяти
Handler живёт с виртуальным контролом от момента подключения к visual tree до отсоединения. Ключевые методы: CreatePlatformView (создание нативной вьюхи), ConnectHandler (подписка на события), DisconnectHandler (отписка), а также внутренние SetVirtualView и SetMauiContext. DisconnectHandler критичен и работает не совсем интуитивно.
По документации DisconnectHandlerне вызывается автоматически при удалении контрола из дерева. MAUI намеренно так сделал: закрытие модальной страницы не должно уничтожать native ресурсы, если вы можете переиспользовать контрол. Соответственно, если вы подписались на события в ConnectHandler, но никогда не вызвали Handler?.DisconnectHandler() из ContentPage.OnDisappearing или явно, нативный контрол будет жить до сборки мусора, а вместе с ним и ваши event handlers, ссылающиеся на страницу.
// Стандартный паттерн: явный disconnect на закрытии страницы
protected override void OnDisappearing()
{
base.OnDisappearing();
if (Navigation.NavigationStack.LastOrDefault() != this)
{
// Страница действительно уходит, освобождаем ресурсы
DisconnectHandlersRecursively(this);
}
}
static void DisconnectHandlersRecursively(Element element)
{
if (element is IView view && view.Handler is not null)
view.Handler.DisconnectHandler();
if (element is IVisualTreeElement tree)
foreach (var child in tree.GetVisualChildren())
if (child is Element childElement)
DisconnectHandlersRecursively(childElement);
}
В моей практике 90% утечек памяти в MAUI-приложениях приходили именно из невыполненного DisconnectHandler. Профилировать это стоит через dotnet-dsrouter вместе с Xcode Instruments (Allocations) на iOS и Android Studio Profiler на Android. Если вас интересует профилирование производительности в целом, у нас есть отдельная статья про NativeAOT в .NET MAUI и оптимизацию запуска.
Частые ошибки и как их избежать
Первая ошибка. Вы модифицируете mapper, а изменения не применяются. Обычно причина в неправильном ключе. Ключ должен быть строкой из имени свойства интерфейса, а не свойства контрола: nameof(IEntry.IsPassword), а не nameof(Entry.IsPassword). Это разные строки в некоторых случаях, и mapping привязан к первому.
Вторая. Вы получаете NullReferenceException в MapXxx. Handler получает mapper-callback, но VirtualView ещё не установлен, и вы пытаетесь применить свойство до вызова SetVirtualView. Проверяйте, что handler.PlatformView is not null и view is not null. В редких сценариях (изменение свойства во время инициализации) один из них может быть null.
Третья. Вы используете ExportRenderer-атрибут из Xamarin.Forms. В .NET MAUI он больше не работает; Handler регистрируется через AddHandler<T, THandler>. Если вы читаете этот текст, потому что разбираетесь с Shell-навигацией в .NET MAUI после Xamarin и по привычке оставили этот атрибут, компилятор не выдаст ошибку, но контрол просто будет использовать дефолтный Handler.
Четвёртая. Кастомизация не сохраняется между экземплярами страниц. Обычно виноват shared static state в Handler'е. Помните: Handler создаётся для каждого экземпляра контрола заново, но mapper это глобальный статический словарь. Не храните ссылку на конкретный VirtualView в статических полях. Подробности архитектуры Handler'ов есть в исходном тексте документации на GitHub, там же можно посмотреть примеры для сложных случаев.
Часто задаваемые вопросы
Что такое Handler в .NET MAUI простыми словами?
Handler это класс, который берёт кросс-платформенный контрол вроде Entry или Button и превращает его в конкретный нативный контрол на iOS (UITextField) и Android (AppCompatEditText). Внутри Handler'а лежит словарь-mapper, где каждое свойство виртуального контрола сопоставлено с действием над нативной вьюхой.
Чем Handlers лучше Renderers из Xamarin.Forms?
Handler не создаёт лишний контейнер-обёртку, обрабатывает изменение каждого свойства отдельным методом (быстрее и легче тестировать) и полностью отделён от фреймворка. По замерам Microsoft это даёт от 15 до 40% ускорения layout pass в приложениях с большим количеством контролов.
Нужно ли писать Handler для каждой платформы отдельно?
Да, но только партиалы с CreatePlatformView, ConnectHandler и mapping-функциями. Общий код (интерфейс, класс контрола, определение mapper) пишется один раз и работает для всех платформ. Обычно это два-три файла на платформу.
Как избежать утечек памяти при работе с Handlers?
Отписывайтесь в DisconnectHandler от всех событий, на которые подписались в ConnectHandler, и явно вызывайте view.Handler.DisconnectHandler() при удалении страницы из навигационного стека. MAUI не делает этого автоматически.
Можно ли использовать Handler и Renderer одновременно в одном проекте?
Технически да, через compatibility-режим (UseMauiCompatibility()), но это временное решение для миграции. В production рекомендуется переписать все Renderers в Handlers, потому что compatibility-слой добавляет тот самый лишний контейнер и сводит на нет выигрыш от Handler-архитектуры.
Полное руководство по MVVM в .NET MAUI 9 с CommunityToolkit.Mvvm 8.4: атрибуты [ObservableProperty] и [RelayCommand], валидация форм, DI, навигация Shell и тестирование ViewModel без MAUI-хоста. Примеры кода на C# 13.