Вход на сайт

Просмотр новости

Найдите то, что Вас интересует

Публикуем .NET MAUI‑приложение в RuStore: от унаследованного Xamarin‑проекта до первого релиза

Дата публикации: 23-10-2026 11:10:31

Миграция с 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‑части нового приложения остался ровно один собственный хендлер — для миниатюр в каталоге.

Самописный список на ScrollView → CollectionView

В старом приложении был собственный контрол SmListView, унаследованный от ScrollView. Он сам создавал элемент под каждую строку и добавлял его в разметку. Работало, но без виртуализации: сколько строк в данных, столько элементов в памяти. В новой версии все такие списки — это стандартный CollectionView.

«Почтальон»: TinyIoC и статический Locator → DI и Shell

Отдельная история — самописный «почтальон», который в старом приложении разносил по коду сервисы, страницы и данные. Это был статический класс 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 (о ней ниже).

Шина событий: MessagingCenter → WeakReferenceMessenger

Для событий — начало и прогресс синхронизации, изменение документов, сворачивание приложения — в старой версии была обёртка 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

Один проект, платформенный код в папке Platforms/

Кастомные рендереры и самописные контролы

Маппинг стандартных хендлеров и стандартные элементы MAUI там, где их хватает

Устаревшие сторонние плагины

Удалены или заменены встроенными возможностями фреймворка

Статический Locator на TinyIoC, страницы и ViewModel — одиночки

Встроенный DI через конструктор и навигация Shell с параметрами

Шина событий на MessagingCenter без отписки

IMessageBus на WeakReferenceMessenger с подпиской на конкретного получателя

Вывод у меня один: не нужно изобретать велосипед, если его уже сделали за тебя и он работает из коробки.

Что изменилось после перехода на.NET MAUI

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

Поэтому результат оценивался не только по успешной компиляции. Нужно было проверить работу интерфейса, бизнес‑логики, хранения данных и взаимодействия с внешними системами.

Особенно важно было убедиться, что изменения не нарушили привычные сценарии работы пользователей.

Отдельный плюс MAUI — один проект на все платформы. Сейчас приложение собирается под Android, iOS, Mac Catalyst и Windows из одной кодовой базы. В работе пока используются Android‑планшеты и iPad, но сама возможность многоплатформенной сборки сильно упрощает дальнейшее развитие.

Вот как выглядят два главных экрана до и после. Все скриншоты сняты на демо‑базе «Магазин продуктов» — клиенты, адреса и долги в ней вымышленные.

Стартовый экран: сверху Xamarin.Forms, снизу.NET MAUI

Xamarin.Forms

Xamarin.Forms

.NET MAUI

NET MAUI

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

Xamarin.Forms

Xamarin.Forms

.NET MAUI

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. Поменять схему можно в любой момент, лишь бы новый код был больше предыдущего.

2. Настраиваем Release‑сборку для Android

Для распространения приложения нужна сборка, предназначенная для выпуска, а не обычная 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.

«Диспетчер архивов»: версия 2.6.5, код версии 265, формат пакета APK.

Если собирать из командной строки, опубликованные файлы обычно находятся в каталоге:

bin/Release/net10.0-android/publish/

Точный путь и состав файлов зависят от параметров публикации и версии SDK. Для загрузки в магазин нужен подписанный файл — он имеет суффикс -Signed, например ru.company.myapp-Signed.apk.

6. Проверяем полученный файл

Получение APK ещё не означает, что приложение готово к распространению.

Перед загрузкой в магазин стоит проверить как минимум следующее:

  • APK создан именно для Release‑конфигурации.

  • Формат файла соответствует выбранному способу распространения.

  • Приложение подписано ожидаемым ключом.

  • Приложение устанавливается на целевое устройство.

  • Приложение запускается после установки.

  • Основные пользовательские сценарии работают.

  • Версия и идентификатор приложения соответствуют планируемому релизу.

Подпись проще всего проверить утилитой apksigner из каталога build-tools Android SDK:

apksigner verify --print-certs ru.company.myapp-Signed.apk

