Недавно передо мной стала задача разработки 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
<script data-main="app" src="lib/require.js"></script>
Атрибут data-main указывает на точку входа в приложение (это наш файл app.js)&
Также можно удалить то, что находится внутри тегов 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 ссылку:
<a href="about.html">Go to About page</a>
Bли из js кода:
app.mainView.loadPage('about.html');

Навигация назад (вместе с анимацией) осуществляется аналогично:
Или добавлением класса back в ссылку:
<a href="index.html" class="back"> Go back to home page </a>
Или из js кода:
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 анимаций. Возможно какие-то из них войдут в будущие версии фреймворка. Ну и возможно кому-то будут полезны для их приложений.