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

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

Совсем недавно мы выпустили в свет первую бета-версию нашей онлайн-читалки, с которой можно ознакомиться, почитав книгу Михаила Лермонтова «Герой нашего времени». Эта читалка — результат почти семимесячной работы, пять из которых ушло только на разработку движка. Казалось бы, в интернете уже есть бесплатные и открытые 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:

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

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

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

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

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

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

for ( var i = 0, il = elems.length; i < il; i++) >

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

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

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

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

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

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

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

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

function getCSS(elem, name)

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

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

function calculatePages(elems, callback)

📎📎📎📎📎📎📎📎📎📎