Pull to refresh

Использование сервисов и обработка их результатов в Xamarin

Reading time9 min
Views2.2K

Продолжим обсуждать различные задачи для Xamarin с которыми приходится регулярно сталкиваться, так как статьи по данной тематике появляются не очень часто. Данная статья больше будет полезна начинающим разработчикам, но и более опытным тоже может быть интересно.

Итак, почему такая тема? Поработав на разных проектах, словил себя на мысли, что на каждом из них для вызова сервисов и обработки ошибок используются совершенно разные подходы. В этой статье я решил собрать все, с чем приходилось столкнуться, показать, какие есть варианты, и подумать, какие у каждого есть плюсы и минусы.

Рассмотрим различные подходы на простом примере, где у нас будет запрос списка стандартных моделек с бекенда, а затем преобразование их в список вью-моделек для отображения коллекции. UI часть мы здесь рассматривать не будем, ограничимся только работой сервисов и вью-модели.

Итак, наша простая модель, которую мы и будем запрашивать с бекенда:

public class ItemModel
{
    public string Id { get; set; }
    public string Title { get; set; }
    public string Description { get; set; }
    public string CreatedDate { get; set; }
    public string ModifiedDate { get; set; }
}

И соответствующая ей View Model, в которой нам понадобятся всего 2 поля из модели для последующего отображения:

public class ItemViewModel : ViewModel
{
    public ItemViewModel(ItemModel item)
    {
        Title = item.Title;
        Description = item.Description;
    }

    public string Title { get; }

    public string Description { get; }
}

Также у нас будет сервис, непосредственно при помощи которого мы будем запрашивать данные из вью-модели:

public interface IDataService
{
    Task<IEnumerable<ItemModel>> LoadItemsAsync();
}

И RequestService, который уже и будет отправлять запросы на сервер:

public interface IRequestService
{
    Task<T> GetAsync<T>(string url);
}

Во вью-модели основной страницы будем хранить список ItemViewModel. Заранее уточню - ObservableCollection используется из MvvmCross, так как в ней присутствует поддержка AddRange(), что лучше сказывается на работе UI.

public class MainViewModel : ViewModel
{
    private readonly IDataService _dataService;

    public MainViewModel(IDataService dataService)
    {
        _dataService = dataService;
    }

    public MvxObservableCollection<ItemViewModel> Items { get; set; } = new();
}

Теперь дело осталось за малым - в MainViewModel вызвать загрузку данных. Добавим ее сразу при инициализации вью-модели.

Наверное, многим начинающим разработчикам приходит первая мысль сделать вот так:

public async Task Initialize()
{
    var result = await _dataService.LoadItemsAsync();
    var itemModels = result.ToList();
    if (itemModels.Any())
    {
        var items = new List<ItemViewModel>();
        itemModels.ForEach(m => items.Add(new ItemViewModel(m)));
        Items.AddRange(items);
    }
}

Где реализация LoadItemsAsync() выглядит как-то так:

public Task<IEnumerable<ItemModel>> LoadItemsAsync()
{
    var url = "https://customapiservice/v1/items";
    return _requestService.GetAsync<IEnumerable<ItemModel>>(url);
}

И действительно, все это дело будет работать, но только до того момента, пока мы не получим в ответе от сервера что-то из 400-х или 500-х ошибок. Как только это произойдет, мы моментально получим crash. И следующая мысль, как это дело фиксить, скорее всего будет вот такая:

public async Task Initialize()
{
    try
    {
        var result = await _dataService.LoadItemsAsync();
        var itemModels = result.ToList();
        if (itemModels.Any())
        {
            var items = new List<ItemViewModel>();
            itemModels.ForEach(m => items.Add(new ItemViewModel(m)));
            Items.AddRange(items);
        }
    }
    catch (Exception e)
    {
        // Показать сообщение об ошибке загрузки пользователю
    }
}

И в целом, такой подход уже имеет право на жизнь. Однако предлагаю сразу рассмотреть преимущества и недостатки такого способа.

Плюсы очевидны:

  • легко дебажить, и сразу можно быстро отловить, что что-то пошло не так

  • сразу видно, где мы обернули вызов, а где нет

Недостатки также легко сразу заметить:

  • нужно постоянно оборачивать вызов сервиса в try-catch, и есть вероятность где-то забыть это сделать

  • соответственно, более громоздкий код вью-модели

Но ведь можно и немного улучшить подход, дабы не писать в каждой вью-модели при каждом обращении к сервисам обертку try-catch, и делегировать это дело непосредственно сервисам. Рассмотрим подобную реализацию метода LoadItemsAsync():

public async Task<IEnumerable<ItemModel>> LoadItemsAsync()
{
    IEnumerable<ItemModel> result;
    try
    {
        var url = "https://customapiservice/v1/items";
        result = await _requestService.GetAsync<IEnumerable<ItemModel>>(url);
    }
    catch (Exception e)
    {
        result = new List<ItemModel>();
    }

    return result;
}

Но тогда возникает вопрос - как нам понять, когда бекенд просто возвращает пустой список, а когда - ошибку. К сожалению, с такой реализацией во вью-модели этого узнать не выйдет. Единственный вариант - возвращать null, но тогда нам потребуется дополнительная проверка во вью модели, а это снова лишний код. Да и не факт, что сам api нам не вернет null, если данных нет.

Потому в данном случае есть свои плюсы:

  • По-прежнему легко дебажить, но теперь breakpoints перемещаются непосредственно в сервис

  • Нет необходимости оборачивать каждый вызов данного метода в try-catch, и код во вью-модели становится менее громоздким

Но так же присутствует немного недостатков:

  • По прежнему необходимо не забывать оборачивать каждый метод сервиса в try-catch

  • Иногда нет возможности на выходе точно понять, мы словили exception, либо же просто не получили результат

Потому можно попробовать еще немного улучшить данный подход. Для этого сначала создадим класс, который будет хранить результат отработки сервиса:

public class ServiceResult<TResult, TError>
{

    public ServiceResult(TResult result)
    {
        IsSuccessful = true;
        Result = result;
    }

    public ServiceResult(TError error)
    {
        IsSuccessful = false;
        Error = error;
    }

    public bool IsSuccessful { get; }
    public TResult Result { get; }
    public TError Error { get; }
}

А далее немного изменим сигнатуру метода LoadItemsAsync(), и отрефакторим его в соответствии с новым подходом:

public interface IDataService
{
    Task<ServiceResult<IEnumerable<ItemModel>, Exception>> LoadItemsAsync();
}
public async Task<ServiceResult<IEnumerable<ItemModel>, Exception>> LoadItemsAsync()
{
    try
    {
        var url = "https://customapiservice/v1/items";
        var result = await _requestService.GetAsync<IEnumerable<ItemModel>>(url);
        return new ServiceResult<IEnumerable<ItemModel>, Exception>(result);
    }
    catch (Exception e)
    {
        return new ServiceResult<IEnumerable<ItemModel>, Exception>(e);
    }
}

Благодаря этому, теперь во вью-модели мы можем понять, что что-то пошло не так, при этом нам не требуется постоянно оборачивать вызов метода в try-catch:

public async Task Initialize()
{
    var result = await _dataService.LoadItemsAsync();
    if (result.IsSuccessful)
    {
        var itemModels = result.Result.ToList();
        if (itemModels.Any())
        {
            var items = new List<ItemViewModel>();
            itemModels.ForEach(m => items.Add(new ItemViewModel(m)));
            Items.AddRange(items);
        }
    }
    else
    {
        // Показать сообщение об ошибке загрузки пользователю
    }   
}

В данном случае можно выделить следующие плюсы:

  • Все еще легко дебажить, как и в предыдущем варианте.

  • Так же нет необходимости оборачивать каждый вызов данного метода в try-catch

  • Теперь во вью-модели сразу понятно, вызов был успешен, либо что-то пошло не так

Но не обошлось и без своих недостатков:

  • Необходимо следить за сигнатурой метода, и не забывать, что необходимо возвращать именно ServiceResult

  • Необходима проверка во вью-модели, что в какой-то степени сравнимо с оберткой в try-catch, но в данном случае эта проверка обязательна, и не получится о ней забыть

  • Все так же нужно каждый метод сервиса оборачивать в try-catch

Идея для следующего подхода была позаимствована у OperationFactory из FlexiMvvm. Суть подхода заключается в том, что мы передаем в сервис в качестве параметров Func OnSuccess и OnError, которые будут вызываться в случае успешного выполнения операции, либо же в случае ошибки соответственно. Изменим наш DataService в соответствии с новым подходом:

public interface IDataService
{
    Task LoadItemsAsync(
        Func<IEnumerable<ItemModel>, Task> onSuccess = null,
        Func<Exception, Task> onError = null);
}
public async Task LoadItemsAsync(
    Func<IEnumerable<ItemModel>, Task> onSuccess = null,
    Func<Exception, Task> onError = null)
{
    try
    {
        var url = "https://customapiservice/v1/items";
        var result = await _requestService.GetAsync<IEnumerable<ItemModel>>(url);
        onSuccess?.Invoke(result);
    }
    catch (Exception e)
    {
        onError?.Invoke(e);
    }
}

Благодаря чему во вью-модели получаем следующую картину:

public async Task Initialize()
{
    await _dataService.LoadItemsAsync(HandleLoadSuccess, HandleLoadError);
}

private Task HandleLoadSuccess(IEnumerable<ItemModel> result)
{
    var itemModels = result.ToList();
    if (itemModels.Any())
    {
        var items = new List<ItemViewModel>();
        itemModels.ForEach(m => items.Add(new ItemViewModel(m)));
        Items.AddRange(items);
    }

    return Task.CompletedTask;
}

private Task HandleLoadError(Exception arg)
{
    // Показать сообщение об ошибке загрузки пользователю
}

Как можно заметить, из преимуществ - теперь все структурировано немного красивее, мы не засоряем Initialize(), и обрабатываем результат в зависимости от успешности, каждый в своем отдельном методе.

Итого, преимущества:

  • Код во вью-модели стал еще чище и более читаемым

  • По прежнему еще легко дебажить

    Недостатки:

  • Добавляются два обязательных параметра в каждый метод сервиса - OnSuccess и OnError, что может стать неудобным решением, особенно, если метод сервиса и так уже принимает несколько различных параметров

  • Все так же нужно каждый метод сервиса оборачивать в try-catch

Потому далее рассмотрим вариант, еще более приближенный к вышеупомянутой OperationFactory. Для этого вернемся к нашему ServiceResult и немного прокачаем его. Назовем получившийся класс ServiceCall, так как по сути теперь он и будет отвечать за вызов основного метода. Хранить он в себе будет 3 различных Func. Первый - непосредственно само действие метода, второй - обработчик успешного выполнения, третий - обработчик ошибки.

При вызове ExecuteAsync() мы оборачиваем вызов основного метода в try-catch-finally. Далее если ловим exception - то вызываем ErrorHandler, но так же внутри try-catch, так как тут тоже можно словить exception. Если же все прошло успешно - вызываем SuccessHandler, так же обернутый в try-catch.

public class ServiceCall<TResult>
{
    private readonly Func<Task<TResult>> _callAction;

    public ServiceCall(Func<Task<TResult>> callAction)
    {
        _callAction = callAction;
    }

    public Func<TResult, Task> SuccessHandler { get; set; }

    public Func<Exception, Task> ErrorHandler { get; set; }

		public async Task ExecuteAsync()
    {
        TResult result = default;
        var isSuccess = false;

        try
        {
            result = await _callAction.Invoke();
            isSuccess = true;
        }
        catch (Exception e)
        {
            try
            {
                await ErrorHandler.Invoke(e);
            }
            catch (Exception)
            {
            }
        }
        finally
        {
            if (isSuccess)
            {
                try
                {
                    await SuccessHandler.Invoke(result);
                }
                catch (Exception)
                {
                }
            }
        }
    }
}

Затем создадим интерфейс ServiceCallHandler, и его реализацию, которую и будет возвращать наш сервис в будущем. Ниже рассмотрим только асинхронную обработку, но так же можно будет добавить и вариант с синхронной обработкой.

public interface IServiceCallHandler<TResult>
{
    IServiceCallHandler<TResult> OnSuccessAsync(Func<TResult, Task> handler);

    IServiceCallHandler<TResult> OnErrorAsync(Func<Exception, Task> handler);

    Task ExecuteAsync();
}
public class ServiceCallHandler<TResult> : IServiceCallHandler<TResult>
{
    private ServiceCall<TResult> _serviceCall;

    public ServiceCallHandler(ServiceCall<TResult> serviceCall)
    {
        _serviceCall = serviceCall;
    }

    public IServiceCallHandler<TResult> OnSuccessAsync(Func<TResult, Task> handler)
    {
        _serviceCall.SuccessHandler = handler;
        return this;
    }

    public IServiceCallHandler<TResult> OnErrorAsync(Func<Exception, Task> handler)
    {
        _serviceCall.ErrorHandler = handler;
        return this;
    }

    public Task ExecuteAsync() => _serviceCall.ExecuteAsync();
}

И изменим соответственно наш сервис

public interface IDataService
{
    IServiceCallHandler<IEnumerable<ItemModel>> LoadItems();
}
public IServiceCallHandler<IEnumerable<ItemModel>> LoadItems()
{
    var serviceCall = new ServiceCall<IEnumerable<ItemModel>>(LoadItemsAction);
    return new ServiceCallHandler<IEnumerable<ItemModel>>(serviceCall);
}

private Task<IEnumerable<ItemModel>> LoadItemsAction()
{
    var url = "https://customapiservice/v1/items";
    return _requestService.GetAsync<IEnumerable<ItemModel>>(url);
}

Тогда метод Initialize() в нашей вью-модели будет выглядеть следующим образом, где HandleLoadSuccess и HandleLoadError уже известные нам методы из предыдущего примера.

public async Task Initialize()
{
    await _dataService
        .LoadItems()
        .OnSuccessAsync(HandleLoadSuccess)
        .OnErrorAsync(HandleLoadError)
        .ExecuteAsync();
}

Преимущества такого подхода:

  • Код во вью модели, как и в предыдущем подходе, стал чище, и более читаемым. К тому же теперь удобно задавать параметры OnSuccess и OnError опционально.

  • Теперь не нужно оборачивать в try-catch ни тело метода сервиса, ни вызов во вью-модели. Для этого можно воспользоваться классом ServiceCall.

Правда, как и везде, не обошлось и без недостатков:

  • Становится немного сложнее дебажить

  • Необходимо следить за сигнатурой метода, и не забывать, что необходимо возвращать именно IServiceCallHandler

  • Необходимо помнить об обязательном вызове ExecuteAsync()

В качестве собственного мнения, скажу, что по опыту использования последний вариант оказался наиболее удобным. Даже несмотря на ряд недостатков, код становится в разы менее громоздким. Результат работы сервиса обрабатывается в отдельных методах, что делает эти методы более компактными.

Если у вас есть также интересные способы, не упомянутые в данной статье - делитесь ими в комментариях, возможно, кто-то почерпнет для себя что-то полезное.

Tags:
Hubs:
Total votes 7: ↑7 and ↓0+7
Comments2

Articles