Byg Cross-Platform Apps med .NET MAUI Blazor Hybrid: Din Guide til Delt Kodebase mellem Web og Mobil

Vil du dele op til 90% af din UI-kode mellem web og mobil? Denne guide viser dig hvordan med .NET MAUI Blazor Hybrid – fra arkitektur og dependency injection til ydeevne, sikkerhed og deployment på tværs af alle platforme.

Introduktion: Blazor Hybrid og fremtiden for cross-platform udvikling

Lad os starte med det åbenlyse problem: brugerne forventer at din app fungerer ens på web, mobil og desktop. Men som udvikler ved du, at det typisk betyder tre separate kodebaser, tre hold af bugs, og tre gange så meget vedligeholdelse. Det er ikke sjovt.

.NET MAUI Blazor Hybrid er Microsofts bud på en løsning, og ærligt talt – i 2026 er teknologien nået et punkt, hvor den faktisk holder hvad den lover. Det er ikke længere bare et eksperiment eller en demo-teknologi.

Konceptet er elegant: Blazor Hybrid kombinerer Razor-komponenternes deklarative UI-model med .NET MAUIs adgang til native platform-API'er. I stedet for at rendere HTML i en browser sender Blazor Hybrid dit Razor-indhold gennem en BlazorWebView – en native kontrol der hoster en WebView direkte i din MAUI-applikation. Resultatet er en app, der føles nativ, men genbruger web-baseret UI-logik.

Det virkelige løfte? Med en velstruktureret løsning kan du dele op til 90% af din UI-kode mellem en ASP.NET Core Blazor-webapp og en .NET MAUI-mobilapp. De resterende 10% håndterer platformspecifikke ting som kameraadgang, push-notifikationer og biometrisk godkendelse. Denne guide tager dig igennem hele processen – fra arkitektur til deployment – så du kan bygge robuste cross-platform applikationer med én samlet kodebase.

Arkitekturoversigt: Sådan fungerer BlazorWebView i MAUI

For at forstå Blazor Hybrid er det afgørende at forstå, hvordan de forskellige lag samarbejder. Når du kører en Blazor Hybrid-app, sker der følgende: din Razor-komponent renderes til HTML, som vises i en BlazorWebView-kontrol. Denne kontrol er en native WebView (WKWebView på iOS/macOS, WebView2 på Windows, Android WebView på Android), men i modsætning til en almindelig webside kører al C#-kode direkte i den native proces – ikke via WebAssembly eller en server.

Det er faktisk ret smart. Kommunikationen mellem .NET-runtime og WebView-laget foregår via en intern kanal med message passing. Når en bruger klikker på en knap i Razor-komponenten, sendes hændelsen direkte til .NET-runtime uden nogen netværksforsinkelse. Det giver markant bedre ydeevne sammenlignet med Blazor Server, hvor hver interaktion kræver en rundtur til serveren.

Fire-lags arkitekturmønsteret

Den anbefalede arkitektur for Blazor Hybrid-applikationer består af fire distinkte lag:

  1. Præsentationslaget (Shared UI) – Razor-komponenter i et Razor Class Library (RCL), der deles mellem web og MAUI. Her ligger dine sider, layouts og genbrugelige komponenter.
  2. Applikationslogiklaget – Services, view models og forretningslogik, også i delte biblioteker. Disse definerer hvad applikationen gør, uafhængigt af platformen.
  3. Platformabstraktionslaget – Interfaces der definerer kontrakter for platformspecifikke operationer. Dette lag muliggør, at delt kode kan kalde native funktioner uden at kende implementationsdetaljerne.
  4. Platformimplementeringslaget – Konkrete implementeringer af platformabstraktionerne for hver målplatform (iOS, Android, Windows, macOS og web).

Denne lagdeling sikrer en ren separation of concerns og gør det muligt at teste hvert lag isoleret. Lad os se på, hvordan dette ser ud i praksis:

MitProjekt/
├── MitProjekt.Shared/              # Razor Class Library (Lag 1 & 2)
│   ├── Components/
│   │   ├── Pages/
│   │   │   ├── Home.razor
│   │   │   └── Counter.razor
│   │   └── Layout/
│   │       ├── MainLayout.razor
│   │       └── NavMenu.razor
│   ├── Services/
│   │   ├── IDeviceService.cs        # Platformabstraktion (Lag 3)
│   │   ├── INotificationService.cs
│   │   └── WeatherService.cs        # Delt forretningslogik (Lag 2)
│   └── _Imports.razor
├── MitProjekt.Maui/                 # MAUI Host (Lag 4)
│   ├── Services/
│   │   ├── MauiDeviceService.cs
│   │   └── MauiNotificationService.cs
│   ├── MauiProgram.cs
│   └── MainPage.xaml
└── MitProjekt.Web/                  # Web Host (Lag 4)
    ├── Services/
    │   ├── WebDeviceService.cs
    │   └── WebNotificationService.cs
    └── Program.cs

