Bollywood HungamaYRF announces title of Ahaan Panday, Sharvari starrer - Beparwah to release on March 26, 2027PunchFULL LIST: FBI says six most-wanted fraudsters captured, $17bn seizedInquirerINQUIRER.net leads PH news websites in first 9 months of 2026 — SimilarwebESPNMan United are stuck with their underperforming players no matter who's managerThe Jerusalem PostAs drone threat grows, Magos expands commercial security capabilities한겨레[Column] What it will take to make a US-North Korea summit happenZDF heuteEntdecken Sie das ZDF-Nachrichtenstudioالنهارمن يقف خلف هذه الظاهرة؟The South AfricanForget city lights: 5 of the best places for stargazing in South African-tv"Vor Scham im Boden versinken": Scharfe Kritik an Audi-Chef für Wahlkampfauftritt mit TrumpIl Fatto Quotidiano“Al dottore ho chiesto ‘sto male?’, mi ha risposto ‘no, sta malissimo’ e se ne è andato. Allora ho detto ‘sono morto’. Ora sono in via di guarigione”: così Teo TeocoliDaily MailBurnham signals he will fight next election on platform of taking Britain back into Europe - and ministers will have to back free movement to keep their jobs
The Daily Newsstand · Free, Always
Monday, October 26, 2026

Почему тормозят списки в .NET MAUI: замеры на слабом планшете и что реально помогло

Translate

В прошлой статье я рассказывала, как переводила приложение для торговых представителей с Xamarin.Forms на .NET MAUI. Там был короткий раздел про жалобы агентов на «тормоза»: бенчмарки показывали, что логика работает быстро, а проблема была в отрисовке списков. В этой статье — подробно: как я это мерила, что делала три с половиной месяца и что в итоге получилось в цифрах.

Спойлер: на слабом планшете новая версия листает каталог почти в 4 раза быстрее старой. Но идеала я не достигла, и про это тоже расскажу честно.

Исходные условия

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

  • Каталог — от 200 до 20 000 товаров в зависимости от базы. В строке товара — миниатюра, название, бренд и страна, плашки акций и маркировки, цена, наличие, остаток, кратность отгрузки, история продаж.

  • Список торговых точек — в режиме «Все» в среднем от 150 точек, с долгами по компаниям.

  • Устройства агентов — когда начались жалобы, это были планшеты 2021 года со слабым по нынешним меркам процессором и 4 ГБ оперативной памяти. Сейчас парк постепенно переходит на Samsung Galaxy Tab A9+, но часть агентов всё ещё работает на старых планшетах.

Жаловались агенты в первую очередь на каталог и на полный список точек.

Результаты

Сначала — итог. Старое Xamarin-приложение и новое MAUI-приложение стоят у агентов рядом, поэтому сравнить их можно честно: одно устройство, одна база, одинаковая прокрутка.

Каталог на 19,5 тыс. товаров, одна и та же база, автоматическая прокрутка, по одному прогону на приложение и устройство. Новая версия — 2.6.5. Как именно мерила — в следующем разделе.

Показатель

Tab K10, Xamarin

Tab K10, MAUI

Tab A9+, Xamarin

Tab A9+, MAUI

Частота экрана / бюджет кадра

60 Гц / 16,7 мс

60 Гц / 16,7 мс

90 Гц / 11,1 мс

90 Гц / 11,1 мс

Кадров за прогон

404

902

1248

1469

Дёрганые кадры

100%

51,8%

18,6%

3,3%

Медиана кадра

113 мс

30 мс

25 мс

11 мс

90% кадров быстрее

150 мс

38 мс

40 мс

20 мс

99% кадров быстрее

200 мс

97 мс

61 мс

38 мс

Lenovo Tab K10 (TB-X6C6X) — один из тех старых планшетов, с которых шли жалобы. Samsung Galaxy Tab A9+ (SM-X216B) — модель, на которую сейчас переводят агентов; на нём же я разрабатываю.

Вывод Measure-Scroll на Lenovo Tab K10: сверху — новое приложение на .NET MAUI, снизу — старое на Xamarin.Forms. Значения GPU 4950 мс у старого приложения — не время, а пустая статистика: для него система не собрала данные о GPU.

