Недавно передо мной стала задача разработки IPhone и Android приложения. Опыта разработки под IOS у меня ранее не было, да и хотелось написать один раз и запускать на обеих платформах. Соответственно был выбран был выбран Javascript и PhoneGap.И если с языком я определился относительно быстро, то далее было много вопросов.
Хотелось сделать, что бы приложение максимально повторяло интерфейс IOS7 и было похоже на native по скорости работы. При этом с одной стороны не было желания использовать «монстров», на подобии dojo или jquery mobile. c другой стороны хотелось получить удобную модульную MVC структуру приложения.
В итоге в финал моего личного сравнения вышли:
— Ionic framework: http://ionicframework.com/
— Framework7: http://www.idangero.us/framework7/
У Ионика сначала мне понравилась документация, простые примеры и знакомая по AngularJs структура кода. Но после первых попыток создать приложение наступило разочарование. Запущенное простое приложение на Iphone5 торм��зило. При нажатии на кнопки или навигации была визуально заметна задержка между нажатием и срабатыванием. На подобии 300мс задержки при клике. Но по заявлениям создателей их фреймворк содержит собственную реализацию библиотеки fastclick… Странно. Так же даже в простом приложении временами были заметны подтормаживания в анимации. В итоге после пары дней чтения документации и тестовых примеров я понял, что надо искать что-то еще.
Дальше я вернулся к Framework7. Запустил тестовые приложения, глянул компоненты в kitchen sink и первоначально испытал wow эффект. На IPhone все работает быстро, красиво и очень похоже на native. При этом столкнулся с двумя достаточно большими минусами:
- На тот момент практически отсутствовала документация. Сейчас она уже есть, достаточно подробная (http://www.idangero.us/framework7/docs/).
- Во всех примерах код был в одном файле-простыне в jquery-like формате. При этом отсутствовала модульность, подгрузка шаблонов из отдельных файлов и т.п.
В общем я подтянул свои теоретические знания, просмотрел различные статьи и примеры и смог решить для себя задачу по совмещению Framework7 и модульного MVC подхода для создания мобильных приложений. Для реализации асинхронной загрузки модулей использовал RequireJs, для шаблонов – Handlebars.
Соответственно создал парочку учебных примеров и сейчас хочу поделиться ими с сообществом. Надеюсь они будут полезны, как начинающим разработчикам, так и более опытным, которые пока еще не знают про данный фреймворк.
Начинаем
Для работы нам потребуются следующие библиотеки:
- Framework7
- Handlebars – необходим для шаблонов
- RequireJS – асинхронная загрузка модулей
- Дополнительные плагины к RequireJs для загрузки шаблонов:
- А также хочу вас познакомить с прекрасной библиотекой иконок – ionicons http://ionicons.com/
Структура проекта

Создадим следующую структуру файлов проекта (файлы index.html и app.js пока оставим пустыми)
Что бы упростить себе жизнь – можно скачать архив со структурой по этой ссылкe:
Dropbox
(В данном архиве уже заполнены первые версии файлов index.html и app.js)
Также сразу даю ссылку на исходники на Github — там лежит последняя версия вместе с пошаговой историей правок — создания данного тестового приложения:
https://github.com/philipshurpik/Framework7-MVC-base
Создадим самый простой index.html файл, в котором подключим все необходимые библиотеки:
<!DOCTYPE html> <html class="with-statusbar-overlay"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, minimal-ui"> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <title>F7 Contacts MVC</title> <link rel="stylesheet" href="lib/css/framework7.css"> <link rel="stylesheet" href="lib/css/ionicons.css"> <link rel="stylesheet" href="css/app.css"> </head> <body> <div class="statusbar-overlay"></div> <div class="views"> <div class="view view-main navbar-fixed"> <div class="navbar"> <div class="navbar-inner"> <div class="left"></div> <div class="center" style="left:22px">Contacts</div> <div class="right"> <a href="contact.html" class="link icon-only"><i class="icon icon-plus">+</i></a> </div> </div> </div> <div class="pages"> <div data-page="list" class="page"> <div class="page-content"> <div class="list-block contacts-list"> <ul> <a href="contact.html" class="item-link item-content"> <div class="item-media"><i class="icon ion-ios7-person"></i></div> <div class="item-inner"> <div class="item-title">Andrey Smirnov</div> </div> </a> <a href="contact.html?id={{id}}" class="item-link item-content"> <div class="item-media"><i class="icon ion-ios7-person"></i></div> <div class="item-inner"> <div class="item-title">Olga Kot</div> </div> </a> </ul> </div> </div> </div> </div> </div> </div> </body> </html> <script type="text/javascript" src="lib/framework7.js"></script> <script type="text/javascript" src="app.js"></script>
Также в файл app.js поместим инициализацию приложения:
var f7 = new Framework7({ modalTitle: 'F7-MVC-Base', animateNavBackIcon: true }); var mainView = f7.addView('.view-main', { dynamicNavbar: true });
Запустим и получим следующую картинку:

Вот. У нас есть первая страничка и на ней даже что-то больше, чем hello-world.
Да, если кто не знает. В Devtools Chrome рядом с консолью есть вкладка Emulation, на которой можно выбрать нужный девайс и посмотреть, как примерно приложение будет выглядеть на экране этого устройства.
Подключаем RequireJs и Handlebars, подгружаем контакты
Теперь нам необходимо динамически подгружать контакты (например из localstorage) и отображать их в списке.
Для этого изменим наши файлы:
1. index.html
Заменим прямое подключение нашего app.js файла на подключение Require.Js
Атрибут data-main указывает на точку входа в приложение (это наш файл app.js)&<script data-main="app" src="lib/require.js"></script>
Также можно удалить то, что находится внутри тегов ul – внутренности списка будут генерироваться с помощью шаблона.
2. app.js
Переделаем наш файл в RequireJs модуль:
define('app', ['js/list/listController'], function(listController) { var f7 = new Framework7({ modalTitle: 'F7-MVC-Base', animateNavBackIcon: true }); var mainView = f7.addView('.view-main', { dynamicNavbar: true }); listController.init(); return { f7: f7, mainView: mainView }; });
Все тоже самое, только обернули в модуль + добавили загрузку нашего первого контроллера, которого пока еще нету.
Главная страница: контроллер, представление, template элемента
Теперь нам необходимо создать контроллер для главной страницы, ее представление, а также handlebars template.
Предлагаю назвать и разместить файлы следующим образом:

Да, подобная группировка – по функциональности – как мне кажется намного удобнее в проектах, чем размещение представлений, моделей, контроллеров в разных директориях.
Создадим простой контроллер для списка. И в нем сразу же инициализируем наш localstorage несколькими объектами контактов:
Файл: js/list/listController.js
define(["js/list/listView"], function(ListView) { function init() { var contacts = loadContacts(); ListView.render({ model: contacts }); } function loadContacts() { var f7Base = localStorage.getItem("f7Base"); var contacts = f7Base ? JSON.parse(f7Base) : tempInitializeStorage(); return contacts; } function tempInitializeStorage() { var contacts = [ {id: "1", firstName: "Alex", lastName: "Black", phone: "+380501234567" }, {id: "2", firstName: "Kate", lastName: "White", phone: "+380507654321" } ]; localStorage.setItem("f7Base", JSON.stringify(contacts)); return JSON.parse(localStorage.getItem("f7Base")); } return { init: init }; });
Так же теперь нам необходимо добавить представление, которое будет отвечать за рендеринг наших данных (которые мы передаем при его инициализации) с помощью темплейта.
Файл: js/list/listView.js
define(['hbs!js/list/contact-list-item'], function(template) { var $ = Framework7.$; function render(params) { $('.contacts-list ul').html(template(params.model)); } return { render: render }; });
А также код нашего простого темплейта:
Файл: js/list/contact-list-item.hbs
{{#.}} <a href="contact.html?id={{id}}" class="item-link item-content"> <div class="item-media"><i class="icon ion-ios7-person"></i></div> <div class="item-inner"> <div class="item-title">{{firstName}} {{lastName}}</div> </div> </a> {{/.}}
Запускаем — и получаем — все тоже самое, но модульное и гораздо более расширяемое.
Теперь нам необходимо добавить страницу просмотра и редактирования к��нтакта.
Навигация между страницами в Framework7
Каждая страница размещена в отдельном html файле.
Страница содержится внутри div c class=”page”
<div class="page" data-page="list">
Аттрибут data-page определяет уникальное название страницы которое будет нам необходимо в дальнейшем для роутинга.
Все визуальные элементы страницы необходимо размещать внутри:
<div class="page-content"> который является дочерним для <div class="page">
Навигация между страницами осуществляется или при нажатии на html ссылку:
Bли из js кода:<a href="about.html">Go to About page</a>
app.mainView.loadPage('about.html');
Навигация назад (вместе с анимацией) осуществляется аналогично:
Или добавлением класса back в ссылку:
Или из js кода:<a href="index.html" class="back"> Go back to home page </a>
app.mainView.goBack();
При переходе между страницами Framework7 генерирует события, на которые можно подписаться:
PageBeforeInit, PageInit, PageBeforeAnimation, PageAfterAnimation, PageBeforeRemove
Полная информация о страницах и событиях тут:
http://www.idangero.us/framework7/docs/pages.html
http://www.idangero.us/framework7/docs/ linking-pages.html
Создаем router.js
Воспользуемся событием, которое возникает после вставки новой страницы в DOM – PageBeforeInit.
Создадим простой роутер (файл router.js) и положим его в папку js, в котором подпишемся на возникновение события pageBeforeInit:
define(function() { var $ = Framework7.$; function init() { $(document).on('pageBeforeInit', function (e) { var page = e.detail.page; load(page.name, page.query); }); } function load(controllerName, query) { require(['js/' + controllerName + '/'+ controllerName + 'Controller'], function(controller) { controller.init(query); }); } return { init: init, load: load }; });
При срабатывании события мы с помощью Require загружаем необходимый нам модуль контроллера и инициализируем его, передавая в него параметры запроса, с которым была открыта страница.
Также переделаем модуль app.js, добавим в него инициализацию роутера и уберем подключение и инициализацию контроллера:
define('app', ['js/router'], function(Router) { Router.init(); var f7 = new Framework7({ modalTitle: 'F7-MVC-Base', animateNavBackIcon: true }); var mainView = f7.addView('.view-main', { dynamicNavbar: true }); return { f7: f7, mainView: mainView, router: router }; });
Теперь при первой загрузке приложения, после вставки главной страницы в DOM сработает обработчик события pageBeforeInit.
При этом его свойство e.detail.page.name будет равняться list, то есть тому, что было задано тут в свойстве data-page: Соответственно будет запущен соответствующий контроллер.
Страница редактирования контакта
Далее необходимо создать страницу добавления и редактирования контакта.
Добавим в корень проекта html файл contact.html (если вы скачивали структуру файлов из архива, то он там уже должен быть)
Соответствующие ссылки на contact.html уже были добавлены ранее в navbar главной страницы и в темплейт элементов списка контактов.
<div class="navbar"> <div class="navbar-inner"> <div class="left sliding"> <a href="#" class="back link"> <i class="icon icon-back-white"></i> <span>Back</span> </a> </div> <div class="center contacts-header"></div> <div class="right contact-save-link"> <a href="#" class="link"> <span>Save</span> </a> </div> </div> </div> <div class="pages"> <div data-page="contact" class="page contact-page"> </div> </div>
Теперь при нажатии на элемент списка или на кнопку добавить – роутер пробует загрузить файл js/contact/contactController.
Соотвественно нам необходимо создать его, представление страницы, а так же шаблон содержимого страницы. Вот так:

Содержимое файла contactController.js:
define(["app","js/contact/contactView"], function(app, ContactView) { var state = {isNew: false}; var contact = null; function init(query){ if (query && query.id) { var contacts = JSON.parse(localStorage.getItem("f7Base")); for (var i = 0; i< contacts.length; i++) { if (contacts[i].id === query.id) { contact = contacts[i]; state.isNew = false; break; } } } else { contact = { id: Math.floor((Math.random() * 100000) + 5).toString()}; state.isNew = true; } ContactView.render({ model: contact, state: state }); } return { init: init }; });
Если страница в режиме редактирования (в query содержится значение id контакта, то получаем его из localStorage.
Если нет, то создаем новый. Пока что для простоты мы не используем модели, поэтому наш контакт – это просто объект.
Также страница представления contactView.js:
define(['hbs!js/contact/contact'], function(viewTemplate) { var $ = Framework7.$; function render(params) { $('.contact-page').html(viewTemplate({ model: params.model })); $('.contacts-header').text(params.state.isNew ? "New contact" : "Contact"); } return { render: render } });
И шаблон contact.hbs:
<div class="page-content"> <form id="contactEdit" class="list-block"> <ul> <input name="id" type="hidden" value="{{model.id}}"> <li> <div class="item-content"> <div class="item-media"><i class="icon ion-ios7-football-outline"></i></div> <div class="item-inner"> <div class="item-input"> <input name="firstName" type="text" placeholder="First name" value="{{model.firstName}}"> </div> </div> </div> </li> <li> <div class="item-content"> <div class="item-media"><i class="icon ion-ios7-football-outline"></i></div> <div class="item-inner"> <div class="item-input"> <input name="lastName" type="text" placeholder="Last name" value="{{model.lastName}}"> </div> </div> </div> </li> <li> <div class="item-content"> <div class="item-media"><i class="icon ion-ios7-telephone-outline"></i></div> <div class="item-inner"> <div class="item-input"> <input name="phone" type="tel" placeholder="Phone" value="{{model.phone}}"> </div> </div> </div> </li> </ul> </form> </div>
Ну что же. Теперь мы можем открыть нашу страницу добавления или редактирования контакта:

Осталось добавить возможность контакты сохранять и удалять.
Начнем с сохранения.
Сохранение контактов
Для начала добавим обработчик кнопки сохранить.
Конечно можно сделать это сразу напрямую в контроллере вот так:
$(‘.contact-save-link’).on(‘click’, function() { // some code here });
Но так делать не хорошо, и лучше отделять работу с DOM и работу с данными и моделями.
Поэтому разделим подписку на обработку события и саму обработку.
В контроллере сделаем массив bindings:
var bindings = [{ element: '.contact-save-link', event: 'click', handler: saveContact }];
Передадим этот массив в качестве одного из свойств объекта params в представление.
И добавим функцию-обработчик:
function saveContact() { // some code here }
А в представлении добавим подписку на события по данному конфигу – функцию bindEvents:
function bindEvents(bindings) { for (var i in bindings) { $(bindings[i].element).on(bindings[i].event, bindings[i].handler); } }
И ее вызов из функции render:
bindEvents(params.bindings);
Теперь необходимо получить значение данных введенные в форму:
Делаем это в функции saveContact:
function saveContact() { var contacts = JSON.parse(localStorage.getItem("f7Base")) var newContact = app.f7.formToJSON('#contactEdit'); if (state.isNew) { contacts.push(newContact) } else { for (var i = 0; i< contacts.length; i++) { if (contacts[i].id === newContact.id) { contacts[i] = newContact; break; } } } localStorage.setItem("f7Base", JSON.stringify(contacts)); app.router.load('list'); app.mainView.goBack(); }
Так же полученные данные сохраняем сразу в localStorage.
Последние две строчки отвечают за возврат на предыдущую страницу (список), а также перезагрузку данных в listController.
У нас теперь все работает!
Создание модели:
Но так оперировать всеми данными в контроллере не очень хорошо. К тому же иногда необходимо добавить специальные функции – например по валидации данных.
Поэтому сделаем модель в файле js/contactModel.js.
За одно добавим в нее функцию валидации, а также установки значений из другого объекта.
define(['app'],function(app) { function Contact(values) { values = values || {}; this.id = values['id'] || Math.floor((Math.random() * 100000) + 5).toString(); this.firstName = values['firstName'] || ''; this.lastName = values['lastName'] || ''; this.phone = values['phone'] || ''; } Contact.prototype.setValues = function(formInput) { for(var field in formInput){ if (this[field] !== undefined) { this[field] = formInput[field]; } } }; Contact.prototype.validate = function() { var result = true; if (!this.firstName && !this.lastName) { result = false; } return result; }; return Contact; });
Заметьте, функции добавляются не в сам объект, а в его прототип. Соответственно при передаче или сохранении объекта в JSON передаются только его свойства, без функций.
Теперь подключим модель в contactController:
Добавим в список зависимостей:
define(["app","js/contact/contactView", "js/contactModel"], function(app, ContactView, Contact)
Изменим в функции init соответственно присвоение и создание контакта:
contact = new Contact(contacts[i]);
и
contact = new Contact();
И модифицируем функцию save, добавив в нее запуск валидации модели:
function saveContact() { var formInput = app.f7.formToJSON('#contactEdit'); contact.setValues(formInput); if (!contact.validate()) { app.f7.alert("First name and last name are empty"); return; } var contacts = JSON.parse(localStorage.getItem("f7Base")); if (state.isNew) { contacts.push(contact); } else { for (var i = 0; i< contacts.length; i++) { if (contacts[i].id === contact.id) { contacts[i] = contact; break; } } } localStorage.setItem("f7Base", JSON.stringify(contacts)); app.mainView.goBack(); app.router.load('list'); }
Сохранение готово.
Swipe to delete
Осталось добавить удаление из списка конта��тов.
Реализуем это с помощью жеста Swipe To Delete в списке.
Модифицируем разметку шаблона элементов:
{{#.}} <li id="{{id}}" class="swipeout"> <a href="contact.html?id={{id}}" class="item-link item-content swipeout-content"> <div class="item-media"><i class="icon ion-ios7-person"></i></div> <div class="item-inner"> <div class="item-title">{{firstName}} {{lastName}}</div> </div> </a> <div class="swipeout-actions"> <div class="swipeout-actions-inner"> <a href="#" class="swipeout-delete">Delete</a> </div> </div> </li> {{/.}}
Добавим в listController подписку на событие:
var bindings = [{ element: '.swipeout', event: 'deleted', handler: itemDeleted }];
И дальше сделаем по аналогии с подпиской в контактах – передадим в представление и там подпишемся в функции bindEvents(bindings)
А также добавим обработчик события удаления:
function itemDeleted(e) { var id = e.srcElement.id; var contacts = JSON.parse(localStorage.getItem("f7Base")); for (var i = 0; i < contacts.length; i++) { if (contacts[i].id === id) { contacts.splice(i, 1); } } localStorage.setItem("f7Base", JSON.stringify(contacts)); }
Смотрим на результат:


Заключение
У нас вышло готовое очень простое мобильное MVC приложение с использованием Framework7.
А сам Framework7 в связке с Phonegap позволяет создавать красивые native-like приложения в первую очередь для IOS. Что может быть полезно для разработчиков, которые плохо знакомы с ObjectiveC.
При этом мы сразу получаем кросс-платформенное приложение, которое отлично и плавно работает на Android 4.4 (и скорее всего должно так же работать и на следующих версиях).
Для нормальной поддержки недорогих Android устройств на предыдущих версиях Android, достаточно отключить анимацию между страницами, что бы получить тоже достаточно приемлимое быстродействие UI.
Исходники проекта вместе с последовательной историей правок доступны тут:
https://github.com/philipshurpik/Framework7-MVC-base
Так же я сделал расширенный учебный пример приложения контактов, имеющий больше фич и использующий больше возможностей Framework7. В нем добавлены левая выдвигающаяся панель меню, popup редактирования, строка поиска и т.д.
Его исходники вот:
https://github.com/philipshurpik/Framework7-Contacts7-MVC
А вот и скриншоты (с котиками):

Надеюсь эти примеры окажутся вам полезными.
Я сам учился на подобных, поэтому и решил создать данную статью.
Буду рад ответить на вопросы.
П.С. Автора данного фреймворка vladimirkharlampidi на хабре пока нету, но если хабровчан заинтересует эта тема — я думаю он тоже будет рад принять инвайт и присоединиться к обсуждению.
П.П.С. Еще я сделал небольшой research по поводу скорости работы на Android, особенно на старых версиях и залил в репозиторий в app.css хаки по оптимизации css анимаций. Возможно какие-то из них войдут в будущие версии фреймворка. Ну и возможно кому-то будут полезны для их приложений.