Утилита выведет отпечатки сертификата (SHA-256 и другие). Сравните их с отпечатками предыдущей опубликованной версии: если они не совпадают, обновление не пройдёт.

Сам вывод консоли не стоит публиковать без предварительной проверки: в нём могут присутствовать локальные пути и другие ненужные сведения.

7. Загружаем приложение в RuStore

После подготовки APK можно переходить к публикации.

Сначала — про права доступа, на которые легко наткнуться. Добавить новое приложение в RuStore Консоль может только пользователь с ролью «Владелец компании». Загружать новые версии могут также Администратор, Релиз‑менеджер и Разработчик. Если аккаунт компании оформлен на руководителя, договоритесь заранее, кто создаёт карточку и какую роль получаете вы.

В общих чертах процесс выглядит так:

  1. Открыть консоль разработчика RuStore.

  2. Создать карточку приложения или выбрать существующую.

  3. Заполнить необходимые сведения о приложении.

  4. Подготовить описание и графические материалы.

  5. Загрузить файл сборки в соответствии с требованиями консоли.

  6. Проверить заполнение обязательных полей, включая контакты разработчика.

  7. Отправить приложение на модерацию.

  8. После завершения проверки убедиться, что приложение доступно пользователям.

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 октября.

Версии приложения в RuStore Консоли: 2.6.2 (код 262) загружена 14 сентября и опубликована 17 сентября, 2.6.5 (код 265) загружена 13 октября и опубликована 16 октября.

На скриншоте хорошо видно, как растёт код версии и сколько проходит от загрузки до публикации.

9. Проверяем приложение после публикации

После того как приложение стало доступно в магазине, работа не заканчивается. Важно убедиться, что опубликованная версия действительно пригодна для использования.

Именно проверка в реальных условиях позволяет обнаружить проблемы, которые не всегда проявляются на компьютере разработчика.

В первую очередь стоит проверить:

  • установку приложения из магазина;

  • первый запуск;

  • основные рабочие сценарии;

  • поведение при отсутствии сети, если приложение поддерживает автономную работу;

  • восстановление работы после ошибок;

  • корректность обновления уже установленной версии.

Отдельно — про переход с тестовых сборок. Если до публикации пользователи ставили APK вручную, а версия в магазине подписана другим ключом, приложение придётся удалить и установить заново, потеряв локальные данные. Поэтому тестовые сборки лучше сразу подписывать тем же релизным ключом.

Ещё один помощник после релиза — лёгкий логгер. Временную отладку через Debug.WriteLine я убрала перед публикацией, а в приложении остался небольшой логгер. Он пишет записи в локальную базу, а при каждом обмене данными отправляет их на сервер вместе с остальными данными. Ресурсов он тратит минимум, зато, когда у агента что‑то идёт не так, мне не нужно гадать по скриншоту — я вижу, что именно произошло.

Переход пользователей: два приложения рядом

Новое приложение я специально не стала выпускать как обновление старого. Оно ставится рядом со старым, как отдельное приложение.

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

Это сработало благодаря тому, что рабочие базы скачиваются с сервера. Оба приложения работали с одними и теми же данными. Разница была только в одном: в старом приложении хранилась история документов, а в новом её не было. Эту проблему я тоже решила — нашла способ перенести историю из старого приложения в новое.

Для этого я добавила отдельный разовый импорт из резервной копии старого приложения. Старое приложение уже умело отправлять копию orders.db на сервер обмена; новое показывает список доступных копий с датами, скачивает выбранную и сливает документы в свою базу. При переносе проверяется UniqueID, поэтому уже существующие документы повторно не добавляются. Переносятся заказы, возвраты, анкеты и связанные с ними строки и свойства; фотографии из старой базы не переносятся.

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

Грабли, на которые я наступила

Симптом

Причина

Решение

Сборка многоплатформенного проекта падает на restore с ошибкой NU1105 о нескольких значениях версии

Для Windows SDK вычисляет Version из ApplicationDisplayVersion, а для Android и iOS оставляет 1.0.0. NuGet требует одно значение на все платформы проекта

Явно задать <Version>$(ApplicationDisplayVersion)</Version> в общей группе свойств

