Мобильный клиент на .NET MAUI для ASP.NET Core API

Опубликовано 28.10.2026 03:30:00 в категории MAUI и Blazor

В первой части мы построили 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.Bundle 2.x — среда выполнения для сгенерированного клиента.
  • Microsoft.OpenApi.Kiota — инструмент .NET для генерации клиента (kiota generate, kiota update).
  • Microsoft.Extensions.Http 10.0.x — IHttpClientFactory и типизированные клиенты.
  • Microsoft.EntityFrameworkCore.Sqlite 10.0.x — локальная база данных.
  • Microsoft.EntityFrameworkCore.Design 10.0.x — создание миграций (только в библиотеке данных).
  • CommunityToolkit.Mvvm 8.4 или новее — ObservableObject, [ObservableProperty], [RelayCommand].
  • Calabonga.Results 2.0.1 — Operation<T, TError> и OperationEmpty<TError> (пространство имён Calabonga.OperationResults).
  • .NET MAUI 10: CollectionView, RefreshView, IConnectivity, IPreferences, IAppInfo, Launcher.

Ссылки

Мои видео

Boosty.to | YouTube | Yandex.Дзен | RuTube | VK Video

Больше видео на boosty Calabonga, а если хотите тоже получать донаты, тогда заходите и регистрируйте свою страницу на boosty. Я использую хостинг reg.ru, Подключайся с промокодом 9A17-953A-8591-CF98 чтобы получить скидку 5%

Комментарии к статье ()

Загрузка...

Что-то пошло не по сценарию и завершилось ошибкой. Перезагрузить страницу (F5) 🗙

Переподключаем сервер...

Переподключение сломалось... Пробуем еще раз сек.

Не получилось переподключится.
Пожалуйста, обновите страницу F5

Сессия была поставлена сервером на паузу.

Восстановить сессию не получилось.
Пожалуйста, обновите страницу F5.