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

в 13:37, , рубрики: .net, breeze, javascript, odata, PhoneJS, Веб-разработка, метки: , ,

За несколько лет, читая новости и события в мире 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, что бы каждый желающий смог попробовать.

Автор: gfhfk

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js