В прошлой статье я рассказывала, как переводила приложение для торговых представителей с 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 — уже нет. Если бы я тестировала только на своём планшете, я бы не поняла, на что жалуются агенты. Тестировать производительность нужно на самом слабом устройстве, которое реально есть у пользователей.
Честная оговорка к этому сравнению: строки в новом каталоге выше и богаче. На экран 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 IOSlistView.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? Буду рада вашим приёмам в комментариях.
ссылка на оригинал статьи https://habr.com/ru/articles/1099326/