Что из этого видно:

  • На K10 старое приложение не успевало ни один кадр. Медиана 113 мс — это примерно 9 кадров в секунду. За одинаковое время прокрутки оно отрисовало вдвое меньше кадров, чем новое: список не листался, а прыгал.

  • Новое приложение на K10 быстрее почти в 4 раза (медиана 30 мс против 113), но на таком железе половина кадров всё ещё не укладывается в бюджет. К этому вернусь в конце.

  • На Samsung дёрганых кадров стало почти в 6 раз меньше, а типичный кадр уложился в бюджет 11,1 мс.

  • Время GPU на Samsung у обеих версий одинаковое — 8–9 мс у 90% кадров. Значит, видеокарта ни при чём: всё время уходило на работу процессора до отрисовки — разметку, привязку данных, создание элементов. Именно это и пришлось оптимизировать.

И отдельный вывод: разница между устройствами огромна. На Samsung новое приложение выглядит почти идеальным, на K10 — уже нет. Если бы я тестировала только на своём планшете, я бы не поняла, на что жалуются агенты. Тестировать производительность нужно на самом слабом устройстве, которое реально есть у пользователей.

Каталог на Lenovo Tab K10: сверху Xamarin.Forms, снизу .NET MAUI. Демо-база.

Каталог на Lenovo Tab K10: сверху Xamarin.Forms, снизу .NET MAUI. Демо-база.

Честная оговорка к этому сравнению: строки в новом каталоге выше и богаче. На экран K10 в старой версии помещается около 14 строк, в новой — около 6, зато в каждой строке теперь бренд и страна, плашки маркировки, цена за единицу, наличие и остаток. Меньше строк на экране — меньше элементов, которые нужно создать и разложить за кадр, и это часть выигрыша. При этом и работы на каждую строку стало больше, так что одними размерами строки результат не объяснить.

Как я мерила

Первая ошибка, которую я сделала, — мерила не то. В приложении есть экран с бенчмарками: он замеряет загрузку каталога, SQL-запросы, поиск, сохранение документов. Цифры были хорошие, а агенты продолжали жаловаться.

Бенчмарк мерил логику и данные. А пользователь видит другое: успевает ли экран нарисовать каждый кадр. Для этого нужен другой инструмент.

На Android он уже есть — dumpsys gfxinfo. Он собирает статистику по каждому кадру, который отрисовало приложение:

adb shell dumpsys gfxinfo <id приложения> reset   # обнулить статистику
# ...листаем список...
adb shell dumpsys gfxinfo <id приложения>         # получить результат

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

function Measure-Scroll($dev, $pkg, $x = 1150, $yTop = 380, $yBottom = 950) {
    adb -s $dev shell dumpsys gfxinfo $pkg reset | Out-Null
    for ($i = 0; $i -lt 15; $i++) {
        adb -s $dev shell input swipe $x $yBottom $x $yTop 250
        adb -s $dev shell input swipe $x $yBottom $x $yTop 250
        adb -s $dev shell input swipe $x $yTop $x $yBottom 250
    }
    adb -s $dev shell dumpsys gfxinfo $pkg | Select-String "Total frames|Janky|percentile"
}

Что смотреть в выводе:

  • Janky frames — доля кадров, которые не успели к своему сроку показа. Это то самое «дёргается».

  • 50th / 90th / 99th percentile — сколько миллисекунд занимает кадр у половины, 90% и 99% кадров.

  • GPU percentile — сколько из этого времени ушло на видеокарту.

И важная деталь: бюджет кадра зависит от частоты экрана. При 60 Гц на кадр есть 16,7 мс, при 90 Гц — 11,1 мс. Частоту можно посмотреть так:

adb shell dumpsys display | Select-String "fps="

Одна из ловушек, на которой я споткнулась при замере: если во время прогона на экране окажется другое окно (экран блокировки, шторка уведомлений), adb откажется отправлять свайп с ошибкой INJECT_EVENTS. Перед прогоном планшет нужно разблокировать, а в настройках разработчика включить «Не выключать экран».

Что я делала

Оптимизация шла волнами, с июля по октябрь. Ниже — то, что дало эффект, примерно в том порядке, в котором я до этого доходила.

1. Виртуализация: CollectionView вместо ListView

Список торговых точек достался из старой версии как ListView с HasUnevenRows="True": строки разной высоты, потому что адрес под названием показывается не всегда. В MAUI в такой конфигурации виртуализация не работала, и в режиме «Все» создавались сразу все строки.

CollectionView виртуализирует нормально и при переменной высоте строк:

<!-- Было -->
<ListView ItemsSource="{Binding SalesPoints}"
          HasUnevenRows="True"
          ItemTemplate="{StaticResource salesPointTemplateSelector}" />

<!-- Стало -->
<CollectionView ItemsSource="{Binding SalesPoints}"
                SelectionMode="Single"
                SelectionChanged="List_SelectionChanged" />

