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