Если вы — профессиональный разработчик, то вам должно быть знакомым чувство, когда хочется сделать что-то не для денег а для души. В один из таких вечеров мне захотелось немного отвлечься и написать именно такое приложение.
Мы находимся в Украине, где локальных приложений для Windows Phone не так много, а приложений на национальную тематику еще меньше. Будучи меломаном, я решил сделать приложение с текстами песен украинских исполнителей. К моему удивлению, я нашел на сайте НАШЕ более 18000 украинских песен, которые исполняют около 800 артистов.
«Неплохо» — подумал я и сел писать простенький парсер, который сложил мне все тексты локально. Я много лет занимался написанием парсеров и прочих подобных приложений, поэтому этот процесс не занял много времени. Для написания кроулера и парсинга HTML использовал написанную мной библиотеку Data Extracting SDK и, несомненно, лучшую библиотеку в .NET мире для этих целей — HtmlAgilityPack.
После того, как вся информация была упакована в один XML файл, стал вопрос о том, как эту информацию лучше всего распаковать в приложении, чтобы пользователь не чувствовал тормоза. И в эту минуту задача «for fun» превратилась в полне прикладную задачу по поиску оптимального подхода для работы с большими (по меркам мобильного устройства) объемами данных.
Вот что с этого вышло.
Основные аспекты производительности
На что необходимо обратить внимание разработчику, чтобы приложение было высокопроизводительным:
Время старта приложения
У приложения есть всего несколько секунд, чтобы стартануть. Если время старта превышает 8 секунд — приложение будет выгруженно из памяти, а автор приложения, скорее всего, получит дополнительную единицу в маркете.
Для того, чтобы уменьшить время старта, необходимо, чтобы приложение содержало как можно меньше файлов ресурсов (Resources), поэтому файлы мультимедия и другие «тяжелые» файлы лучше включать в проект как контент (Content).
Но есть и обратная сторона — доступ к ресурсам после запуска приложения занимает меньше времени, чем чтение контента. Поэтому разработчикам нужно учитывать эти отличия.
Также нужно помнить что splash скрин не поддерживает анимацию, т.к. это обычный jpeg файл.
Время загрузки данных
После старта приложения начинают загружаться данные. В нашем случае данных много и время чтения — тоже большое.
Если данные загружаются несколько секунд (с локального хранилища или через веб-сервис), то часто делают дополнительную фейковую страницу, которая эмулирует splash экран, но добавляют анимацию (progress bar с текстом «Пожалуйста, подождите...»). Посде загрузки данных пользователь перенаправляется на страницу, где уже отображаются загруженные данные, будучи увенным, что Microsoft наконец таки добавил возможность отображать анимированные splash скрины. Более подробно о том, как сделать анимированный splash скрин, читаем здесь.
Если вы загружаете данные в основном потоке, обычно это выглядит так:
public MainPage()
{
InitializeComponent();
DataContext = new SomeDataObject();
}
то пользователь, наверняка, увидит подвисания интерфейса (особенно это актуально для элемента управления Panorama).
Исправить это таким образом — дождаться полной загрузки всего UI и ресурсов и только потом отображать данные:
public MainPage()
{
InitializeComponent();
this.Loaded += MainPage_Loaded;
}
void MainPage_Loaded(object sender, RoutedEventArgs e)
{
DataContext = new SomeDataObject();
}
Второй вариант — грузить все отдельном потоке или создать BackgroundWorker:
public MainPage()
{
InitializeComponent();
StartLoadingData();
}
private void StartLoadingData()
{
this.Dispatcher.BeginInvoke(() =>
{
var backroungWorker = new BackgroundWorker();
backroungWorker.DoWork += backroungWorker_DoWork;
backroungWorker.RunWorkerCompleted += backroungWorker_RunWorkerCompleted;
backroungWorker.RunWorkerAsync();
});
}
void backroungWorker_DoWork(object sender, DoWorkEventArgs e)
{
// heavy operation
}
void backroungWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
this.Dispatcher.BeginInvoke(() =>
{
// update layout with new data
});
}
Написали чуть больше кода, зато улучшили производительность.
Ну и хорошим тоном является показывать progress bar во время выполнения тяжелых операций (можно использовать Microsoft.Phone.Shell.ProgressIndicator, компонент из Silverlight Toolkit или из Telerik RadControls for Windows Phone).
Скорость переключения между экранами
Обычно переходы между страницами не занимают много времени, но улучшить этот процесс также можно, добавив красивые анимации *также можно воспользоваться Telerik RadControls for Windows Phone).
Это была «минутка теории». Плавно переходим к главной задаче: загрузка и отображение данных.
Загрузка и отображение данных
Есть Windows Console программа, которая подготавливает данные, а Windows Phone приложение — эти данные читает.
Самый простой способ: сериализация данных, а именно в XML:
private static void Serialize(object obj, string name)
{
var ser = new XmlSerializer(obj.GetType());
var sb = new StringBuilder();
var writer = new StringWriter(sb);
ser.Serialize(writer, obj);
File.WriteAllText(name + ".xml", sb.ToString().Replace("encoding="utf-16"", null));
}
private static void Deserialize(Type type)
{
//Assuming doc is an XML document containing a serialized object and objType is a System.Type set to the type of the object.
XmlNodeReader reader = new XmlNodeReader(doc.DocumentElement);
XmlSerializer ser = new XmlSerializer(objType);
object obj = ser.Deserialize(reader);
// Then you just need to cast obj into whatever type it is eg:
var myObj = (typeof(obj))obj;
}
Модель данных выглядит таким образом:
public class Artist
{
public Artist()
{
Songs = new List<Song>();
}
public int Id { get; set; }
public string Title { get; set; }
public List<Song> Songs { get; set; }
}
public class Song
{
public int Id { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public int ArtistId { get; set; }
}
В итоге получили XML файл, который занимал больше 24 мегабайт.
Еще пробовал сериализовать в JSON формат, а также уменьшать названия свойств, что дало экономию в полмегабайта, что можно назвать «микро» оптимизацией.
24 мегабайта тащить за собой в приложение — не самая лучшая идея, поэтому было принято решение использовать базу SQL CE и читать данные с нее.
В Windows Phone приложение был добавлен код, который парсит XML файл, и записывает все данные в локальную базу.
Выводы:
- проверка и вставка данных происходила очень медленно;
- из-за длины некоторых текстов пришлось использовать тип DbType.NText;
- финальный размер базы был очень большим и даже не смотря на это его нужно было при старте приложения переписать в локальное хранилище, что занимало много времени.
Для того, чтобы скопировать локально сгенерированную базу использовался инструмент Isolated Storage Explorer:
Чтобы Isolated Storage Explorer у вас заработал, необходимо добавить соответствующую библиотеку в проект и прописать код:
Т.к. вариант с базой пролетел, начал искать другие варианты оптимизации. Понятно, что там где есть большой текстовый файл, там можно его сжать с помощью архиватора. Посе сжатия Zip архиватором файл стал весить около 5 мегабайт.
Таким образом, при старте приложения необходимо 1) скопировать файл в локальное хранилище 2) распаковать его 3) загрузить данные из файла в память.
Zip архив в проект добавлен как Content, при старте делаем все вышеперечисленные действия. Для этого использовались различные комбинации методов:
private void CopyFromContentToStorage(IsolatedStorageFile store, string dbName)
{
var src = Application.GetResourceStream(new Uri(dbName, UriKind.Relative)).Stream;
var dest = new IsolatedStorageFileStream(dbName, FileMode.OpenOrCreate, FileAccess.Write, store);
src.Position = 0;
CopyStream(src, dest);
dest.Flush();
dest.Close();
src.Close();
dest.Dispose();
}
private static void CopyStream(Stream input, IsolatedStorageFileStream output)
{
var buffer = new byte[32768];
long tempPos = input.Position;
int readCount;
do
{
readCount = input.Read(buffer, 0, buffer.Length);
if (readCount > 0)
{
output.Write(buffer, 0, readCount);
}
} while (readCount > 0);
input.Position = tempPos;
}
// load items from "fileName" file that exists in "zipName" file
private static List<Artist> Load(string zipName, string fileName)
{
var info = Application.GetResourceStream(new Uri(zipName, UriKind.Relative));
var zipInfo = new StreamResourceInfo(info.Stream, null);
var s = Application.GetResourceStream(zipInfo, new Uri(fileName, UriKind.Relative));
var serializer = new XmlSerializer(typeof (List<Artist>));
return serializer.Deserialize(s.Stream) as List<Artist>;
}
Также в ход пошла библиотека SharpZipLib:
using (ZipInputStream s = new ZipInputStream(src))
{
s.Password = "123456";//if archive is encrypted
ZipEntry theEntry;
try
{
while ((theEntry = s.GetNextEntry()) != null)
{
string directoryName = Path.GetDirectoryName(theEntry.Name);
string fileName = Path.GetFileName(theEntry.Name);
// create directory
if (directoryName.Length > 0)
{
Directory.CreateDirectory(directoryName);
}
if (fileName != String.Empty)
{
// save file to isolated storage
using (BinaryWriter streamWriter =
new BinaryWriter(new IsolatedStorageFileStream(theEntry.Name,
FileMode.OpenOrCreate, FileAccess.Write, FileShare.Write, iso)))
{
int size = 2048;
byte[] data = new byte[2048];
while (true)
{
size = s.Read(data, 0, data.Length);
if (size > 0)
{
streamWriter.Write(data, 0, size);
}
else
{
break;
}
}
}
}
}
}
catch (ZipException ze)
{
Debug.WriteLine(ze.Message);
}
Я испробовал много вариантов:
- zip файл с файлом с метаинформацией (список артистов и ссылки на их песни), в то вемя как тексты песен находились в отдельных файлах — большой минус по производительности, т.к. 18000 файлов читались и копировались очень медленно;
- zip с двумя файлами: один — со списком артистов, другой — с песнями — плохо, т.к. артисты читались быстро, а песни уже во время использования приложения — токже долго;
- вариант загрузки при старте только списка исполнителей, а в фоне незаметно для пользователя — загрузка песен — возникала ситуация, что пользователь нажимал на песню раньше, чем файл успел загрузиться.
Этот этап закончился с одним выводом: файл должен быть один и он должен быть в архиве, т.к. само чтение zip файла — достоточно быстрое.
Раз дальше уменьшать размер файла нельзя, можно уменьшить время его чтения и загрузки в память. И тогда я пошел по пути кастомной сериализации.
Бинарная сериализация
Еще раньше я знал, что встроенные сериализации — медленные и если нужно быстродействие, то надо использовать бинарную сериализацию.
В качестве сериализатора был выбран SilverlightSerializer by Mike Talbot.
В Windows Console Application все работало как следует (serialization / deserialization), а вот с чтением созданного файла в Windows Phone проекте возникли трудности, связанные с разными версиями mscorlib.
На странице описания проекта есть абзац о проблемах совместимости между .NET и Silverlight проектами:
The vital thing to do in these circumstances is to define the classes you want to share in a Silverlight assembly that only references System, System.Core and mscorlib.
К сожалению, побороть эту проблему так и не получилось.
Тогда я попал на проект protobuf-net.
Помечаем нужные классы и свойства и получаем бинарный файл на выходе:
[ProtoContract]
public class Person
{
[ProtoMember(1)]
public int Id {get;set;}
[ProtoMember(2)]
public string Name {get;set:}
[ProtoMember(3)]
public Address Address {get;set;}
}
Проблем с чтением файла из Windows Phone проекта не было.
Что в итоге
В результате получилось такое решение:
1. Zip файл, в котором лежит бинарник artists.bin, а сам zip файл подключен как Content;
2. С помощью BackgroundWorker после загрузки UI начинаем загружать данные (читаем bin файл прямо из zip файла и десериализируем его в локальную модель данных):
public List<Artist> LoadData()
{
var info = Application.GetResourceStream(new Uri("artists.zip", UriKind.Relative));
var zipInfo = new StreamResourceInfo(info.Stream, null);
using (var file = Application.GetResourceStream(zipInfo, new Uri("artists.bin", UriKind.Relative)).Stream)
{
return Serializer.Deserialize<List<Artist>>(file);
}
}
Итого: запуск приложения и парсинг данных на Lumia 800: ~ 5-6 секунд, после чего вы можете легко просматривать любой контент. Результат мог бы быть лучше и я еще продолжу исследования и работу над производительность, но и этот результат, на мой взгляд, достаточно не плохой.
Есть еще одно небольшое узкое место — при переходах между текстами в рамках одного исполнителя или результатами поиска текстовый парсер может чуть-чуть притормаживать (решение я уже описал — нужно добавить анимации, которые скроют этот артефакт, может в рбновлении добавлю, если пользователям понравится приложение).
В общем, на все про все ушло:
- 1 час на написание парсера песен;
- 6 часов на исследования различных вариантов улучшения производительности;
- 2 час на написание, собственно, Windows Phone приложения;
- 1 час на рисование оконок и создание графических файлов;
- 0,25 часа на отправку на сертификацию;
- 1,5 часов на написание этой статьи.
Всего ~ 12 часов.
Результат работы можно увидеть и оценить здесь.
Спасибо за внимание! Надеюсь, Windows Phone Store и хабр стал немножко лучше после этой статьи!
Автор: sashaeve