Делаем виртуализацию данных в LongListSelector

в 10:25, , рубрики: tutorial, windows phone, windows phone 8, wp8, виртуализация данных, метки: , , ,

Приветствую.

Этот пост меня побудило практически полное отсутствие описание того, как же на платформе WP8 делать виртуализацию длинных списков. Методы, использующиеся в дектопной Windows 8 тут не работают. Например, тот же ISupportIncrementalLoading попросту отсутствует на WP8.

А так, как я (в свободное от работы менеджером время :) ) делаю приложение, где такая виртуализация жизненно необходима, решил поделиться своим решением. Сразу скажу, что не претендую на идеальность, это просто работающий вариант, который может сэкономить вам часы гугления и тестов.

PS Сейчас я перешел на iOS под Monotouch и подобных проблем нет совсем, поэтому решил достать статью из черновиков. Мало ли кому окажется полезным.

Изложение будет в виде туториала, чтобы его могли воспроизвести даже те, кто еще мало знаком с платформой и .net приложениями.

О чем я говорю

  • У нас есть список.
  • В списке есть over 100500 пунктов. Для полноты задачи — каждый из пунктов отображает картинку.
  • Мы хотим их отображать так, чтобы телефон не умер от нехватки памяти. И не просто отображать, а полноценно с ними работать

Что же нужно для этого сделать

Создаем в XAML LongListSelector

Именно лонглистселектор является контролом, официально рекомендованным MS для разработки списков. ListBox настоятельно рекомендовано более не использовать. Что ж, не будем.

<phone:LongListSelector Width="480"
                                            DataContext="{Binding}"
                                            Name="List_ListSelector"
                                            ItemTemplate="{StaticResource List_ListSelectorItemDataTemplate}" />
Создаем в App.xaml DataTemplate с шаблоном для нашего LongListSelector.

В моем случае это просто текст, который отображает номер элемента и картинку.

<Application.Resources>  
        <DataTemplate x:Key="List_ListSelectorItemDataTemplate">
            <StackPanel Margin="0,0,0,27" Height="400">
                <TextBlock Text="{Binding Path=ID}" />
                <Image Source="{Binding Path=ImageToShow}", Name="ListImage"></Image>
            </StackPanel>
        </DataTemplate>
    </Application.Resources>
