Миграция с Xamarin.Forms на.NET MAUI, десятки тысяч строк чужого кода, несколько неудачных попыток переписывания и первая публикация приложения в RuStore. Расскажу, как проходил этот путь, с какими трудностями столкнулась и что стоит учесть при подготовке Android‑приложения к публикации. А в конце — о том, что ждёт впереди: переход на новую версию на iPad. Читать про миграцию и RuStore
Миграция с Xamarin.Forms на.NET MAUI, десятки тысяч строк чужого кода, несколько неудачных попыток переписывания и первая публикация приложения в RuStore. Расскажу, как проходил этот путь, с какими трудностями столкнулась и что стоит учесть при подготовке Android‑приложения к публикации. А в конце — о том, что ждёт впереди: переход на новую версию на iPad.
С чего всё началосьЛетом 2025 года я пришла в компанию, и мне достался проект на Xamarin.Forms, который несколько лет никто не поддерживал и не обновлял. Документации практически не было, а разобраться в приложении предстояло самостоятельно.
Это было решение из четырёх проектов: общий проект Xamarin.Forms и отдельные проекты под Android, iOS и UWP. По моим подсчётам, только в общем проекте — около 45 тысяч строк C# и XAML, не считая SQL‑скриптов, а ещё примерно 19 тысяч строк сгенерированного кода лежало в Android‑проекте. За каждой строкой могли скрываться особенности бизнес‑логики, неочевидные зависимости и решения, о причинах появления которых оставалось только догадываться.
На тот момент я только закончила обучение и получила диплом. Коммерческого опыта почти не было, а знания, полученные во время учёбы, далеко не всегда соответствовали тому, с чем приходится сталкиваться в реальной разработке.
Учебные задачи обычно имеют понятные условия и ожидаемый результат. В рабочем проекте всё сложнее: требования могут быть известны лишь частично, документация отсутствует, а изменение одного участка кода неожиданно влияет на другой.
Поэтому первой задачей стало не добавление новых функций, а попытка понять, как вообще устроено приложение.
Важная деталь: в разработке я одна. Компания небольшая, коллеги в основном занимаются сопровождением 1С, а над приложением работаю только я. Поэтому на меня легла работа целой команды: дизайнер, разработчик, тестировщик и тимлид — всё в одном лице.
А со временем к мобильному приложению добавилась ещё одна зона ответственности — поддержка и сопровождение выгрузки данных из 1С, которой питается приложение. Это ещё около 37,1 тысячи строк кода, за которые теперь тоже отвечаю я.
Почему миграция оказалась сложнее, чем казалосьПереход с Xamarin.Forms на.NET MAUI на первый взгляд выглядел как техническое обновление. Но довольно быстро стало понятно, что просто заменить старые зависимости и исправить ошибки компиляции не получится.
Нужно было разобраться в существующей архитектуре, понять связи между компонентами, сохранить бизнес‑логику и добиться того, чтобы приложение продолжало выполнять свои задачи.
При этом я несколько раз выбирала неудачные подходы. Проект фактически переписывался трижды.
Попытка первая: довериться инструменту. Где‑то я прочитала, что приложение можно обновить с помощью ассистента миграции. Опыта с MAUI у меня не было: до этого я работала с десктопом, а мобильные приложения писала на Java. Поэтому идея автоматического обновления показалась спасением. Я переносила файлы как есть и напоролась на гору ошибок в XAML. В этот момент стало понятно, что затея так себе, и я пошла читать документацию.
Попытка вторая: новый проект и свежепрочитанная документация. Воодушевлённая, я решила, что прочитанного хватит, и создала новый проект. Снова переносила файлы, но уже с учётом особенностей платформы. Дело пошло лучше, но не продвинулось даже до середины. Тогда я решила, что всё дело в многопроектной структуре старого приложения. На самом деле дело было не в ней, а в нехватке опыта: я не понимала, почему решение изначально было устроено именно так.
Попытка третья: одно приложение и много чтения. В третий раз я сделала обычное однопроектное MAUI‑приложение. Буквально тонула в документации к платформе, API, NuGet‑пакетам и расширениям, переписывала страницы, в некоторых местах адаптировала логику. Переломный момент наступил, когда сборка прошла успешно, а приложение стало выполнять базовый набор функций: создание и редактирование заказов, просмотр каталога. Тогда я наконец выдохнула и перестала работать в режиме пожара.
На миграцию ушло около семи месяцев чистого времени.
Главный вывод из этих попыток для меня такой: ни инструмент, ни документация не заменяют понимания того, почему проект устроен именно так. Пока я не разобралась в причинах старых решений, я боролась с симптомами, а не с проблемой.
Это, пожалуй, одна из самых неприятных особенностей работы с большим незнакомым проектом: не всегда можешь заранее оценить последствия своего решения. Иногда только после нескольких дней работы становится понятно, что выбранный путь не приведёт к нужному результату.
В такие моменты приходилось возвращаться назад, пересматривать архитектурные решения и начинать заново.
Были моменты, когда опускались руки. Но меня никто не торопил и не ругал за ошибки. Меня поддерживали близкие, руководитель и коллеги, и это помогало продолжать работу.
В итоге самым сложным этапом стал именно перенос с Xamarin.Forms на.NET MAUI. После того как основная миграция была завершена, двигаться дальше стало заметно легче.
Что конкретно пришлось переделыватьСамой болезненной частью стали кастомные контролы. Первые два раза я переносила их бездумно, как есть. В третий раз начала их чистить и задала себе простой вопрос: зачем хранить самописные элементы, если всё это уже реализовано в MAUI из коробки?
Больше всего времени ушло на страницы: их пришлось переписывать на стандартные элементы платформы. Я хотела сделать приложение максимально простым и лёгким, с минимумом зависимостей от сторонних пакетов. Часть старых плагинов устарела, а часть просто потеряла смысл, потому что та же функциональность появилась в самом фреймворке.
Ниже — несколько примеров «было → стало» из реального проекта.
Кастомные рендереры → маппинг хендлеровВ Xamarin‑версии в Android‑проекте жила папка CustomRenderer с шестью рендерерами плюс отдельный рендерер камеры. Многие из них делали мелочи. Например, целый класс нужен был только для того, чтобы убрать у поля ввода стандартную подложку:
// Xamarin.Forms, Android-проект
[assembly: ExportRenderer(typeof(SmBorderlessEntry), typeof(SmBorderlessEntryRenderer))]
public class SmBorderlessEntryRenderer : EntryRenderer
{
public SmBorderlessEntryRenderer(Context context) : base(context) { }
protected override void OnElementChanged(ElementChangedEventArgs<Entry> e)
{
base.OnElementChanged(e);
if (e.OldElement == null)
{
Control.Background = null;
// ...и ещё десяток строк про отступы
}
}
}
В MAUI то же самое делается несколькими строками в MauiProgram.cs, причём сразу для всех полей ввода приложения и без отдельного класса‑наследника:
// .NET MAUI, MauiProgram.cs
#if ANDROID
Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping("NoUnderline", (handler, view) =>
{
handler.PlatformView.Background = null;
});
#endif
Так же глобально я убрала подчёркивание у Picker и DatePicker на Android и нативную рамку у полей на iOS. В итоге в Android‑части нового приложения остался ровно один собственный хендлер — для миниатюр в каталоге.
В старом приложении был собственный контрол SmListView, унаследованный от ScrollView. Он сам создавал элемент под каждую строку и добавлял его в разметку. Работало, но без виртуализации: сколько строк в данных, столько элементов в памяти. В новой версии все такие списки — это стандартный CollectionView.
Отдельная история — самописный «почтальон», который в старом приложении разносил по коду сервисы, страницы и данные. Это был статический класс Locator поверх контейнера TinyIoC. Сам контейнер лежал в проекте одним файлом исходников почти на 180 КБ.
В Locator регистрировалось практически всё, причём одиночками (singleton): сервисы, ViewModel и даже страницы:
// Xamarin.Forms, Locator.cs
RegisterSingleton<IMessageBus, MessageBus>();
RegisterSingleton<ISyncService, SyncService>();
RegisterSingleton<OrderEditorViewModel>();
RegisterSingleton<CatalogViewModel>();
RegisterSingleton<GoodPage>();
RegisterSingleton<OrderEditor>();
Дальше любой класс мог в любом месте достать что угодно:
// Xamarin.Forms, App.xaml.cs
Locator.Resolve<MessageBus>().Send(MessageKeys.ApplicationSleep);
Удобно, пока проект маленький. Но по такому коду не видно, от чего зависит класс, — зависимости спрятаны внутри методов. А раз страницы и ViewModel были одиночками, данные между экранами могли «доставляться» просто тем, что жили в одном и том же объекте весь сеанс работы. В MAUI этот механизм вёл себя капризно, и именно с него во многом начались мои проблемы с зависимостями. Поэтому от «почтальона» я избавилась и разделила его обязанности:
Зависимости — через встроенный DI. Сервисы, страницы и ViewModel регистрируются в MauiProgram.cs (сейчас там больше сотни регистраций) и приходят через конструктор. Зависимости каждого класса видны сразу.
Переходы и передача данных между экранами — через навигацию Shell с параметрами:
// .NET MAUI, ShellNavigationService
return shell.GoToAsync(route, new Dictionary<string, object>
{
["data"] = parameter
});
События между компонентами, которые напрямую друг о друге не знают, — через шину сообщений IMessageBus (о ней ниже).
Для событий — начало и прогресс синхронизации, изменение документов, сворачивание приложения — в старой версии была обёртка MessageBus над MessagingCenter:
// Xamarin.Forms
public void Subscribe<T>(MessageKeys message, Action<T> action)
{
MessagingCenter.Subscribe<MessageBus, T>(
this, message.ToString(), (sender, msg) => action(msg));
}
Обратите внимание на this: подписчиком всегда числилась сама шина, а не страница или ViewModel. Отписаться было нельзя — такого метода в интерфейсе просто не было. Повторная подписка добавляла ещё один обработчик, а снять старые было нечем.
В MAUI MessagingCenter объявлен устаревшим, поэтому шину я переписала. Набор событий остался прежним, а реализация — новая, поверх WeakReferenceMessenger из CommunityToolkit.Mvvm:
// .NET MAUI
public void Subscribe<T>(object recipient, MessageKeys message, Action<T> action)
{
_messenger.Unregister<MessageWrapper<T>, string>(recipient, message.ToString());
_messenger.Register<MessageWrapper<T>, string>(
recipient, message.ToString(), (_, w) => action(w.Value));
}
public void Unsubscribe(object recipient) => _messenger.UnregisterAll(recipient);
Теперь подписчик — конкретный объект, повторная подписка не дублирует обработчик, а отписаться можно одной строкой.
ПакетыXamarin.Forms | NET MAUI |
|---|---|
18 пакетов в общем и Android‑проектах: Xamarin.Forms 4.8, Xamarin.Essentials (предрелизная версия), Xamarin.Android.Support, Acr.UserDialogs, Xam.Plugin.Media, Xamarin.Forms.GoogleMaps, App Center и другие | 6 пакетов, не считая самого MAUI: CommunityToolkit.Maui, CommunityToolkit.Mvvm, Syncfusion.Maui.Toolkit, sqlite‑net‑pcl, FluentFTP и логирование только для Debug‑сборки |
Итог:
Было в старом приложении | Что стало в MAUI |
|---|---|
Четыре проекта: общий, Android, iOS, UWP | Один проект, платформенный код в папке |
Кастомные рендереры и самописные контролы | Маппинг стандартных хендлеров и стандартные элементы MAUI там, где их хватает |
Устаревшие сторонние плагины | Удалены или заменены встроенными возможностями фреймворка |
Статический | Встроенный DI через конструктор и навигация Shell с параметрами |
Шина событий на |
|
Вывод у меня один: не нужно изобретать велосипед, если его уже сделали за тебя и он работает из коробки.
Что изменилось после перехода на.NET MAUIГлавной целью было не просто получить проект, который собирается на новой платформе. Важно было довести приложение до состояния, в котором его можно использовать в реальных рабочих сценариях.
Поэтому результат оценивался не только по успешной компиляции. Нужно было проверить работу интерфейса, бизнес‑логики, хранения данных и взаимодействия с внешними системами.
Особенно важно было убедиться, что изменения не нарушили привычные сценарии работы пользователей.
Отдельный плюс MAUI — один проект на все платформы. Сейчас приложение собирается под Android, iOS, Mac Catalyst и Windows из одной кодовой базы. В работе пока используются Android‑планшеты и iPad, но сама возможность многоплатформенной сборки сильно упрощает дальнейшее развитие.
Вот как выглядят два главных экрана до и после. Все скриншоты сняты на демо‑базе «Магазин продуктов» — клиенты, адреса и долги в ней вымышленные.
Стартовый экран: сверху Xamarin.Forms, снизу.NET MAUI