Opsætning af projektet med .NET 10

Med .NET 10 har Microsoft forbedret projektskabelonerne markant for Blazor Hybrid. Den nye .NET MAUI Blazor Hybrid and Web App-skabelon opretter automatisk en løsning med både MAUI- og webprojekter samt et delt Razor Class Library. Lad os starte med at oprette projektet fra kommandolinjen – det er overraskende nemt.

# Installer eller opdater .NET 10 SDK
dotnet --version
# Output: 10.0.100

# Opret en ny MAUI Blazor Hybrid + Web App løsning
dotnet new maui-blazor-web -n KrydsplatformApp -o KrydsplatformApp

# Navigér ind i løsningsmappen
cd KrydsplatformApp

# Se den oprettede struktur
dotnet sln list

Skabelonen opretter tre projekter: KrydsplatformApp (MAUI-appen), KrydsplatformApp.Web (Blazor-webappen) og KrydsplatformApp.Shared (det delte RCL). Både MAUI- og webprojekterne refererer til Shared-projektet, så ændringer i delte komponenter automatisk reflekteres begge steder.

Lad os kigge på den vigtigste fil i MAUI-projektet – MauiProgram.cs – som konfigurerer BlazorWebView og registrerer services:

using Microsoft.Extensions.Logging;
using KrydsplatformApp.Shared.Services;
using KrydsplatformApp.Services;

namespace KrydsplatformApp;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
            });

        // Tilføj BlazorWebView
        builder.Services.AddMauiBlazorWebView();

#if DEBUG
        builder.Services.AddBlazorWebViewDeveloperTools();
        builder.Logging.AddDebug();
#endif

        // Registrer platformspecifikke services
        builder.Services.AddSingleton<IDeviceService, MauiDeviceService>();
        builder.Services.AddSingleton<INotificationService, MauiNotificationService>();
        builder.Services.AddSingleton<ISecureStorageService, MauiSecureStorageService>();

        // Registrer delte services
        builder.Services.AddScoped<WeatherService>();
        builder.Services.AddScoped<AuthenticationStateProvider, AppAuthStateProvider>();

        return builder.Build();
    }
}

MAUI-appens hovedside indeholder BlazorWebView-kontrollen, der hoster Razor-indholdet. Filen MainPage.xaml ser typisk sådan ud:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:KrydsplatformApp"
             x:Class="KrydsplatformApp.MainPage"
             BackgroundColor="{DynamicResource PageBackgroundColor}">

    <BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html">
        <BlazorWebView.RootComponents>
            <RootComponent Selector="#app"
                           ComponentType="{x:Type local:Components.Routes}" />
        </BlazorWebView.RootComponents>
    </BlazorWebView>

</ContentPage>

Læg mærke til, at BlazorWebView peger på en HostPage og en RootComponent. Hostsiden er en simpel HTML-fil, mens rodkomponenten er dit Razor-komponenttræ. Denne opsætning giver dig fuld kontrol over, hvordan Blazor-indholdet integreres i den native app.

Shared Razor Class Library: Hjertet i din delte kode

Her kommer vi til kernen af det hele. Razor Class Library (RCL) er det centrale element i strategien for kodedeling. Et RCL er i bund og grund et .NET-klassebibliotek, der også understøtter Razor-komponenter, statiske filer og CSS. Alt hvad du placerer i dette bibliotek, kan bruges af både MAUI-appen og webappen.

Når du opretter et nyt RCL, skal projektfilen konfigureres korrekt for at understøtte begge platforme. .NET 10-skabelonen håndterer dette automatisk, men det er værd at forstå hvad der sker under motorhjelmen:

<Project Sdk="Microsoft.NET.Sdk.Razor">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <SupportedPlatform Include="browser" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.0" />
  </ItemGroup>

</Project>

En typisk delt komponent i RCL'et kan være en produktliste, der viser data hentet fra en service. Her er et eksempel på en komponent, der fungerer identisk på både web og mobil:

@page "/produkter"
@using KrydsplatformApp.Shared.Services
@using KrydsplatformApp.Shared.Models
@inject ProductService ProductService
@inject IDeviceService DeviceService

<h3>Produktkatalog</h3>

@if (_isLoading)
{
    <div class="loading-spinner">
        <span>Indlæser produkter...</span>
    </div>
}
else if (_products is not null)
{
    <div class="product-grid @(_isNative ? "native-layout" : "web-layout")">
        @foreach (var product in _products)
        {
            <div class="product-card">
                <img src="@product.ImageUrl" alt="@product.Name" loading="lazy" />
                <h4>@product.Name</h4>
                <p class="price">@product.Price.ToString("C", _cultureInfo)</p>
                <button class="btn btn-primary" @onclick="() => AddToCart(product)">
                    Tilføj til kurv
                </button>
            </div>
        }
    </div>
}

@code {
    private List<Product>? _products;
    private bool _isLoading = true;
    private bool _isNative;
    private CultureInfo _cultureInfo = new("da-DK");

    protected override async Task OnInitializedAsync()
    {
        _isNative = DeviceService.IsNativePlatform;
        _products = await ProductService.GetProductsAsync();
        _isLoading = false;
    }

    private async Task AddToCart(Product product)
    {
        await ProductService.AddToCartAsync(product.Id);
        // Platformspecifik feedback
        if (_isNative)
        {
            await DeviceService.VibrateAsync(TimeSpan.FromMilliseconds(50));
        }
    }
}

Bemærk, hvordan komponenten bruger IDeviceService til at detektere, om den kører på en nativ platform, og tilpasser sin opførsel derefter. På mobil giver den haptisk feedback ved tilføjelse til kurven, mens webversionen springer dette over. Selve UI-strukturen forbliver identisk – og det er hele pointen.

Hvad angår CSS-styling understøtter RCL'et både komponent-isoleret CSS (component.razor.css) og globale stylesheets. En god praksis er at definere CSS custom properties i et delt stylesheet, som både web- og MAUI-projekterne kan overskrive efter behov. Så kan du tilpasse farver og typografi til hver platform uden at duplikere komponentkode.

Platformspecifikke services med Dependency Injection

Okay, her er en af de ting jeg virkelig godt kan lide ved Blazor Hybrid-arkitekturen: den elegante håndtering af platformforskelle gennem dependency injection. Ved at definere interfaces i det delte bibliotek og implementere dem forskelligt i hvert hostprojekt, kan dine Razor-komponenter kalde native funktioner uden at kende de underliggende platformdetaljer.

Lad os se på et konkret eksempel med en IDeviceService, der abstraherer enhedsinformation og native kapaciteter:

// I KrydsplatformApp.Shared/Services/IDeviceService.cs
namespace KrydsplatformApp.Shared.Services;

public interface IDeviceService
{
    bool IsNativePlatform { get; }
    string Platform { get; }
    string DeviceModel { get; }
    Task VibrateAsync(TimeSpan duration);
    Task<string?> PickFileAsync(IEnumerable<string> allowedTypes);
    Task ShareTextAsync(string title, string text);
}

// I KrydsplatformApp/Services/MauiDeviceService.cs (MAUI-implementering)
using KrydsplatformApp.Shared.Services;

namespace KrydsplatformApp.Services;

public class MauiDeviceService : IDeviceService
{
    public bool IsNativePlatform => true;
    public string Platform => DeviceInfo.Platform.ToString();
    public string DeviceModel => DeviceInfo.Model;

    public async Task VibrateAsync(TimeSpan duration)
    {
        try
        {
            Vibration.Default.Vibrate(duration);
            await Task.CompletedTask;
        }
        catch (FeatureNotSupportedException)
        {
            // Enheden understøtter ikke vibration
        }
    }

    public async Task<string?> PickFileAsync(IEnumerable<string> allowedTypes)
    {
        var customFileTypes = new FilePickerFileType(
            new Dictionary<DevicePlatform, IEnumerable<string>>
            {
                { DevicePlatform.iOS, allowedTypes },
                { DevicePlatform.Android, allowedTypes },
                { DevicePlatform.WinUI, allowedTypes },
                { DevicePlatform.macOS, allowedTypes },
            });

        var result = await FilePicker.Default.PickAsync(new PickOptions
        {
            PickerTitle = "Vælg en fil",
            FileTypes = customFileTypes,
        });

        return result?.FullPath;
    }

    public async Task ShareTextAsync(string title, string text)
    {
        await Share.Default.RequestAsync(new ShareTextRequest
        {
            Title = title,
            Text = text,
        });
    }
}

// I KrydsplatformApp.Web/Services/WebDeviceService.cs (Web-implementering)
using KrydsplatformApp.Shared.Services;
using Microsoft.JSInterop;

namespace KrydsplatformApp.Web.Services;

public class WebDeviceService : IDeviceService
{
    private readonly IJSRuntime _jsRuntime;

    public WebDeviceService(IJSRuntime jsRuntime)
    {
        _jsRuntime = jsRuntime;
    }

    public bool IsNativePlatform => false;
    public string Platform => "Web";
    public string DeviceModel => "Browser";

    public async Task VibrateAsync(TimeSpan duration)
    {
        await _jsRuntime.InvokeVoidAsync(
            "navigator.vibrate", duration.TotalMilliseconds);
    }

    public async Task<string?> PickFileAsync(IEnumerable<string> allowedTypes)
    {
        // Brug JavaScript file input til webplatformen
        return await _jsRuntime.InvokeAsync<string?>(
            "blazorFileHelper.pickFile", allowedTypes);
    }

    public async Task ShareTextAsync(string title, string text)
    {
        await _jsRuntime.InvokeVoidAsync(
            "navigator.share", new { title, text });
    }
}

Denne tilgang giver fuld fleksibilitet. MAUI-implementeringen bruger native API'er direkte (FilePicker, Vibration, Share), mens webimplementeringen falder tilbage på Web API'er via JavaScript interop. Dine Razor-komponenter forbliver fuldstændig uafhængige af platformen – og det er præcis som det skal være.

For mere komplekse scenarier kan du også kombinere dette mønster med conditional compilation ved hjælp af preprocessor-direktiver i MAUI-projektet, så du kan håndtere forskelle mellem iOS og Android inden for den samme MAUI-implementering.

JavaScript Interop i Blazor Hybrid

Selvom Blazor Hybrid primært bruger C# til logik, er der situationer, hvor JavaScript interop er nødvendigt. Det er typisk når du skal interagere med eksisterende JavaScript-biblioteker, browser-API'er eller DOM-manipulation, der ikke er tilgængelig via Blazor. I Blazor Hybrid fungerer IJSRuntime på samme måde som i Blazor WebAssembly, men med nogle vigtige forskelle i ydeevne og begrænsninger.

I en Blazor Hybrid-app kommunikerer .NET-runtime med WebView-laget via en intern beskedkanal. Hvert JavaScript-interop-kald kræver serialisering af data til JSON, overførsel via denne kanal og deserialisering på den anden side. Derfor er det vigtigt at minimere antallet af interop-kald og undgå synkrone operationer.

Bedste praksis for JS Interop

Den første og vigtigste regel er: brug aldrig synkron JavaScript interop i Blazor Hybrid. Brug altid de asynkrone metoder InvokeAsync<T> og InvokeVoidAsync. Synkrone kald kan blokere UI-tråden og forårsage, at appen fryser. Det lyder måske indlysende, men jeg har set det ske i produktion mere end én gang.

Den anden vigtige teknik er batching af kald. I stedet for at foretage mange små interop-kald, kan du samle flere operationer i et enkelt JavaScript-funktionskald:

// Dårlig praksis: Mange individuelle kald
await JSRuntime.InvokeVoidAsync("chart.setWidth", 800);
await JSRuntime.InvokeVoidAsync("chart.setHeight", 400);
await JSRuntime.InvokeVoidAsync("chart.setTitle", "Salgsdata");
await JSRuntime.InvokeVoidAsync("chart.setData", salesData);
await JSRuntime.InvokeVoidAsync("chart.render");

// God praksis: Et enkelt batched kald
await JSRuntime.InvokeVoidAsync("chart.initialize", new
{
    Width = 800,
    Height = 400,
    Title = "Salgsdata",
    Data = salesData
});

Forskellen i ydeevne kan være overraskende stor, især på ældre mobile enheder.

En tredje teknik er at bruge JavaScript-moduler med IJSObjectReference for bedre isolation og lazy loading af JavaScript-kode. Dette reducerer den initielle indlæsningstid og holder din JavaScript-kode pænt modulopdelt:

@inject IJSRuntime JSRuntime
@implements IAsyncDisposable

@code {
    private IJSObjectReference? _chartModule;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _chartModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
                "import", "./_content/KrydsplatformApp.Shared/js/chartHelper.js");

            await _chartModule.InvokeVoidAsync("initializeChart",
                _chartElementRef, _chartOptions);
        }
    }

    public async ValueTask DisposeAsync()
    {
        if (_chartModule is not null)
        {
            await _chartModule.InvokeVoidAsync("destroyChart");
            await _chartModule.DisposeAsync();
        }
    }
}

For store datamængder bør du også overveje at bruge streaming interop, der blev introduceret i .NET 7 og forbedret i efterfølgende versioner. I stedet for at serialisere hele datasættet til JSON kan du streame data som en byte-array. Det reducerer hukommelsesforbrug og forbedrer ydeevne markant for billeder, filer og store datasets.

Optimering af ydeevne

Ydeevne er kritisk for mobilapplikationer – det er ingen hemmelighed. Blazor Hybrid-apps kræver opmærksomhed på flere niveauer, men den gode nyhed er, at .NET 10 tilbyder kraftfulde værktøjer til optimering. Lad os gennemgå de vigtigste strategier.

AOT-kompilering og IL Trimming

Ahead-of-Time (AOT) kompilering konverterer din .NET IL-kode til native maskinkode på kompileringstidspunktet i stedet for at afhænge af JIT-kompilering ved kørsel. I .NET 10 er AOT for MAUI-apps blevet betydeligt hurtigere og producerer mindre binaries. For at aktivere AOT og trimming tilføjer du følgende til dit MAUI-projekt:

<PropertyGroup Condition="'$(Configuration)' == 'Release'">
    <RunAOTCompilation>true</RunAOTCompilation>
    <PublishTrimmed>true</PublishTrimmed>
    <TrimMode>full</TrimMode>
    <EnableLLVM>true</EnableLLVM>

    <!-- Optimer WebView-rendering -->
    <BlazorWebViewOptimized>true</BlazorWebViewOptimized>

    <!-- Fjern ubrugte runtime-komponenter -->
    <InvariantGlobalization>false</InvariantGlobalization>
    <DebuggerSupport>false</DebuggerSupport>
    <EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
</PropertyGroup>

Vær opmærksom på, at trimming kan fjerne kode, der bruges via reflection. Test altid din release-build grundigt, og brug [DynamicDependency]-attributten til at beskytte kode, der tilgås dynamisk. Det er en af de ting man nemt glemmer – og så bruger man timer på at debugge et problem der kun opstår i produktion.

Minimering af genrenderinger

Blazor bruger en diffing-algoritme til at opdatere DOM-træet effektivt, men unødvendige genrenderinger kan stadig påvirke ydeevnen negativt. Her er de vigtigste teknikker til at holde det i skak:

  • Brug @key-direktivet på listeelementer for at hjælpe Blazors diffing-algoritme med at genkende individuelle elementer.
  • Implementer ShouldRender() i komponenter, der ikke behøver at opdatere ved hver stateændring.
  • Brug StateHasChanged() med omtanke – kald den kun, når tilstanden faktisk er ændret.
  • Virtualiser lange lister med <Virtualize>-komponenten i stedet for at rendere alle elementer på én gang.
  • Undgå inline delegates i løkker, da de opretter nye objekter ved hver rendering. Brug i stedet metodegrupper eller cachelagrede delegates.

For lange lister er forskellen mellem en naiv @foreach-løkke og <Virtualize> dramatisk. Med 10.000 elementer renderer <Virtualize> kun de synlige elementer (typisk 20-30), hvilket reducerer DOM-størrelsen enormt:

@* I stedet for dette: *@
@foreach (var item in _allItems)
{
    <ItemCard Item="@item" />
}

@* Brug Virtualize-komponenten: *@
<Virtualize Items="@_allItems" Context="item" OverscanCount="5">
    <ItemCard Item="@item" @key="item.Id" />
</Virtualize>

@* Eller med item provider for on-demand indlæsning: *@
<Virtualize ItemsProvider="@LoadItems" Context="item" OverscanCount="5">
    <ItemContent>
        <ItemCard Item="@item" @key="item.Id" />
    </ItemContent>
    <Placeholder>
        <ItemCardSkeleton />
    </Placeholder>
</Virtualize>

CollectionView vs. HTML-lister

Et vigtigt arkitekturbeslutning i Blazor Hybrid-apps er, hvornår man skal bruge HTML-baserede lister i WebView versus native MAUI-kontroller som CollectionView. Min erfaring er, at for lister med op til et par hundrede elementer fungerer HTML-lister med <Virtualize> udmærket. For meget store datasæt med kompleks scrolling-adfærd eller krav om pull-to-refresh kan det være fordelagtigt at bruge en hybrid tilgang, hvor dele af skærmen renderes som native MAUI-kontroller, mens resten forbliver i BlazorWebView.

Lazy loading af komponenter

For at reducere den initielle indlæsningstid kan du lazy-loade Razor-komponenter og assemblies. .NET 10 understøtter lazy loading af assemblies i MAUI Blazor Hybrid via LazyAssemblyLoader, men den mest praktiske tilgang er at bruge dynamisk komponentindlæsning med DynamicComponent kombineret med on-demand navigation. Hold sjældent brugte funktioner i separate projekter, der først indlæses, når brugeren navigerer til dem. Det gør en mærkbar forskel på opstartstiden.

Autentificering og sikkerhed

Sikkerhed i Blazor Hybrid-apps kræver en gennemtænkt tilgang, der tager højde for både web- og mobilspecifikke udfordringer. Lad os gennemgå de vigtigste aspekter.

Integration med ASP.NET Core Identity

For apps, der deler backend med webversionen, er ASP.NET Core Identity det naturlige valg. I MAUI-appen kan du bruge token-baseret autentificering (JWT eller opaque tokens) til at kommunikere med din API, mens webversionen bruger cookie-baseret autentificering. Den delte AuthenticationStateProvider abstraherer disse forskelle:

// I KrydsplatformApp.Shared/Services/AppAuthStateProvider.cs
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;

