Самодостаточные контроллы на Xamarin.Forms: «Переиспользуй код на максимум!». Часть 2



    Итак, в прошлой главе я рассмотрел такую архитектурную модель, как контрол – сервис. В такой модели на слое бизнес-логики создается сервис, у которого есть ивенты, на которые подписывается 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, в котором видно откуда именно пришла ошибка.

    image image

    На изображениях видно, как работает динамичная подгрузка товаров из сервиса на странице каталога. В момент добавления товара в корзину, уменьшается его количество на складе, даже если добавить его из сервиса корзины, которая не имеет отношения к остаткам. Вся синхронизация работает благодаря подписке ViewModels на соответствующие сервисы в изменениях.

    Пример можно посмотреть на GitHub.

    В этой статье мы рассмотрели как мы организуем взаимодействие двух самодастаточных контроллов, которые в последствии можно будет использовать в других проектах. В следующей, заключительной, главе я расскажу о том, как собирать все созданные контроллы в библиотеки NuGet и переиспользовать их в других проектах. Так же рассмотрю проблему создания кастомного интерфейса для различных платформ.
    Mobile Dimension
    62,00
    Mobile Dimension — разработчик мобильных решений
    Поделиться публикацией

    Комментарии 4

      +1
      В первый раз пользуетесь системой контроля версий?
        0
        Нет, просто кое-кто забыл апрувнуть PR :)
        0
        Ваши контролы нормально переживают специфику Android Activity/Fragment Lifecycle?
        Тесты при включенной опции «Don't Keep Activities» в настройках разработчика все проходятся или есть нюансы?
          0
          Спасибо за хороший вопрос! Вообще у Xamarin.Forms интересная история с Activity Lifecycle в Android. Долгое время известным багом платформы было то, что приложение невозможно было поднять в принципе после того, как оно было убито ОС. После долгих обсуждений на форумах (пример), разработчики все-таки добавили этот баг в свою «internal feature tracking system». Но сейчас все хорошо, причем сервисы даже могут помочь контроллам возвращаться в исходное состояние, если это описать в соответствующих событиях. В данном примере это не предусмотрено, основной упор был сделан именно на взаимодействии 2 контроллов через сервисы.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое