История одного приложения или Борьба за производительность

    Если вы — профессиональный разработчик, то вам должно быть знакомым чувство, когда хочется сделать что-то не для денег, а для души. В один из таких вечеров мне захотелось немного отвлечься и написать именно такое приложение.

    Мы находимся в Украине, где локальных приложений для 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:

    image

    Чтобы Isolated Storage Explorer у вас заработал, необходимо добавить соответствующую библиотеку в проект и прописать код:

    image

    Т.к. вариант с базой пролетел, начал искать другие варианты оптимизации. Понятно, что там где есть большой текстовый файл, там можно его сжать с помощью архиватора. Посе сжатия 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 часов.

    image

    Результат работы можно увидеть и оценить здесь.

    Спасибо за внимание! Надеюсь, Windows Phone Store и хабр стал немножко лучше после этой статьи!
    DevRain Solutions
    38,00
    Компания
    Поделиться публикацией

    Комментарии 19

      +1
      Вы молодец.
      Но, я надеюсь, в About о том сайтике упомянули? :)
        +1
        Конечно, работу других людей мы ценим :-)
        –2
        Вы не шутите? Винфон так тормозит на банальном чтении файла?? И опять же зачем все подряд парсить и грузить в память? Почему нельзя просто запихнуть все песни в один текстовик и сделать файл индекса со смещениями, а уже в программе навигацию по индексу и чтение по одной песне за раз из файла? Если даже маленький кусочек текста из файла вызывает такие тормоза… о каких современных технологиях у майкрософт все говорят, инновационных телефонах и т.д.?
          +1
          Винфон не тормозит на чтении файлов, есть проблемы при конвертации одного формата в другой и с последующей загрузкой.

          В конкретно этом случае база с песнями каким-то образом должна попасть в приложение — нужно либо загрузить файл из веба (нужен интернет), либо включить zip файл и распаковать при первом запуске (нужно много времени), либо поставлять приложение с уже готовой базой (большой размер приложения). Я старался соблюдать баланс между 1) размером приложения 2) скоростью первого запуска 3) скоростью последующих запусков.

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

          Навигация по индексу — идея хорошая, но это требовало еще некоторое время на изучение. Как вариант дальнейших исследований — подойдет.
            0
            Какой примерно размер каждой песни? Попробуйте зазиповать каждый текст по-отдельности и склейте вместе, возможно получится не сильно больше и не надо будет распаковывать всё при первом старте. Если компрессия слишком маленькой получится — паковать по 10-20 штук, вычитывать и распаковываться будет всё так же быстро, а по объёму сильно выиграете.
              0
              Если запаковывать все отдельно или создавать для каждой песни отельный файл — тормоза еще больше, т.к. добавляется большое количество файлов, с которыми нужно работать. А выбирать отдельный файл из архива — тоже не вариант, т.к. архив все равно нужно читать, искать нужный файл, распаковывать и читать с него — тормоза при переходах между песнями.
                +1
                Вы немного неправильно меня поняли. У вас будет всего 2 файла: индексный, и файл с данными. В индексном файле хранится название песни и артиста со всякой прочей дополнительной информацией, смещение в файле с данными и длина собственно текста. Внутри файла с данными каждая песня по-отдельности упакована зипом. То есть это будет много маленьких архивчиков, и все они лежат в одном большом файле, чтобы не надо было файловую систему насиловать. Индексный файл, на сколько я понимаю, у вас уже готов — artists.bin, надо будет только добавить смещение+длину в класс Song.

                Чтобы быстро переход работал — всасывать индексный файл со смещениями и длинами в память при старте, тогда чтение текста песни превращается в вычитывание небольшого кусочка файла по известному заранее смещению, длина кусочка тоже известна. Кажется, это должно работать очень быстро.
                  +1
                  Именно так и надо делать. Кастомный вариант БД. Примитивный, но максимально подходящий для данного случая. Причем все работать будет примерно с такой же скоростью, как и в авторском варианте, но первоначальная загрузка будет почти мгновенной и без всех этих придуманных наворотов-оптимизаций. Я делал нечто подобное для силверного приложения — локальная БД для фб2 файлов-книг (вернее индекс — описание книги, картинки обложек — в одном бинарном файле). Идея точно такая же была. На десятках тысячах книг поиск происходит (с подгрузкой картинок из такой БД) практически мгновенно. Поиск — в смысле загрузка картинок-обложек из такого вот внешнего большого бинарника. Ну, а индекс в памяти конечно.
          +6
          this.Dispatcher.BeginInvoke(() =>
              {
                 var backroungWorker = new BackgroundWorker();
                 backroungWorker.DoWork += backroungWorker_DoWork;
                 backroungWorker.RunWorkerCompleted += backroungWorker_RunWorkerCompleted;
                 backroungWorker.RunWorkerAsync();
              });


          Кто-нибудь объяснит мне, зачем тут нужен Dispatcher и почему нельзя создать BackgroundWorker синхронно?
            0
            Впечатляющая производительность!!!
            судя по скриншотам вы делали приложение для WP 7.5 и выше.
            А можно ли вообще заработать на отечественном рынке?
              0
              А что, Windows Phone не поддерживает C# и async?
                +1
                Поддерживает с версии WP 8.0.
                Но также есть и реализация для 7.5 (Mango).
                  0
                  но под 7.5 она довольно уродская и ее сложнее дебажить
                  • НЛО прилетело и опубликовало эту надпись здесь
                      0
                      В 2010 студии она не работает, только AsyncCTP3 только хардкор
                      • НЛО прилетело и опубликовало эту надпись здесь
                          0
                          Там хоть бы компы и девайсы нормальные купили. В 2012 году работать за celeron 2 ядерным и монитором 1366x768 еще то удовольствие и всего один девайс lumia 710 на двух девелоперов и тестеровщиков
                      +1
                      Нормальная она. Написали 3 коммерческих WP7 проекта и не пискнули. Никаких проблем не было.
                      Дебаг тоже вполне удобный, единственное на слове await F10 себя ведет некорректно, но это и проблемой то не назовёшь.

                  0
                  Спасибо за интересную статью! Одно небольшое замечание, вместо Isloated Storage Explorer порекомендовал бы обратить внимание на очень хорошую утилитку wptools.codeplex.com которая позволяет просматривать Isloated Storage. При этом вносить какие либо исправления в код самого прилоежния не требуется.

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

                  Самое читаемое