Xamarin.Forms

NET MAUI
Маршрут торгового представителя: сверху Xamarin.Forms, снизу.NET MAUI

Xamarin.Forms

NET MAUI
Что изменилось:
Экран | Было в Xamarin.Forms | Стало в.NET MAUI |
|---|---|---|
Стартовый | Почти пустой экран: две кнопки отчётов и служебная строка с базой, агентом и временем обновления | Сводка за день: прогресс маршрута, время обновления данных и остатков, расписание обмена, число заявок и сумма за день, клиенты с долгом и просрочкой |
Стартовый | Новый документ создаётся с других экранов | Быстрые действия: новый заказ, анкета, возврат — в одно касание |
Маршрут | Список точек слева, карточка точки, карта Google, кнопки создания документов справа | Та же раскладка, чтобы агентам не пришлось переучиваться |
Маршрут | Один общий долг точки | Долг по каждой компании отдельно, с выделенной просрочкой; суммы видны прямо в списке точек |
Навигация | Шесть вкладок: Старт, Каталог, Маршрут, Документы, Обмен, Настройки | Пять вкладок: Главная, Каталог, Маршрут, Документы, Ещё |
Обратите внимание на экран маршрута: раскладка осталась прежней. Это сознательное решение. Агенты открывают этот экран десятки раз за день, и если поменять расположение блоков, на первых порах они будут ошибаться и тратить время на поиск привычных кнопок. Поэтому я обновила внешний вид и добавила нужную информацию, но не стала двигать то, к чему руки уже привыкли.
От сборки к демонстрацииКогда появилась первая рабочая сборка, начался следующий этап — демонстрация приложения руководству компании.
Для меня это был отдельный опыт. Одно дело — видеть, что код компилируется и приложение запускается на твоём устройстве. Другое — демонстрировать работающий продукт людям, которые оценивают его с точки зрения реальных бизнес‑задач.
К этому моменту приложение уже перестало быть просто набором файлов проекта. Оно постепенно превращалось в инструмент, которым можно пользоваться и который можно показывать другим.
Но успешная демонстрация ещё не означала, что работа закончена. Следовало проверить приложение в реальных условиях и подготовить его к распространению.
Тестирование на живых пользователяхСначала приложение тестировал один торговый представитель. Но тестирование шло вяло: один человек в своём рабочем ритме просто не успевает пройти все сценарии. Тогда мне выделили ещё троих тестировщиков из числа агентов.
Главной их жалобой была скорость работы. При этом тесты и бенчмарки показывали, что сама логика работает быстро. Проблема крылась в другом — в отрисовке длинных списков: каталога товаров и списка торговых точек. Это оказалось самой критичной проблемой всего тестирования.
Начала с виртуализации. Список торговых точек достался из старой версии как ListView с HasUnevenRows="True": строки разной высоты, потому что адрес под названием точки показывается не всегда. В MAUI в такой конфигурации виртуализация не работала, и при отображении большого списка «Все» создавались сразу все строки. Я перевела список на CollectionView — он виртуализирует нормально и при строках переменной высоты:
<!-- Было (Xamarin.Forms и первая версия на MAUI) -->
<ListView ItemsSource="{Binding SalesPoints}"
HasUnevenRows="True"
ItemTemplate="{StaticResource salesPointTemplateSelector}" />
<!-- Стало -->
<CollectionView ItemsSource="{Binding SalesPoints}"
SelectionMode="Single"
SelectionChanged="List_SelectionChanged" />
Затем исправила обновление каталога и торговых точек. Раньше при каждом новом результате фильтра в ItemsSource подставлялась новая коллекция, и CollectionView пересобирал нативный список целиком, теряя пул ячеек. Теперь обновляется содержимое существующей коллекции:
// Обновляем содержимое коллекции, а не подменяем ссылку
_goods.ReplaceRange(value ?? Enumerable.Empty<Good>());
Позицию прокрутки после обновления каталог восстанавливает отдельно.
В каталоге я дополнительно перенесла расчёт цен, остатков, кратности отгрузки и истории продаж в фоновый поток — до того, как товары попадают в список. Иначе тысячи уведомлений об изменении свойств сыпались на UI‑поток сразу после показа списка и давали рывки. На UI‑поток теперь уходит только готовый результат.
Ещё я сделала ленивое создание представлений каталога. В старой версии они добавлялись в разметку заранее и переключались через IsVisible. Теперь список, плитка и карусель создаются только при первом выборе и кэшируются:
goodsViewsHost.Content = _vm.ViewMode switch
{
CatalogViewMode.Icon => _iconView ??= new GoodsIconView(),
CatalogViewMode.Carousel => _carouselView ??= new GoodsCarouselView(),
_ => _listView ??= new GoodsListView(),
};
И последний штрих для Android: нативному RecyclerView под каталогом я увеличила кэш уже привязанных ячеек (по умолчанию он держит вне экрана всего две) и указала, что его размер не зависит от содержимого. При прокрутке туда‑обратно строки перестали заново проходить привязку и раскладку.
В итоге проблема была не в одном «медленном запросе», а в сочетании виртуализации, обновления коллекций и лишней работы при отрисовке.
Вывод, который я из этого сделала: пользователь не отличает «медленно считает» от «медленно рисует». Для него это одно и то же — «тормозит». Поэтому замерять стоит не только логику, но и то, что видит человек на экране.
Отдельным уроком стала работа над интерфейсом. Большую часть решений я принимала сама, по принципу «вот это было бы круто», «так будет удобнее», «здесь точно чего‑то не хватает». А обсуждая дизайн с руководством и пользователями, поняла две вещи. Во‑первых, в рабочем приложении лучше не вносить глобальных изменений интерфейса: люди привыкли к нему и работают в нём каждый день. Во‑вторых, хороший интерфейс получается из баланса между пожеланиями руководства, которое видит бизнес‑задачу, и пользователей, которые знают, как с приложением работают на самом деле.
Что оказалось самым трудным в разработкеЗа время работы я особенно хорошо поняла, насколько сильно реальные проекты отличаются от учебных примеров.
АсинхронностьРабота с асинхронным кодом потребовала отдельного внимания. В приложении важно не только получить правильный результат, но и не заблокировать интерфейс, корректно обработать ошибки и учесть ситуации, когда несколько операций выполняются одновременно.
Особенно сложно разбираться с асинхронностью в уже существующем проекте, когда не всегда понятно, где именно возникает проблема и какие участки кода зависят друг от друга.
Как оказалось, MAUI — очень строгий фреймворк, и ошибок он не прощает:
не указала зависимость — «я упал, подними»;
не разобралась со сборщиком мусора — «я завис, помоги»;
неправильно разделила работу между потоками — «ну что ж, сочувствую».
Искать и исправлять всё это помогал вывод в отладку. На тот момент я ещё не знала, как правильно чинить такие ошибки, поэтому в коде появилось больше тысячи строк отладочного вывода. Неизящно, зато я наконец видела, в каком порядке на самом деле выполняются операции и где именно всё ломается.
Один конкретный случай нашёлся при синхронизации. Событие о начале загрузки приходило из фонового потока и запускало UninitializeAsync там же. У документной ViewModel сбрасывался текущий документ, а изменение свойства заставляло OrderContextBar перестраивать нативную сетку динамических полей. На Android это приводило к падению с ошибкой Can't create handler ... Looper.prepare() — нативные элементы пытались обновиться не на UI‑потоке. Я перенесла вызов UninitializeAsync на главный поток прямо в базовой ViewModel:
// ViewModelBase: LoadStarted приходит из фоновой синхронизации,
// поэтому весь сброс состояния уводим на главный поток
messageBus.Subscribe<ProgressInfo>(this, MessageKeys.LoadStarted,
async _ => await MainThread.InvokeOnMainThreadAsync(UninitializeAsync)
.ConfigureAwait(false));
И дополнительно защитила само обновление сетки: если изменение приходит из фонового потока, перестроение откладывается на UI‑поток.
private void OnViewModelPropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(DocumentViewModelBase.Document)) return;
// PropertyChanged может прийти с фонового потока (например, из синхронизации).
// Перестройка нативной сетки — только на UI-потоке.
if (MainThread.IsMainThread)
RefreshOnDocumentChanged();
else
MainThread.BeginInvokeOnMainThread(RefreshOnDocumentChanged);
}
UI/UXИнтерфейс — это не только расположение элементов на экране. Нужно учитывать сценарии использования, удобство взаимодействия и поведение приложения на конкретных устройствах.
Можно реализовать функциональность технически правильно, но сделать её неудобной для пользователя. Поэтому работа над интерфейсом требует смотреть на приложение не только глазами разработчика.
Обмен данными и фоновые процессыОтдельной сложностью стало понимание взаимодействия компонентов и процессов, которые выполняются независимо от действий пользователя. Тем более что цепочка у нас длинная: данные выгружаются из 1С, проходят через сервер обмена и только потом попадают на планшет. Поскольку выгрузку из 1С я тоже сопровождаю сама, ошибку приходится искать на любом из этих участков.
Здесь недостаточно добиться того, чтобы один метод отработал без ошибки. Важно учитывать жизненный цикл операций, обработку сбоев и последствия ситуации, когда одна часть системы выполнилась успешно, а другая — нет.
ТестированиеТестирование оказалось ещё одной областью, в которой теория и практика заметно различаются.
Недостаточно просто запустить приложение и убедиться, что оно открывается. Нужно проверять реальные пользовательские сценарии, граничные случаи и поведение при ошибках.
Особенно важно тестировать изменения в приложении, которое уже используется в работе: ошибка может проявиться не сразу, а в конкретной последовательности действий.
Как я училась в процессе работыКогда сталкиваешься с незнакомой технологией или сложной ошибкой, одного поиска готового решения часто недостаточно. Нужно понять, почему проблема возникла и какие последствия может иметь выбранный способ исправления.
В процессе работы я много читала официальную документацию и книги. Это помогало разбираться не только с конкретными ошибками, но и с принципами, на которых основаны используемые технологии.
Особенно выручила книга Михаила Фленова «Библия C#», которую когда‑то посоветовал мне друг. Ещё одними помощниками стали форумы: Хабр, Stack Overflow и Киберфорум. Очень часто оказывалось, что с моей проблемой кто‑то уже сталкивался, и важно было не просто скопировать ответ, а понять, почему он работает.
Также я использовала нейросети. Для меня это был инструмент обучения: можно было разобрать непонятный фрагмент кода, изучить возможные причины ошибки, сравнить несколько подходов и разобраться в незнакомой теме.
При этом предложенный ответ не становился автоматически правильным решением. Его приходилось проверять, сопоставлять с документацией и адаптировать к существующему проекту.
На мой взгляд, главная польза таких инструментов — возможность быстрее разобраться в проблеме. Но ответственность за итоговое решение всё равно остаётся у разработчика.
Подготовка.NET MAUI‑приложения к публикации в RuStoreПосле миграции и получения рабочей сборки предстояло подготовить приложение к распространению.
Почему RuStoreВыбор магазина мы делали вместе с руководством, и RuStore победил по принципу «дёшево и сердито».
В Google Play за аккаунт разработчика нужно платить, а регистрация организации занимает прилично времени.
В RuStore аккаунт бесплатный. Аккаунт организации я оформила примерно за 20 минут, и проверку он прошёл быстро.
В RuStore можно зарегистрироваться и как индивидуальный разработчик. Именно так я и начинала, чтобы спокойно потренироваться на тестовых публикациях.
Для внутреннего приложения, которым пользуются сотрудники компании, этого более чем достаточно: приложение ставится из магазина, а обновления приходят сами, без рассылки APK в чатах.
В этой части разберём основные этапы: настройки проекта, ключ подписи, Release‑сборку, получение APK, загрузку приложения в RuStore и проверку результата.
Ниже приведены настройки, близкие к моему проекту. Идентификаторы и имена заменены на условные. В другом приложении настройки могут отличаться в зависимости от версии.NET, используемых библиотек, требований к производительности и способа распространения.
1. Целевые платформы и версия приложенияПроект — один многоплатформенный MAUI‑проект. Список целевых платформ задаётся в .csproj и зависит от ОС, на которой идёт сборка:
<PropertyGroup>
<TargetFrameworks>net10.0-android</TargetFrameworks>
<TargetFrameworks Condition="!$([MSBuild]::IsOSPlatform('linux'))">$(TargetFrameworks);net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>
</PropertyGroup>
Это стандартная схема из шаблона MAUI.
Идентификатор и версия приложения задаются там же:
<PropertyGroup>
<ApplicationTitle>MyApp</ApplicationTitle>
<ApplicationId>ru.company.myapp</ApplicationId>
<ApplicationDisplayVersion>2.6.5</ApplicationDisplayVersion>
<ApplicationVersion>265</ApplicationVersion>
</PropertyGroup>
ApplicationTitle задаёт название приложения, а ApplicationId — уникальный идентификатор пакета. Его следует выбирать внимательно: он используется платформой для различения приложений и не меняется после первой публикации.
ApplicationDisplayVersion — версия, которую видит пользователь (versionName). ApplicationVersion — целочисленный код версии (versionCode), по которому Android определяет порядок обновлений. Он должен расти с каждым релизом: магазин не примет сборку с тем же или меньшим кодом, а устройство не установит её поверх более новой.
Код удобно выводить из видимой версии: 2.6.5 → 265. Но у простой склейки цифр есть ловушка: как только любая часть версии дойдёт до 10, порядок сломается. 2.6.10 даст 2610, а следующая 2.7.0 — всего 270, и такое обновление магазин уже не примет. Надёжнее отводить под каждую часть по два разряда: 2.6.5 → 20605, 2.6.10 → 20610, 2.7.0 → 20700. Поменять схему можно в любой момент, лишь бы новый код был больше предыдущего.
Для распространения приложения нужна сборка, предназначенная для выпуска, а не обычная Debug‑сборка, которую разработчик использует во время отладки.
В моём проекте Release‑настройки Android выглядят так:
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-android|AnyCPU'">
<AndroidKeyStore>True</AndroidKeyStore>
<RuntimeIdentifiers>android-arm64</RuntimeIdentifiers>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<RunAOTCompilation>true</RunAOTCompilation>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<AndroidUseAssemblyStore>true</AndroidUseAssemblyStore>
</PropertyGroup>
Этот фрагмент показывает настройки моего проекта, а не универсальную конфигурацию для любого.NET MAUI‑приложения.
Кратко разберём назначение параметров.
AndroidKeyStore включает подпись приложения собственным ключом из хранилища ключей (подробнее — в разделе про ключ подписи).
RuntimeIdentifiers определяет, для каких архитектур процессора в APK попадёт нативный код. Здесь указана только android-arm64.
AndroidPackageFormat задаёт формат пакета — APK. По умолчанию MAUI собирает Release в формате AAB.
RunAOTCompilation включает Ahead‑of‑Time‑компиляцию.
AndroidEnableProfiledAot со значением false отключает профилируемую AOT‑компиляцию: заранее компилируется весь код приложения, а не только участки из профиля запуска.
AndroidUseAssemblyStore включает упаковку сборок.NET в единое хранилище внутри APK.
Полную AOT‑компиляцию я выбрала из‑за поведения приложения на слабых планшетах. Стандартный профиль MAUI покрывает только запуск самого фреймворка. Код страниц, ViewModel и сервисов приложения JIT‑компилировался при первом открытии каждой страницы, и на слабых устройствах первый переход заметно подтормаживал.
У такого решения есть цена: сборка занимает больше времени, а APK становится крупнее. Поэтому подобные параметры стоит выбирать с учётом особенностей приложения и проверять на целевых устройствах.
Интересно, что в моём случае APK в итоге не вырос, а уменьшился. До перехода на полную AOT он весил около 50 МБ. Затем я в четыре подхода вычищала из проекта мёртвый, устаревший и не особо нужный код, убирала лишние пакеты и старалась везде использовать стандартные возможности MAUI. В итоге текущий APK с полной AOT весит около 36 МБ.
Важно понимать: эти цифры не сравнивают AOT «в чистом виде», ведь за это время изменился и сам код. Но они хорошо показывают, что чистка проекта может с запасом перекрыть рост размера от полной AOT.
Отдельно про RuntimeIdentifiers. Многоплатформенность проекта и архитектуры внутри APK — разные вещи. Проект собирается под несколько ОС, но Android‑пакет с такой настройкой содержит нативный код только для 64-битных ARM‑устройств. Это легко проверить: внутри APK есть только папка lib/arm64-v8a. На устройство с 32-битным Android такая сборка не установится.
Мне такой вариант подходит: я знаю, какие планшеты у пользователей. Если парк устройств неоднородный, перед сужением списка архитектур убедитесь, что все устройства 64-битные. Иначе добавьте android-arm через точку с запятой: APK станет больше, зато установится везде.
Не стоит бездумно копировать все настройки из чужого проекта. Конфигурация, которая помогла одному приложению, может не дать такого же результата в другом.
3. Ключи, пароли и конфигурацияПомимо ключа подписи у приложения обычно есть и другие секреты: ключи API сторонних сервисов, адреса серверов, токены. Правило для всех одно: в репозиторий они не попадают.
Мой подход:
значения хранятся в локальном файле настроек сборки, который исключён из git через .gitignore;
в .csproj остаются только ссылки на эти параметры, без самих значений;
на другой машине или в CI те же параметры задаются отдельно, через переменные окружения или секреты CI.
Перед публикацией стоит проверить, что секретные значения не попадают в логи сборки, скриншоты и любые артефакты, которые увидят посторонние. Это касается и статей: скриншот Solution Explorer или вывод консоли легко может показать то, что показывать не стоит.
4. Ключ подписиКлюч подписи — самая важная вещь в процессе публикации. Каждая следующая версия приложения должна быть подписана тем же ключом, что и предыдущая. Иначе RuStore не примет обновление, а у пользователей новая версия не установится поверх старой.
В моём случае ключ достался вместе со старым проектом, и новое приложение подписано им же. Если ключа ещё нет, его создают один раз утилитой keytool из JDK:
keytool -genkeypair -v -keystore myapp.keystore -alias myapp \
-keyalg RSA -keysize 2048 -validity 10000
Утилита спросит пароль хранилища, пароль ключа и данные владельца сертификата.
Путь к хранилищу и псевдоним ключа указывают в настройках проекта, а пароли передают при сборке и не хранят в репозитории:
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-android|AnyCPU'">
<AndroidSigningKeyStore>myapp.keystore</AndroidSigningKeyStore>
<AndroidSigningKeyAlias>myapp</AndroidSigningKeyAlias>
</PropertyGroup>
MAUI умеет читать пароли из переменной окружения или из файла через префиксы env: и file::
dotnet publish MyApp/MyApp.csproj -f net10.0-android -c Release \
-p:AndroidSigningKeyPass=env:ANDROID_SIGNING_PASSWORD \
-p:AndroidSigningStorePass=env:ANDROID_SIGNING_PASSWORD
Префикс env: подходит для APK. Если собираете AAB, по документации Microsoft он не поддерживается — используйте file: или передавайте значение из секретов CI.
Что сделать сразу после создания или получения ключа:
добавить *.keystore и *.jks в .gitignore;
сохранить хранилище и пароли в менеджере паролей компании;
сделать резервную копию вне рабочего компьютера.
RuStore не хранит ваш ключ и не сможет его восстановить. Если ключ потерян, смена подписи идёт через поддержку RuStore, а все версии придётся заново загрузить и опубликовать с новым сертификатом.
5. Собираем APKДля публикации в RuStore я использую формат APK.
Release‑сборка запускается командой:
dotnet publish MyApp/MyApp.csproj \
-f net10.0-android \
-c Release
Команда указывает проект, целевую платформу и конфигурацию сборки. Указывать стоит именно файл .csproj, а не решение целиком: при публикации всего решения dotnet publish попытается обработать и вспомогательные проекты, например тесты.
У меня эта короткая команда работает, потому что параметры подписи лежат в локальных настройках сборки. На другой машине их нужно будет передать явно, как в предыдущем разделе.
После завершения сборки нужно проверить, что процесс действительно завершился успешно и что получен ожидаемый артефакт.
Сама я обычно собираю релиз через Visual Studio. После публикации сборка появляется в «Диспетчере архивов», и там удобно ещё раз сверить главное: версию, код версии, идентификатор и формат пакета.

