На руках действующее приложение на Xamarin, и команда смотрит в будущее с тревогой: официальная поддержка закончилась 1 мая 2024 года, а магазины приложений с каждым годом требуют сборок всё более свежими SDK. Паниковать не стоит: кодовая база на C# никуда не девается, разметка почти не меняется, а преемник .NET MAUI несёт ту же архитектуру, доведённую до современного дотнета. По пути расскажем и о том, как был устроен этот мир, потому что на нём выросли тысячи приложений, живущих до сих пор. Разберём, как Xamarin устроен под капотом, почему он занял своё место в истории, чем жива его логика сегодня и как перевести проект на MAUI без переписывания с нуля.

Архитектура Xamarin делит проект на общую библиотеку и платформенные головы

Технология выросла из Mono, открытой среды исполнения .NET, основанной в 2011 году создателями этой среды, и в 2016 году вошла в состав Microsoft, которая открыла инструменты для всех желающих. В 2014 году вышел Xamarin.Forms, скрывший различия платформ за общей разметкой, в 2018 году кодовая база полностью открылась, а в 2022 году появился .NET MAUI, за которым в мае 2024 года закрылась дверь старого стека. Идея при этом не менялась: бизнес-логика, модели и работа с сетью пишутся один раз на C#, а вокруг общей библиотеки стоят платформенные головы, по одной на целевую систему.

На iOS среда Mono компилируется в машинный код с опережающей генерацией, как того требует Apple, на Android работает с динамической компиляцией, как привычно этой платформе. Обе головы обращаются к родным API через тонкие привязки: Xamarin.iOS даёт полный доступ к Cocoa Touch, Xamarin.Android к пространствам имён Android, и приложение остаётся нативальным с точки зрения системы, просто говорит на C#.

Точка входа платформенной головы занимает считанные строки и лишь инициализирует движок Forms, после чего управление переходит общей библиотеке:

[Activity(Label = "Shop", MainLauncher = true)]
public class MainActivity
    : global::Xamarin.Forms.Platform.Android.FormsAppCompatActivity
{
    protected override void OnCreate(Bundle savedInstanceState)
    {
        base.OnCreate(savedInstanceState);
        Xamarin.Forms.Forms.Init(this, savedInstanceState);
        LoadApplication(new App());
    }
}

Аналогичная голова на iOS делает то же самое в AppDelegate, и вся специфика платформ остаётся за бортом логики приложения.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>
</Project>

Общая библиотека целилась в стандарт .NET, поэтому в неё безболезненно ложились LINQ, асинхронность и любые пакеты NuGet. Головы подключали библиотеку и добавляли точку входа, а сборка каждого приложения шла своим инструментарием под конкретную операционную систему.

Xamarin Forms описывает экраны кроссплатформенной разметкой XAML

Писать интерфейс отдельно для каждой платформы утомительно, и в 2014 году появился Xamarin.Forms: слой, который описывает экраны один раз на XAML, а на устройство отрисовывает нативные управляющие элементы. Кнопка на Android выглядит как кнопка Android, на iOS как кнопка iOS, код при этом общий.

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             x:Class="Shop.CatalogPage">
  <StackLayout Padding="16">
    <Label Text="Каталог" FontSize="24" />
    <ListView ItemsSource="{Binding Items}" />
    <Button Text="Обновить" Command="{Binding RefreshCommand}" />
  </StackLayout>
</ContentPage>

Разметка сводится к знакомому набору: страницы, компоновки вроде стеков и сеток, управляющие элементы от ярлыка до списка. Тонкая настройка внешнего вида конкретной платформы делается эффектами, когда нужно поправить отступ или тень, и визуализаторами, когда нужен собственный управляющий элемент с нуля. Общие ресурсы собираются в словаре приложения, и единый стиль кнопок задаётся один раз:

<Application.Resources>
  <Color x:Key="Accent">#2c7be5</Color>
  <Style TargetType="Button">
    <Setter Property="BackgroundColor"
            Value="{StaticResource Accent}" />
    <Setter Property="TextColor" Value="White" />
  </Style>
</Application.Resources>

Навигация строилась на стеке страниц: стек толкал следующий экран методом PushAsync, модальные окна открывались поверх, а оболочка Shell из поздних версий Forms собрала маршрутизацию по адресам в одном месте, приблизив мобильное приложение к привычкам веб-разработки. Такой баланс покрывал большинство корпоративных приложений, для которых.Forms и покупали, а правки разметки в горячей перезагрузке отражались на экране за секунды, не требуя пересборки.

Связывание в стиле MVVM соединяет свойства модели с интерфейсом

Паттерн MVVM в Xamarin.Forms скрепляет разметку и логику: у страницы есть контекст связывания, у контекста свойства и команды, изменения свойств оповещают интерфейс через событие. Пишется это на чистом C#, без сторонних каркасов, хотя сообщество охотно подключало и их.

public class CatalogViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    private ObservableCollection<Item> items;
    public ObservableCollection<Item> Items
    {
        get => items;
        set
        {
            items = value;
            OnPropertyChanged();
        }
    }

    public ICommand RefreshCommand { get; }

    public CatalogViewModel()
    {
        RefreshCommand = new Command(async () =>
        {
            Items = new ObservableCollection<Item>(
                await rest.GetCatalogAsync());
        });
    }

    private void OnPropertyChanged(
        [CallerMemberName] string name = null) =>
        PropertyChanged?.Invoke(this,
            new PropertyChangedEventArgs(name));
}

