Почему CollectionView — теперь единственный путь
С выходом .NET MAUI 10 Microsoft наконец-то сделала то, чего все ждали (ну, или боялись): ListView, TableView и все связанные типы ячеек — TextCell, ImageCell, ViewCell, SwitchCell — официально объявлены устаревшими. И это не просто пометка в документации. При компиляции вы теперь получите CS0618, а в следующих версиях эти элементы либо удалят совсем, либо вынесут в отдельный compatibility-пакет.
CollectionView стал единственным рекомендованным элементом для отображения коллекций данных. Логично — он изначально задумывался как более гибкая и быстрая замена ListView. Но вот в чём проблема: на практике многие разработчики сталкиваются с лагами и тормозами, особенно на Android при работе с большими списками.
В этом руководстве я собрал всё, что нужно знать о CollectionView в .NET MAUI 10 — от базовой настройки до продвинутых техник, которые реально влияют на скорость прокрутки. Давайте разбираться.
Что нового в CollectionView в .NET MAUI 10
Оптимизированные обработчики стали стандартными
Пожалуй, главное изменение — оптимизированные обработчики (handlers) для CollectionView и CarouselView на iOS и Mac Catalyst теперь включены по умолчанию. Раньше, в .NET 9, их нужно было активировать руками, а сейчас вы просто обновляете target framework и получаете улучшенную производительность «из коробки».
Что конкретно дают эти обработчики:
- Более эффективное использование памяти
- Заметно более плавная прокрутка и рендеринг элементов
- Лучшая стабильность при быстрой навигации
Устаревание ListView и TableView
При компиляции проекта под .NET 10 вы увидите предупреждения вида:
warning CS0618: 'ListView' is obsolete: 'ListView is deprecated. Please use CollectionView instead.'
warning CS0618: 'TextCell' is obsolete: 'The controls which use TextCell (ListView and TableView) are obsolete.'
Если вы ещё не мигрировали свои списки на CollectionView — откладывать дальше уже некуда.
Генератор исходного кода XAML
Ещё одна приятная штука в .NET 10: XAML Source Generator теперь переводит обработку XAML полностью на этап компиляции. Для CollectionView это значит, что привязки данных и шаблоны элементов обрабатываются быстрее, а ошибки в XAML ловятся ещё до запуска приложения. Честно говоря, давно пора было это сделать.
Критические ошибки, которые убивают производительность
Прежде чем лезть в оптимизацию, давайте разберём типичные ошибки, которые я встречаю в проектах снова и снова. Серьёзно — устранение даже одной из них может кардинально изменить ситуацию.
Ошибка #1: CollectionView внутри ScrollView
Это самая частая и самая убийственная ошибка. Когда CollectionView вложен в ScrollView, виртуализация полностью отключается. Все элементы списка рендерятся одновременно, независимо от их количества. Тысяча элементов? Пожалуйста, все сразу в память.
<!-- ❌ НЕПРАВИЛЬНО: виртуализация отключена -->
<ScrollView>
<StackLayout>
<Label Text="Заголовок" />
<CollectionView ItemsSource="{Binding Items}" />
</StackLayout>
</ScrollView>
<!-- ✅ ПРАВИЛЬНО: используйте Header/Footer -->
<CollectionView ItemsSource="{Binding Items}">
<CollectionView.Header>
<Label Text="Заголовок" Padding="16" />
</CollectionView.Header>
</CollectionView>
Если нужно разместить контент до и после списка — используйте свойства Header и Footer самого CollectionView. Просто и эффективно.
Ошибка #2: CollectionView внутри StackLayout
StackLayout не ограничивает размер дочерних элементов, и CollectionView начинает расти, пока не отрендерит вообще всё. Виртуализация снова мертва. А если у вас ещё и инкрементальная загрузка — поздравляю, вы получили бесконечный цикл.
<!-- ❌ НЕПРАВИЛЬНО -->
<VerticalStackLayout>
<CollectionView ItemsSource="{Binding Items}"
RemainingItemsThreshold="5"
RemainingItemsThresholdReachedCommand="{Binding LoadMoreCommand}" />
</VerticalStackLayout>
<!-- ✅ ПРАВИЛЬНО: размещайте в Grid с RowDefinition="*" -->
<Grid RowDefinitions="Auto, *">
<Label Text="Мои элементы" Grid.Row="0" />
<CollectionView Grid.Row="1"
ItemsSource="{Binding Items}"
RemainingItemsThreshold="5"
RemainingItemsThresholdReachedCommand="{Binding LoadMoreCommand}" />
</Grid>
Ошибка #3: Глубоко вложенные шаблоны элементов
Каждый уровень вложенности в DataTemplate — это дополнительная работа при рендеринге и рециклировании. Вместо матрёшки из StackLayout используйте плоский Grid:
<!-- ❌ НЕПРАВИЛЬНО: глубокая вложенность -->
<DataTemplate x:DataType="models:Product">
<VerticalStackLayout>
<HorizontalStackLayout>
<Image Source="{Binding ImageUrl}" />
<VerticalStackLayout>
<Label Text="{Binding Name}" />
<HorizontalStackLayout>
<Label Text="{Binding Price}" />
<Label Text="{Binding Currency}" />
</HorizontalStackLayout>
</VerticalStackLayout>
</HorizontalStackLayout>
</VerticalStackLayout>
</DataTemplate>
<!-- ✅ ПРАВИЛЬНО: плоский Grid -->
<DataTemplate x:DataType="models:Product">
<Grid ColumnDefinitions="64, *, Auto"
RowDefinitions="Auto, Auto"
Padding="8"
ColumnSpacing="12">
<Image Source="{Binding ImageUrl}"
Grid.RowSpan="2"
WidthRequest="64"
HeightRequest="64" />
<Label Text="{Binding Name}"
Grid.Column="1"
FontAttributes="Bold" />
<Label Text="{Binding PriceDisplay}"
Grid.Column="1"
Grid.Row="1" />
</Grid>
</DataTemplate>
Компилируемые привязки: обязательная оптимизация
Если вы сделаете только одну оптимизацию из всего этого руководства — пусть это будут компилируемые привязки (compiled bindings). Серьёзно. По умолчанию .NET MAUI использует рефлексию для разрешения привязок во время выполнения, а compiled bindings переносят эту работу на этап компиляции.
Цифры говорят сами за себя:
- Привязки с уведомлениями об изменениях: в 8 раз быстрее
- Привязки OneTime: в 20 раз быстрее
Двадцать раз, Карл! И для активации нужно всего лишь добавить атрибут x:DataType:
<CollectionView ItemsSource="{Binding Products}">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Product">
<Grid Padding="8" ColumnDefinitions="64, *">
<Image Source="{Binding ImageUrl}" />
<VerticalStackLayout Grid.Column="1">
<Label Text="{Binding Name}" FontAttributes="Bold" />
<Label Text="{Binding Description}" MaxLines="2" />
</VerticalStackLayout>
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
Компилятор проверит, что свойства Name, Description и ImageUrl реально существуют в типе Product. Если что-то не так — ошибка при компиляции, а не тихий баг в рантайме, который вы обнаружите через три месяца.
Режим привязки OneTime для статических данных
По умолчанию привязки работают в режиме OneWay — подписываются на INotifyPropertyChanged и отслеживают все изменения. Но если данные в списке не меняются после загрузки (а в большинстве каталогов это именно так), переключите режим на OneTime:
<DataTemplate x:DataType="models:Product">
<Grid Padding="8">
<Label Text="{Binding Name, Mode=OneTime}" />
<Label Text="{Binding Description, Mode=OneTime}" />
<Label Text="{Binding PriceDisplay, Mode=OneTime}" />
</Grid>
</DataTemplate>
Это избавляет CollectionView от отслеживания изменений в каждом элементе, что заметно снижает нагрузку при прокрутке длинных списков.
Инкрементальная загрузка данных
Загрузка тысяч элементов разом — верный способ убить и производительность, и пользовательский опыт. К счастью, CollectionView поддерживает инкрементальную загрузку «из коробки» через RemainingItemsThreshold.
Базовая реализация с MVVM
// ViewModel
public partial class ProductsViewModel : ObservableObject
{
private readonly IProductService _productService;
private int _currentPage = 0;
private const int PageSize = 20;
[ObservableProperty]
private ObservableCollection<Product> _products = new();
[ObservableProperty]
private bool _isLoadingMore;
public ProductsViewModel(IProductService productService)
{
_productService = productService;
}
[RelayCommand]
private async Task LoadInitialDataAsync()
{
_currentPage = 0;
var items = await _productService.GetProductsAsync(_currentPage, PageSize);
Products = new ObservableCollection<Product>(items);
}
[RelayCommand]
private async Task LoadMoreAsync()
{
if (IsLoadingMore) return;
IsLoadingMore = true;
try
{
_currentPage++;
var items = await _productService.GetProductsAsync(_currentPage, PageSize);
foreach (var item in items)
{
Products.Add(item);
}
}
finally
{
IsLoadingMore = false;
}
}
}
<!-- XAML -->
<Grid RowDefinitions="*">
<CollectionView ItemsSource="{Binding Products}"
RemainingItemsThreshold="5"
RemainingItemsThresholdReachedCommand="{Binding LoadMoreCommand}">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Product">
<Grid Padding="12" ColumnDefinitions="64, *">
<Image Source="{Binding ImageUrl, Mode=OneTime}"
WidthRequest="64" HeightRequest="64" />
<Label Grid.Column="1"
Text="{Binding Name, Mode=OneTime}"
FontSize="16" />
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
<CollectionView.Footer>
<ActivityIndicator IsRunning="{Binding IsLoadingMore}"
IsVisible="{Binding IsLoadingMore}"
HorizontalOptions="Center"
Margin="0,16" />
</CollectionView.Footer>
</CollectionView>
</Grid>
Подводные камни инкрементальной загрузки
Тут есть несколько нюансов, на которые я лично наступал:
- Не помещайте CollectionView в StackLayout — получите бесконечный цикл загрузки, потому что элемент будет расти без ограничений
- На Android с Header/Footer: если задали
RemainingItemsThreshold="0"и одновременно используете Header или Footer, порог может просто не сработать. Минимальное рабочее значение —2 - Группированные данные на iOS: событие
RemainingItemsThresholdReachedиногда не срабатывает при группировке на iOS и Windows — это известный баг, который пока не исправлен
Миграция с ListView на CollectionView
Итак, вы обновляетесь до .NET MAUI 10 и у вас ещё остались ListView. Вот практический чек-лист для миграции.
Замена элементов управления
<!-- Было: ListView -->
<ListView ItemsSource="{Binding Items}"
HasUnevenRows="True"
ItemSelected="OnItemSelected">
<ListView.ItemTemplate>
<DataTemplate>
<ViewCell>
<StackLayout Padding="10">
<Label Text="{Binding Title}" FontSize="16" />
<Label Text="{Binding Subtitle}" FontSize="12" />
</StackLayout>
</ViewCell>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<!-- Стало: CollectionView -->
<CollectionView ItemsSource="{Binding Items}"
SelectionMode="Single"
SelectionChangedCommand="{Binding ItemSelectedCommand}"
SelectionChangedCommandParameter="{Binding Source={RelativeSource Self}, Path=SelectedItem}">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:MyItem">
<Grid Padding="10" RowDefinitions="Auto, Auto">
<Label Text="{Binding Title}" FontSize="16" />
<Label Text="{Binding Subtitle}" FontSize="12" Grid.Row="1" />
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
Ключевые отличия при миграции
Вот что нужно держать в голове:
- ViewCell больше не нужен: CollectionView не использует концепцию ячеек. Удалите все
ViewCell,TextCell,ImageCell— корневой элемент DataTemplate должен быть обычным View - ItemSelected → SelectionChanged: событие изменилось, аргументы другие. Используйте
SelectionChangedEventArgsвместоSelectedItemChangedEventArgs - ContextActions → SwipeView: свайп-действия теперь реализуются через обёртку
SwipeViewвнутри DataTemplate - GroupDisplayBinding: для заголовков групп нужен
GroupHeaderTemplate - Pull-to-Refresh: оборачивайте CollectionView в
RefreshView
Оптимизация для Android: борьба с лагами прокрутки
Будем честны — Android остаётся самой проблемной платформой для CollectionView. Под капотом используется RecyclerView, и при неправильной настройке шаблонов прокрутка тормозит ощутимо, особенно на бюджетных устройствах.
Стратегия определения размера элементов
Когда CollectionView не знает точный размер элемента заранее, он пересчитывает layout при каждом рециклировании. Это дорого. Указание фиксированной высоты через ItemSizingStrategy здорово ускоряет работу:
<CollectionView ItemsSource="{Binding Items}"
ItemSizingStrategy="MeasureFirstItem">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Product">
<Grid HeightRequest="72" Padding="12" ColumnDefinitions="56, *">
<Image Source="{Binding ImageUrl, Mode=OneTime}"
WidthRequest="56" HeightRequest="56" />
<Label Grid.Column="1"
Text="{Binding Name, Mode=OneTime}"
VerticalOptions="Center" />
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
MeasureFirstItem говорит CollectionView: «измерь первый элемент и используй его размер для всех остальных». Количество layout-вычислений падает в разы.
Кэширование изображений
Если в списке есть картинки — обязательно позаботьтесь о кэшировании. Встроенный механизм .NET MAUI кэширует изображения по умолчанию, но для сетевых картинок стоит задать явные размеры, чтобы избежать лишних перерасчётов layout:
<Image Source="{Binding ImageUrl, Mode=OneTime}"
WidthRequest="56"
HeightRequest="56"
Aspect="AspectFill" />
Для более серьёзного контроля присмотритесь к FFImageLoading.Maui — там и дисковое кэширование, и плейсхолдеры при загрузке, и автоматический downsampling.
Сборка в режиме Release
Звучит банально, но я не раз видел, как разработчики делают выводы о производительности CollectionView на Debug-сборке. Не делайте так. В Release включается AOT-компиляция и trimming, и картина меняется кардинально. Всегда тестируйте на реальном устройстве в Release:
dotnet build -f net10.0-android -c Release
Продвинутые техники: Grid-компоновка и DataTemplateSelector
Сетка вместо списка
CollectionView легко превращается в сетку через свойство ItemsLayout. Для каталогов товаров или фотогалерей — самое то:
<CollectionView ItemsSource="{Binding Products}">
<CollectionView.ItemsLayout>
<GridItemsLayout Orientation="Vertical"
Span="2"
HorizontalItemSpacing="8"
VerticalItemSpacing="8" />
</CollectionView.ItemsLayout>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Product">
<Border StrokeShape="RoundRectangle 8"
Stroke="{StaticResource Gray200}"
Padding="8">
<Grid RowDefinitions="140, Auto, Auto">
<Image Source="{Binding ImageUrl, Mode=OneTime}"
HeightRequest="140"
Aspect="AspectFill" />
<Label Text="{Binding Name, Mode=OneTime}"
Grid.Row="1"
FontAttributes="Bold"
LineBreakMode="TailTruncation" />
<Label Text="{Binding PriceDisplay, Mode=OneTime}"
Grid.Row="2" />
</Grid>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
DataTemplateSelector для разнородных элементов
Когда в списке разные типы элементов, DataTemplateSelector — ваш друг. Гораздо чище, чем пихать условную логику в один шаблон:
public class FeedItemTemplateSelector : DataTemplateSelector
{
public DataTemplate TextPostTemplate { get; set; }
public DataTemplate ImagePostTemplate { get; set; }
public DataTemplate VideoPostTemplate { get; set; }
protected override DataTemplate OnSelectTemplate(object item, BindableObject container)
{
return item switch
{
TextPost => TextPostTemplate,
ImagePost => ImagePostTemplate,
VideoPost => VideoPostTemplate,
_ => TextPostTemplate
};
}
}
<CollectionView ItemsSource="{Binding FeedItems}"
ItemTemplate="{StaticResource FeedTemplateSelector}" />
Но имейте в виду: каждый уникальный шаблон создаёт свой пул рециклированных элементов. Чем меньше разных шаблонов — тем лучше работает рециклирование.
ObservableCollection и пакетные обновления
ObservableCollection<T> отлично подходит для автоматического обновления UI при изменении коллекции. Но есть подвох — каждый вызов Add или Remove генерирует событие CollectionChanged, а значит и перерисовку. При добавлении 50 элементов по одному — это 50 перерисовок.
Для пакетных обновлений есть простой трюк с заменой всей коллекции:
// Неэффективно: каждый Add вызывает обновление UI
foreach (var item in newItems)
{
Products.Add(item);
}
// Эффективнее: создаём новую коллекцию
var updatedList = new ObservableCollection<Product>(
Products.Concat(newItems)
);
Products = updatedList;
Второй подход генерирует только одно событие привязки вместо N отдельных обновлений. При инкрементальной загрузке (20–30 элементов за раз) разница в плавности вполне заметна.
Профилирование: измеряйте, прежде чем оптимизировать
Золотое правило — не оптимизируйте вслепую. Прежде чем что-то менять, убедитесь, что знаете, где именно узкое место. .NET MAUI 10 добавил встроенные средства диагностики для мониторинга layout-производительности.
Используйте dotnet-trace для профилирования на реальном устройстве:
# Запуск трассировки на Android
dotnet-trace collect --process-id <pid> --providers Microsoft-Maui-Essentials
# Анализ результатов
dotnet-trace convert trace.nettrace --format speedscope
А для поиска утечек памяти (особенно когда элементы списка содержат изображения) пригодится dotnet-gcdump:
dotnet-gcdump collect --process-id <pid>
dotnet-gcdump report <file.gcdump>
И ещё раз: профилируйте только на реальных устройствах в Release-конфигурации. Эмулятор врёт, а Debug-режим добавляет кучу проверок, которые замедляют всё приложение.
Чек-лист оптимизации CollectionView
Вот компактный чек-лист, который удобно держать под рукой при код-ревью или перед релизом:
- CollectionView размещён в
Grid, а не вScrollViewилиStackLayout - Указан
x:DataTypeдля компилируемых привязок - Статические данные используют
Mode=OneTime - DataTemplate имеет плоскую структуру (максимум 2–3 уровня вложенности)
- Задан
ItemSizingStrategy="MeasureFirstItem"при одинаковых элементах - Для длинных списков реализована инкрементальная загрузка
- Изображения имеют фиксированные размеры (
WidthRequest/HeightRequest) - Производительность тестируется в Release на реальных устройствах
- Проект нацелен на .NET 10 для получения оптимизированных обработчиков
FAQ
Можно ли продолжать использовать ListView в .NET MAUI 10?
Технически — да. ListView пока не удалён, а только помечен как устаревший. Код скомпилируется, но вы получите предупреждения CS0618. Microsoft планирует в будущем либо полностью убрать ListView, либо вынести в отдельный compatibility-пакет. Так что лучше начать миграцию на CollectionView, пока есть время.
Почему CollectionView тормозит на Android при быстрой прокрутке?
Самые частые причины: нет компилируемых привязок, слишком глубоко вложенные шаблоны, не заданы фиксированные размеры элементов и (классика) CollectionView сидит внутри ScrollView. Пройдитесь по чек-листу выше. Если после всех оптимизаций проблема остаётся — присмотритесь к сторонним компонентам от Telerik, DevExpress или Syncfusion, у них свои реализации виртуализированных списков.
Как реализовать Pull-to-Refresh с CollectionView?
В отличие от ListView, у CollectionView нет встроенного Pull-to-Refresh. Оберните его в RefreshView и привяжите IsRefreshing и Command к ViewModel. RefreshView сам покажет индикатор загрузки и вызовет команду при жесте обновления.
Как выбрать между вертикальным списком и GridItemsLayout?
Стандартный вертикальный LinearItemsLayout подходит для элементов с текстом — список сообщений, лента новостей, настройки. GridItemsLayout лучше для каталогов товаров, фотогалерей и всего, где элементы компактны и визуальны. Но учтите, что Grid-компоновка чуть тяжелее при рендеринге, поэтому шаблоны ячеек стоит держать максимально простыми.
Стоит ли использовать NativeAOT с CollectionView?
NativeAOT в .NET 10 доступен для iOS, Mac Catalyst и Windows (Android пока нет). Он заметно ускоряет запуск приложения и уменьшает размер сборки, а для CollectionView это значит более быстрое появление первого экрана со списком. Но есть нюанс — все ваши зависимости должны быть trim-compatible и AOT-compatible. Проверьте совместимость библиотек перед включением.