За несколько лет, читая новости и события в мире Web разработки, у меня нарисовалась розовая мечта: написал один раз — работает везде и всегда. При этом очень часто встречаю негативные отзывы о разработке мобильных приложений на HTML5 ( тут и комментарии на статьи 1 и 2 ). Основные доводы бастующих: несоответствие родному интерфейсу, глючность и тормознутость, проблемы с хранением данных и тд и тп. Ни в коем случае не хочу запустить очередные холи вары на эту тему. Но мечта живет и ее можно подтвердить или отвергнуть только после собственного наступления на грабли.
Итак, цель – написать на HTML5 мобильное приложения для сбора заказов торговым агентом в торговых точках. Я сталкивался с данными решениями разных компаний, поэтому знаком с предметной областью, и эта тема идеально подходит для мечты.
К основным требованиям я добавлю несколько заметок из собственного опыта:
- Программа должна работать на многих устройствах и на разных платформах. Обычно у компаний, особенно больших, уже есть парк мобильный устройств. Некоторые компании-дистрибьюторы даже заставляют использовать собственные телефоны (так сказать добровольно принудительный BYOD).
- Поддержка офлайн работы. К сожалению интернет покрытие оставляет желать лучшего. Нативные решения хорошо справляются с данной проблемой.
- Программа должна легко расширяться. Почему-то у поставщиков таких решений возникает проблема нормального обновления версий
- Использование железа ( камера, GPS).
Маленькая заметка: Статья написана с целью закрепления пройденного материала по изучению новой технологии. В связи с полным отсутствием реального опыта создания приложений такого рода, заранее прошу прощения за возможные огрехи.
Предварительная архитектура:
Backend — .net MVC with OData. Глобально не важно, что я буду использовать в этой роли, главное, чтобы соответствовало новым стандартам WEB API. Frontend – тут все сложно для меня. При отсутствии опыта выбрать что-то очень сложно. После некоторого просматривания остановился на PhoneJS. Меня подкупило то, что это полноценный фреймворк для SPA приложения, так что не требуется связывать насколько библиотек в кучу, а также использование knockoutjs. Для работы с данными решил использовать breeze. Уверен, что список будет меняться в процессе разработки. Все это потом запаковать при помощи PhoneGap и получить подобие приложения.
В этой статье построим что-то простенькое для начала: просмотр данных торговой точки на определённом маршруте торгового агента.
Создание проекта.
Создаем новый проект ASP.NET MVC 4 Web Application и назовем «MSales». В диалоге New ASP.NET MVC 4 Project выбираем шаблон Web API.
Обновляем пакеты: Update-Package knockoutjs и Update-Package jQuery
, и устанавливаем: Install-Package Breeze.WebApi и Install-Package datajs
.
К сожалению, для PhoneJS нет пакета, поэтому ручками добавляем все необходимые css и js в проект. На выбор есть нескольто типов layout, я использовал NavbarLayout, поменяв файл _Layout.cshtml:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>@ViewBag.Title</title>
@Styles.Render("~/Content/css")
@Styles.Render("~/Content/dx")
@Styles.Render("~/Content/layouts")
@Scripts.Render("~/bundles/modernizr")
</head>
<body>
@Html.Partial("NavbarLayout")
@RenderBody()
@Scripts.Render("~/bundles/jquery")
@RenderSection("scripts", required: false)
</body>
</html>
В файле BundleConfig прописываем весь контент и скрипты. У меня получилось вот так:
// Сокращено для упрощения
bundles.Add(new ScriptBundle("~/bundles/knockout").Include(
"~/Scripts/knockout-{version}.js"));
bundles.Add(new ScriptBundle("~/bundles/breeze").Include(
"~/Scripts/q.js",
"~/Scripts/datajs-{version}.js",
"~/Scripts/breeze.debug.js"
));
bundles.Add(new ScriptBundle("~/bundles/dx").Include(
"~/Scripts/dx.phonejs.js",
"~/Scripts/globalize"
));
bundles.Add(new ScriptBundle("~/bundles/app").Include(
"~/Scripts/App/app.init.js",
"~/Scripts/App/app.viewmodel.js",
"~/Scripts/App/NavbarLayout.js"
));
bundles.Add(new StyleBundle("~/Content/dx").Include("~/Content/dx/dx.*"));
bundles.Add(new StyleBundle("~/Content/layouts").Include("~/Content/layouts/NavbarLayout.css"));
Модель и контролеры
В модель на данный момент включим два файла: классы для маршрутов (по этим маршрутам ходит торговый агент) и торговых точек (магазинов):
public class Route
{
public int RouteID { get; set; }
[Required]
[StringLength(30)]
public string RouteName { get; set; }
}
public class Customer
{
public int CustomerID { get; set; }
[Required]
[StringLength(50)]
public string CustomerName { get; set; }
[StringLength(150)]
public string Address { get; set; }
public string Comment { get; set; }
[ForeignKey("Route")]
public int RouteID { get; set; }
virtual public Route Route { get; set; }
}
Контроллеры будут очень простые (более детально про OData можно почитать тут ):
public class RoutesController : EntitySetController<Route, int>
{
private MSalesContext db = new MSalesContext();
public override IQueryable<Route> Get()
{
return db.Routes; ;
}
protected override void Dispose(bool disposing)
{
db.Dispose();
base.Dispose(disposing);
}
}
public class CustomersController : EntitySetController<Customer, int>
{
private MSalesContext db = new MSalesContext();
public override IQueryable<Customer> Get()
{
return db.Customers; ;
}
protected override Customer GetEntityByKey(int key)
{
return db.Customers.FirstOrDefault(p => p.CustomerID == key);
}
protected override void Dispose(bool disposing)
{
db.Dispose();
base.Dispose(disposing);
}
}
public static class WebApiConfig
{
public static void Register(HttpConfiguration config)
{
config.Routes.MapODataRoute("odata", "odata", GetEdmModel());
config.Routes.MapHttpRoute(
name: "DefaultApi",
routeTemplate: "api/{controller}/{id}",
defaults: new { id = RouteParameter.Optional }
);
config.EnableQuerySupport();
config.EnableSystemDiagnosticsTracing();
}
public static IEdmModel GetEdmModel()
{
ODataModelBuilder builder = new ODataConventionModelBuilder();
builder.EntitySet<Route>("Routes");
builder.EntitySet<Customer>("Customers");
builder.Namespace = "MSales.Models";
return builder.GetEdmModel();
}
}
При регистрации маршрута для протокола OData необходимо указать строку builder.Namespace = "MSales.Models";
, необходимую для работы библиотек breeze и datajs.
Frontend.
В папке Scripts/app создадим файл скрипта app.init.js для инициализации библиотек:
window.MyApp = {};
$(function () {
MyApp.app = new DevExpress.framework.html.HtmlApplication({
namespace: MyApp,
defaultLayout: "navbar",
navigation: [
{
title: "Routes",
action: "#route",
icon: "home"
},
{
title: "About",
action: "#about",
icon: "info"
}
]
});
MyApp.app.router.register(":view/:id", { view: "route", id: 0 });
MyApp.app.navigate();
var serverAddress = "/odata/";
breeze.config.initializeAdapterInstances({ dataService: "OData" });
MyApp.manager = new breeze.EntityManager(serverAddress);
});
Создаем HTML приложение, в котором указываем layout и параметры навигации, которая состоит из двух пунктов: маршруты и about; а также инициализируем библиотеку breeze.
В файле Index.cshtml необходимо разместить dxView и специальную область с именем “content”, в котором выводится обычный список:
<div data-options="dxView : { name: 'route', title: 'Routes' } " >
<div class="route-view" data-options="dxContent : { targetPlaceholder: 'content' } " >
<div data-bind="dxList: { dataSource: dataSource }">
<div data-options="dxTemplate : { name: 'item' }" data-bind="text: RouteName, dxAction: '#customers/{RouteID}'"/>
</div>
</div>
</div>
Для того, чтобы эти пару строк заработали необходимо, создать Viewmodel, поэтому в папке Scripts/app создадим файл app.viewmodel.js:
MyApp.route = function (params) {
var viewModel = {
dataSource: {
load: function (loadOptions) {
if (loadOptions.refresh) {
var deferred = new $.Deferred();
var query = breeze.EntityQuery.from("Routes").orderBy("RouteID");
MyApp.manager.executeQuery(query, function (result) {
deferred.resolve(result.results);
});
return deferred;
}
}
}
}
return viewModel;
};
Хочу обратить внимание что имя Viewmodel совпадает с именем dxView, и содержит только объект dataSource, в которой мы определяем один метод load для загрузки данных. Параметр refresh определяет должны ли данные виджета обновлены полностью. В методе строим запрос, сортируя по полю RouteID и выполняем его.
Добавим еще одну View – About:
<div data-options="dxView : { name: 'about', title: 'About' } ">
<div data-options="dxContent : { targetPlaceholder: 'content' } ">
<div data-bind="dxScrollView: {}">
<p style="padding: 5px">This is my first SPA application.</p>
</div>
</div>
</div>
Результат для IPhone:
Вы, наверно, обратили внимание, что на элемент списка повешено событие dxAction: '#customers/{RouteID}'
, где, согласно заданной навигации, '#customers
– это вызываемое View, а RouteID
– параметр, передаваемый в это View:
<div data-options="dxView : { name: 'customers', title: 'Customers' } " >
<div data-bind="dxCommand: { title: 'Search', placeholder: 'Search...', location: 'create', icon: 'find', action: find }" ></div>
<div data-options="dxContent : { targetPlaceholder: 'content' } " >
<div data-bind="dxTextbox: { mode: 'search', value: searchString, visible: showSearch, valueUpdateEvent: 'search change keyup' }"></div>
<div data-bind="dxList: { dataSource: dataSource }">
<div data-options="dxTemplate : { name: 'item' } " data-bind="text: name, dxAction: '#customer-details/{id}'"/>
</div>
</div>
</div>
В связи с тем, что покупателей может быть много, добавил возможность поиска: добавил dxCommand — кнопка поиска, которая вызывает функцию find, и поле ввода перед списком.
Viewmodel:
MyApp.customers = function (params) {
var skip = 0;
var PAGE_SIZE = 10;
var viewModel = {
routeId: params.id,
searchString: ko.observable(''),
showSearch: ko.observable(false),
find: function () {
viewModel.showSearch(!viewModel.showSearch());
viewModel.searchString('');
},
dataSource: {
changed: new $.Callbacks(),
load: function (loadOptions) {
if (loadOptions.refresh) {
skip = 0;
}
var deferred = new $.Deferred();
var query = breeze.EntityQuery.from("Customers")
.where("CustomerName", "substringof", viewModel.searchString())
.where("RouteID", "eq", viewModel.routeId)
.skip(skip)
.take(PAGE_SIZE)
.orderBy("CustomerID");
MyApp.manager.executeQuery(query, function (result) {
skip += PAGE_SIZE;
console.log(result);
var mapped = $.map(result.results, function (data) {
return {
name: data.CustomerName,
id: data.CustomerID
}
});
deferred.resolve(mapped);
});
return deferred;
}
}
};
ko.computed(function () {
return viewModel.searchString();
}).extend({
throttle: 500
}).subscribe(function () {
viewModel.dataSource.changed.fire();
});
return viewModel;
};
Переменные skip и PAGE_SIZE необходимы для загрузки части данных (в данном случае 10 записей), а дозагрузка будет идти по мере необходимости.
Переменные searchString и showSearch для поиска, при чем поиск срабатывает с пол секундной задержкой после ввода символа.
Результат:
Ну и напоследок, выведем информацию о выбранном покупателе:
View:
<div data-options="dxView : { name: 'customer-details', title: 'Product' } " >
<div data-options="dxContent : { targetPlaceholder: 'content' } " >
<div class="dx-fieldset">
<div class="dx-field">
<div class="dx-field-label">Id: </div>
<div class="dx-field-value" data-bind="text: id"></div>
</div>
<div class="dx-field">
<div class="dx-field-label">Name: </div>
<div class="dx-field-value" data-bind="text: name"></div>
</div>
<div class="dx-field">
<div class="dx-field-label">Address: </div>
<div class="dx-field-value" data-bind="text: address"></div>
</div>
<div class="dx-field">
<div class="dx-field-label">Comment: </div>
<div class="dx-field-value" data-bind="text: comment"></div>
</div>
</div>
</div>
</div>
ViewModel:
MyApp['customer-details'] = function (params) {
var viewModel = {
id: parseInt(params.id),
name: ko.observable(''),
address: ko.observable(''),
comment:ko.observable('')
};
var data = MyApp.manager.getEntityByKey("Customer", viewModel.id);
console.log(data);
viewModel.name(data.CustomerName());
viewModel.address(data.Address());
viewModel.comment(data.Comment());
return viewModel;
};
Примечание: скриншоты сделаны с эмулятора Ripple Emulator (Beta).
Резюме.
Мы получили довольно просто полноценное SPA приложение для мобильных устройств с навигацией и загрузкой данных. На данный момент сложно судить о качестве/скорости/ и т.д. приложения, поэтому в следующей статье я немного расширю функционал и выложу на Azure, что бы каждый желающий смог попробовать.
Автор: gfhfk