«Диспетчер архивов»: версия 2.6.5, код версии 265, формат пакета APK.
Если собирать из командной строки, опубликованные файлы обычно находятся в каталоге:
bin/Release/net10.0-android/publish/
Точный путь и состав файлов зависят от параметров публикации и версии SDK. Для загрузки в магазин нужен подписанный файл — он имеет суффикс -Signed, например ru.company.myapp-Signed.apk.
Получение APK ещё не означает, что приложение готово к распространению.
Перед загрузкой в магазин стоит проверить как минимум следующее:
APK создан именно для Release‑конфигурации.
Формат файла соответствует выбранному способу распространения.
Приложение подписано ожидаемым ключом.
Приложение устанавливается на целевое устройство.
Приложение запускается после установки.
Основные пользовательские сценарии работают.
Версия и идентификатор приложения соответствуют планируемому релизу.
Подпись проще всего проверить утилитой apksigner из каталога build-tools Android SDK:
apksigner verify --print-certs ru.company.myapp-Signed.apk
Утилита выведет отпечатки сертификата (SHA-256 и другие). Сравните их с отпечатками предыдущей опубликованной версии: если они не совпадают, обновление не пройдёт.
Сам вывод консоли не стоит публиковать без предварительной проверки: в нём могут присутствовать локальные пути и другие ненужные сведения.
7. Загружаем приложение в RuStoreПосле подготовки APK можно переходить к публикации.
Сначала — про права доступа, на которые легко наткнуться. Добавить новое приложение в RuStore Консоль может только пользователь с ролью «Владелец компании». Загружать новые версии могут также Администратор, Релиз‑менеджер и Разработчик. Если аккаунт компании оформлен на руководителя, договоритесь заранее, кто создаёт карточку и какую роль получаете вы.
В общих чертах процесс выглядит так:
Открыть консоль разработчика RuStore.
Создать карточку приложения или выбрать существующую.
Заполнить необходимые сведения о приложении.
Подготовить описание и графические материалы.
Загрузить файл сборки в соответствии с требованиями консоли.
Проверить заполнение обязательных полей, включая контакты разработчика.
Отправить приложение на модерацию.
После завершения проверки убедиться, что приложение доступно пользователям.
RuStore принимает и APK, и AAB. Для APK действует простое правило: подпись новой версии должна совпадать с подписью предыдущей. Для AAB перед загрузкой файла нужно отдельно добавить в консоль ключи подписи, то есть передать магазину закрытый ключ в зашифрованном виде. Для внутреннего корпоративного приложения это один из аргументов в пользу APK.
Конкретные требования к карточке приложения, файлу сборки и публикации могут меняться, поэтому перед отправкой стоит сверяться с актуальной документацией RuStore.
8. Сколько занимает модерацияЗдесь могу поделиться собственным опытом, но важно не путать его с официальным сроком проверки.
При первой публикации модерация заняла примерно полтора дня. Впоследствии ожидание стало заметно дольше: в моём опыте оно составляло 3–5 рабочих дней.
Это наблюдения по конкретному приложению, а не гарантия сроков для других публикаций. Фактическое время зависит от обстоятельств конкретной проверки и требований магазина.
На этот этап лучше закладывать запас времени, особенно если релиз привязан к определённой дате.
Модерацию я не прошла лишь однажды — с первой попытки. Причиной стали остатки App Center в коде, доставшиеся от старого приложения. Помимо аналитики и сбора падений, в старой версии была модель релиза из API App Center — с полями download_url и install_url. То есть приложение было рассчитано на то, чтобы узнавать о новых версиях и скачивать их мимо магазина. А RuStore не допускает, чтобы приложение давало пользователю возможность скачивать приложения из сторонних источников в обход магазина.
После того как я полностью вычистила App Center из проекта, приложение прошло проверку. К тому же сам App Center Microsoft закрыла ещё в 2025 году, так что держать его в проекте не было смысла.
Совет тем, кто публикует унаследованное приложение: до отправки на модерацию поищите в коде любые механизмы самообновления и загрузки APK из сторонних мест — SDK распространения сборок, прямые ссылки на файлы, собственные «проверки обновлений».

