Pull to refresh

Как создавалась Айчиталка. Часть 1: движок

Reading time 10 min
Views 28K
Совсем недавно мы выпустили в свет первую бета-версию нашей онлайн-читалки, с которой можно ознакомиться, почитав книгу Михаила Лермонтова «Герой нашего времени». Эта читалка — результат почти семимесячной работы, пять из которых ушло только на разработку движка. Казалось бы, в интернете уже есть бесплатные и открытые JavaScript-движки для чтения электронных книг и такой долгий срок может вызвать сомнения в профпригодности разработчика (то есть меня). Но есть одно большое и жирное «НО». Мы поставили перед собой слишком амбициозную и трудновыполнимую задачу: мы хотели использовать один и тот же движок на разных устройствах, в том числе маломощных, таких как айфон или электронная читалка.

В чём же заключается трудновыполнимость задачи? В первую очередь — в очень низкой скорости работы веб-приложений на айфоне. Например, мобильный Сафари по моим прикидкам работает раз в 100 медленнее своего десктопного собрата. Если на декстопе одна и та же операция выполняется 10 мс и совершенно незаметна для пользователя, то на айфоне она может выполняться больше секунды. Для сравнения: первая версия движка разбивала небольшую главу на страницы примерно за 15 секунд. Сейчас, спустя полгода, он делает то же самое менее, чем за секунду и вполне сносно работает в нашем приложении booq.

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



Задача


Для начала стоит объяснить, почему мы выбрали именно веб-технологии в качестве основы для читалки. Во-первых, это их распространённость. Сейчас довольно сложно найти устройство, которое не имеет встроенного браузера. Телефоны, компьютеры, нетбуки, планшеты, электронные книги – все они способны прочитать HTML, украсить его с помощью CSS и оживить через JavaScript. Имея один и тот же движок, мы можем легко создавать приложения для различных платформ и устройств. Во-вторых, ни один «классический» движок читалки не способен отобразить то, что может веб-браузер. Таблицы, векторная графика, аудио/видео контент, интерактивные элементы – всё это давно и успешно работает в браузерах. Представьте, что вы читаете научную книгу и тут же видите видеоролик, демонстрирующий описываемый процесс. Ну или читаете детектив, в котором нужно пройти паззл, чтобы открыть следующую главу :). Возможности ограничены только фантазией и умениями разработчика.

Всё это красивая маркетинговая обёртка, но спустимся с небес на землю и посмотрим, каким должен быть движок с технической точки зрения:

  • быть кроссбраузерным и кроссплатформенным;
  • иметь модульную структуру, чтобы легче было создавать версии под различные устройства;
  • поддерживать два режима чтения: постраничный и с прокруткой (как обычная веб-страница), а также быстро переключаться между ними;
  • обрабатывать главы объёмом 1 МБ+ (для сравнения: первый том «Войны и мира» Толстого весит 1,2 МБ);
  • иметь гибкие настройки внешнего вида (по сути, ограничены возможностями CSS).


В качестве тестовой площадки использовался айфон 2G с прошивкой 3.1 и глава из книги Ивана Миронова «Замурованные» весом 500 КБ. Такая большая глава скорее исключение, чем правило, однако задаёт хорошую планку по производительности, ниже которой не стоит опускаться.

Итак, приступим к оптимизации.

Объём JS-кода


Сразу хочу огорчить любителей наставить кучу фрэймворков на страницу и обвешать их плагинами, чтобы решить простые задачи вроде перетаскивания блоков или выбора элементов по CSS-селектору: объём JS-кода на странице имеет огромное значение, по крайней мере для мобильного Safari. Например, парсинг и инициализация популярного jQuery занимает 1400 мс для оригинальной, не сжатой версии (155 КБ) и 1200 мс для сжатой (76 КБ). Несмотря на то, что сжатая версия в 2 раза меньше оригинала, по функциональности они идентичны: отсюда такая «небольшая» разница в скорости парсинга. То есть на скорость влияет не длина названий переменных, а количество функций, объектов, методов и так далее. Для сравнения: на десктопе парсинг занимает порядка 30 мс.

Идеальный вариант: держать весь JS-код в самом низу страницы и вообще отказаться от фрэймворков. Так как сам по себе WebKit поддерживает много чего, я вынес стандартные DOM-операции (добавление событий, поиск элементов по селектору и т.д.) в виде отдельного дополнительного модуля, а для десктоп-версии переопределил этот слой, чтобы вызовы транслировались в jQuery.

Парсинг HTML


Сама читалка ориентирована на формат ePub, в котором каждая глава книги представлена отдельным документом в формате XHTML. Главу нужно каким-то образом передать в JavaScript, чтобы он её распарсил, разбил на страницы и начал показывать.

Тут стоит сказать пару слов о принципе вывода контента на экран. Напомню, что движок должен поддерживать два режима чтения: постраничный и «портянкой». Поэтому я решил весь контент обрамить в две обёртки: первая является своеобразным «окном», а вторая смещает контент вверх-вниз. Подобрав правильные размеры окна и смещения контента, можно создать иллюзию разбитой на страницы главы:



Так как мне в любом случае нужно всё содержимое главы, а для расчётов размеров страниц мне нужны полноценные DOM-элементы, я решил вывести главу прямо в HTML:

<div id="window">
  <div id="content">
    <p>В департаменте… но лучше не называть, в каком департаменте…</p>
  </div>
</div>


И тут же столкнулся с серьёзной проблемой: парсинг и сопутствующее отображение главы длилось аж 7 секунд. Я предположил, что основное время занимает именно отрисовка контента, поэтому в качестве эксперимента скрыл контент с помощью display: none:

<div id="window">
  <div id="content" style="display:none">
    <p>В департаменте… но лучше не называть, в каком департаменте…</p>
  </div>
</div>


На этот раз парсинг страницы занимал 800 мс, что очень даже неплохо: ускорил почти в 10 раз. А так как у айфона довольно маленький экран, достаточно было достать из дерева несколько первых элементов и показать их, чтобы пользователь мог начать читать, пока на фоне идёт обсчёт главы.

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

Я предположил, что когда HTML разбирается прямо в теле документа, браузер делает какие-то дополнительные действия, чтобы элементы могли в нужный момент показаться на странице. Например, поиск и применение соответствующих CSS-правил. Лично мне эти действия на данный момент не нужны: мне нужно как можно быстрее передать содержимое главы в виде DOM-дерева прямо в JavaScript. Как заставить браузер не парсить определённый фрагмент документа? Правильно, закомментировать его:

<div id="window">
  <div id="content">
    <!--
    <p>В департаменте… но лучше не называть, в каком департаменте…</p>
    -->
  </div>
</div>


Смех смехом, но время парсинга страницы сократилось до 350 мс. А комментарий — это ведь полноценный DOM-элемент, к которому можно обратиться через JavaScript и получить его содержимое:

var elems = document.getElementById('content').childNodes;

for (var i = 0, il = elems.length; i < il; i++) {
  var el = elems[i];
  if (el.nodeType == 8) { //comment
    var div = document.createElement('div');
    div.innerHTML = el.nodeValue;
    // внутри div теперь полноценное DOM-дерево, с которым можно работать
    break;
  }
}


Суммарное время парсинга страницы и разбора кода в дерево составило примерно 550 мс (против 800 мс в предыдущем варианте), что, на мой взгляд, очень даже неплохо.

Расчёт размеров страниц


Итак, содержимое главы я получил и распарсил, теперь нужно разбить главу на страницы. Во время оптимизации парсинга я понял, что мой первоначальный вариант вывода главы в постраничном режиме в виде окна и движущегося контента имел ряд недостатков. Во-первых, нужно выводить (отрисовывать) всю главу целиком, что, как вы уже поняли, занимает очень много времени. Во-вторых, при таком раскладе я не мог вывести больше одной страницы на экран: для второй страницы пришлось бы полностью дублировать всю главу, что, опять же, медленно и вызовет неминуемый крэш приложения из-за нехватки памяти на больших главах.

После примерно двух месяцев безуспешных попыток написать постраничную разбивку с приемлемым временем исполнения было найдено довольно неплохое решение. Вкратце, что оно из себя представляет.

По сути глава книги — это набор параграфов. Параграфы можно представить как элементы первого уровня. С учётом скорости отрисовки HTML-контента в айфоне, для максимально быстрого отображения одной страницы нужно определить минимальный набор элементов первого уровня, который необходим для её представления. У меня есть вся глава в виде списка элементов первого уровня, а также список страниц. Страница представляет из себя объект, в котором хранятся порядковые номера начального и конечного элемента первого уровня, размер окна и смещения. Получилась довольно компактная и быстрая конструкция: для отображения одной страницы достаточно клонировать набор элементов первого уровня и вывести их на экран, указав правильное смещение и размер окна.

Для того, чтобы просчитать все страницы, нужно знать габариты каждого элемента первого уровня, их внутренние и внешние отступы, бордюры, размер шрифта и так далее. Чтобы получить все эти данные, элементы должны быть на странице и к ним должы быть применены стили. Для этих целей я создал специальный скрытый контейнер, который наследует все стилевые описания самой страницы, добавлял в него параграфы и проводил расчёты.

Для получения необходимых характеристик элемента нужно обратиться к его CSS-свойствам. За основу я взял функцию css() из jQuery:

function getCSS(elem, name) {
  if (elem.style[name]) {
    return elem.style[name];
  } else {
    var cs = window.getComputedStyle(elem, "");
    return cs && cs.getPropertyValue(name);
  }
}


Так как мне нужно было получить сразу довольно много свойств, это функция, судя по профайлеру из Web Inspector (имеется в виду десктопный браузер, на айфоне таких инструментов отладки нет, что сильно усложняет работу), была самой медленной. Как оказалось, обращение к getComputedStyle() — очень дорогое в плане производительности. Поэтому я модифицировал эту функцию, чтобы можно было отдать массив свойств, которые нужно получить, а также убрал проверку elem.style[name], так как в 99% случаев элементам не выставлялись CSS-свойства через объект style и эта оптимизация скорее вредила, чем помогала:

function getCSS(elem, name) {
  var names = (typeof name == 'string') ? [name] : name,
    cs = window.getComputedStyle(elem, ""),
    result = {};
  
  for (var i = 0, il = names.length; i < il; i++) {
    var n = names[i];
    result[n] = cs && cs.getPropertyValue(n);
  }
  
  return (typeof name == 'string') ? result[name] : result;
}


После такой оптимизации функция getCSS() не попадала даже в первую тройку самых медленных функций :).

Следующий шаг: правильно «размазать» расчёт страниц во времени. Дело в том, что пока выполняется JS, интерфейс браузера полностью блокируется, а также вступают в действия ограничения на время выполнения скрипта. Могла сложиться ситуация, что экран «застынет» секунд на 20—30, а потом и вовсе вывалится с ошибкой о превышении таймаута на выполнение. Современный способ избавления от таких проблем — Web Workers, но мобильный Сафари их не поддерживает. Поэтому будем использовать проверенный «дедовский» метод, работающий во всех браузерах: вызывать каждую итерацию обсчёта страниц через setTimeout(). Примерный код этого решения:

function calculatePages(elems, callback) {
  // elems — массив элементов, которые нужно обсчитать
  var cur_elem = 0;
  
  var run = function() {
    createPage(elems[cur_elem]); // делаем обсчёт страницы
    
    cur_elem++;
    if (cur_elem < elems.length)
      setTimeout(run, 1);
    else
      callback(); // обсчёт окончен
  };
  
  run();
}


Функция работает следующим образом. Например, нам нужно обсчитать 30 элементов первого уровня. Отдаём массив этих элементов в функцию calculatePages(), внутри которой создано замыкание в виде функции run(), которая является одной итерацией обсчёта. Когда закончили считать проверяем, остались ли ещё элементы в массиве. Если да, то через setTimeout() вызываем новую итерацию, иначе вызываем callback-функцию, сообщая, что расчёт окончен и можно двигаться дальше.

У такого подхода есть один важный аспект — это нагрузка на одну итерацию. В данном случае — это сколько элементов нужно обсчитать за один вызов функции run(). Если, например, считать один элемент за одну итерацию, то интерфейс для пользователя будет максимально отзывчивым, но общее время расчёта всей главы может увеличиться в 2—3 раза из-за накладных расходов, возникающих при запуске функции через setTimeout(). Если будем считать 10 элементов за один проход, то общее время обсчёта главы снизится, но также снизится отзывчивость интерфейса и повысится риск не пройти по таймауту, если параграфы будут очень большими.

Поэтому нужно найти некую золотую середину, чтобы и время расчёта не сильно увеличивать, и не снижать отзывчивость интерфейса. Я решил ориентироваться не на количество элементов первого уровня, а на их объём, который можно получить через свойство innerHTML или textContent. В качестве порогового объёма методом проб и ошибок было выбрано значение в 5 КБ. Перед вызовом calculatePages() я делил все объекты на группы: как только суммарный объем одной группы становился больше 5 КБ, она закрывалась и создавалась новая. Соответственно, расчёт размеров страниц вёлся не по отдельным элементам, а по группам.

Удаление элементов


После того, как посчиталась одна группа, нужно очистить скрытый контейнер и освободить ресурсы для следующей группы элементов. Самый простой способ очистить содержимое контейнера — это обнулить свойство innerHTML:

function emptyElement(elem) {
  elem.innerHTML = '';
}


Однако «самый простой» не всегда означает «самый быстрый» — как показали замеры, вот такой способ работает намного быстрее:

function emptyElement(elem) {
  while (elem.firstChild)
    elem.removeChild(elem.firstChild);
}


Пожалуй, пока всё. В этой статье были рассмотрены некоторые особенности парсинга и обсчёта больших объёмов данных на маломощных устройствах. На практике все описанные трюки оказались очень эффективными. Например, я протестировал главу объёмом более 1 МБ: наша читалка смогла её переварить где-то за 30—40 секунд, в то время как другие (причём довольно популярные) читалки из AppStore, написанные на Objective C/C++, попросту падали.

В следующей статье рассмотрим некоторые факторы, влияющие на время отрисовки одной страницы в айфоне, а также некоторые трюки, позволяющие это время заметно снизить.

Сергей Чикуёнок
Tags:
Hubs:
+137
Comments 98
Comments Comments 98

Articles

Information

Website
www.imobilco.ru
Registered
Founded
Employees
11–30 employees
Location
Россия