Search code examples
c#uwpcollectionviewsourceicollectionview

Implement deferred item loading in a grouped ICollectionView


I have a set of items (~12.000) i want to show in a ListView. Each of those items is a view model that has an assigned image that is not part of the app package (it's in an 'external' folder on a local disc). And because of UWP's limitations i can't (afaik and tested) assign an Uri to the ImageSource and have to use the SetSourceAsync method instead. Because of this the initial loading time of the app is too high, because all ImageSource objects have to be initialized at start up even if the image will not be seen by the user (the list is unfiltered at startup) and the resulting memory consumption is ~4GB. Copying the image files to an app data directory would resolve the issue, but is no solution for me, because the images are updated regularly and it would waste disc space.

The items are displayed in a ListView that uses a grouped ICollectionView as source.

Now i thought i could implement either IItemsRangeInfo or ISupportIncrementalLoading on each group and defer the initialization of the view model so only images are loaded if they are to be displayed. I testet this and it does not seem to work because neither interface's method gets called on the groups at runtime (please correct me here if that is not true and can be achieved). The current (not working) version uses a custom ICollectionView (for testing purposes) but the DeferredObservableCollection could as well implement IGrouping<TKey, TElement> and be used in a CollectionViewSource.

Is there any way i could achieve the deferred initialization or use an Uri for the image source or do i have to use a 'plain' collection or custom ICollectionView as ItemsSource on the ListView that implements the desired behaviour?

Current target version of the app: 1803 (Build 17134) Current target version of the app: Fall Creators Update (Build 16299) Both (minimum and target version) can be changed.

Code for creating the image source:

public class ImageService
{
    // ...
    private readonly IDictionary<short, ImageSource> imageSources;

    public async Task<ImageSource> GetImageSourceAsync(Item item)
    {
        if (imageSources.ContainsKey(item.Id))
            return imageSources[item.Id];

        try
        {
            var imageFolder = await storageService.GetImagesFolderAsync();
            var imageFile = await imageFolder.GetFileAsync($"{item.Id}.jpg");

            var source = new BitmapImage();
            await source.SetSourceAsync(await imageFile.OpenReadAsync());

            return imageSources[item.Id] = source;
        }
        catch (FileNotFoundException)
        {
            // No image available.
            return imageSources[item.Id] = unknownImageSource;
        }
    }
}

Code for the resulting groups that are returned by the ICollectionView.CollectionGroups property:

public class CollectionViewGroup : ICollectionViewGroup
{
    public object Group { get; }

    public IObservableVector<object> GroupItems { get; }

    public CollectionViewGroup(object group, IObservableVector<object> items)
    {
        Group = group ?? throw new ArgumentNullException(nameof(group));
        GroupItems = items ?? throw new ArgumentNullException(nameof(items));
    }
}

Code of the collection that contains the items of each group:

public sealed class DeferredObservableCollection<T, TSource>
    : ObservableCollection<T>, IObservableVector<T>, IItemsRangeInfo //, ISupportIncrementalLoading
    where T : class
    where TSource : class
{
    private readonly IList<TSource> source;
    private readonly Func<TSource, Task<T>> conversionFunc;

    // private int currentIndex; // Used for ISupportIncrementalLoading.

    // Used to get the total number of items when using ISupportIncrementalLoading.
    public int TotalCount => source.Count;

    /// <summary>
    /// Initializes a new instance of the <see cref="DeferredObservableCollection{T, TSource}"/> class.
    /// </summary>
    /// <param name="source">The source collection.</param>
    /// <param name="conversionFunc">The function used to convert item from <typeparamref name="TSource"/> to <typeparamref name="T"/>.</param>
    /// <exception cref="ArgumentNullException">
    /// <paramref name="source"/> is <see langword="null"/> or
    /// <paramref name="conversionFunc"/> is <see langword="null"/>.
    /// </exception>
    public DeferredObservableCollection(IList<TSource> source, Func<TSource, Task<T>> conversionFunc)
    {
        this.source = source ?? throw new ArgumentNullException(nameof(source));
        this.conversionFunc = conversionFunc ?? throw new ArgumentNullException(nameof(conversionFunc));

        // Ensure the underlying lists capacity.
        // Used for IItemsRangeInfo.
        for (var i = 0; i < source.Count; ++i)
            Items.Add(default);
    }

    private class VectorChangedEventArgs : IVectorChangedEventArgs
    {
        public CollectionChange CollectionChange { get; }

        public uint Index { get; }

        public VectorChangedEventArgs(CollectionChange collectionChange, uint index)
        {
            CollectionChange = collectionChange;
            Index = index;
        }
    }

    protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
    {
        base.OnCollectionChanged(e);
        // For testing purposes the peformed action is not differentiated.
        VectorChanged?.Invoke(this, new VectorChangedEventArgs(CollectionChange.ItemInserted, (uint)e.NewStartingIndex));
    }

    //#region ISupportIncrementalLoading Support

    //public bool HasMoreItems => currentIndex < source.Count;

    //public IAsyncOperation<LoadMoreItemsResult> LoadMoreItemsAsync(uint count)
    //{
          // Won't get called.
    //    return AsyncInfo.Run(async cancellationToken =>
    //    {
    //        if (currentIndex >= source.Count)
    //            return new LoadMoreItemsResult();

    //        var addedItems = 0u;

    //        while (currentIndex < source.Count && addedItems < count)
    //        {
    //            Add(await conversionFunc(source[currentIndex]));
    //            ++currentIndex;
    //            ++addedItems;
    //        }

    //        return new LoadMoreItemsResult { Count = addedItems };
    //    });
    //}

    //#endregion

    #region IObservableVector<T> Support

    public event VectorChangedEventHandler<T> VectorChanged;

    #endregion

    #region IItemsRangeInfo Support

    public void RangesChanged(ItemIndexRange visibleRange, IReadOnlyList<ItemIndexRange> trackedItems)
    {
        // Won't get called.
        ConvertItemsAsync(visibleRange, trackedItems).FireAndForget(null);
    }

    private async Task ConvertItemsAsync(ItemIndexRange visibleRange, IReadOnlyList<ItemIndexRange> trackedItems)
    {
        for (var i = visibleRange.FirstIndex; i < source.Count && i < visibleRange.LastIndex; ++i)
        {
            if (this[i] is null)
            {
                this[i] = await conversionFunc(source[i]);
            }
        }
    }

    public void Dispose()
    { }

    #endregion
}

Solution

  • From the perspective of reducing memory consumption, the method of using BitmapImage.SetSourceAsync is not recommended, because it is not conducive to memory release. But considering your actual situation, I can provide some suggestions to help you optimize application performance.

    1. Don't initialize 12000 images uniformly

    Reading 12,000 pictures at a time will inevitably increase the memory usage. But we can create UserControl as one picture unit, and hand over the work of loading pictures to these units.

    -ImageItem.cs

    public class ImageItem
    {
        public string Name { get; set; }
        public BitmapImage Image { get; set; } = null;
        public ImageItem()
        {
    
        }
        public async Task Init()
        {
            // do somethings..
            // get image from folder, named imageFile
            Image = new BitmapImage();
            await Image.SetSourceAsync(await imageFile.OpenReadAsync());
        }
    }
    

    -ImageItemControl.xaml

    <UserControl
        ...>
    
        <StackPanel>
            <Image Width="200" Height="200" x:Name="MyImage"/>
        </StackPanel>
    </UserControl>
    

    -ImageItemControl.xaml.cs

    public sealed partial class ImageItemControl : UserControl
    {
        public ImageItemControl()
        {
            this.InitializeComponent();
        }
    
    
        public ImageItem Data
        {
            get { return (ImageItem)GetValue(DataProperty); }
            set { SetValue(DataProperty, value); }
        }
    
        public static readonly DependencyProperty DataProperty =
            DependencyProperty.Register("Data", typeof(ImageItem), typeof(ImageItemControl), new PropertyMetadata(null,new PropertyChangedCallback(Data_Changed)));
    
        private static async void Data_Changed(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            if(e.NewValue != null)
            {
                var image = e.NewValue as ImageItem;
                var instance = d as ImageItemControl;
                if (image.Image == null)
                {
                    await image.Init();
                }
                instance.MyImage.Source = image.Image;
            }
        }
    }
    

    -Usage

    <Page.Resources>
        <DataTemplate x:DataType="local:ImageItem" x:Key="ImageTemplate">
            <controls:ImageItemControl Data="{Binding}"/>
        </DataTemplate>
    </Page.Resources>
    <Grid>
        <GridView ItemTemplate="{StaticResource ImageTemplate}"
                  .../>
    </Grid>
    

    Please modify this code according to your actual situation

    There are some advantages to this. Through the distributed method, on the one hand, the loading speed of pictures is increased (simultaneous loading). On the other hand, with virtualization, some pictures are not actually rendered, which can reduce memory usage.

    2. Limiting the resolution of BitmapImage

    This is very important, it can greatly reduce the memory consumption when loading a large number of pictures.

    For example, you have a picture with 1920x1080 resolution, but only 200x200 resolution is displayed on the application. Then loading the original image will waste system resources.

    We can modify the ImageItem.Init method:

    public async Task Init()
    {
        // do somethings..
        // get image from folder, named imageFile
        Image = new BitmapImage() { DecodePixelWidth = 200 };
        await Image.SetSourceAsync(await imageFile.OpenReadAsync());
    }
    

    Hope these two methods can help you reduce memory usage.