Мобильный клиент на .NET MAUI для ASP.NET Core API
В первой части мы построили API для мобильного приложения «Музей юмора»: версионирование, политику контента на сервере, пагинацию по ключу, ETag, дельта-синхронизацию с надгробиями и единый формат ошибок. Теперь — вторая половина: клиент на .NET MAUI, который всем этим пользуется.
Цель простая: приложение открывается мгновенно, показывает ленту даже в авиарежиме, тихо подтягивает изменения, когда появляется сеть, и вежливо просит обновиться, если версия устарела. Платформа — Android (публикуем только в RuStore), .NET 10. Клиент API генерируется Kiota и работает через типизированный HttpClient, локальная база данных — EF Core SQLite.
Структура решения
Плохо: всё в code-behind страницы
public partial class FeedPage : ContentPage
{
private async void OnAppearing(object sender, EventArgs e)
{
using var http = new HttpClient();
var json = await http.GetStringAsync("https://www.calabonga.net/api/v1/humor");
var items = JsonSerializer.Deserialize<List<HumorDto>>(json);
JokesList.ItemsSource = items;
}
}
Здесь собраны почти все типичные проблемы: new HttpClient() на каждый вызов, адрес строкой прямо в странице, ручной разбор JSON, никакой обработки ошибок, а без сети экран просто пустой. Такой код невозможно протестировать и сложно менять.
Правильно: слои и внедрение зависимостей
Решение делится на три проекта:
- MuseumOfHumor.App — MAUI-приложение (
net10.0-android): страницы, модели представления (ViewModel), сервисы синхронизации, регистрация зависимостей. - MuseumOfHumor.Api.Client — библиотека
net10.0со сгенерированным клиентом Kiota. В неё никто не пишет код руками. - MuseumOfHumor.Data — библиотека
net10.0с локальным контекстом EF Core и миграциями.
Почему контекст базы данных вынесен из MAUI-проекта, объясню в разделе «Локальная база данных на EF Core SQLite».
Экран читает только локальную базу. Сеть лишь обновляет её через синхронизациюГлавное архитектурное решение видно на схеме: источник истины для экрана — локальная база данных. Модель представления никогда не ждёт сеть, чтобы что-то показать. Сеть работает в фоне и только обновляет базу. Такой подход называют «сначала офлайн» (offline-first).
Отсюда следует отступление от плана, о котором стоит сказать сразу. Эндпоинт ленты с курсором из первой части этому клиенту не нужен: лента листается по локальной базе, а сервер отдаёт данные через синхронизацию. Эндпоинт ленты остаётся для клиентов без локального хранилища — например, для веб-версии. Это нормально: API предлагает два способа получить данные, а каждый клиент выбирает подходящий.
Клиент API из документа OpenAPI
Что нужно дописать на сервере
Kiota генерирует клиент по документу OpenAPI, и качество клиента целиком зависит от качества документа. В первой части обработчики возвращали IResult, а по нему генератор OpenAPI не может понять, какой тип лежит в ответе. В итоге Kiota сгенерирует метод, который возвращает поток байтов вместо HumorDto. Это нужно исправить на сервере — описать ответы явно:
public sealed record ApiProblem(string Type, string Title, int Status, string Code, string? TraceId);
v1.MapGet("/{id:guid}", HumorEndpoints.GetByIdAsync)
.Produces<HumorDto>()
.Produces(StatusCodes.Status304NotModified)
.Produces<ApiProblem>(StatusCodes.Status404NotFound, "application/problem+json");
v1.MapGet("/changes", HumorEndpoints.GetChangesAsync)
.Produces<ChangesDto>()
.Produces<ApiProblem>(StatusCodes.Status400BadRequest, "application/problem+json")
.Produces<ApiProblem>(StatusCodes.Status410Gone, "application/problem+json");
Запись ApiProblem повторяет формат ответа с ошибкой из первой части, включая поле code. Благодаря ей Kiota сгенерирует исключение с типизированным свойством Code, и клиенту не придётся выковыривать код ошибки из словаря дополнительных полей.
Генерация клиента
Kiota ставится как инструмент .NET (Microsoft.OpenApi.Kiota). Клиент генерируется командой:
kiota generate -l csharp \
-d https://localhost:5001/openapi/v1.json \
-c HumorApiClient -n MuseumOfHumor.Api.Client \
-o ./src/MuseumOfHumor.Api.Client/Generated \
--exclude-backward-compatible --clean-output
Рядом с кодом появится файл kiota-lock.json с параметрами генерации. Его нужно хранить в репозитории: при изменении API клиент обновляется командой kiota update, и параметры не придётся вспоминать. Проекту с клиентом нужен пакет Microsoft.Kiota.Bundle.
Сгенерированный клиент повторяет структуру адресов:
var config = await api.Api.V1.App.Config.GetAsync(cancellationToken: ct);
var joke = await api.Api.V1.Humor[id].GetAsync(cancellationToken: ct);
var changes = await api.Api.V1.Humor.Changes.GetAsync(r => r.QueryParameters.Token = token, ct);
Если на сервере переименуют поле или удалят эндпоинт, после kiota update проект просто не соберётся. Расхождение контракта обнаружится на этапе сборки, а не у пользователя в RuStore.
Сгенерированные модели — только на границе
У сгенерированных моделей все свойства допускают null: Guid? Id, string? Content, DateTimeOffset? CreatedAt. Так Kiota защищается от неполных ответов. Протаскивать такие модели в модели представления и в базу данных — плохая идея: проверки на null расползутся по всему приложению. Правильно — один раз преобразовать их в свои модели на границе:
internal static class HumorMapping
{
public static LocalHumor ToLocal(this HumorDto dto) => new()
{
Id = dto.Id ?? throw new InvalidDataException("В ответе нет Id"),
Content = dto.Content ?? string.Empty,
CreatedAt = dto.CreatedAt?.UtcDateTime ?? DateTime.MinValue,
UpdatedAt = dto.UpdatedAt?.UtcDateTime ?? DateTime.MinValue,
};
}
Обратите внимание на UtcDateTime: в локальной базе даты хранятся как DateTime в UTC, а не как DateTimeOffset. Причина объяснена в разделе «Локальная база данных на EF Core SQLite».
Типизированный HttpClient и конвейер обработчиков
Регистрация
Kiota умеет работать с IHttpClientFactory. Сгенерированный клиент создаётся фабрикой, которая сама является типизированным клиентом:
public sealed class HumorApiClientFactory(HttpClient httpClient)
{
public HumorApiClient GetClient() =>
new(new HttpClientRequestAdapter(new AnonymousAuthenticationProvider(), httpClient: httpClient));
}
Регистрация в MauiProgram:
builder.Services.AddKiotaHandlers();
builder.Services.AddTransient<AppVersionHandler>();
builder.Services.AddHttpClient<HumorApiClientFactory>(client =>
{
client.BaseAddress = new Uri(ApiSettings.BaseUrl);
client.Timeout = TimeSpan.FromSeconds(20);
})
.AddHttpMessageHandler<AppVersionHandler>()
.AttachKiotaHandlers();
builder.Services.AddTransient(sp => sp.GetRequiredService<HumorApiClientFactory>().GetClient());
AddKiotaHandlers и AttachKiotaHandlers — два небольших метода расширения из официального руководства Kiota по внедрению зависимостей. Они регистрируют стандартные обработчики Kiota и подключают их к HttpClient. Ссылка на руководство — в конце статьи. Адрес API берётся из BaseAddress: адаптер Kiota использует его как базовый и сам убирает завершающий слэш.
Каждый запрос проходит через заголовок версии и стандартные обработчики Kiota. Слой повторов — один
Заголовок версии приложения
В первой части сервер размечал метрики версией приложения из заголовка X-App-Version. Клиент добавляет этот заголовок в одном месте:
public sealed class AppVersionHandler(IAppInfo appInfo) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.TryAddWithoutValidation("X-App-Version", appInfo.VersionString);
return base.SendAsync(request, cancellationToken);
}
}
Любой исход запроса превращается в Operation: успех или ошибка одного из трёх видов
Решение — та же связка, что и на сервере: паттерн «Результат» из Calabonga.Results. Пакет нацелен на .NET Standard 2.1, поэтому работает и в MAUI. Сначала опишем ошибки клиента:
public enum ClientErrorKind { Network, Api, Server }
public sealed record ClientError(ClientErrorKind Kind, string Code)
{
public static readonly ClientError NoNetwork = new(ClientErrorKind.Network, "network.unavailable");
public static readonly ClientError Timeout = new(ClientErrorKind.Network, "network.timeout");
}
Затем — одна обёртка для всех вызовов API:
public static class ApiCall
{
public static async Task<Operation<T, ClientError>> RunAsync<T>(
Func<CancellationToken, Task<T>> call, CancellationToken ct)
{
try
{
return Operation.Result(await call(ct));
}
catch (ApiProblem problem)
{
return Operation.Error(new ClientError(ClientErrorKind.Api, problem.Code ?? "unknown"));
}
catch (ApiException ex)
{
return Operation.Error(new ClientError(ClientErrorKind.Server, $"http.{ex.ResponseStatusCode}"));
}
catch (HttpRequestException)
{
return Operation.Error(ClientError.NoNetwork);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
return Operation.Error(ClientError.Timeout);
}
}
}
Несколько деталей:
- Порядок
catchважен.ApiProblemнаследуется отApiException, поэтому должен стоять раньше. - Отмена не равна таймауту. Если пользователь ушёл со страницы и токен отмены сработал, это не ошибка, и исключение должно пройти дальше. Фильтр
whenотличает таймаутHttpClientот отмены пользователем. - Общего
catch (Exception)нет намеренно. Ошибка в собственном коде, напримерNullReferenceException, должна упасть громко, а не превратиться в сообщение «нет сети».
Модель представления больше не знает про HTTP. Она смотрит на Kind и Code: по sync.token-expired запускает полную синхронизацию, по ClientErrorKind.Network показывает ненавязчивую плашку «нет сети», а текст для пользователя берёт из ресурсов приложения.
Проверка версии при запуске
В первой части мы добавили на сервер эндпоинт /app/config с минимальной поддерживаемой версией приложения. Пора им воспользоваться.
Без сети приложение не блокируется: используется последний известный ответ
public sealed class StartupService(HumorApiClient api, IPreferences preferences, IAppInfo appInfo)
{
private const string MinVersionKey = "config.min-version";
public async Task<bool> IsUpdateRequiredAsync(CancellationToken ct)
{
var operation = await ApiCall.RunAsync(
c => api.Api.V1.App.Config.GetAsync(cancellationToken: c), ct);
if (operation.Ok && operation.Result?.MinSupportedVersion is { } fresh)
{
preferences.Set(MinVersionKey, fresh);
}
var minVersion = preferences.Get<string?>(MinVersionKey, null);
return Version.TryParse(minVersion, out var min) && appInfo.Version < min;
}
}
Логика такая. Если сервер ответил, минимальная версия сохраняется в настройках (Preferences). Если сети нет, используется значение из прошлого успешного запуска. Если и его нет (первый запуск без сети), приложение просто работает. Блокировать пользователя из-за отсутствия сети нельзя: он не сможет даже прочитать то, что уже загружено.
Если обновление нужно, приложение показывает экран с кнопкой, которая открывает страницу приложения в RuStore:
await Launcher.Default.OpenAsync(new Uri(ApiSettings.StoreUrl));
Модель
Для статьи локальная модель минимальна: записи ленты и состояние синхронизации.
public sealed class LocalHumor
{
public Guid Id { get; set; }
public string Content { get; set; } = null!;
public DateTime CreatedAt { get; set; } // UTC
public DateTime UpdatedAt { get; set; } // UTC
public string? ETag { get; set; }
}
public sealed class SyncState
{
public int Id { get; set; } = 1; // единственная строка
public string? Token { get; set; }
public DateTime? LastSyncedAt { get; set; }
}
public sealed class LocalDbContext(DbContextOptions<LocalDbContext> options) : DbContext(options)
{
public DbSet<LocalHumor> Humors => Set<LocalHumor>();
public DbSet<SyncState> SyncStates => Set<SyncState>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<LocalHumor>()
.HasIndex(x => new { x.CreatedAt, x.Id })
.IsDescending(true, true);
}
}
Почему DateTime, а не DateTimeOffset
Kiota отдаёт даты как DateTimeOffset, и хочется так их и хранить. Но в SQLite нет встроенного типа для DateTimeOffset. EF Core умеет его записывать и читать, а также проверять на равенство, но сравнение и сортировку по таким колонкам приходится выполнять на стороне клиента. Для ленты, которая сортируется по дате, это неприемлемо. Документация EF Core прямо рекомендует хранить DateTime в UTC — так мы и делаем.
Контекст через фабрику
Как и в API блога, контекст создаётся через IDbContextFactory: короткоживущий контекст на одну операцию, никаких долгоживущих экземпляров в моделях представления.
var dbPath = Path.Combine(FileSystem.Current.AppDataDirectory, "museum.db3");
builder.Services.AddDbContextFactory<LocalDbContext>(o => o.UseSqlite($"Data Source={dbPath}"));
Миграции: плохо, хорошо и с поправкой на мобильную платформу
Плохо: Database.EnsureCreated(). Он создаёт базу один раз и больше никогда не меняет схему. Добавите в следующей версии приложения новую колонку — у пользователей, обновивших приложение, она не появится.
Хорошо: миграции EF Core. Здесь и пригодился отдельный проект MuseumOfHumor.Data. MAUI-проект нацелен на net10.0-android, и инструменты dotnet ef не могут использовать его как стартовый проект. Поэтому контекст и миграции живут в библиотеке net10.0, а при создании миграции стартовым проектом указывается небольшой консольный проект. Контекст для времени разработки (design-time) создаёт IDesignTimeDbContextFactory.
С поправкой на мобильную платформу: начиная с EF Core 9 миграции защищены блокировкой от одновременного запуска. В SQLite нет блокировок уровня приложения, поэтому EF Core создаёт таблицу __EFMigrationsLock и вставляет в неё строку. Если процесс убьют во время миграции, строка останется, и все следующие попытки будут ждать её освобождения бесконечно. Для сервера это редкость, а Android убивает процессы приложений регулярно. Документация EF Core предлагает в такой ситуации удалить таблицу блокировки. В мобильном приложении с базой работает только один процесс, поэтому параллельных миграций быть не может, и таблицу можно безопасно удалять перед каждой миграцией:
public static async Task MigrateLocalDbAsync(this IDbContextFactory<LocalDbContext> factory, CancellationToken ct)
{
await using var db = await factory.CreateDbContextAsync(ct);
await db.Database.ExecuteSqlRawAsync("""DROP TABLE IF EXISTS "__EFMigrationsLock";""", ct);
await db.Database.MigrateAsync(ct);
}
Миграция выполняется при запуске, до первого обращения к базе, и не в потоке интерфейса.
Ещё одно предупреждение. В Release-сборке Android применяется обрезка неиспользуемого кода (trimming), а EF Core активно использует отражение (reflection). Обязательно проверяйте Release-сборку на реальном устройстве, а не только Debug в эмуляторе.
Синхронизация
Синхронизация — сердце офлайн-клиента. Алгоритм прямо следует из контракта эндпоинта /changes из первой части.
Изменения применяются порциями до тех пор, пока сервер не скажет, что больше нет. Устаревший токен — сброс и загрузка заново
public sealed class SyncService(HumorApiClient api, IDbContextFactory<LocalDbContext> dbFactory)
{
// Сервис transient (см. «Типизированный клиент нельзя захватывать в singleton»),
// поэтому защита от параллельного запуска — статическая
private static readonly SemaphoreSlim Gate = new(1, 1);
public async Task<OperationEmpty<ClientError>> SyncAsync(CancellationToken ct)
{
if (!await Gate.WaitAsync(0, ct))
{
return Operation.Result(); // синхронизация уже идёт
}
try
{
var resetDone = false;
while (true)
{
var token = await ReadTokenAsync(ct);
var operation = await ApiCall.RunAsync(
c => api.Api.V1.Humor.Changes.GetAsync(r => r.QueryParameters.Token = token, c), ct);
if (!operation.Ok)
{
if (operation.Error.Code == "sync.token-expired" && !resetDone)
{
await ResetAsync(ct);
resetDone = true;
continue;
}
return Operation.Error(operation.Error);
}
await ApplyAsync(operation.Result!, ct);
if (operation.Result!.HasMore != true)
{
return Operation.Result();
}
}
}
finally
{
Gate.Release();
}
}
}
Методы ReadTokenAsync и ResetAsync здесь не показаны, они тривиальны: первый читает Token из таблицы SyncState, второй в одной транзакции очищает локальные записи и сбрасывает токен в null. OperationEmpty<ClientError> из Calabonga.Results — результат без данных: либо успех, либо ошибка. Флаг resetDone защищает от бесконечного цикла, если сервер почему-то вернёт sync.token-expired и для пустого токена.
Применение изменений: всё или ничего
Самое важное в применении порции — транзакция. Изменения и новый токен должны сохраняться вместе. Если сохранить данные, а токен нет (процесс убили между двумя операциями), следующая синхронизация повторит ту же порцию — это не страшно, применение идемпотентно. Но если сохранить токен, а данные нет, порция потеряется навсегда.
private async Task ApplyAsync(ChangesDto changes, CancellationToken ct)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
await using var tx = await db.Database.BeginTransactionAsync(ct);
var incoming = (changes.Upserted ?? []).Select(x => x.ToLocal()).ToList();
var ids = incoming.Select(x => x.Id).ToList();
var existing = await db.Humors.Where(x => ids.Contains(x.Id)).ToDictionaryAsync(x => x.Id, ct);
foreach (var item in incoming)
{
if (existing.TryGetValue(item.Id, out var current))
db.Entry(current).CurrentValues.SetValues(item); // ETag сбрасывается: версия изменилась
else
db.Humors.Add(item);
}
var deleted = (changes.Deleted ?? []).OfType<Guid>().ToList();
await db.Humors.Where(x => deleted.Contains(x.Id)).ExecuteDeleteAsync(ct);
var state = await db.SyncStates.FindAsync([1], ct) ?? db.SyncStates.Add(new SyncState()).Entity;
state.Token = changes.NextToken;
state.LastSyncedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
В EF Core нет встроенной операции «вставить или обновить» (upsert), поэтому существующие записи загружаются одним запросом, а дальше каждая либо обновляется, либо добавляется. Надгробия (tombstones) удаляются одной командой ExecuteDeleteAsync. Порции небольшие, так что такой подход работает быстро.
Когда запускать синхронизацию
- При запуске приложения — после проверки версии и миграции базы.
- По жесту «потянуть вниз» в ленте — об этом в следующем разделе.
- При появлении сети:
connectivity.ConnectivityChanged += async (_, e) =>
{
if (e.NetworkAccess == NetworkAccess.Internet)
{
await services.GetRequiredService<SyncService>().SyncAsync(CancellationToken.None);
}
};
Подписка делается один раз в классе App, которому через конструктор передаются IConnectivity и IServiceProvider. Экземпляр SyncService создаётся на каждый вызов — помните про типизированный клиент и singleton. Исключений здесь не будет: SyncAsync сообщает об ошибках через результат, а не через исключения.
Защита Gate гарантирует, что одновременно выполняется только одна синхронизация, даже если все три триггера сработали разом.
Лента с бесконечной прокруткой
Постраничное чтение из локальной базы
Лента читает локальную базу той же пагинацией по ключу, что и сервер в первой части. Курсором служит последняя показанная запись:
public async Task<IReadOnlyList<Joke>> GetPageAsync(Joke? after, int size, CancellationToken ct)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var query = db.Humors.AsNoTracking();
if (after is not null)
{
query = query.Where(x => x.CreatedAt < after.CreatedAt ||
(x.CreatedAt == after.CreatedAt && x.Id.CompareTo(after.Id) < 0));
}
return await query
.OrderByDescending(x => x.CreatedAt).ThenByDescending(x => x.Id)
.Take(size)
.Select(x => new Joke(x.Id, x.Content, x.CreatedAt, x.UpdatedAt))
.ToListAsync(ct);
}
Joke — неизменяемая запись (record) для экрана. Модель представления не работает ни с сущностями EF Core, ни с моделями Kiota.
Плохо: подгрузка без защиты
[RelayCommand]
private async Task LoadMoreAsync()
{
var page = await jokes.GetPageAsync(Items.LastOrDefault(), PageSize, default);
foreach (var joke in page) Items.Add(joke);
}
Событие достижения порога может сработать несколько раз подряд, пока первая подгрузка ещё не завершилась. Обе подгрузки возьмут одну и ту же последнюю запись как курсор, и в ленте появятся дубли. А когда записи закончатся, команда будет снова и снова ходить в базу за пустыми страницами.
Правильно: защита от параллельных загрузок и признак конца
public partial class FeedViewModel(JokesRepository jokes, SyncService sync) : ObservableObject
{
private const int PageSize = 30;
private bool _isLoading;
private bool _isEndReached;
public ObservableCollection<Joke> Items { get; } = [];
[ObservableProperty]
public partial bool IsRefreshing { get; set; }
[ObservableProperty]
public partial string? ErrorMessage { get; set; }
[RelayCommand]
private async Task LoadMoreAsync(CancellationToken ct)
{
if (_isLoading || _isEndReached) return;
_isLoading = true;
try
{
var page = await jokes.GetPageAsync(Items.LastOrDefault(), PageSize, ct);
foreach (var joke in page) Items.Add(joke);
_isEndReached = page.Count < PageSize;
}
finally
{
_isLoading = false;
}
}
}
Флаг _isLoading достаточен: команды вызываются в потоке интерфейса, поэтому гонки между проверкой и установкой флага нет. AsyncRelayCommand из CommunityToolkit.Mvvm по умолчанию тоже не допускает параллельного выполнения, но явная проверка делает намерение очевидным и не зависит от того, как именно элемент управления вызывает команду.
Обновление жестом «потянуть вниз»
[RelayCommand]
private async Task RefreshAsync(CancellationToken ct)
{
try
{
var result = await sync.SyncAsync(ct);
ErrorMessage = result.Ok ? null : ErrorTexts.For(result.Error);
Items.Clear();
_isEndReached = false;
await LoadMoreAsync(ct);
}
finally
{
IsRefreshing = false;
}
}
ErrorTexts.For — небольшой статический класс, который по коду ошибки возвращает строку из ресурсов приложения. Даже если синхронизация не удалась, лента перечитывается из базы: пользователь увидит то, что есть, а над лентой — сообщение о проблеме.
Разметка
<RefreshView IsRefreshing="{Binding IsRefreshing}" Command="{Binding RefreshCommand}">
<CollectionView ItemsSource="{Binding Items}"
RemainingItemsThreshold="5"
RemainingItemsThresholdReachedCommand="{Binding LoadMoreCommand}">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Joke">
<Border Style="{StaticResource JokeCard}">
<Label Text="{Binding Content}" Style="{StaticResource JokeText}" />
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</RefreshView>
RemainingItemsThreshold="5" означает, что подгрузка начнётся, когда до конца списка останется пять непоказанных записей, и пользователь не упрётся в пустоту. x:DataType включает компилируемые привязки (compiled bindings): ошибки в путях привязок видны при сборке, а не во время работы.
Важное предупреждение из документации MAUI: не помещайте CollectionView с подгрузкой внутрь StackLayout и не используйте для неё макет на основе StackLayout. У такого контейнера нет понятия области прокрутки, порог считается достигнутым всегда, и подгрузка уходит в бесконечный цикл, который быстро сажает батарею.
ETag на клиенте
Раз лента обновляется синхронизацией, ETag для неё не нужен: сервер и так отдаёт только изменения. Но ETag полезен для отдельного ресурса, который приложение запрашивает напрямую. Например, при открытии шутки можно проверить, не изменилась ли она с момента последней синхронизации, не скачивая её заново.
Главное правило: ETag непрозрачен. Клиент хранит ровно ту строку, которую прислал сервер, и возвращает её без изменений. Собирать ETag на клиенте из UpdatedAt нельзя, даже если вы знаете, как его строит сервер: завтра сервер изменит формат, и все проверки начнут промахиваться.
var headers = new HeadersInspectionHandlerOption { InspectResponseHeaders = true };
var operation = await ApiCall.RunAsync(c => api.Api.V1.Humor[local.Id].GetAsync(r =>
{
r.Options.Add(headers);
if (local.ETag is not null)
{
r.Headers.Add("If-None-Match", local.ETag);
}
}, c), ct);
if (operation.Ok && operation.Result is null)
{
// 304 Not Modified: локальная копия актуальна
}
Здесь пригодилось знание того, как устроен Kiota. На ответ 304 Not Modified сгенерированный метод не выбрасывает исключение, а возвращает null, поэтому Ok и null в результате означают «данные не изменились». Заголовок ETag из ответа достаётся через HeadersInspectionHandlerOption — его обработчик уже есть в стандартном наборе Kiota — и сохраняется в колонку ETag локальной записи.
При синхронизации ETag записи сбрасывается: SetValues записывает null из новых данных. Это правильно: если запись изменилась, старый ETag больше ей не соответствует.
Оформление
Блог calabonga.net всегда тёмный, и приложение продолжает тот же стиль. Цвета брендбука — в одном словаре ресурсов:
<Color x:Key="Canvas">#141414</Color>
<Color x:Key="Surface">#1E1E1E</Color>
<Color x:Key="Border">#3A3A3A</Color>
<Color x:Key="TextPrimary">#F5F5F5</Color>
<Color x:Key="TextSecondary">#B3B3B3</Color>
<Color x:Key="BrandOrange">#FF7200</Color>
<Style x:Key="JokeCard" TargetType="Border">
<Setter Property="BackgroundColor" Value="{StaticResource Surface}" />
<Setter Property="Stroke" Value="{StaticResource Border}" />
<Setter Property="StrokeShape" Value="RoundRectangle 10" />
<Setter Property="Padding" Value="16" />
<Setter Property="Margin" Value="16,8" />
</Style>
Шрифт для текста — Source Sans 3, как в статьях блога. Он распространяется по свободной лицензии SIL Open Font License, поэтому его можно включить в приложение. Регистрация в MauiProgram:
builder.ConfigureFonts(fonts =>
{
fonts.AddFont("SourceSans3-Regular.ttf", "SourceSans");
fonts.AddFont("SourceSans3-Semibold.ttf", "SourceSansSemibold");
});
Оранжевый Brand Orange — только для акцентов: активный элемент, индикатор обновления, кнопка «Обновить» на экране новой версии. Если оранжевого на экране много, он перестаёт быть акцентом.
Тёмная тема задаётся принудительно: UserAppTheme = AppTheme.Dark в конструкторе App. Так приложение выглядит одинаково независимо от системной темы.
Заключение
Клиент — зеркало сервера: каждое решение из первой части получило свою пару. Чек-лист клиента:
- экран читает только локальную базу, сеть лишь обновляет её (offline-first);
- клиент API генерируется Kiota из документа OpenAPI, а ответы на сервере описаны явно через
Produces; - сгенерированные модели преобразуются в свои на границе и дальше не расходятся;
HttpClient— типизированный, черезIHttpClientFactory, без захвата в singleton;- заголовок
X-App-Versionдобавляется одним обработчиком; - слой повторов один — стандартный обработчик Kiota;
- любой исход запроса превращается в
Operationс видом и кодом ошибки, решения принимаются по коду; - минимальная версия проверяется при запуске, а без сети пользователь не блокируется;
- даты в SQLite хранятся как
DateTimeв UTC; - миграции живут в отдельной библиотеке, а таблица блокировки миграций удаляется перед запуском;
- изменения и токен синхронизации сохраняются в одной транзакции;
- подгрузка ленты защищена от параллельных вызовов и знает, где конец;
- ETag хранится как есть и никогда не собирается на клиенте.
Использованные пакеты
Microsoft.Kiota.Bundle2.x — среда выполнения для сгенерированного клиента.Microsoft.OpenApi.Kiota— инструмент .NET для генерации клиента (kiota generate,kiota update).Microsoft.Extensions.Http10.0.x —IHttpClientFactoryи типизированные клиенты.Microsoft.EntityFrameworkCore.Sqlite10.0.x — локальная база данных.Microsoft.EntityFrameworkCore.Design10.0.x — создание миграций (только в библиотеке данных).CommunityToolkit.Mvvm8.4 или новее —ObservableObject,[ObservableProperty],[RelayCommand].Calabonga.Results2.0.1 —Operation<T, TError>иOperationEmpty<TError>(пространство имёнCalabonga.OperationResults).- .NET MAUI 10:
CollectionView,RefreshView,IConnectivity,IPreferences,IAppInfo,Launcher.
Ссылки
- Register a Kiota API client in .NET with dependency injection — Microsoft Learn
- Error handling with Kiota API clients — Microsoft Learn
- HTTP status code handling with Kiota API clients — Microsoft Learn
- Implementing middleware — Kiota, Microsoft Learn
- kiota-dotnet — исходный код библиотек Kiota для .NET
- SQLite EF Core Database Provider Limitations — Microsoft Learn
- Populate a CollectionView with data: load data incrementally — Microsoft Learn
- Trim a .NET MAUI app — Microsoft Learn
- Introduction to the MVVM Toolkit — Microsoft Learn
- Calabonga.Results — NuGet
Мои видео
Boosty.to | YouTube | Yandex.Дзен | RuTube | VK Video