namespace KrydsplatformApp.Shared.Services;

public class AppAuthStateProvider : AuthenticationStateProvider
{
    private readonly ITokenStorageService _tokenStorage;
    private readonly IAuthApiService _authApi;
    private ClaimsPrincipal _currentUser = new(new ClaimsIdentity());

    public AppAuthStateProvider(
        ITokenStorageService tokenStorage,
        IAuthApiService authApi)
    {
        _tokenStorage = tokenStorage;
        _authApi = authApi;
    }

    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        try
        {
            var token = await _tokenStorage.GetAccessTokenAsync();

            if (string.IsNullOrEmpty(token))
                return new AuthenticationState(_currentUser);

            // Valider token og hent brugeroplysninger
            var userInfo = await _authApi.GetUserInfoAsync(token);

            if (userInfo is null)
            {
                await _tokenStorage.ClearTokensAsync();
                return new AuthenticationState(_currentUser);
            }

            var claims = new List<Claim>
            {
                new(ClaimTypes.Name, userInfo.DisplayName),
                new(ClaimTypes.Email, userInfo.Email),
                new(ClaimTypes.NameIdentifier, userInfo.UserId),
            };

            claims.AddRange(
                userInfo.Roles.Select(r => new Claim(ClaimTypes.Role, r)));

            var identity = new ClaimsIdentity(claims, "jwt");
            _currentUser = new ClaimsPrincipal(identity);

            return new AuthenticationState(_currentUser);
        }
        catch
        {
            return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
        }
    }

    public async Task LoginAsync(string email, string password)
    {
        var result = await _authApi.LoginAsync(email, password);

        if (result.Succeeded)
        {
            await _tokenStorage.SaveTokensAsync(
                result.AccessToken!, result.RefreshToken!);
            NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
        }
    }

    public async Task LogoutAsync()
    {
        await _tokenStorage.ClearTokensAsync();
        _currentUser = new ClaimsPrincipal(new ClaimsIdentity());
        NotifyAuthenticationStateChanged(
            Task.FromResult(new AuthenticationState(_currentUser)));
    }
}

Sikker opbevaring af tokens

På mobile platforme skal tokens opbevares sikkert – det kan ikke understreges nok. MAUI giver adgang til platformspecifik sikker lagring via SecureStorage-API'en, der bruger Keychain på iOS, EncryptedSharedPreferences på Android og DPAPI på Windows. I webversionen kan du bruge ProtectedLocalStorage fra Blazor.

Definér et interface i det delte projekt og implementér det specifikt for hver platform. MAUI-implementeringen wrapper SecureStorage.Default, mens webimplementeringen bruger ProtectedLocalStorage. På denne måde sikrer du, at tokens altid opbevares med den bedst tilgængelige krypteringsmekanisme på den aktuelle platform.

Biometrisk autentificering

For mobilapps kan biometrisk autentificering (fingeraftryk, ansigtsgenkendelse) tilføje et ekstra sikkerhedslag. I MAUI kan du bruge platform-invoke eller NuGet-pakker som Plugin.Fingerprint til at integrere biometri. Abstraher dette bag et interface, så webversionen kan falde tilbage til en alternativ metode som f.eks. WebAuthn/FIDO2.

En vigtig sikkerhedsovervejelse: BlazorWebView kører indhold fra et lokalt app://-origin. Det betyder, at standard web-sikkerhedsmekanismer som CORS ikke fungerer på samme måde. Sørg for, at alle API-kald bruger HTTPS, og implementer certificate pinning for følsomme applikationer.

Teststrategier for Blazor Hybrid

Test af Blazor Hybrid-apps kræver en lagdelt tilgang, der matcher arkitekturen. Hvert lag har sine egne testværktøjer og -strategier – og det er faktisk en af styrkerne ved den lagdelte arkitektur.

Unit testing af Razor-komponenter med bUnit

bUnit er det foretrukne framework til unit testing af Razor-komponenter. Det giver dig mulighed for at rendere komponenter i en testhost, simulere brugerinteraktioner og verificere det renderede output. bUnit fungerer rigtig godt med komponenter fra dit delte RCL:

using Bunit;
using Xunit;
using KrydsplatformApp.Shared.Components.Pages;
using KrydsplatformApp.Shared.Services;
using KrydsplatformApp.Shared.Models;
using Moq;

namespace KrydsplatformApp.Tests;

public class ProductListTests : TestContext
{
    [Fact]
    public void ProductList_ViserIndlaeserNaarDataIkkeErKlar()
    {
        // Arrange
        var mockProductService = new Mock<ProductService>();
        mockProductService
            .Setup(s => s.GetProductsAsync())
            .Returns(new TaskCompletionSource<List<Product>>().Task);

        var mockDeviceService = new Mock<IDeviceService>();
        mockDeviceService.Setup(d => d.IsNativePlatform).Returns(false);

        Services.AddSingleton(mockProductService.Object);
        Services.AddSingleton(mockDeviceService.Object);

        // Act
        var cut = RenderComponent<Produkter>();

        // Assert
        cut.Find(".loading-spinner")
           .TextContent.Should().Contain("Indlæser produkter...");
    }

    [Fact]
    public void ProductList_ViserProdukterNaarDataErIndlaest()
    {
        // Arrange
        var testProducts = new List<Product>
        {
            new() { Id = 1, Name = "Laptop", Price = 8999.00m,
                     ImageUrl = "/images/laptop.jpg" },
            new() { Id = 2, Name = "Tastatur", Price = 599.00m,
                     ImageUrl = "/images/keyboard.jpg" },
        };

        var mockProductService = new Mock<ProductService>();
        mockProductService
            .Setup(s => s.GetProductsAsync())
            .ReturnsAsync(testProducts);

        var mockDeviceService = new Mock<IDeviceService>();
        mockDeviceService.Setup(d => d.IsNativePlatform).Returns(false);

        Services.AddSingleton(mockProductService.Object);
        Services.AddSingleton(mockDeviceService.Object);

        // Act
        var cut = RenderComponent<Produkter>();

        // Assert
        var productCards = cut.FindAll(".product-card");
        productCards.Should().HaveCount(2);
        cut.Find(".product-card h4").TextContent.Should().Be("Laptop");
    }

    [Fact]
    public async Task TilfoejTilKurv_KalderServiceKorrekt()
    {
        // Arrange
        var testProducts = new List<Product>
        {
            new() { Id = 1, Name = "Laptop", Price = 8999.00m,
                     ImageUrl = "/images/laptop.jpg" },
        };

        var mockProductService = new Mock<ProductService>();
        mockProductService
            .Setup(s => s.GetProductsAsync())
            .ReturnsAsync(testProducts);
        mockProductService
            .Setup(s => s.AddToCartAsync(1))
            .Returns(Task.CompletedTask);

        var mockDeviceService = new Mock<IDeviceService>();
        mockDeviceService.Setup(d => d.IsNativePlatform).Returns(false);

        Services.AddSingleton(mockProductService.Object);
        Services.AddSingleton(mockDeviceService.Object);

        var cut = RenderComponent<Produkter>();

        // Act
        cut.Find(".btn-primary").Click();

        // Assert
        mockProductService.Verify(s => s.AddToCartAsync(1), Times.Once);
    }
}

bUnit-tests kører hurtigt, fordi de ikke kræver en WebView eller browser. De er ideelle til at teste komponentlogik, rendering og brugerinteraktioner. For platformspecifikke services bruger du mocks (som vist ovenfor), så testene forbliver uafhængige af platformen.

Platformspecifikke testovervejelser

Ud over bUnit-tests bør du også tænke over:

  • Integrationstests for dine platformspecifikke service-implementeringer. Disse kræver typisk en emulator eller fysisk enhed.
  • UI-tests med Appium eller .NET MAUIs Microsoft.Maui.TestUtils for at teste den fulde app-oplevelse på hver platform.
  • Snapshot-tests med bUnits MarkupMatches() for at opdage utilsigtede ændringer i komponenternes HTML-output.
  • Ydeevnetest med BenchmarkDotNet for kritiske kodestier, især omkring serialisering og JavaScript interop.

En god testpyramide for Blazor Hybrid består af mange bUnit-tests i bunden (hurtige og billige), færre integrationstest i midten (kræver mere opsætning), og et lille antal ende-til-ende UI-tests i toppen (langsomme, men dækker hele stakken). Automatiser det hele i din CI/CD-pipeline – det sparer dig for rigtig mange hovedpiner på sigt.

Deployment til flere platforme

Når din Blazor Hybrid-app er udviklet og testet, skal den ud til brugerne. Og her er virkeligheden, at hver platform har sine egne krav og processer. Lad os tage dem én ad gangen.

Android

For Android bygger du en .aab-fil (Android App Bundle) til Google Play Store eller en .apk til direkte distribution. Sørg for at konfigurere signing, versioning og minimumskrav korrekt i Platforms/Android/AndroidManifest.xml:

# Byg en signeret Android App Bundle
dotnet publish -f net10.0-android -c Release \
    -p:AndroidKeyStore=true \
    -p:AndroidSigningKeyStore=keystore.jks \
    -p:AndroidSigningKeyAlias=upload-key \
    -p:AndroidSigningKeyPass=env:KEYSTORE_PASSWORD \
    -p:AndroidSigningStorePass=env:KEYSTORE_PASSWORD