Версии приложения в RuStore Консоли: 2.6.2 (код 262) загружена 14 сентября и опубликована 17 сентября, 2.6.5 (код 265) загружена 13 октября и опубликована 16 октября.
На скриншоте хорошо видно, как растёт код версии и сколько проходит от загрузки до публикации.
9. Проверяем приложение после публикацииПосле того как приложение стало доступно в магазине, работа не заканчивается. Важно убедиться, что опубликованная версия действительно пригодна для использования.
Именно проверка в реальных условиях позволяет обнаружить проблемы, которые не всегда проявляются на компьютере разработчика.
В первую очередь стоит проверить:
установку приложения из магазина;
первый запуск;
основные рабочие сценарии;
поведение при отсутствии сети, если приложение поддерживает автономную работу;
восстановление работы после ошибок;
корректность обновления уже установленной версии.
Отдельно — про переход с тестовых сборок. Если до публикации пользователи ставили APK вручную, а версия в магазине подписана другим ключом, приложение придётся удалить и установить заново, потеряв локальные данные. Поэтому тестовые сборки лучше сразу подписывать тем же релизным ключом.
Ещё один помощник после релиза — лёгкий логгер. Временную отладку через Debug.WriteLine я убрала перед публикацией, а в приложении остался небольшой логгер. Он пишет записи в локальную базу, а при каждом обмене данными отправляет их на сервер вместе с остальными данными. Ресурсов он тратит минимум, зато, когда у агента что‑то идёт не так, мне не нужно гадать по скриншоту — я вижу, что именно произошло.
Новое приложение я специально не стала выпускать как обновление старого. Оно ставится рядом со старым, как отдельное приложение.
Причина простая: людям нужно продолжать работать. Если в новой версии найдётся ошибка, агент не должен сидеть и ждать, пока я её исправлю и выпущу обновление. Он просто открывает старое приложение и продолжает работу.
Это сработало благодаря тому, что рабочие базы скачиваются с сервера. Оба приложения работали с одними и теми же данными. Разница была только в одном: в старом приложении хранилась история документов, а в новом её не было. Эту проблему я тоже решила — нашла способ перенести историю из старого приложения в новое.
Для этого я добавила отдельный разовый импорт из резервной копии старого приложения. Старое приложение уже умело отправлять копию orders.db на сервер обмена; новое показывает список доступных копий с датами, скачивает выбранную и сливает документы в свою базу. При переносе проверяется UniqueID, поэтому уже существующие документы повторно не добавляются. Переносятся заказы, возвраты, анкеты и связанные с ними строки и свойства; фотографии из старой базы не переносятся.
Если вы мигрируете приложение, которым люди пользуются каждый день, советую подумать о таком параллельном запуске. Он снимает давление и с пользователей, и с разработчика.
Грабли, на которые я наступилаСимптом | Причина | Решение |
|---|---|---|
Сборка многоплатформенного проекта падает на restore с ошибкой | Для Windows SDK вычисляет | Явно задать |
Первый переход на страницу подтормаживает на слабых планшетах | Профилируемая AOT не покрывает код самого приложения, он компилируется JIT при первом открытии | Включить полную AOT: |
Новая версия не ставится поверх старой | Сборки подписаны разными ключами | Подписывать все сборки, включая тестовые, одним ключом |
| Команда запущена по решению, а не по | Указать путь к проекту приложения |
RuStore отклоняет приложение на модерации | В коде остался App Center, который умеет предлагать загрузку обновлений в обход магазина | Полностью удалить App Center и другие механизмы самообновления |
Пользователи жалуются на «тормоза», а бенчмарки показывают, что всё быстро | Медленная отрисовка длинных списков, а не логика | Замерять и оптимизировать отображение, а не только код |
Большой список торговых точек подвисает при прокрутке |
| Перейти на |
Приложение падает с ошибкой | Нативные элементы обновляются из фонового потока | Проверять |
На Android переход пользователей на новое приложение уже идёт. Следующий шаг — iPad. И здесь как раз видна главная ценность кроссплатформенности: тот же код, та же бизнес‑логика, те же экраны. Отдельно переписывать приложение под iOS не нужно.
Но публикация на iPad устроена совсем иначе, чем в RuStore. RuStore — магазин только для Android, поэтому на iPad приложение попадает через инфраструктуру Apple.
Что нужно учесть:
Аккаунт разработчика. Нужна платная подписка Apple Developer Program. Для компании регистрируется аккаунт организации, и для него требуется номер D‑U‑N‑S. Его получение может занять время, поэтому начинать стоит заранее, параллельно с разработкой.
Mac для сборки. Собрать и подписать iOS‑приложение можно только на Mac с установленным Xcode. Сборка запускается командой вида:
dotnet publish MyApp/MyApp.csproj -f net10.0-ios -c Release \
-p:ArchiveOnBuild=true \
-p:RuntimeIdentifier=ios-arm64 \
-p:CodesignKey="Apple Distribution: ..." \
-p:CodesignProvision="..."
Вместо keystore здесь сертификат распространения и профиль обеспечения (provisioning profile), которые создаются в аккаунте разработчика Apple.
План. Порядок действий у нас такой:
Перевести на новое приложение всех сотрудников с Android‑планшетами.
Отладить интерфейс на iPad.
Оплатить аккаунт Apple Developer и опубликовать приложение в App Store.
Аккаунт оплачиваем в последнюю очередь сознательно: подписка платная и годовая, и нет смысла платить за неё, пока приложение не готово к публикации. А отлаживать интерфейс на своём iPad можно и без неё.
Способ распространения. У Apple несколько вариантов:
Способ | Для чего подходит | Ограничения |
|---|---|---|
TestFlight | Тестирование перед релизом | Сборка доступна для тестирования ограниченное время; для внешних тестировщиков нужна проверка Apple |
Ad Hoc | Установка на конкретные устройства | Каждое устройство регистрируется вручную, не более 100 устройств каждого типа в год |
Custom App через Apple Business Manager | Закрытое распространение внутри организации | Нужен Apple Business Manager у компании; проходит проверку Apple |
App Store | Публичное распространение | Полная проверка, приложение видно всем |
Мы планируем App Store. Но у этого пути есть подводный камень, о котором стоит знать заранее. Приложения, рассчитанные только на сотрудников одной компании, Apple нередко отклоняет при проверке и предлагает распространять их как Custom App через Apple Business Manager. Поэтому для проверяющих стоит подготовить демо‑доступ, а к отказу по этой причине — быть готовыми и иметь запасной вариант.
Отличия платформы, которые проявятся в коде.
На iOS JIT‑компиляция запрещена, приложение всегда компилируется заранее (AOT). Проблема подтормаживания первого перехода, которую я решала на Android, здесь возникает в другом виде или не возникает вовсе.
Доступ к камере, геолокации и фотографиям требует строк с описанием в Info.plist, например NSCameraUsageDescription и NSLocationWhenInUseUsageDescription. Без них приложение упадёт при первом обращении к функции, а проверка Apple его не пропустит. В моём проекте они уже прописаны — вместе с режимами фоновой работы.
Фоновая работа на iOS ограничена строже, чем на Android. Если синхронизация данных рассчитана на работу в фоне, её поведение на iPad нужно проверить отдельно.
Интерфейс стоит проверить на экранах iPad в обеих ориентациях: разметка, удобная на Android‑планшете, на другом соотношении сторон может выглядеть иначе.
Минимальная версия iOS в проекте — 15. Перед переходом стоит убедиться, что самые старые iPad пользователей её поддерживают.
О том, как пройдёт переход на iPad, расскажу в отдельной статье.
Что я вынесла из этого проектаЭтот проект стал для меня гораздо большим, чем просто миграция с одной технологии на другую.
Мне пришлось разбираться в большом незнакомом коде, пересматривать собственные решения, несколько раз начинать заново и осваивать темы, с которыми раньше почти не сталкивалась на практике.
Самым сложным оказался перенос Xamarin.Forms на.NET MAUI. После него стало легче двигаться дальше, но это не означало, что все проблемы исчезли: оставались тестирование, подготовка сборки и проверка работы на реальных устройствах.
Я также поняла, что ошибка или неудачная попытка — не обязательно потерянное время. Иногда только после неудачного решения становится понятно, почему первоначальный подход был неверным. Главное — не продолжать двигаться по заведомо неудачному пути только потому, что уже вложено много сил.
Отдельно хочу отметить поддержку со стороны близких, руководителя и коллег. Когда опыта мало, а проект большой и плохо документирован, возможность спокойно разобраться в проблеме без постоянного давления имеет большое значение.
И ещё один вывод: успешная сборка — это только один из этапов. Чтобы довести приложение до пользователей, нужно пройти путь от понимания существующего кода до тестирования, подготовки релиза, публикации и проверки результата.
Чек‑лист перед релизом в RuStoreApplicationVersion увеличен, ApplicationDisplayVersion обновлён.
Сборка выполнена в Release по файлу .csproj.
Список RuntimeIdentifiers покрывает все устройства пользователей.
Подпись проверена через apksigner, отпечаток совпадает с предыдущей версией.
Релизная сборка проверена на реальном устройстве, включая обновление поверх старой версии.
В карточке заполнены описание, скриншоты и контакты разработчика.
Ключ подписи, пароли и ключи API хранятся вне репозитория.
На скриншотах для карточки и статей нет рабочих данных.
Буду рада, если мой опыт окажется полезным тем, кому предстоит разбираться в унаследованном проекте, переносить приложение на.NET MAUI или проходить путь от первой рабочей сборки до публикации в магазине.
А вы уже публиковали MAUI‑приложения в RuStore? Какой формат выбрали — APK или AAB? И как распространяете корпоративные приложения на iPad?
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | DroidScript. Впечатления от года работы | 0 | 7.17 | 23-10-2026 |
| 2 | Разработка на Авроре под Аврору: разворачиваем Coder в облаке и пишем плагин для VS Code | 0 | 7.72 | 27-08-2026 |
| 3 | Считаем пакеты на Rust | 0 | 9.87 | 04-07-2026 |
| 4 | Ошибки и подозрительные места в исходниках .NET 10 | 0 | 12.66 | 04-06-2026 |
| 5 | Я ненавижу AutoMapper | 0 | 7.7 | 02-07-2026 |
| 6 | Делаем модуль на C++ для приложения React Native | 0 | 6.53 | 30-07-2026 |
| 7 | Как мигрировать Flutter-приложение на SPM — гайд с граблями, костылями и скриптами | 0 | 8.24 | 13-07-2026 |
| 8 | Обзор нововведений в C# 15 | 0 | 8.55 | 21-10-2026 |
| 9 | Разработчики приступили к адаптации RuStore на ОС "Аврора" | 0 | 0 | 11-09-2023 |
| 10 | VK напомнил пользователям о доступности приложений VK для Android в RuStore | 0 | 5 | 16-07-2026 |