Создаем хэлперный класс, который будет оберткой для нашего листа, коллекции и данных. Назовем его LongVirtualList.
    class LongVirtualList
    {
        public LongListSelector List; // это сам список
        
        public ObservableCollection<BaseListElement> Collection; //это коллекция, которая служит ресурсом для списка

        public DataSource DataSource;// это источник данных для коллекции. Основная задача - по номеру элемента коллекции отдать нам какую-то информацию. В данном случае просто заглушка, умеющая отдавать картинки.
        
        public LongVirtualList(LongListSelector longListSelector)
        {
            this.List = longListSelector;

            this.Collection = new ObservableCollection<BaseListElement>();
            this.DataSource = new DataSource();
            this.InitializeCollection(this.DataSource); // Этот метод заполняет коллекцию пустыми элементами в количестве, maxCount от источника данных. Каждому элементу присваивается постоянный номер. 

            this.List.ItemsSource = this.Collection;

            longListSelector.ItemRealized+=this.longListSelector_ItemRealized;
            longListSelector.ItemUnrealized+=this.longListSelector_ItemUnrealized;
        }

        private void InitializeCollection(DataSource dataSource)
        {
            for (int i = 0; i < dataSource.Count; i++)
            {
                this.Collection.Add(new ListTestElement(i)); //ListTestElement это наследник-заглушка класса BaseListElement.
            }
        }

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

Теперь идет самое интересное, собственно то, ради чего я тут все это пишу.

MS представило следующие события:

ItemRealized и ItemUnrealized

Первое из них срабатывает тогда, когда List хочет загрузить в себя новый итем. Второе срабатывает тогда, когда данный итем требуется выгрузить.

Очень важное дополнение: Управлять вызовом этих событий вы не можете. Они вызываются автоматически, когда телефон «чувствует», что ему скоро потребуются данные. Как он это понимает? По тому, сколько элементов списка помещается на экране + чуть-чуть предыдущих и следующих. И тут прячется интересный подводный камень, который я выяснил опытным путем, убив на это несколько часов. Количество элементов списка на экране он определяет до рендеринга. Элементы с динамическим размером (например, картинки) игнорируются, если только не задавать их размер вручную.

Например, если вы укажете в XAML высоту StackPanel Height=«400», то событие ItemRealized будет вызвано последовательно для ~6 элементов списка. Если же в этом же примере вы не укажете высоту, то внешний результат будет тем же (если вы используете большую картинку), однако движок попробует загрузить уже штук 50 элементов и велика вероятность схватить ошибку переполнения памяти.

Итак:


        public void longListSelector_ItemUnrealized(object sender, ItemRealizationEventArgs e)
        {
            BaseListElement item = (BaseListElement)e.Container.Content;

            if (item != null)
            {
                item.NullCache();
            }
        }

        public void longListSelector_ItemRealized(object sender, Microsoft.Phone.Controls.ItemRealizationEventArgs e)
        {
            BaseListElement item = (BaseListElement)e.Container.Content;

            if (item != null)
            {
                if (item.Cached == false) { item.FillCache(); }
            }
        }
Настало время пройтись по самим элементам списка.

Базовым элементом списка является класс BaseListElement. В этот же самый список можно добавлять любых потомков базового класса.

class BaseListElement : PropertyHelper //обратите внимание, мы наследуем PropertyChangedEventHandler от другого класса. Это позволяет обрабатывать изменения как базовых свойств BaseListElement, так и свойств его потомков с помощью одного EventHandler. В классах-потомках от BaseListElement наследовать PropertyHelper уже не нужно.
    {
        public int ID;
        public bool Cached;
        
        private BitmapImage imageToShow;
        public BitmapImage ImageToShow
        {
            get
            {
                return this.imageToShow;
            }
            set 
            {
                this.imageToShow = value;

                NotifyChange("ImageToShow"); 
            }
        }

        public BaseListElement(int id)
        {
            this.ID = id;
            this.Cached = false;
        }

        public virtual void NullCache()
        {
            this.Cached = false;
            if (this.ImageToShow != null)
            {
                this.ImageToShow = null;
                GC.Collect();
             }
        }

        public virtual void FillCache()
        {
            this.Cached = true;
           // this.ImageToShow = DataSource.LoadImage(this.ID);  тут любой метод загрузки картинки, у меня он реализован в дочерних классах
            // например, такой 
            BitmapImage bi = new BitmapImage(new Uri("Assets/test.jpg", UriKind.Relative));
            bi.DecodePixelWidth = 400;
            bi.CreateOptions = BitmapCreateOptions.IgnoreImageCache;
            this.ImageToShow = bi;
        }

       //Ничто не мешает нам так же сделать асинхронную загрузку, и использовать этот метод как основной.
        public virtual async Task FillCacheAsync()
        {
            this.FillCache(); 
        }
    }

Думаете, все? Как бы не так. Код с подобной реализацией класса умрет через несколько сотен загруженных картинок. Все потому, что WP8 очень «своевольно» (не то слово!) обращается с кэшем BitmapImage данных и не выгружает картинки самостоятельно ни в какую!

Поэтому модифицируем методы NullCache() и FillCache(). Теперь они требуют для работы ссылки на контрол Image, которые можно передать им из методов. Мы получим эту ссылку из контейнера e.Container методов ItemUnrealized и ItemRelized.

Итак, правильное кэширование картинок:

public virtual void NullCache(Image image)
        {
            if (this.ImageToShow != null)
            { //Обнулений потребуется не одно, а сразу несколько. 
                BitmapImage bitmapImage = image.Source as BitmapImage;
                bitmapImage.UriSource = null;//обнуляем само изображение
                image.Source = null;//обнуляем привязку, иначе это изображение останется навсегда в кэше контрола.

                DisposeImage(this.ImageToShow)// Обнуляем переменную в данном классе, переопределяя ее заранее заданным маленьким изображением. Просто обнуление =null ничего не даст, переменная при привязке помечается как статическая и мусорщик на ней не работает. 
                GC.Collect();
            }
            this.Cached = false;
        }

        public virtual void FillCache(Image image)
        {
            this.Cached = true;

            BitmapImage bi = new BitmapImage(new Uri("Assets/test.jpg", UriKind.Relative));
            bi.DecodePixelWidth = 400;
            bi.CreateOptions = BitmapCreateOptions.IgnoreImageCache;
            this.ImageToShow = bi;

//при обнулении кэша контрола image мы убили ему source, поэтому придется привязывать ресурс динамически при каждом заполнении контрола. А привязку в XAML можно вообще убрать. 

            Binding ImageValueBinding = new Binding("ImageToShow");
            ImageValueBinding.Source = this;
            args.ImageControl.SetBinding(Image.SourceProperty, ImageValueBinding);

        }

    public static void DisposeImage(BitmapImage image)
    {
        Uri uri= new Uri("oneXone.png", UriKind.Relative);//ссылка на картинку 1x1, которая загружена в проект
        StreamResourceInfo sr=Application.GetResourceStream(uri);
        try
        {
         using (Stream stream=sr.Stream)
         {
          image.DecodePixelWidth=1; //Крайне важный пункт. Именно от него зависит, сколько картинка потребует места для хранения. Если на него "забить", то картинка растянется на изначальный размер BitmapImage и отожрет кучу памяти. Как сделать так, чтобы использованные картинки вообще не занимали места в WP8, я не нашел. (т.е. как их убить полностью, не используя хаков прямой работы с данными).
          image.SetSource(stream);
         }
        }
        catch
        {}
}

Откуда мы возьмем Image для наших методов подгрузки/выгрузки элементов?
Вот отсюда:

        public void longListSelector_ItemRealized(object sender, Microsoft.Phone.Controls.ItemRealizationEventArgs e)
        {
            BaseListElement item = (BaseListElement)e.Container.Content;
            Image img= FindChild<Image>(e.Container, "ListImage");

            if (item != null)
            {
                if (item.Cached == false) { item.FillCache(); }
            }
        }

        public static T FindChild<T>(DependencyObject parent, string childName)
        where T : DependencyObject
        {
            if (parent == null)
            {
                return null;
            }

            T foundChild = null;

            int childrenCount = VisualTreeHelper.GetChildrenCount(parent);
            for (int i = 0; i < childrenCount; i++)
            {
                DependencyObject child = VisualTreeHelper.GetChild(parent, i);
                var childType = child as T;
                if (childType == null)
                {
                    // Рекурсивно идем вниз по дереву
                    foundChild = FindChild<T>(child, childName);

                    if (foundChild != null)
                    {
                        break;
                    }
                }
                else if (!string.IsNullOrEmpty(childName))
                {
                    var frameworkElement = child as FrameworkElement;
                    // Если задано имя потомка
                    if (frameworkElement != null && frameworkElement.Name == childName)
                    {
                        foundChild = (T)child;
                        break;
                    }

                    // Если мы нашли элемент, но он содержит еще вложения с тем же типом и именем
                    foundChild = FindChild<T>(child, childName);
                }
                else
                {
                    foundChild = (T)child;
                    break;
                }
            }

            return foundChild;
        }


Осталась самая малость, покажу реализацию хэлперного класса PropertyHelper, у нас ведь подробный туториал:

   public abstract class PropertyHelper:INotifyPropertyChanged
    {
        protected void NotifyChange(string args)
        {
            if (PropertyChanged != null)
            {
                PropertyChanged(this, new PropertyChangedEventArgs(args));
            }
        }

        public event PropertyChangedEventHandler PropertyChanged;
    }

Наличие событий PropertyChanged в свойствах элементов списка гарантирует нам обновление элементов даже если они уже загружены в список, без дополнительных телодвижений. Это очень удобно. Например, мы можем сменить в настройках приложения язык и при обновлении ресурсов списка, его элементы обновятся сами собой, без перезагрузки страницы.

Последний момент и все готово.

        public MainPage()
        {
            InitializeComponent();
            LongVirtualList virtualList = new LongVirtualList(List_ListSelector);
        }

На эту основу можете прикручивать навороты, например дополнительный кэш или что-то, что вы еще хотите сделать.

Данный список у меня работает с тестовой коллекцией из тысяч картинок 1600*1200, обеспечивая их плавную прокрутку и своевременную подгрузку.

Вопрос асинхронной подгрузки данных я затрагивать тут не стал.

Рад, если кому-то все это будет полезным. Во всяком случае, перерыв весь английский интернет, какого-либо сборного рецепта, подобного этому, не нашел, пришлось все изобретать самому.

Автор: gleb_kudr

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js