En ting man nemt overser: Android WebView-versionen varierer mellem enheder. Test din app på enheder med ældre WebView-versioner for at sikre kompatibilitet. Angiv minimum Android API 24 eller højere for en konsistent oplevelse.

iOS og macOS

iOS-deployment kræver (naturligvis) et Apple Developer-certifikat og en provisioning-profil. Du skal bygge på en Mac eller bruge en cloud-baseret Mac-byggetjeneste. Apple kræver desuden, at appen gennemgår App Store Review, hvilket kan tage fra et par timer til flere dage – så planlæg derefter.

Vær opmærksom på Apples retningslinjer for apps, der bruger WebView. De kræver, at den primære funktionalitet ikke udelukkende består af en webside pakket i en nativ shell. Med Blazor Hybrid er det normalt ikke et problem, da du har ægte nativ integration, men det er godt at have i baghovedet.

For macOS kan du distribuere via Mac App Store eller som en notarized .dmg-fil. .NET 10 MAUI understøtter både Intel og Apple Silicon via universal binaries.

Windows

På Windows pakkes appen som en MSIX-pakke, der kan distribueres via Microsoft Store, din egen hjemmeside eller virksomhedens app-distributionssystem. MSIX giver automatisk opdatering, ren installation og afinstallation. Windows-versionen kræver WebView2 Runtime, som er forudinstalleret på Windows 10/11, men du bør inkludere et fallback for ældre systemer.

Webappen

Din Blazor-webapp deployes som en standard ASP.NET Core-applikation. Du kan bruge Azure App Service, Docker-containere, eller enhver anden hostingplatform, der understøtter .NET 10. Overvej at aktivere server-side rendering (SSR) for første sidevisning og derefter skifte til interaktiv Blazor for efterfølgende navigation – det giver den bedste kombination af indlæsningstid og interaktivitet.

CI/CD med GitHub Actions

For at automatisere build og deployment på tværs af alle platforme anbefaler jeg en CI/CD-pipeline med GitHub Actions, da det understøtter Windows-, macOS- og Linux-runners. Opret separate workflows for hver platform, men del den fælles build-logik via reusable workflows. Kør bUnit-tests som en del af alle workflows for at sikre, at delte komponenter fungerer korrekt uanset målplatform.

Konklusion og fremtidsudsigter

.NET MAUI Blazor Hybrid er blevet en modnet og praktisk tilgang til cross-platform udvikling i 2026. Ved at kombinere Razor-komponenternes fleksibilitet med MAUIs native platformadgang får du det bedste fra begge verdener: én delt UI-kodebase, der kører native på alle platforme med fuld adgang til enhedens funktioner.

Her er de vigtigste takeaways:

  • Arkitektur er afgørende – Fire-lags mønsteret med delt RCL, forretningslogik, platformabstraktioner og platformimplementeringer giver en ren og vedligeholdelig kodebase.
  • Dependency Injection er din bedste ven – Abstraher platformforskelle bag interfaces og lad DI-containeren håndtere resten.
  • Ydeevne kræver bevidst indsats – AOT-kompilering, virtualisering, batching af JS interop-kald og lazy loading er alle teknikker, du bør have i din værktøjskasse.
  • Sikkerhed skal tænkes ind fra starten – Brug platformens sikre lagringsmekanismer, implementer biometrisk autentificering på mobil, og sørg for korrekt token-håndtering.
  • Test på alle niveauer – bUnit for komponenttests, mocks for platformspecifikke services, og ende-til-ende tests på faktiske enheder.

Når vi ser fremad, er der flere spændende ting på horisonten. Microsoft fortsætter med at forbedre ydeevnen af BlazorWebView, og der er igangværende arbejde med at reducere opstartstiden yderligere. Hybrid rendering – hvor dele af UI'en renderes nativt, mens andre dele kører i WebView – bliver stadig mere sofistikeret.

Integration med .NET Aspire gør det desuden nemmere at udvikle, teste og deploye den fulde stak – fra backend-API'er og databaser til frontend-web og mobile apps – som en samlet løsning. Ærligt talt er denne holistiske tilgang et af de mest overbevisende argumenter for .NET-økosystemet lige nu.

For teams, der allerede har erfaring med C# og .NET, er Blazor Hybrid en naturlig evolution. I stedet for at lære nye sprog og frameworks for hver platform kan du udnytte din eksisterende viden og dine eksisterende komponenter.

Mit råd? Start småt – f.eks. med en enkel intern virksomhedsapp – og udvid gradvist efterhånden som du opbygger erfaring med mønstrene. Blazor Hybrid-økosystemet er modent nok til produktion, og community'et vokser. Det har aldrig været et bedre tidspunkt at starte med cross-platform .NET-udvikling.

Om Forfatteren Editorial Team

Our team of expert writers and editors.