Асинхронность через async и await дышала здесь полной грудью: обработчик команды не блокирует поток интерфейса, список обновляется одним присваиванием, а коллекция следит за изменениями сама. Тестируется такая моделька без эмулятора, обычными тестами, что было сильной стороной подхода с первого дня.

Когда значение нужно показать не как есть, а привести к виду, в разметке работают конвертеры, маленькие классы с парой методов:

public class PriceConverter : IValueConverter
{
    public object Convert(object value, Type targetType,
        object parameter, CultureInfo culture)
    {
        var price = (decimal)value;
        return $"{price:0.00} руб.";
    }

    public object ConvertBack(object value, Type targetType,
        object parameter, CultureInfo culture) =>
        throw new NotSupportedException();
}

Конвертер подключается к связыванию по ключу, объявленному в ресурсах страницы, и превращает число в готовую строку с валютой, не трогая модель.

Доступ к платформенным API идёт через службы зависимостей и эффекты

Общая библиотека не видит платформенных деталей, а они ей нужны: пути файлов, датчики, уведомления, буфер обмена. Мост работает по классической схеме: интерфейс живёт в общем коде, реализация в голове платформы, а регистрация через атрибут сборки.

// общая библиотека
public interface IFileService
{
    string GetLocalPath(string fileName);
}

// голова Android
[assembly: Dependency(typeof(FileService))]
namespace Shop.Droid
{
    public class FileService : IFileService
    {
        public string GetLocalPath(string fileName) =>
            Path.Combine(Environment.GetFolderPath(
                Environment.SpecialFolder.Personal), fileName);
    }
}

Общий код зовет службу одной строкой DependencyService.Get, получая реализацию, о которой он ничего не знает. Приём знакомый по любому инверсионному каркасу, и его аккуратно используют в проектах, где важно держать слой логики чистым от операционных систем.

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

Жизненный цикл поддержки Xamarin завершился в мае 2024 года

Последняя мажорная версия Xamarin.Forms осталась пятой, её обслуживание тянулось сервисными выпусками до весны 2024 года. Затем Microsoft зафиксировала дату: с 1 мая 2024 года инструменты переходят в разряд неподдерживаемых, а рекомендуемым путём объявлен .NET MAUI, вышедший в 2022 году и продолжающий ту же архитектурную линию в современном дотнете.

Что это значит на практике. Компилятор и эмуляторы продолжают работать, старые сборки не отвалятся с полки магазина сами по себе. Но с каждым обновлением требований магазинов приложения, собранные устаревшим инструментарием, начинают возвращаться с отказом, потому что магазины требуют свежих SDK целевых платформ. Ошибки в среде больше не чинятся, уязвимости среды не закрываются, и корпоративные политики безопасности одно за другим вычёркивают неподдерживаемые стеки из списка разрешённых.

Оставаться на Xamarin разумно ровно до ближайшего крупного обновления целевой платформы, и этот момент лучше планировать заранее, чем ловить его в очереди релизов. Окно в два года между появлением MAUI и финалом старого стека и было планом Microsoft: ничего резкого, только эволюция.

Миграция проекта на NET MAUI проходит по проторённому пути

Хорошая новость в том, что переезд механический, а не сжигательный. Разметка почти не меняется, C# остаётся, XAML остаётся, меняются пространства имён и детали инфраструктуры. Чек-лист переезда умещается в шесть пунктов:

  1. обновить целевые платформы в файле проекта на современные net10.0-android и net10.0-ios;
  2. заменить корневые пространства имён с Xamarin.Forms на Microsoft.Maui.Controls;
  3. перенести обращения к платформенным службам с DependencyService на встроенный контейнер внедрения зависимостей;
  4. переписать пользовательские визуализаторы на обработчики MAUI, которые легче и предсказуемее;
  5. прогнать ассистент обновления и погасить список различий, который покажет компиляция;
  6. проверить на живых устройствах жесты, переходы и жизненный цикл, которые изменились тонко.
<!-- файл проекта после переезда -->
<TargetFrameworks>
  net10.0-android;net10.0-ios
</TargetFrameworks>

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

Практические выводы для команд с действующими проектами

Если приложение живёт на Xamarin и стабильно работает, паника не помощник. Составьте план: зафиксируйте дату, когда очередное требование магазина заблокирует сборку, отведите окно на переезд и держите его, не позволяя релизам смести его в долгий ящик. Если проект большой и ветвистый, имеет смысл переезжать по экранам, запуская обе оболочки параллельно через общую библиотеку.

Если приложение только в планах, выбор очевиден: писать сразу на MAUI, где новая графика, лучший доступ к платформам и живое сообщество, в которое легко нанять людей. Опыт Xamarin здесь не обуза: инженер, знающий старый стек, осваивает новый за недели, потому что язык, разметка и паттерн остались прежними. Xamarin как материал изучения тоже не пропал: его разметка, MVVM и службы зависимостей переехали в MAUI почти без изменений, поэтому часы, вложенные в старую документацию, окупятся и на новом стеке.

Финальный вывод прост. Xamarin был мостом между миром дотнета и мобильными платформами, и мост сработал: миллионы строк C# на телефонах, целая школа разработчиков и преемник, который не заставил эту школу переучиваться. Технологии уходят, когда доводят идею до конца, и именно это произошло здесь.