Мобильное приложение HTML5: ошибка или успех. Попытка №0

от автора

За несколько лет, читая новости и события в мире 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 прописываем весь контент и скрипты. У меня получилось вот так:

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);         }      } 

Маленький штрих в файле WebApiConfig:

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:
image
Вы, наверно, обратили внимание, что на элемент списка повешено событие 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 для поиска, при чем поиск срабатывает с пол секундной задержкой после ввода символа.
Результат:
image
Ну и напоследок, выведем информацию о выбранном покупателе:
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; }; 

image
Примечание: скриншоты сделаны с эмулятора Ripple Emulator (Beta).

Резюме.

Мы получили довольно просто полноценное SPA приложение для мобильных устройств с навигацией и загрузкой данных. На данный момент сложно судить о качестве/скорости/ и т.д. приложения, поэтому в следующей статье я немного расширю функционал и выложу на Azure, что бы каждый желающий смог попробовать.

ссылка на оригинал статьи http://habrahabr.ru/post/185316/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *