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

от автора

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

Этот пост меня побудило практически полное отсутствие описание того, как же на платформе 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);                 // If the child is not of the request child type child                 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, обеспечивая их плавную прокрутку и своевременную подгрузку.

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

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

ссылка на оригинал статьи http://habrahabr.ru/post/164543/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *