Защо правилната HTTP комуникация е критична за мобилните приложения
Почти всяко реално мобилно приложение комуникира с отдалечен сървър. Вход с потребителско име и парола, списък с продукти, синхронизиране на данни — зад всичко стои HTTP заявка. И ако тази заявка се провали без подходяща обработка, потребителят вижда бял екран, замръзнал интерфейс или — в най-лошия случай — загубени данни.
В .NET MAUI разполагаме с доста солидна инфраструктура за мрежова комуникация: вграден HttpClient, поддръжка на IHttpClientFactory чрез dependency injection, интеграция с библиотеки като Refit за типово-безопасни API клиенти и Microsoft.Extensions.Http.Resilience за устойчивост на заявките. Проблемът? Повечето ръководства покриват само основите — GET заявка, десериализация, край. В реалния свят обаче нещата са доста по-сложни.
В това ръководство ще изградим цялостна архитектура за работа с REST API в .NET MAUI 10 — от правилната конфигурация на HTTP клиента, през Refit интерфейси и resilience стратегии, до обработка на грешки и проверка на свързаността. Всичко с работещ код, който можете да хванете и да използвате директно в проектите си.
Настройка на IHttpClientFactory в .NET MAUI
Първото правило, което трябва да запомните: никога не създавайте new HttpClient() ръчно за всяка заявка. Това води до изчерпване на сокетите (socket exhaustion) и DNS проблеми, особено при интензивна мрежова активност. Вместо това използвайте IHttpClientFactory, който управлява жизнения цикъл на HTTP handler-ите вместо вас.
Инсталиране на необходимите пакети
Добавете следните NuGet пакети към вашия .NET MAUI проект:
dotnet add package Microsoft.Extensions.Http
dotnet add package System.Net.Http.Json
Регистрация на именуван HTTP клиент
В MauiProgram.cs регистрирайте HTTP клиент с подходяща базова конфигурация:
// MauiProgram.cs
using Microsoft.Extensions.Http;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
// Регистрация на именуван HTTP клиент
builder.Services.AddHttpClient("ApiClient", client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(30);
});
// Регистрация на услуги и ViewModel-и
builder.Services.AddSingleton<IApiService, ApiService>();
builder.Services.AddTransient<MainViewModel>();
builder.Services.AddTransient<MainPage>();
return builder.Build();
}
}
Типизиран (Typed) HTTP клиент
За по-чист код можете да регистрирате типизиран клиент, при който HttpClient се инжектира директно в конструктора на вашия сервизен клас:
// Регистрация на типизиран клиент
builder.Services.AddHttpClient<IApiService, ApiService>(client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
При този подход ApiService получава конфигуриран HttpClient през конструктора си, без да се налага ръчно извикване на CreateClient(). Лично аз предпочитам точно този вариант в по-сериозните проекти — кодът е по-четим и DI контейнерът поема повече от работата.
CRUD операции с HttpClient и System.Text.Json
Нека създадем пълноценен сервизен слой за работа с REST API. Ще използваме класическия пример с API за управление на задачи (to-do) — нищо оригинално, но пък е нагледно.
Моделът на данните
// Models/TodoItem.cs
using System.Text.Json.Serialization;
namespace MyApp.Models;
public class TodoItem
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("isCompleted")]
public bool IsCompleted { get; set; }
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; }
}
Интерфейс на API сервиза
// Services/IApiService.cs
namespace MyApp.Services;
public interface IApiService
{
Task<List<TodoItem>> GetTodosAsync();
Task<TodoItem?> GetTodoByIdAsync(int id);
Task<TodoItem?> CreateTodoAsync(TodoItem item);
Task<bool> UpdateTodoAsync(TodoItem item);
Task<bool> DeleteTodoAsync(int id);
}
Имплементация с HttpClient
// Services/ApiService.cs
using System.Net.Http.Json;
using MyApp.Models;
namespace MyApp.Services;
public class ApiService : IApiService
{
private readonly HttpClient _httpClient;
public ApiService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<List<TodoItem>> GetTodosAsync()
{
var response = await _httpClient.GetAsync("api/todos");
response.EnsureSuccessStatusCode();
return await response.Content
.ReadFromJsonAsync<List<TodoItem>>() ?? [];
}
public async Task<TodoItem?> GetTodoByIdAsync(int id)
{
return await _httpClient
.GetFromJsonAsync<TodoItem>($"api/todos/{id}");
}
public async Task<TodoItem?> CreateTodoAsync(TodoItem item)
{
var response = await _httpClient
.PostAsJsonAsync("api/todos", item);
response.EnsureSuccessStatusCode();
return await response.Content
.ReadFromJsonAsync<TodoItem>();
}
public async Task<bool> UpdateTodoAsync(TodoItem item)
{
var response = await _httpClient
.PutAsJsonAsync($"api/todos/{item.Id}", item);
return response.IsSuccessStatusCode;
}
public async Task<bool> DeleteTodoAsync(int id)
{
var response = await _httpClient
.DeleteAsync($"api/todos/{id}");
return response.IsSuccessStatusCode;
}
}
Забележете, че използваме GetFromJsonAsync, PostAsJsonAsync и PutAsJsonAsync от пакета System.Net.Http.Json. Те автоматично сериализират и десериализират JSON, така че можете да забравите за ръчната работа с JsonSerializer. Простичко и ефективно.
Типово-безопасни API клиенти с Refit
Ръчното писане на HTTP заявки с HttpClient работи, но при по-голямо API бързо става повторяемо и склонно към грешки. Refit решава този проблем, като генерира имплементацията на HTTP клиента автоматично от интерфейс — по време на компилация чрез source generator.
Инсталиране на Refit
dotnet add package Refit.HttpClientFactory
Дефиниране на API интерфейс
Вместо да пишете HTTP логика ръчно, просто декорирате методите с атрибути. Честно казано, първия път когато видях Refit код, се зачудих дали наистина е толкова просто. Оказа се, че е:
// Services/ITodoApi.cs
using Refit;
using MyApp.Models;
namespace MyApp.Services;
public interface ITodoApi
{
[Get("/api/todos")]
Task<List<TodoItem>> GetTodosAsync();
[Get("/api/todos/{id}")]
Task<TodoItem> GetTodoByIdAsync(int id);
[Post("/api/todos")]
Task<TodoItem> CreateTodoAsync([Body] TodoItem item);
[Put("/api/todos/{id}")]
Task UpdateTodoAsync(int id, [Body] TodoItem item);
[Delete("/api/todos/{id}")]
Task DeleteTodoAsync(int id);
[Get("/api/todos")]
Task<List<TodoItem>> SearchTodosAsync(
[Query] string? search,
[Query] int page = 1,
[Query] int pageSize = 20);
}
Регистрация на Refit клиент в MauiProgram.cs
using Refit;
// В MauiProgram.CreateMauiApp()
builder.Services
.AddRefitClient<ITodoApi>()
.ConfigureHttpClient(client =>
{
client.BaseAddress = new Uri("https://api.example.com");
client.Timeout = TimeSpan.FromSeconds(30);
});
И това е. Сега можете да инжектирате ITodoApi директно във вашите ViewModel-и чрез конструктора и да извиквате методите му. Refit генерира цялата HTTP логика по време на компилация — без рефлексия, без runtime overhead.
Обработка на API грешки с Refit
Когато сървърът върне грешка (4xx или 5xx), Refit хвърля ApiException, която съдържа детайли за отговора:
try
{
var todo = await _todoApi.GetTodoByIdAsync(999);
}
catch (ApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Елементът не съществува — покажете подходящо съобщение
await Shell.Current.DisplayAlert(
"Грешка", "Задачата не беше намерена.", "OK");
}
catch (ApiException ex)
{
// Друга HTTP грешка
Debug.WriteLine($"API грешка: {ex.StatusCode} — {ex.Content}");
}
catch (HttpRequestException)
{
// Мрежова грешка — няма свързаност
await Shell.Current.DisplayAlert(
"Грешка", "Няма връзка с интернет.", "OK");
}
Устойчивост на HTTP заявките с Microsoft.Extensions.Http.Resilience
Мобилните мрежи са ненадеждни по своята природа. Потребителят влиза в асансьор, превключва от Wi-Fi на мобилни данни или просто попада в зона с лошо покритие — и точно тогава приложението решава да зареди нещо важно. Ако не обработвате тези ситуации, потребителят ще получи грешка при първата нестабилна връзка.
Microsoft.Extensions.Http.Resilience (базирана на Polly v8) предоставя готови стратегии за устойчивост, които можете да закачите към всеки HttpClient с буквално един ред код.
Инсталиране на пакета
dotnet add package Microsoft.Extensions.Http.Resilience
Стандартен resilience handler
Най-лесният начин да добавите устойчивост е чрез AddStandardResilienceHandler(), който включва пет стратегии наведнъж:
- Rate Limiter — ограничава броя едновременни заявки
- Глобален таймаут — максимално време за цялата операция, включително повторните опити
- Retry — автоматично повтаря неуспешни заявки с експоненциално нарастващо забавяне
- Circuit Breaker — спира заявките временно, когато нивото на грешки стане твърде високо
- Таймаут на опит — максимално време за всеки отделен опит
using Microsoft.Extensions.Http.Resilience;
// Refit клиент със стандартна устойчивост
builder.Services
.AddRefitClient<ITodoApi>()
.ConfigureHttpClient(client =>
{
client.BaseAddress = new Uri("https://api.example.com");
})
.AddStandardResilienceHandler();
С тази единствена добавка вашият API клиент автоматично ще повтори неуспешни заявки при HTTP статуси 500+, 429 (Too Many Requests) и 408 (Request Timeout), както и при HttpRequestException. По подразбиране се правят до 3 повторни опита с експоненциално забавяне и jitter.
Персонализирана конфигурация на устойчивостта
За по-фин контрол можете да настроите всяка стратегия поотделно. Тук е въпрос на опит — стойностите по-долу са добра отправна точка, но в крайна сметка зависят от конкретното ви API:
builder.Services
.AddRefitClient<ITodoApi>()
.ConfigureHttpClient(client =>
{
client.BaseAddress = new Uri("https://api.example.com");
})
.AddStandardResilienceHandler(options =>
{
// Повторни опити
options.Retry.MaxRetryAttempts = 4;
options.Retry.Delay = TimeSpan.FromMilliseconds(800);
options.Retry.BackoffType = DelayBackoffType.Exponential;
// Глобален таймаут за цялата операция
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(45);
// Таймаут за всеки отделен опит
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(10);
// Circuit breaker
options.CircuitBreaker.FailureRatio = 0.3;
options.CircuitBreaker.MinimumThroughput = 10;
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(30);
});
За мобилни приложения обикновено е разумно да държите глобалния таймаут по-кратък — някъде в диапазона 20–45 секунди. Никой потребител няма да чака цяла минута, нали?
Проверка на свързаността и офлайн стратегии
Преди да изпращате заявки, е добра практика да проверявате дали устройството изобщо има мрежова свързаност. .NET MAUI предоставя Connectivity API за тази цел.
Проверка на мрежовия статус
// Services/IConnectivityService.cs
namespace MyApp.Services;
public interface IConnectivityService
{
bool IsConnected { get; }
event EventHandler<bool> ConnectivityChanged;
}
// Services/ConnectivityService.cs
namespace MyApp.Services;
public class ConnectivityService : IConnectivityService, IDisposable
{
public bool IsConnected =>
Connectivity.Current.NetworkAccess == NetworkAccess.Internet;
public event EventHandler<bool>? ConnectivityChanged;
public ConnectivityService()
{
Connectivity.Current.ConnectivityChanged += OnConnectivityChanged;
}
private void OnConnectivityChanged(
object? sender, ConnectivityChangedEventArgs e)
{
ConnectivityChanged?.Invoke(this,
e.NetworkAccess == NetworkAccess.Internet);
}
public void Dispose()
{
Connectivity.Current.ConnectivityChanged -= OnConnectivityChanged;
}
}
Обвиващ сервиз с офлайн проверка
Идеята тук е проста — създавате wrapper сервиз, който проверява свързаността преди всяка заявка. Така вместо потребителят да чака 10 секунди за таймаут, получава отговор веднага:
// Services/SafeApiService.cs
namespace MyApp.Services;
public class SafeApiService
{
private readonly ITodoApi _api;
private readonly IConnectivityService _connectivity;
public SafeApiService(
ITodoApi api,
IConnectivityService connectivity)
{
_api = api;
_connectivity = connectivity;
}
public async Task<ApiResult<T>> ExecuteAsync<T>(
Func<Task<T>> apiCall)
{
if (!_connectivity.IsConnected)
{
return ApiResult<T>.Fail(
"Няма връзка с интернет. Проверете настройките на мрежата.");
}
try
{
var result = await apiCall();
return ApiResult<T>.Success(result);
}
catch (Refit.ApiException ex)
{
return ApiResult<T>.Fail(
$"Грешка от сървъра: {(int)ex.StatusCode}");
}
catch (HttpRequestException)
{
return ApiResult<T>.Fail(
"Неуспешна връзка със сървъра. Опитайте отново.");
}
catch (TaskCanceledException)
{
return ApiResult<T>.Fail(
"Заявката отне твърде дълго. Опитайте отново.");
}
}
}
// Models/ApiResult.cs
namespace MyApp.Models;
public class ApiResult<T>
{
public bool IsSuccess { get; init; }
public T? Data { get; init; }
public string? ErrorMessage { get; init; }
public static ApiResult<T> Success(T data) =>
new() { IsSuccess = true, Data = data };
public static ApiResult<T> Fail(string message) =>
new() { IsSuccess = false, ErrorMessage = message };
}
Интеграция с MVVM: чист ViewModel слой
Добре, нека видим как всичко се свързва на практика. Ето един ViewModel, който използва CommunityToolkit.Mvvm за намаляване на boilerplate кода:
// ViewModels/TodoListViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MyApp.Models;
using MyApp.Services;
using System.Collections.ObjectModel;
namespace MyApp.ViewModels;
public partial class TodoListViewModel : ObservableObject
{
private readonly SafeApiService _apiService;
public TodoListViewModel(SafeApiService apiService)
{
_apiService = apiService;
}
[ObservableProperty]
private ObservableCollection<TodoItem> _todos = [];
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private string? _errorMessage;
[RelayCommand]
private async Task LoadTodosAsync()
{
IsLoading = true;
ErrorMessage = null;
var result = await _apiService
.ExecuteAsync(() => _apiService._api.GetTodosAsync());
if (result.IsSuccess && result.Data is not null)
{
Todos = new ObservableCollection<TodoItem>(result.Data);
}
else
{
ErrorMessage = result.ErrorMessage;
}
IsLoading = false;
}
[RelayCommand]
private async Task DeleteTodoAsync(TodoItem item)
{
var result = await _apiService
.ExecuteAsync(() => _apiService._api.DeleteTodoAsync(item.Id));
if (result.IsSuccess)
{
Todos.Remove(item);
}
else
{
ErrorMessage = result.ErrorMessage;
}
}
}
Забележете — ViewModel-ът не знае абсолютно нищо за HTTP, Refit или resilience стратегии. Той просто извиква метод и обработва резултата: успех или грешка. Цялата мрежова логика е капсулирана в сервизния слой, което е точно онова разделение на отговорностите, за което толкова много говорим, но рядко виждаме последователно приложено в реален код.
Пълна конфигурация в MauiProgram.cs
Ето как изглежда финалната конфигурация, обединяваща всичко — Refit, устойчивост и DI:
// MauiProgram.cs
using Microsoft.Extensions.Http.Resilience;
using Refit;
using MyApp.Services;
using MyApp.ViewModels;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
// Refit API клиент + resilience
builder.Services
.AddRefitClient<ITodoApi>()
.ConfigureHttpClient(client =>
{
client.BaseAddress = new Uri("https://api.example.com");
})
.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 3;
options.Retry.Delay = TimeSpan.FromMilliseconds(500);
options.TotalRequestTimeout.Timeout =
TimeSpan.FromSeconds(30);
options.AttemptTimeout.Timeout =
TimeSpan.FromSeconds(8);
});
// Услуги
builder.Services.AddSingleton<IConnectivityService,
ConnectivityService>();
builder.Services.AddSingleton<SafeApiService>();
// ViewModel-и и страници
builder.Services.AddTransient<TodoListViewModel>();
builder.Services.AddTransient<MainPage>();
return builder.Build();
}
}
Добри практики и типични грешки
След като разгледахме цялата архитектура, нека обобщим основните неща. Тези правила съм ги научил по трудния начин в реални проекти.
Правете така
- Използвайте
IHttpClientFactoryвинаги — той управлява жизнения цикъл на HTTP handler-ите и предотвратява socket exhaustion - Предпочитайте Refit за API-та с повече от 3–4 endpoint-а — спестява ви десетки редове повторяем код и елиминира правописни грешки в URL-ите
- Добавяйте resilience стратегии — мобилните мрежи са непредвидими, а
AddStandardResilienceHandler()покрива 90% от случаите - Проверявайте свързаността преди заявки — дава на потребителя мигновена обратна връзка вместо да чака таймаут
- Използвайте
CancellationToken— позволява на потребителя да отмени дълга заявка при навигация встрани от страницата
Избягвайте това
new HttpClient()за всяка заявка — класическа грешка, която съм виждал дори в production код на сериозни проекти, и последствията не са приятни- Блокиращи извиквания (
.Resultили.Wait()) — замразяват UI нишката и правят приложението неизползваемо - Игнориране на HTTP грешките — ако не обработвате
HttpRequestException, приложението просто ще се срине - Твърде агресивни retry стратегии — 10 повторни опита на секунда ще претоварят сървъра и ще изразходват батерията на устройството
- Поставяне на HTTP логика във ViewModel — нарушава разделението на отговорностите и прави кода нетестируем
Работа с удостоверяване: JWT токени и Authorization header
Повечето реални API-та изискват удостоверяване. Типичният подход е Bearer JWT токен, добавян към всяка заявка. С Refit можете да го реализирате елегантно чрез DelegatingHandler:
// Handlers/AuthHeaderHandler.cs
using System.Net.Http.Headers;
namespace MyApp.Handlers;
public class AuthHeaderHandler : DelegatingHandler
{
private readonly ITokenService _tokenService;
public AuthHeaderHandler(ITokenService tokenService)
{
_tokenService = tokenService;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var token = await _tokenService.GetAccessTokenAsync();
if (!string.IsNullOrEmpty(token))
{
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", token);
}
return await base.SendAsync(request, cancellationToken);
}
}
Регистрирайте handler-а в DI контейнера и го добавете към Refit клиента:
// В MauiProgram.cs
builder.Services.AddTransient<AuthHeaderHandler>();
builder.Services
.AddRefitClient<ITodoApi>()
.ConfigureHttpClient(client =>
{
client.BaseAddress = new Uri("https://api.example.com");
})
.AddHttpMessageHandler<AuthHeaderHandler>()
.AddStandardResilienceHandler();
Хубавото на този подход е, че API интерфейсът остава чист — без логика за удостоверяване. Токенът се добавя автоматично към всяка изходяща заявка и вие просто се фокусирате върху бизнес логиката.
Често задавани въпроси
Мога ли да използвам HttpClient директно вместо IHttpClientFactory в .NET MAUI?
Технически да, но не е препоръчително. Създаването на new HttpClient() за всяка заявка води до изчерпване на сокетите (socket exhaustion) при интензивна мрежова активност. Използването на статичен/singleton HttpClient пък води до DNS проблеми. IHttpClientFactory решава и двата проблема, като управлява пул от HttpMessageHandler инстанции с контролиран жизнен цикъл (по подразбиране 2 минути).
Каква е разликата между Refit и ръчна имплементация с HttpClient?
Refit генерира HTTP клиентски код по време на компилация от декорирани интерфейси, което елиминира boilerplate за сериализация, URL конструиране и обработка на отговори. При малки API-та с 2–3 endpoint-а ръчният подход с HttpClient е напълно приемлив. Но при по-големи API-та Refit спестява значително количество код и намалява риска от грешки. И двата подхода се регистрират чрез IHttpClientFactory и работят еднакво добре с resilience стратегии.
Как да обработвам таймаут грешки в .NET MAUI приложение?
При стандартния resilience handler, Polly хвърля TimeoutRejectedException (не стандартния TimeoutException), когато отделен опит или цялата операция надхвърли зададения таймаут. Трябва да прихващате и двете изключения: TaskCanceledException (от HttpClient.Timeout) и TimeoutRejectedException (от Polly). В обвиващия сервиз ги трансформирайте в разбираемо за потребителя съобщение като „Заявката отне твърде дълго".
Трябва ли да проверявам свързаността преди всяка HTTP заявка?
Проверката на свързаността чрез Connectivity.Current.NetworkAccess дава бърза обратна връзка на потребителя, но не е 100% надеждна — устройството може да има Wi-Fi връзка, но без реален достъп до интернет (всеки, който е работил в хотелска мрежа, знае за какво говоря). Затова е добра практика да проверявате свързаността за бързо отхвърляне на заявки при очевидна липса на мрежа, но винаги обработвайте и HttpRequestException за случаите, когато мрежата „изглежда" налична, но реално не работи.
AddStandardResilienceHandler достатъчен ли е за повечето мобилни приложения?
Да, за огромното мнозинство от случаите. Стандартният handler включва retry с експоненциално забавяне, circuit breaker, rate limiter и таймаути — както за отделните опити, така и глобален. Конфигурацията по подразбиране обработва HTTP 500+, 429, 408 и мрежови грешки. Може да се наложи да коригирате стойностите на таймаутите и броя повторни опити за вашия конкретен сценарий, но рядко ще ви трябва изцяло персонализиран resilience pipeline.