클라우드 스토리지 비용 최적화: S3, Azure Blob, GCS 자동 계층화로 70% 절감하기

AWS S3, Azure Blob, GCS 스토리지 비용을 수명주기 정책과 자동 계층화로 최대 70%까지 절감하는 실전 가이드입니다. Terraform 코드와 비용 시뮬레이션을 포함합니다.

Почему 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

Вот компактный чек-лист, который удобно держать под рукой при код-ревью или перед релизом:

  1. CollectionView размещён в Grid, а не в ScrollView или StackLayout
  2. Указан x:DataType для компилируемых привязок
  3. Статические данные используют Mode=OneTime
  4. DataTemplate имеет плоскую структуру (максимум 2–3 уровня вложенности)
  5. Задан ItemSizingStrategy="MeasureFirstItem" при одинаковых элементах
  6. Для длинных списков реализована инкрементальная загрузка
  7. Изображения имеют фиксированные размеры (WidthRequest/HeightRequest)
  8. Производительность тестируется в Release на реальных устройствах
  9. Проект нацелен на .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. Проверьте совместимость библиотек перед включением.

저자 소개 Editorial Team

Our team of expert writers and editors.