Итак, в прошлой главе я рассмотрел такую архитектурную модель, как контрол – сервис. В такой модели на слое бизнес-логики создается сервис, у которого есть ивенты, на которые подписывается ViewModel нужного контролла. В результате такого подхода, слою отображения интерфейса не нужно думать о данных, а заниматься исключительно интерфейсными вещами.
В прошлой главе приводился пример авторизации. Изначально не понятно, зачем использовать модель контрол – сервис, однако, если самодостаточных контроллов должно быть больше – обращение одного контролла к другому может стать проблематичным, если обращаться на прямую к методам ViewModel другого контрола, из-за возможных зацикливаний вызовов методов – подписок.
Рассмотрим следующий пример: Каталог товаров – Корзина, в которую добавляется товар. Предположим ситуацию, в которой, необходимо отображать каталог товаров, в виде листинга, каждый товар можно добавить в корзину, при этом товар, добавленный в корзину – помечается в листинге, как добавленный. Происходит двойная зависимость.
В такой модели появляется сильная зависимость контроллов друг от друга, что влечет за собой сразу целый ряд проблем. Основная из которых – невозможно использовать в другом приложении, например, контролл листинга без корзины, а переиспользование является главной нашей целью. В этой ситуации модель контролл – сервис подойдет больше. Пусть ViewModel и дальше занимается отображением всей актуальной информации, однако вопрос взаимодействия с другими контроллами унесем на уровень сервисов. Сами сервисы – singleton’ы, их интерфейсы добавляются в DI контейнер. Таким образом, например, при добавлении товара в корзину, достаточно будет один раз проверить есть ли в DI контейнере сервис корзины, и если да – отображать кнопку добавления в корзину, ну и осуществлять сами подписки на изменения в корзине. Тоже самое и с сервисом корзины. Товары можно добавлять откуда угодно, например с сайта, а в приложении только отображать корзину. Корзина в таком случае не зависит от наличия листинга. И сам контролл теперь можно переиспользовать во всех других приложениях внутри компании.
Перейдем конкретно к коду. Допустим, существует API с методами:
- поиск товаров внутри категории (api/catalogue/search)
- получение основной информации о товаре по его SKU (api/catalogue/materials)
- получение информации о цене товара по его SKU (api/calatogue/price)
- получение информации о остатках товара по SKU (api/catalogue/remains)
- добавление товара в корзину (api/cart/add)
- удаление товара из корзины (api/cart/remove)
Необходимо на основании существующих методов реализовать приложение с листингом товаров, в хедере листинга будет находиться иконка корзины, с актуальным количеством товаров, при нажатии на которую открывается экран корзины. На экране с корзиной, можно увеличить или уменьшить количество конкретного товара в корзине или совсем очистить ее.
Пред созданием контролла листинга, хотелось бы немного затронуть тему самого обращения к API методам. При создании клиентского приложения, надо всегда учитывать, то что не все запросы могут доходить до сервера (в какой-то момент времени интернет может просто пропасть). По этому, необходимо реализовать слой обращения к данным, которые учитывая эту ситуацию, смогут сначала выстоиться в очередь запросов, и, при появлении интернета осуществить запрос еще раз. В такой ситуации, чтоб не тормозить работу клиента с приложением, можно, в зависимости от необходимой точности данных – отображать данные из кэша. Данная тема заслуживает отдельного цикла статей. Как мы решаем данную ситуацию в Mobile Dimension, если будет такой интерес – мы опишем в отдельном цикле статей.
О том, как создавать сам интерфейс этих контроллов, о том как расширять существующие или, как сделать адаптивные к любому размеру экрана контролы – рассказывает мой коллега в данном цикле статей.
Слой бизнес логики не должен знать о том, каким образом приходят данные, а учитывая то, что все сервисы как раз таки находятся на уровне бизнес логики – они будут обращаться к «виртуальному» слою данных, который по запросу может вернуть результат (успешный или не успешный). Надо учитывать, что запрос может обрабатываться неопределенное количество времени, однако, как писала моя коллега в своей статье про интерфейсы, интерфейс должен моментально реагировать на любое нажатие. Поэтому, любой запрос на уровне данных (DAL) имеет события, на которые можно подписаться – это событие успешного запроса с результатом, и неуспешного запроса с ошибками. В общем случае — это билет (tiket) в котором есть сам запрос, метод обращения к API, ну и сами события. Таким образом сервис отправляет запрос, подписывается на эти события и, в зависимости от пришедшего результата, вызывает собственные события, на которые подписывается какой угодно контролл. В самом контролле, по нажатию инициализируется начало работы по запросу в сервисе (BLL), в этот момент показывается индикатор загрузки и происходит подписка на событие результата.
Например, код вызова запроса добавления товара в корзину, на уровне сервиса будет выглядеть следующим образом:
public event Action<string, string> OnProductAddedSuccessfully;
public event Action<string> OnProductAddedFailure;
public void StartAddingProduct(string sku)
{
var newProduct = new BasketProduct() { Sku = sku, State = Enums.RequestState.InProgress };
//сохраняем локально новый экземпляр возможного продукта в корзине
_products.Add(newProduct);
//обращаемся к хранилищу корзины, получаем билет, в котором выполняется запрос...
var tiket = _basketRepository.AddToBasket(sku);
tiket.OnSuccess += (response) =>
{
//результат содержит поле, которое указывает на то удалось ли добавить товар в корзину
if(response.Data.Succseeded != null && response.Data.Succseeded.Value)
{
//каждый товар в корзине должен содержать свой собственный идентификатор, который тоже приходит в ответе (positionId)
newProduct.PositionId = response.Data.PositionId;
newProduct.State = Enums.RequestState.Succseeded;
//оповещаем подписчиков (контроллы), что товар добавлен успешно
OnProductAddedSuccessfully?.Invoke(newProduct.Sku, newProduct.PositionId);
}
else
{
//оповещаем подписчиков, что товар не удалось добавить
OnProductAddedFailure?.Invoke(sku);
newProduct.State = Enums.RequestState.Failed;
}
};
//дополнительный запрос на то, чтоб конкретизировать цену (необходимо при подсчете общей стоимости корзины)
var priceTicket = _catalogRepository.GetPriceTicket(sku);
priceTicket.OnSuccess += (response) =>
{
if(response.Data != null){
//обновляем данные о цене
newProduct.Price = response.Data.Price;
}
};
}
Сервисный метод добавления товара в корзину
Как видим из примера, у нас есть 2 eventа, которые обозначают то, что в корзину что-то пытались добавить. Код удаления товара из сервиса будет выглядеть примерно так же.
На события этого сервиса корзины теперь может подписаться как ViewModel корзины, так и листинга. Товар из листинга добавляется напрямую в сервис, а так как и листинг и корзина подписаны на события сервиса – во всех view произойдут необходимые изменения. Реализация подписки на изменения будет происходить следующим образом:
public int? TotalCount
{
get
{
return _basketService.TotalCount;
}
}
public int? TotalPrice
{
get
{
return _basketService.TotalPrice;
}
}
void _basketService_OnProductAddedSuccessfully(string sku, string positionId)
{
var product = Products.ToList().FirstOrDefault(x => x.Sku == sku);
product.CountInBasket++;
product.IsAddingInProfress = false;
product.PositionIds.Add(positionId);
//Рейз происходит не в сеттере поля, а именно в тот момент, когда в корзине действительно что-то поменялось
RaizePropertyChanged(nameof(TotalCount), nameof(TotalPrice));
}
Сервисный метод добавления товара в корзину
Cами поля из ViewModel, отображаемые интерфейсом выглядят не так, как обычно, кода стало меньше а само обновление полей стало управляемым. Это является дополнительным бонусом такого подхода. Тут же могут существовать и другие ViewModel, если выносить их взаимодействие на уровень сервисов – код становится понятнее, его проще поддерживать.
Минусом такого подхода является сам момент дебага. Если вдруг, где-то на уровне сервиса или данных возникнет NRE или другие исключения – сама ошибка будет отображаться на уровне ViewModel. Visual Studio не определит на каком именно уровне возникла ошибка, так как по сути ошибка возникает в callback функции. Возможно это можно настроить где-то в самой студии, однако меня обычно спасает call-stack, в котором видно откуда именно пришла ошибка.
На изображениях видно, как работает динамичная подгрузка товаров из сервиса на странице каталога. В момент добавления товара в корзину, уменьшается его количество на складе, даже если добавить его из сервиса корзины, которая не имеет отношения к остаткам. Вся синхронизация работает благодаря подписке ViewModels на соответствующие сервисы в изменениях.
Пример можно посмотреть на GitHub.
В этой статье мы рассмотрели как мы организуем взаимодействие двух самодастаточных контроллов, которые в последствии можно будет использовать в других проектах. В следующей, заключительной, главе я расскажу о том, как собирать все созданные контроллы в библиотеки NuGet и переиспользовать их в других проектах. Так же рассмотрю проблему создания кастомного интерфейса для различных платформ.
Комментарии (4)
R2D2_RnD
22.06.2017 13:50Ваши контролы нормально переживают специфику Android Activity/Fragment Lifecycle?
Тесты при включенной опции «Don't Keep Activities» в настройках разработчика все проходятся или есть нюансы?MobileDimension
22.06.2017 17:43Спасибо за хороший вопрос! Вообще у Xamarin.Forms интересная история с Activity Lifecycle в Android. Долгое время известным багом платформы было то, что приложение невозможно было поднять в принципе после того, как оно было убито ОС. После долгих обсуждений на форумах (пример), разработчики все-таки добавили этот баг в свою «internal feature tracking system». Но сейчас все хорошо, причем сервисы даже могут помочь контроллам возвращаться в исходное состояние, если это описать в соответствующих событиях. В данном примере это не предусмотрено, основной упор был сделан именно на взаимодействии 2 контроллов через сервисы.
ANTPro
В первый раз пользуетесь системой контроля версий?
sprodan8
Нет, просто кое-кто забыл апрувнуть PR :)