На прошедшей 18 мая в Калифорнии юбилейной конференции для разработчиков Google I/O было много всего.
Серьёзные штуки для Android, монументальные изменения и интеграции в продуктах Firebase, да и просто множество анонсов и классных технологий. Но кое-что ещё мы пока не обсуждали. Речь идёт о Progressive Web App’ах (современных веб-приложенях) — сайтах, написанных так, словно это современные мобильные приложения: удобные, простые, интуитивно понятные и комфортные для использования на сенсорном дислпее.
Поэтому в ближайшие два месяца мы собираемся не только публиковать статьи по теме PWA, но и провести тематическую онлайн конференцию 11 октября — Progressive Web Apps Day. Пока же предлагаем вашему вниманию реальный кейс использования PWA от AirBerlin.
Авиакомпания airberlin первая в мире разработала такой сайт-программу, а Ханс Швагер, глава мобильных разработок и инноваций, поделился своим опытом с посетителями конференции: «Популярность и важность умных мобильных интернет-сервисов в сфере авиаперевозок будет и дальше расти, и мы точно знаем, что нашим пассажирам, вне зависимости от того, где они находятся, важно легко и быстро проверять информацию и получать посадочные талоны непосредственно с их мобильных устройств. Именно поэтому мы привлекли подразделение перспективных разработок к созданию Progressive Web App’а — гибрида, сочетающего лучшие стороны мобильных приложений и интернет-сайтов».
Технология современных веб-приложений позволяет пассажирам airberlin получать доступ к посадочным талонам и информации об их путешествии в любой время, даже если последний раз он заходили на наш сайт через Wi-Fi отеля или дома, а в аэропорту внезапно остались без связи. Это позволяет нам предоставлять клиентам очень простой и понятный сервис, повысить удобство и на маленький шажок приблизить будущее мобильной разработки.
Если говорить по-простому, то это вебсайт, который выглядит и ощущается, как мобильное приложение. После первого захода доступен (частично или полностью) оффлайн благодаря кешированию, поддерживает браузеры Chrome и Firefox с версии 40, а также актуальные сборки Opera. Такое «приложение», в отличие от обычной мобильной версии сайта, хорошо работает на медленном интернет-соединении (например, в перегруженном бесплатном Wi-Fi в аэропорту), тратит минимум трафика, может быть добавлено на рабочий стол, как обычная иконка, имеет доступ к системе уведомлений смартфона и не требовательно к ресурсам смартфона.
Рассказывают Marian Pöschmann, главный по тому-то и тому-то, и Axel Michel, ответственный за то-то и то-то.
Под капотом PWA скрываются простые вещи, основная задача — собрать всё правильным образом.
Web Components
Идея проста: всё, кроме интерфейса прогрессивного веб-приложения — это компоненты. Мы использовали Polymer 1.0, создали отдельные компоненты для слайдера, которые включают всевозможные формы, детали и элементы, из которых строится результат: «виртуальный билет», который увидит пользователь.
Custom Events
Для взаимодействия между компонентами, мы написали основной скрипт, который централизованно управляет асинхронными запросами, историей, данными, используемыми в приложении, их кеширование и предоставление.
HistoryAPI
Наше прогрессивное приложение, фактически, состоит из одной страницы. Так как service worker или кэширование не умеет работать с хэшем в url’ах, мы решили использовать GET’ы для различения статусов и различных «экранов» приложения. В принципе, решение нормальное, но у него есть некоторые проблемы с оффлайн-работой. На будущее — не используйте только значения, отсылайте сразу пары параметр — значение, если вы хотите, чтобы ваши запросы нормально обрабатывались. Ну или пересоздавайте эти запросы, исходя из информации, которую закодируете в URL (об этом будет отдельно сказано чуть дальше, в примерах кода).
Service worker
Как мы уже отмечали, наше приложение, фактически — сайт-одностраничник с одним-единственным юзкейсом. Добавить к такому проекту service worker для оффлайн-работы — проще простого. Сам интерфейс кешируется в процессе первичной «установки», данные и дополнительные файлы закачиваются по первому запросу. Больше проблем доставило удаление нужных данных в нужное время. Также через service worker мы интегрировали push-уведомления для реализации check-in’а в два тапа.
WebSQL
В добавок к оффлайн-обработчику мы хотели улучшить впечатления пользователей от работы без подключения к сети, используя localForage: технологию, которая содержит в себе сразу IndexDB, WebSQL и / или localStorage. Всё взаимодействие между сервером и клиентом описывается JSON’ом, что сильно упрощает дальнейшую разработку.
VanillaJS
Используется для всего остального. Базовые DOM-селекторы, кое-какие асинхронные запросы, в общем всё то, что мы не хотели реализовывать через сторонние библиотеки. Единственный случай подключения готового js — использование moment’а, который полностью обрабатывает расчёт различных часовых поясов и дат: в конце-концов, некоторые рейсы могут и в прошлое / будущее вас отправить. For the rest. Some basic
Manifest and Meta data
Эти штуки нужны, чтобы пользователь мог добавить приложение к себе на домашний экран / рабочий стол. К сожалению, iOS остатёт от Android в плане поддержки оффлайн-возможностей приложения (её попросту нет), мы решили предоставить пользователям правильную иконку, заголовок и цветовую схему на Android и iOS.
Быстрая регистрация на рейс и возможность сесть в самолёт, не устанавливая на телефон вообще ничего отдельного — это круто, поэтому мы хотели, чтобы приложение было быстрым. Поэтому мы загружаем всё, кроме базового css и кое-каких плэйсхолдеров динамически, не блокируя DOM и не ожидая прогрузок.
Базовая HTML-структура:
<section class="page" id="dashboard"> <header> <slider-element name="dashboard" display="all"></slider-element> </header> <div class="contents"> <ul class="collection"> <li><a href="#flightdetails">Journey details</a></li> <li><a href="#explore">Explore destination</a></li> <li><checkin-element></checkin-element></li> </ul> </div> </section> <section class="page" id="flightdetails"> <header> <slider-element name="flightdetail" display="activeFlight"></slider-element> </header> <flightdetails-element></flightdetails-element> </section> <section class="page" id="explore"> <place-element></place-element> </section>
Стартовый JavaScript:
(function() { var raf = window.RequestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.msRequestAnimationFrame; // defer loading of all app relevant javascript // and non criticial CSS function deferLoad() { // JS var element = document.createElement("script"), l = document.createElement('link'); element.src = "javascript/app.js"; document.body.appendChild(element); // CSS l.rel = 'stylesheet'; l.href = 'css/main.css'; document.getElementsByTagName('head')[0].appendChild(l); } if (raf) { raf(deferLoad); } else if (window.addEventListener) { window.addEventListener("load", deferLoad, false); } else if (window.attachEvent) { window.attachEvent("onload", deferLoad); } else { window.onload = deferLoad; } })();
Изначально единственное, что отправляется на устройство пользователя — скелет приложения. Базовая разметка, строка меню, цветные заглушки на те места, где должны быть картинки, короткий текст в той части страницы, которую видит пользователь.
Основной CSS, большая часть нашего скрипта и сторонних библиотек (polymer, moment, local forage) загружаются в фоне, после чего основной сайт подключает различные элементы через polymer. Пол Льюис написал отличную статью по этой теме: aerotwist.com/blog/polymer-for-the-performance-obsessed
По сравнению с обычной мобильной страницей (m.airberlin) общее время загрузки примерно одинаковое — полторы секунды для нашего подхода и две с половиной для классического, при использовании 3G-соединения. Однако отрисовка контента и самого сайта начинается намного раньше: спустя пол секунды после открытия приложения. У мобильного сайта — ужасные 1.2 секунды до появления первых элементов. К тому моменту PWA уже открыт и готов к работе. Мы стремимся сделать загрузку ещё быстрее, но применённые технологии и так минимизировали время загрузки и избавили пользователей от необходимости наблюдать, как скачут элементы по странице в процессе подгрузки стилей и картинок.
Ещё один трюк, помогающий сократить время ожидания и улучить пользовательский опыт мы подсмотрели у Facebook’а. Дело вот в чём: современные девайсы отличаются и разрешением дисплея, и весьма некислым разбросом показателей — вы можете встретить как шестидюймовую лопату с 720p, так и 5-дюймовый девас с дисплеем 2560х1440 или вообще наткнуться на 4k2k в мобильном девайсе. Для каждого из популярных решений подготовлена своя фоновая картинка, а для того, чтобы пользователь не смотрел на одноцветную подложку, мы применили очень маленькое изображение (60х40 точек) и размытие по гауссу. В итоге пользователь видит всё почти так, как надо, а как только в фоне подгрузится изображение с нужным разрешенем — мы заменяем размытый low-res на актуальную картинку из кэша.
Наш первый обработчик состоял всего из нескольких строк кода. После его активации мы просто подгружали весь статический контент, и тупым образом всё запихивали в кэш. Такой подход устраивал нас, пока мы работали с прототипом приложения, в котором был всего один «полёт», с одним местом назначения, без истории, вообще без чего бы то ни было, что могло бы устареть или потерять актуальность. Разумеется, в реальности от PWA требуется несколько больше: отобразить информацию о полёте, создать посадочный талон. А он, в свою очередь, должен быть уничтожен или забракован после полёта, да и для разных рейсов требуется отображать всякую дополнительную информацию: картинки, текст, что угодно.
Регистрация на рейс стала проще и быстрее: добавили полёт, номер бронирования, нажали на кнопку и получили посадочный талон. Проще не бывает.
Заливать всё это в кэш или хранить всю информацию обо всех направлениях разом как-то не особо progressive, поэтому мы просто уничтожаем всё через 48 часов после посадки самолёта. Так как мы используем WebSQL и локальное хранилище, удалять данные приходится дважды. Сейчас за это отвечает вот такой код:
Code Fragment app.js:
function _isEmpty = function(obj) { if ('undefined' !== Object.keys) { return (0 === Object.keys(obj).length); } for(var prop in obj) { if(obj.hasOwnProperty(prop)) { return false; } } return true; }; // called whenever a checkin is requested to be displayed function checkCheckinStatus( checkinID ) { var tS = Math.floor(now.getTime() / 1000), removeCheckinFromApp = function(cid) { // remove from data delete app.data[cid]; // update local cache localforage.setItem('flightData',app.data); // trigger event for updating UI elements var event = new CustomEvent( 'updatedData', {detail: {modified: cid}} ); document.dispatchEvent(event); }; // no data or no checkin data? - return if(_isEmpty(app.data) || !app.data[checkinID]) { return false; } // remove only in case arrival time is min. 48 hours in past if((tS - app.data[checkinID].ticket.arrivalTimestamp) < (60 * 60 * 48) ) { return false;} if ('serviceWorker' in navigator) { // delete cache of flight in service worker... app.sendMessage( { command: 'deleteCheckin', keyID: checkinID } ).then(function(data) { // remove the checkin from app data... removeCheckinFromApp(checkinID); }).catch(e) { // could not remove checkin from service worker }; } else { removeCheckinFromApp(checkinID); } } // send data to the service worker app.sendMessage = function(message) { return new Promise(function(resolve, reject) { var messageChannel = new MessageChannel(); // the onmessage handler messageChannel.port1.onmessage = function(event) { if (event.data.error) { reject(event.data.error); } else { resolve(event.data); } }; if(!navigator.serviceWorker.controller){ return; } // This sends the message data and port to the service worker. // The service worker can use the port to reply via postMessage(), which // will he onmessage handler on messageChannel.port1. navigator.serviceWorker .controller.postMessage(message,[messageChannel.port2]); }); }
Code Fragment service-worker.js:
var cacheName = 'v1', checkinDataRegex = /applicable\?pnr=([a-zA-Z0-9]+)&lastname=([a-zA-Z]+)/ ticketRegex = /image\/pnr\/([a-zA-Z0-9]+)\/lastname\/([a-zA-Z]+)\/ticket\/([0-9]+)/; self.addEventListener('fetch', function(event) { var request = event.request, matchCheckin = checkinDataRegex.exec(request.url); if (matchCheckin) { // Use regex capturing to grab only the bit of the URL // that we care about (in this case the checkinID) var cacheRequest = new Request(match[1]); event.respondWith( caches.match(cacheRequest).then(function(response) { return response || fetch(request).then(function(response) { caches.open(cacheName).then(function(cache) { cache.put(cacheRequest, response); }) return response; }); }) ); } if (ticketRegex) { // disable the image (by replacing it) [...] } [...] }); // communication between the service worker and the app.js self.addEventListener("message", function(event) { var data = event.data; switch(data.command) { case 'deleteCheckin': // open current cache caches.open(cacheName).then(function(cache) { // remove the flight data (JSON) cache.delete(data.checkinID).then(function(success) { event.ports[0].postMessage({ error: success ? null : 'Item was not found in the cache.' }); )}; }) break; [...] } });
Некоторые элементы polymer’а могут запустить app.js, внутри которого специальный метод проверяет, актуальна ли информация, хранящаяся в кэше, или нет. В случае, если данные устарели, обработчик получает команду «жги», стирает внутренний кэш и удаляет данные из локального хранилища, после чего сообщает всем заинтересованным элементам polymer, что данные изменились.
Приведённый выше код также содержит обработчик загрузки (fetch). Так как URL, с которым взаимодействует обработчик, может меняться (например, появятся дополнительные GET-параметры для firebase analytics), мы написали регулярное выражение, которое просто выдёргивает необходимый набор параметров и помещает его в кэш веб-приложения. Таким образом мы не привязаны к URL’у и легко можем получать из него данные, которые проще обрабатывать и хранить.
Самым эффективным способом кардинально сократить время загрузки на мобильных устройствах оказалось уменьшение количества отдельных файлов и элементов и применение «ленивой загрузки» через обработчик для всего остального. Первым шагом мы запаковываем все веб-компоненты и отправляем на мобильное устройство, а обработчик ждёт, пока всё необходимое будет загружено. В то же время мы грузим в фоне CSS, скрипты, картинки и помещаем их в кэш. Далее странится собирается из «базовой» конструкции и обратастает деталями и проработанным оформлением по мере загрузки ресурсов на пользовательское устройство.
Например, в фоне загружается симпатичный фона или дополнительная информация о месте назначения. А основные функции будут работать и без этих красивостей, даже если вы подключены по еле живому edge-соединению.
К сожалению, волшебной кнопки «сделать зашибись» ещё не придумано, так что просто взять и через какое-нибудь API показать окошко с кнопкой «добавьте наш сайт на домашний экран» не выйдет. Придётся применить смекалку и набор костылей: то есть написать своё сообщение и диалоговое окно, в котором мы расскажем пользователию, как добавить PWA на рабочий стол. В нашем случае к изобретению велосипеда добавились проверка на то, в какой момент высвечивать диалог. Вся информация становится доступной оффлайн только после регистрации на рейс, так что именно после неё мы и предлагаем пользователю создать ярлык. Собственно, тут всё просто:
var deferredPromptEvent; window.addEventListener('beforeinstallprompt', function(e) { e.preventDefault(); deferredPromptEvent = e; return false; }); // and in the moment your condition is fulfilled // check if the prompt had been triggered if(deferredPromptEvent !== undefined && deferredPromptEvent) { // show message deferredPromptEvent.prompt(); // do something on the user choice deferredPromptEvent.userChoice.then(function(choiceResult) { if(choiceResult.outcome != 'dismissed') { } // finally remove it deferredPromptEvent = null; }); }
В нашем случае сообщение появляется после того, как пользователь закроет pop-up с уведомлением о том, что его билет сохранён.
Элементы polymer’а атомарны (сейчас под атомарностью продолжают понимать неделимость, хотя мы-то знаем, что атомы ещё как делятся). Так вот, из атомарности следует, что каждый элемент polymer’а будет нести в себе inline CSS и javascript. Само собой, вы можете добавить и внешние стили / скрипты, но inline реализация быстрее загружается и надёжнее работает. Разумеется, у неё есть свой минус — обслуживать такой код сложнее, особенно CSS. Наше решение — использовать Grunt и встраивать inline именно его, ну и использовать SCSS как прекомпилятор для CSS. Каждый элемент вместе с нормализованным CSS получает собственный SCSS файл с базовыми параметрами (функциями и переменными). Grunt берёт и сгенерированный CSS и внедряет его как inline-стиль, после чего привязывает его к элементу. Само собой, то же самое будет работать с gulp’ом или LESS’ом.
Облегчить работу и ускорить загрузку можно разными способами, и один из самых интересных и эффективных — использовать массив объектов как базу данных для объектов polymer’а, но там есть одна маленькая сложность, особенно если вы работаете со вложенными элементами. В нашем случае в приложении был слайдер, который содержит посадочные талоны. Так как мы перевели всю отрисовку талонов с сервера на клиент, то данные для отрисовки, которые клиент получит с сервера, весят очень мало: немного JSON’а, пара бинарников… Всё это улучшает время загрузки, облегчает работу сервера, особено если речь идёт о современных гаджетах. На старых устройствах всё не так однозначно. Иногда проще отдать пре-рендеренный контент, чем данные для его построения, правда, первый запуск PWA будет происходить дольше. Всё это — объект изучения конкретных случаев в конкретных приложениях, цель A/B тестирования и оценки удобства того или иного подхода к решению посталвенной задачи.
Вторая штука, которая сильно упрощает жизнь, но может подпортить вам кровь — обработчик. Да, он увеличивает скорсоть загрузки и отрисовки, упрощает оффлайн работу, убирает большое количество запросов к серверу, что ещё сильнее влияет на ощущения от работы при нестабильном мобильном соединении. Вместе с тем, подобный подход ставит вопрос «что кэшировать» и «как кэшировать». Если с Android всё более-менее понятно, на современных устройствах проблем не будет, то вот с iOS до сих пор есть проблемы из-за… скажем так, особенностей закрытой архитектуры и платформы. Вы можете добавить ярлык на рабочий стол, и (из-за определённых механик кеширования браузера) оно может быть даже будет работать оффлайн или при очень плохом соединении… но в то же время всегда может показать динозавра.