2. Загрузка данных: N+1, задержка поиска, одна загрузка за раз

В июле я прошлась по загрузке каталога и нашла классические проблемы: запросы N+1 при загрузке и сохранение документов без транзакций.

Поиск по каталогу не запускает загрузку на каждую нажатую букву, а ждёт паузы в 300 мс:

_filterCancellationTokenSource?.Cancel();
_filterCancellationTokenSource = new CancellationTokenSource();
var token = _filterCancellationTokenSource.Token;

_ = Task.Run(async () =>
{
    await Task.Delay(300, token).ConfigureAwait(false);
    await LoadDataSafeAsync().ConfigureAwait(false);
}, token);

А в октябре я сделала так, чтобы загрузки списка больше не шли параллельно. Раньше несколько загрузок конкурировали за UI-поток и растягивались до 300–550 мс. Теперь, если загрузка уже идёт, новые запросы схлопываются в одну повторную, и она читает уже актуальные фильтры:

lock (_loadGate)
{
    if (_loadRunning)
    {
        _pendingReload ??= new TaskCompletionSource(
            TaskCreationOptions.RunContinuationsAsynchronously);
        return _pendingReload.Task;
    }

    _loadRunning = true;
}

return RunLoadsAsync();

3. Готовить данные до того, как они попадут в список

Цены, остатки, кратность отгрузки и история продаж по всему каталогу считаются в фоновом потоке, пока товары ещё не привязаны к CollectionView. Если делать это после привязки, на UI-поток сразу после показа списка сыпались тысячи уведомлений об изменении свойств. На UI-поток теперь уходит только готовый результат.

4. Обновлять коллекцию, а не подменять её

При каждом новом результате фильтра в ItemsSource раньше подставлялась новая коллекция, и CollectionView пересобирал нативный список целиком, теряя пул ячеек. Теперь обновляется содержимое существующей коллекции:

// Обновляем содержимое коллекции, а не подменяем ссылку
_goods.ReplaceRange(value ?? Enumerable.Empty<Good>());

5. Строка списка — без «дорогих» конструкций

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

Для плавного скролла — фиксированная высота строки, без FlexLayout, BindableLayout, RelativeSource и Scaled*.

Что за этим стоит:

  • Фиксированная высота строки и ItemSizingStrategy="MeasureFirstItem": списку не нужно мерить каждую строку.

  • Плашки в фиксированных слотах вместо BindableLayout. Раньше плашки акций и маркировки выводились через BindableLayout, и при переиспользовании ячейки он пересоздавал дочерние элементы. Теперь у товара три фиксированных слота, а в разметке — три элемента, которые просто показываются или скрываются:

  <Border Style="{StaticResource BadgeColumnChip}"
          BackgroundColor="{Binding Badge1.BackgroundColor}"
          IsVisible="{Binding HasBadge1}">
      <Label Text="{Binding Badge1.Name}" TextColor="{Binding Badge1.TextColor}" />
  </Border>
  <!-- так же Badge2 и Badge3 -->
  • Без RelativeSource в ячейке. Режим «каталог» и режим «заказ» раньше переключались привязкой к родительской ViewModel через RelativeSource, и обход дерева предков в каждой ячейке тормозил прокрутку. Теперь шаблон строки выбирается один раз в коде:

  private void ApplyRowTemplate()
  {
      var key = BindingContext is GoodsViewModel { ShowQuantityStepper: true }
          ? "OrderRowTemplate"
          : "BrowseRowTemplate";

      if (Resources.TryGetValue(key, out var template)
          && !ReferenceEquals(listView.ItemTemplate, template))
          listView.ItemTemplate = (DataTemplate)template;
  }
  • Поле ввода количества создаётся только на время ввода, а не живёт в каждой строке.

  • Кэш цветов в конвертере. Цвет текста и фона строки приходит из базы строкой вида "255128000", и конвертер разбирал её на каждой привязке при прокрутке. Цветов в каталоге единицы, поэтому разобранные значения кэшируются:

  if (Cache.TryGetValue(rgb, out var cached))
      return cached;

  var color = Color.FromRgb(r, g, b);
  if (Cache.Count < MaxCachedColors)
      Cache.TryAdd(rgb, color);

6. Миниатюры без перекомпоновки списка (Android)

Картинка товара догружается асинхронно, и когда она приходит, стандартный ImageView на Android вызывает requestLayout. Посреди прокрутки это заставляет RecyclerView перекомпоновать строку — и получается рывок.

Решение — свой хендлер для миниатюр. Если картинка уже разложена и её размер задан явно, вместо перекомпоновки достаточно перерисовки:

private sealed class FixedSizeImageView : AndroidX.AppCompat.Widget.AppCompatImageView
{
    // ...конструкторы...

    public override void RequestLayout()
    {
        if (IsLaidOut && !IsInLayout && Width > 0 && Height > 0)
        {
            Invalidate();
            return;
        }

        base.RequestLayout();
    }
}

Важно: это работает, только если размер миниатюры в разметке задан явно (WidthRequest и HeightRequest). Иначе экономить нечего.

7. Настройка RecyclerView

По умолчанию RecyclerView держит вне экрана всего две уже привязанные ячейки. При прокрутке туда-обратно каждая строка заново проходит привязку и раскладку. Я увеличила этот кэш и указала, что размер списка не зависит от содержимого:

collectionView.HandlerChanged += (_, _) =>
{
    if (collectionView.Handler?.PlatformView
        is not AndroidX.RecyclerView.Widget.RecyclerView recycler)
        return;

    recycler.SetItemViewCacheSize(8);
    recycler.HasFixedSize = true;
};

Ещё одна мелочь: в разметке у каталога стоит GridItemsLayout — он нужен только для Windows. На Android и iOS код подменяет его на LinearItemsLayout.Vertical, чтобы не платить за лишнюю раскладку на каждом кадре.

8. Ленивые представления каталога

У каталога три режима: список, плитка и карусель. В старой версии представления добавлялись в разметку заранее и переключались через IsVisible — то есть все держались в памяти и получали обновления привязок, хотя виден только один. Теперь каждое создаётся при первом выборе и кэшируется:

goodsViewsHost.Content = _vm.ViewMode switch
{
    CatalogViewMode.Icon     => _iconView     ??= new GoodsIconView(),
    CatalogViewMode.Carousel => _carouselView ??= new GoodsCarouselView(),
    _                        => _listView     ??= new GoodsListView(),
};

Обратные ходы и грабли

Не всё сработало с первого раза, и это, пожалуй, самая полезная часть.

CollectionView — не волшебная таблетка. В списке торговых точек есть алфавитный указатель справа. Я сделала его тоже на CollectionView — по инерции, раз уж всё переводим. На Android прокрутка такого указателя не доходила до последних букв. Сначала я попробовала сжимать буквы под высоту колонки, чтобы алфавит помещался целиком без прокрутки, и несколько раз подбирала размер шрифта и отступы. В итоге вернулась к обычному ScrollView с BindableLayout: для 30 элементов виртуализация не нужна, а простой контейнер работает предсказуемо.

MeasureFirstItem на iOS в MAUI 10.0.110. На iOS при ItemSizingStrategy="MeasureFirstItem" переиспользованная строка не перемерялась: цена, наличие и подпись «Не оферта» оставались с размерами предыдущего товара и съезжали. Пришлось на iOS переключиться на MeasureAllItems. Раз высота строки фиксированная, замер недорогой:

#if IOS
listView.ItemSizingStrategy = ItemSizingStrategy.MeasureAllItems;
#endif

Тонкие разделители на iPad. Однопиксельные линии между строками на iPad пропадали у части строк. Вместо линий строки стали отдельными скруглёнными карточками с зазором.

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

Что дальше

На Lenovo Tab K10 новая версия всё ещё теряет половину кадров: медиана 30 мс — это около 33 кадров в секунду при бюджете в 60. Пользоваться можно, и жалоб стало заметно меньше. Но выжимать из этих планшетов оставшееся я не планирую: их осталось немного, и они постепенно заменяются на Galaxy Tab A9+, где новая версия укладывается в бюджет кадра.

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

Чек-лист: как ускорить список в .NET MAUI

  • Мерить не только логику, но и кадры: adb shell dumpsys gfxinfo.

  • Мерить на самом слабом устройстве пользователей, а не на своём.

  • CollectionView вместо ListView, особенно если строки разной высоты.

  • Фиксированная высота строки и MeasureFirstItem (на iOS в MAUI 10.0.110 — MeasureAllItems).

  • В шаблоне строки — без BindableLayout, FlexLayout и RelativeSource.

  • Данные готовить до привязки к списку, в фоновом потоке.

  • Обновлять содержимое коллекции, а не подменять ItemsSource.

  • Явный размер у миниатюр; на Android — без requestLayout при догрузке картинки.

  • Больший кэш ячеек RecyclerView.

  • Тяжёлые представления создавать лениво.

  • Для коротких списков (до нескольких десятков элементов) виртуализация не нужна.

А как вы боретесь с производительностью списков в MAUI? Буду рада вашим приёмам в комментариях.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.