Как стать автором
Обновить

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

Время на прочтение8 мин
Количество просмотров10K
Приветствую.

Этот пост меня побудило практически полное отсутствие описание того, как же на платформе 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, обеспечивая их плавную прокрутку и своевременную подгрузку.

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

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

Публикации