Первый переход на страницу подтормаживает на слабых планшетах

Профилируемая AOT не покрывает код самого приложения, он компилируется JIT при первом открытии

Включить полную AOT: AndroidEnableProfiledAot=false

Новая версия не ставится поверх старой

Сборки подписаны разными ключами

Подписывать все сборки, включая тестовые, одним ключом

dotnet publish пытается собрать лишние проекты

Команда запущена по решению, а не по .csproj

Указать путь к проекту приложения

RuStore отклоняет приложение на модерации

В коде остался App Center, который умеет предлагать загрузку обновлений в обход магазина

Полностью удалить App Center и другие механизмы самообновления

Пользователи жалуются на «тормоза», а бенчмарки показывают, что всё быстро

Медленная отрисовка длинных списков, а не логика

Замерять и оптимизировать отображение, а не только код

Большой список торговых точек подвисает при прокрутке

ListView с HasUnevenRows="True" в MAUI создаёт сразу все строки

Перейти на CollectionView

Приложение падает с ошибкой Can't create handler ... Looper.prepare()

Нативные элементы обновляются из фонового потока

Проверять MainThread.IsMainThread и переносить обновление UI на главный поток

Что дальше: переход на iPad

На 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.

План. Порядок действий у нас такой:

  1. Перевести на новое приложение всех сотрудников с Android‑планшетами.

  2. Отладить интерфейс на iPad.

  3. Оплатить аккаунт 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. После него стало легче двигаться дальше, но это не означало, что все проблемы исчезли: оставались тестирование, подготовка сборки и проверка работы на реальных устройствах.

Я также поняла, что ошибка или неудачная попытка — не обязательно потерянное время. Иногда только после неудачного решения становится понятно, почему первоначальный подход был неверным. Главное — не продолжать двигаться по заведомо неудачному пути только потому, что уже вложено много сил.

Отдельно хочу отметить поддержку со стороны близких, руководителя и коллег. Когда опыта мало, а проект большой и плохо документирован, возможность спокойно разобраться в проблеме без постоянного давления имеет большое значение.

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

Чек‑лист перед релизом в RuStore
  • ApplicationVersion увеличен, ApplicationDisplayVersion обновлён.

  • Сборка выполнена в Release по файлу .csproj.

  • Список RuntimeIdentifiers покрывает все устройства пользователей.

  • Подпись проверена через apksigner, отпечаток совпадает с предыдущей версией.

  • Релизная сборка проверена на реальном устройстве, включая обновление поверх старой версии.

  • В карточке заполнены описание, скриншоты и контакты разработчика.

  • Ключ подписи, пароли и ключи API хранятся вне репозитория.

  • На скриншотах для карточки и статей нет рабочих данных.

Буду рада, если мой опыт окажется полезным тем, кому предстоит разбираться в унаследованном проекте, переносить приложение на.NET MAUI или проходить путь от первой рабочей сборки до публикации в магазине.

А вы уже публиковали MAUI‑приложения в RuStore? Какой формат выбрали — APK или AAB? И как распространяете корпоративные приложения на iPad?

Схожие новости

#Наименование новостиТональностьИнформативностьДата публикации
1DroidScript. Впечатления от года работы07.1723-10-2026
2Разработка на Авроре под Аврору: разворачиваем Coder в облаке и пишем плагин для VS Code07.7227-08-2026
3Считаем пакеты на Rust09.8704-07-2026
4Ошибки и подозрительные места в исходниках .NET 10012.6604-06-2026
5Я ненавижу AutoMapper07.702-07-2026
6Делаем модуль на C++ для приложения React Native06.5330-07-2026
7Как мигрировать Flutter-приложение на SPM — гайд с граблями, костылями и скриптами08.2413-07-2026
8Обзор нововведений в C# 1508.5521-10-2026
9Разработчики приступили к адаптации RuStore на ОС "Аврора"0011-09-2023
10VK напомнил пользователям о доступности приложений VK для Android в RuStore0516-07-2026

Классификация: . Схожих патентов: 0. Схожих новостей: 10. Тональность: 0. Информативность: 8.99. Источник: habr.com.