บทนำ: Blazor Hybrid คืออะไร แล้วทำไมมันถึงน่าสนใจขนาดนี้?
ถ้าคุณเป็นนักพัฒนาที่ต้องรับมือกับโจทย์ "ทำแอปให้รองรับทุกแพลตฟอร์ม" — เว็บ, Android, iOS, Windows, macOS — คุณน่าจะเข้าใจดีว่ามันปวดหัวขนาดไหน ทุกครั้งที่ต้องเลือกเทคโนโลยี มันเหมือนกำลังเลือกว่าจะเสียสละอะไร
.NET MAUI Blazor Hybrid คือคำตอบที่ Microsoft เสนอให้กับนักพัฒนา C# อย่างเราๆ สร้างแอปข้ามแพลตฟอร์มได้ด้วย C# กับ Razor syntax โดยไม่ต้องเขียนโค้ดแยกสำหรับแต่ละแพลตฟอร์ม พูดตรงๆ ว่ามันค่อนข้างน่าทึ่ง
แล้ว Blazor Hybrid ต่างจาก Blazor WebAssembly กับ Blazor Server ยังไง? ง่ายๆ เลยครับ — มันรัน Razor components โดยตรงบน native process ของอุปกรณ์ผ่าน BlazorWebView ไม่ได้รันบน WebAssembly หรือพึ่ง server-side rendering แต่อย่างใด ซึ่งหมายความว่าแอปเข้าถึง native API ได้เต็มที่ ไม่ว่าจะเป็นกล้อง, GPS, ระบบไฟล์ หรือ Bluetooth แต่ก็ยังแชร์ UI components กับเว็บแอปได้ด้วย
ข้อดีหลักๆ ที่ทำให้ Blazor Hybrid น่าสนใจ:
- แชร์โค้ด UI ระหว่างเว็บและมือถือ — เขียน Razor component ครั้งเดียว ใช้ได้ทั้ง Blazor Server/WASM และแอป .NET MAUI
- เข้าถึง Native API ได้เต็มรูปแบบ — ต่างจาก PWA หรือ Blazor WASM ที่ถูกขังอยู่ใน browser sandbox ตรงนี้รันบน native process ตรงๆ เลย
- ประสิทธิภาพสูง — C# รันบน .NET runtime ของอุปกรณ์โดยตรง ไม่ต้องแปลงเป็น WebAssembly จึงเร็วใกล้เคียง native app
- ใช้ทักษะ .NET ที่มีอยู่ได้เลย — คุ้นเคยกับ ASP.NET Core หรือ Blazor อยู่แล้ว? เริ่มสร้างแอปมือถือได้ทันที
- ระบบนิเวศ NuGet ที่แข็งแกร่ง — ไลบรารี .NET นับพันตัวพร้อมให้ใช้งาน
ในบทความนี้ ผมจะพาคุณผ่านทุกแง่มุมของการสร้างแอป .NET MAUI Blazor Hybrid ตั้งแต่สถาปัตยกรรม, การตั้งค่าโปรเจกต์, การสร้าง shared components, การเข้าถึง native API ไปจนถึงเทคนิคเพิ่มประสิทธิภาพ งั้นเรามาเริ่มกันเลยครับ
สถาปัตยกรรมของ .NET MAUI Blazor Hybrid
ก่อนจะลงมือเขียนโค้ด ต้องเข้าใจสถาปัตยกรรมก่อน สถาปัตยกรรมหลักประกอบด้วยสามส่วนสำคัญครับ
BlazorWebView
BlazorWebView เป็น native control ที่ฝัง WebView (WebView2 บน Windows, WKWebView บน iOS/macOS, WebView บน Android) ลงในแอป .NET MAUI แล้วโฮสต์ Blazor app ภายใน WebView นั้น จุดที่น่าสนใจคือ Razor components ไม่ได้รันบน browser engine แต่รันบน .NET runtime ของอุปกรณ์โดยตรง — WebView แค่ทำหน้าที่เป็น renderer สำหรับ HTML/CSS เท่านั้นเอง
Razor Class Library (RCL)
RCL คือหัวใจของการแชร์โค้ดระหว่างเว็บและมือถือ คุณสร้าง Razor components, services, models และ static assets ไว้ใน RCL แล้วอ้างอิงจากทั้งโปรเจกต์ MAUI และ Blazor Web App ได้เลย ง่ายมาก
โครงสร้างโซลูชันที่แนะนำ
โครงสร้างที่เหมาะสำหรับการแชร์โค้ดข้ามแพลตฟอร์มมีลักษณะแบบนี้ครับ:
MyApp/
├── MyApp.sln
├── MyApp.Shared/ # Razor Class Library (RCL)
│ ├── Components/
│ │ ├── Pages/
│ │ │ ├── Home.razor
│ │ │ ├── Counter.razor
│ │ │ └── Weather.razor
│ │ ├── Layout/
│ │ │ ├── MainLayout.razor
│ │ │ └── NavMenu.razor
│ │ └── Shared/
│ │ └── DataGrid.razor
│ ├── Services/
│ │ ├── IWeatherService.cs
│ │ └── IDeviceService.cs
│ ├── Models/
│ │ └── WeatherForecast.cs
│ ├── wwwroot/
│ │ └── css/
│ │ └── app.css
│ └── MyApp.Shared.csproj
├── MyApp.Maui/ # .NET MAUI Blazor Hybrid
│ ├── Services/
│ │ ├── MauiWeatherService.cs
│ │ └── MauiDeviceService.cs
│ ├── MainPage.xaml
│ ├── MauiProgram.cs
│ └── MyApp.Maui.csproj
└── MyApp.Web/ # Blazor Web App
├── Services/
│ ├── WebWeatherService.cs
│ └── WebDeviceService.cs
├── Program.cs
└── MyApp.Web.csproj
การออกแบบแบบนี้ช่วยให้กำหนด interface ใน RCL แล้วให้แต่ละแพลตฟอร์ม implement เอง business logic กับ UI components แชร์กันได้หมด ส่วน platform-specific code ก็แยกกันอย่างสวยงาม
การตั้งค่าโปรเจกต์ .NET MAUI Blazor Hybrid
มาเริ่มกันที่การสร้างโปรเจกต์ด้วย .NET CLI กัน ซึ่งรองรับตั้งแต่ .NET 8 ขึ้นไป (ส่วน .NET 9/10 ก็ได้ปรับปรุง template ให้ดียิ่งขึ้นอีก โดยเฉพาะ solution template แบบ Blazor Hybrid + Web ในตัว)
# ติดตั้ง .NET MAUI workload (ถ้ายังไม่ได้ติดตั้ง)
dotnet workload install maui
# สร้างโซลูชันแบบ MAUI Blazor Hybrid + Web App (.NET 9/10)
dotnet new maui-blazor-solution -n MyHybridApp
# หรือสร้างแยกทีละโปรเจกต์
dotnet new razorclasslib -n MyHybridApp.Shared -o MyHybridApp.Shared
dotnet new maui-blazor -n MyHybridApp.Maui -o MyHybridApp.Maui
dotnet new blazor -n MyHybridApp.Web -o MyHybridApp.Web
# เพิ่ม reference ไปยัง Shared library
dotnet add MyHybridApp.Maui reference MyHybridApp.Shared
dotnet add MyHybridApp.Web reference MyHybridApp.Shared
พอสร้างโปรเจกต์เสร็จแล้ว มาดูไฟล์สำคัญที่ต้องตั้งค่ากัน เริ่มจาก MauiProgram.cs ซึ่งเป็นจุดเริ่มต้นของแอป MAUI:
using Microsoft.Extensions.Logging;
using MyHybridApp.Shared.Services;
using MyHybridApp.Maui.Services;
namespace MyHybridApp.Maui;
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");
});
// เพิ่ม BlazorWebView
builder.Services.AddMauiBlazorWebView();
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
builder.Logging.AddDebug();
#endif
// ลงทะเบียน services สำหรับ MAUI platform
builder.Services.AddSingleton<IWeatherService, MauiWeatherService>();
builder.Services.AddSingleton<IDeviceService, MauiDeviceService>();
builder.Services.AddSingleton<ICameraService, MauiCameraService>();
builder.Services.AddSingleton<IGeolocationService, MauiGeolocationService>();
// ลงทะเบียน HttpClient สำหรับเรียก API
builder.Services.AddHttpClient("ApiClient", client =>
{
client.BaseAddress = new Uri("https://api.myapp.com/");
});
return builder.Build();
}
}
ต่อมาคือไฟล์ MainPage.xaml ที่เป็นที่ตั้งของ BlazorWebView:
<?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:MyHybridApp.Maui"
x:Class="MyHybridApp.Maui.MainPage"
BackgroundColor="{DynamicResource PageBackgroundColor}">
<BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html">
<BlazorWebView.RootComponents>
<RootComponent Selector="#app"
ComponentType="{x:Type local:Routes}" />
</BlazorWebView.RootComponents>
</BlazorWebView>
</ContentPage>
สำหรับไฟล์ .csproj ของโปรเจกต์ MAUI ต้องตั้งค่า target frameworks ให้ครบทุกแพลตฟอร์มที่ต้องการรองรับ:
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFrameworks>net9.0-android;net9.0-ios;net9.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">
$(TargetFrameworks);net9.0-windows10.0.19041.0
</TargetFrameworks>
<OutputType>Exe</OutputType>
<RootNamespace>MyHybridApp.Maui</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDefaultCssItems>false</EnableDefaultCssItems>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MyHybridApp.Shared\MyHybridApp.Shared.csproj" />
</ItemGroup>
</Project>
การสร้าง Shared UI Components ด้วย Razor Syntax
ส่วนนี้คือหัวใจจริงๆ ของ Blazor Hybrid — การสร้าง components ที่แชร์ได้ระหว่างเว็บและมือถือ มาดูตัวอย่างที่ใช้งานจริงกันครับ
ตัวอย่าง: DataGrid Component ที่ใช้ซ้ำได้
นี่คือ generic DataGrid component ที่ผมชอบใช้เป็นตัวอย่าง เพราะมันแสดงให้เห็นพลังของ Razor generics ได้ชัดเจนมาก:
@* MyHybridApp.Shared/Components/Shared/DataGrid.razor *@
@typeparam TItem
<div class="data-grid-container">
@if (IsLoading)
{
<div class="loading-overlay">
<div class="spinner"></div>
<p>กำลังโหลดข้อมูล...</p>
</div>
}
else if (Items is null || !Items.Any())
{
<div class="empty-state">
<p>@EmptyMessage</p>
</div>
}
else
{
<table class="data-grid">
<thead>
<tr>
@HeaderTemplate
</tr>
</thead>
<tbody>
@foreach (var item in GetPagedItems())
{
<tr @onclick="() => OnRowSelected.InvokeAsync(item)"
class="@(SelectedItem?.Equals(item) == true ? "selected" : "")">
@RowTemplate(item)
</tr>
}
</tbody>
</table>
@if (EnablePagination)
{
<div class="pagination">
<button @onclick="PreviousPage" disabled="@(CurrentPage <= 1)">
ก่อนหน้า
</button>
<span>หน้า @CurrentPage / @TotalPages</span>
<button @onclick="NextPage" disabled="@(CurrentPage >= TotalPages)">
ถัดไป
</button>
</div>
}
}
</div>
@code {
[Parameter] public IEnumerable<TItem>? Items { get; set; }
[Parameter] public RenderFragment? HeaderTemplate { get; set; }
[Parameter] public RenderFragment<TItem> RowTemplate { get; set; } = default!;
[Parameter] public EventCallback<TItem> OnRowSelected { get; set; }
[Parameter] public TItem? SelectedItem { get; set; }
[Parameter] public bool IsLoading { get; set; }
[Parameter] public bool EnablePagination { get; set; } = true;
[Parameter] public int PageSize { get; set; } = 20;
[Parameter] public string EmptyMessage { get; set; } = "ไม่พบข้อมูล";
private int CurrentPage { get; set; } = 1;
private int TotalPages => Items is null ? 0
: (int)Math.Ceiling(Items.Count() / (double)PageSize);
private IEnumerable<TItem> GetPagedItems()
{
if (Items is null) return Enumerable.Empty<TItem>();
return Items.Skip((CurrentPage - 1) * PageSize).Take(PageSize);
}
private void PreviousPage()
{
if (CurrentPage > 1) CurrentPage--;
}
private void NextPage()
{
if (CurrentPage < TotalPages) CurrentPage++;
}
}
การใช้งาน DataGrid ในหน้า Weather
ทีนี้มาดูว่าเอา DataGrid ไปใช้จริงยังไง:
@* MyHybridApp.Shared/Components/Pages/Weather.razor *@
@page "/weather"
@using MyHybridApp.Shared.Models
@using MyHybridApp.Shared.Services
@inject IWeatherService WeatherService
<h3>พยากรณ์อากาศ</h3>
<DataGrid TItem="WeatherForecast"
Items="@forecasts"
IsLoading="@isLoading"
PageSize="10"
OnRowSelected="HandleForecastSelected"
SelectedItem="@selectedForecast">
<HeaderTemplate>
<th>วันที่</th>
<th>อุณหภูมิ (°C)</th>
<th>สรุป</th>
</HeaderTemplate>
<RowTemplate Context="forecast">
<td>@forecast.Date.ToString("dd/MM/yyyy")</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.Summary</td>
</RowTemplate>
</DataGrid>
@if (selectedForecast is not null)
{
<div class="forecast-detail">
<h4>รายละเอียดพยากรณ์</h4>
<p>วันที่: @selectedForecast.Date.ToLongDateString()</p>
<p>อุณหภูมิ: @selectedForecast.TemperatureC°C (@selectedForecast.TemperatureF°F)</p>
<p>สรุป: @selectedForecast.Summary</p>
</div>
}
@code {
private List<WeatherForecast>? forecasts;
private WeatherForecast? selectedForecast;
private bool isLoading = true;
protected override async Task OnInitializedAsync()
{
try
{
forecasts = await WeatherService.GetForecastsAsync();
}
finally
{
isLoading = false;
}
}
private void HandleForecastSelected(WeatherForecast forecast)
{
selectedForecast = forecast;
}
}
สังเกตไหมครับว่า components พวกนี้ไม่มี platform-specific code เลย ใช้ได้ทั้งเว็บและมือถือโดยไม่ต้องแก้อะไรสักบรรทัด
การเข้าถึง Native Device API จาก Blazor Components
ตรงนี้คือจุดที่ Blazor Hybrid โดดเด่นจริงๆ ครับ — ความสามารถในการเข้าถึง native API ของอุปกรณ์ผ่าน dependency injection โดยกำหนด interface ใน Shared library แล้ว implement ใน MAUI project
ขั้นตอนที่ 1: กำหนด Interface ใน Shared Library
// MyHybridApp.Shared/Services/ICameraService.cs
namespace MyHybridApp.Shared.Services;
public interface ICameraService
{
Task<PhotoResult?> TakePhotoAsync();
Task<PhotoResult?> PickPhotoAsync();
bool IsCameraAvailable { get; }
}
public record PhotoResult(
Stream Stream,
string FileName,
string ContentType
);
// MyHybridApp.Shared/Services/IGeolocationService.cs
namespace MyHybridApp.Shared.Services;
public interface IGeolocationService
{
Task<LocationResult?> GetCurrentLocationAsync();
Task<LocationResult?> GetLastKnownLocationAsync();
}
public record LocationResult(
double Latitude,
double Longitude,
double? Altitude,
double? Accuracy
);
// MyHybridApp.Shared/Services/IFileService.cs
namespace MyHybridApp.Shared.Services;
public interface IFileService
{
Task<string> SaveFileAsync(string fileName, Stream content);
Task<Stream?> ReadFileAsync(string fileName);
Task<bool> FileExistsAsync(string fileName);
Task DeleteFileAsync(string fileName);
string GetAppDataDirectory();
}
ขั้นตอนที่ 2: Implement สำหรับ MAUI Platform
ตรงนี้คือส่วนที่แต่ละแพลตฟอร์มจะ implement ของตัวเอง สำหรับ MAUI ก็จะเรียก native API ตรงๆ เลย:
// MyHybridApp.Maui/Services/MauiCameraService.cs
using MyHybridApp.Shared.Services;
namespace MyHybridApp.Maui.Services;
public class MauiCameraService : ICameraService
{
public bool IsCameraAvailable => MediaPicker.Default.IsCaptureSupported;
public async Task<PhotoResult?> TakePhotoAsync()
{
try
{
// ตรวจสอบสิทธิ์การใช้งานกล้อง
var status = await Permissions.CheckStatusAsync<Permissions.Camera>();
if (status != PermissionStatus.Granted)
{
status = await Permissions.RequestAsync<Permissions.Camera>();
if (status != PermissionStatus.Granted)
return null;
}
var photo = await MediaPicker.Default.CapturePhotoAsync(
new MediaPickerOptions
{
Title = "ถ่ายภาพ"
});
if (photo is null) return null;
var stream = await photo.OpenReadAsync();
return new PhotoResult(
stream,
photo.FileName,
photo.ContentType
);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine(
$"เกิดข้อผิดพลาดในการถ่ายภาพ: {ex.Message}");
return null;
}
}
public async Task<PhotoResult?> PickPhotoAsync()
{
try
{
var photo = await MediaPicker.Default.PickPhotoAsync(
new MediaPickerOptions
{
Title = "เลือกรูปภาพ"
});
if (photo is null) return null;
var stream = await photo.OpenReadAsync();
return new PhotoResult(
stream,
photo.FileName,
photo.ContentType
);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine(
$"เกิดข้อผิดพลาดในการเลือกรูปภาพ: {ex.Message}");
return null;
}
}
}
// MyHybridApp.Maui/Services/MauiGeolocationService.cs
using MyHybridApp.Shared.Services;
namespace MyHybridApp.Maui.Services;
public class MauiGeolocationService : IGeolocationService
{
public async Task<LocationResult?> GetCurrentLocationAsync()
{
try
{
var status = await Permissions
.CheckStatusAsync<Permissions.LocationWhenInUse>();
if (status != PermissionStatus.Granted)
{
status = await Permissions
.RequestAsync<Permissions.LocationWhenInUse>();
if (status != PermissionStatus.Granted)
return null;
}
var request = new GeolocationRequest(
GeolocationAccuracy.Medium,
TimeSpan.FromSeconds(10));
var location = await Geolocation.Default
.GetLocationAsync(request);
if (location is null) return null;
return new LocationResult(
location.Latitude,
location.Longitude,
location.Altitude,
location.Accuracy
);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine(
$"เกิดข้อผิดพลาดในการดึงตำแหน่ง: {ex.Message}");
return null;
}
}
public async Task<LocationResult?> GetLastKnownLocationAsync()
{
var location = await Geolocation.Default.GetLastKnownLocationAsync();
if (location is null) return null;
return new LocationResult(
location.Latitude,
location.Longitude,
location.Altitude,
location.Accuracy
);
}
}
ขั้นตอนที่ 3: ใช้ Native API ใน Razor Component
ส่วนนี้คือที่ทุกอย่างมารวมกัน — component เรียกใช้ native API ผ่าน interface โดยไม่ต้องรู้ว่ารันอยู่บนแพลตฟอร์มไหน:
@* MyHybridApp.Shared/Components/Pages/CameraPage.razor *@
@page "/camera"
@using MyHybridApp.Shared.Services
@inject ICameraService CameraService
@inject IGeolocationService GeolocationService
<h3>ถ่ายภาพพร้อมตำแหน่ง</h3>
<div class="camera-controls">
<button class="btn btn-primary"
@onclick="TakePhoto"
disabled="@(!CameraService.IsCameraAvailable || isBusy)">
📷 ถ่ายภาพ
</button>
<button class="btn btn-secondary"
@onclick="PickPhoto"
disabled="@isBusy">
🖼️ เลือกจากแกลเลอรี
</button>
</div>
@if (isBusy)
{
<p>กำลังประมวลผล...</p>
}
@if (photoDataUrl is not null)
{
<div class="photo-preview">
<img src="@photoDataUrl" alt="รูปที่ถ่าย" style="max-width: 100%;" />
@if (location is not null)
{
<div class="location-info">
<p>ละติจูด: @location.Latitude.ToString("F6")</p>
<p>ลองจิจูด: @location.Longitude.ToString("F6")</p>
@if (location.Accuracy.HasValue)
{
<p>ความแม่นยำ: @location.Accuracy.Value.ToString("F1") เมตร</p>
}
</div>
}
</div>
}
@if (errorMessage is not null)
{
<div class="alert alert-danger">@errorMessage</div>
}
@code {
private string? photoDataUrl;
private LocationResult? location;
private bool isBusy;
private string? errorMessage;
private async Task TakePhoto()
{
await CaptureWithLocation(
() => CameraService.TakePhotoAsync());
}
private async Task PickPhoto()
{
await CaptureWithLocation(
() => CameraService.PickPhotoAsync());
}
private async Task CaptureWithLocation(
Func<Task<PhotoResult?>> photoAction)
{
isBusy = true;
errorMessage = null;
try
{
// ดึงรูปภาพและตำแหน่งพร้อมกัน
var photoTask = photoAction();
var locationTask = GeolocationService
.GetCurrentLocationAsync();
await Task.WhenAll(photoTask, locationTask);
var photo = await photoTask;
location = await locationTask;
if (photo is not null)
{
// แปลง stream เป็น data URL สำหรับแสดงผล
using var ms = new MemoryStream();
await photo.Stream.CopyToAsync(ms);
var base64 = Convert.ToBase64String(ms.ToArray());
photoDataUrl = $"data:{photo.ContentType};base64,{base64}";
await photo.Stream.DisposeAsync();
}
}
catch (Exception ex)
{
errorMessage = $"เกิดข้อผิดพลาด: {ex.Message}";
}
finally
{
isBusy = false;
}
}
}
ตรงนี้ที่น่าสนใจมากครับ Razor component ไม่จำเป็นต้องรู้เลยว่ามันรันอยู่บนแพลตฟอร์มไหน มันรู้จักแค่ interface เท่านั้น ซึ่ง DI container จะ resolve ให้เป็น implementation ที่ถูกต้องตอนรันไทม์ สำหรับเว็บ คุณอาจ implement ICameraService ให้ใช้ JavaScript interop เข้าถึง browser API ส่วน MAUI ก็ใช้ native API ตรงๆ
การแชร์โค้ดระหว่างเว็บและมือถือผ่าน Razor Class Library
การแชร์โค้ดอย่างมีประสิทธิภาพเป็นกุญแจสำคัญของ Blazor Hybrid จากประสบการณ์ การออกแบบที่ดีสามารถแชร์ได้มากถึง 80-90% ของโค้ด UI และ business logic ระหว่างเว็บกับมือถือ ซึ่งเยอะมากเลยนะ
การใช้ Conditional Rendering ตามแพลตฟอร์ม
แน่นอนว่าบางทีคุณก็ต้องแสดงผลต่างกันตามแพลตฟอร์ม วิธีที่ผมแนะนำคือสร้าง service ที่บอกข้อมูลแพลตฟอร์ม:
// MyHybridApp.Shared/Services/IPlatformService.cs
namespace MyHybridApp.Shared.Services;
public interface IPlatformService
{
PlatformType CurrentPlatform { get; }
bool IsNativeApp { get; }
bool IsWeb { get; }
string AppVersion { get; }
}
public enum PlatformType
{
Web,
Android,
iOS,
Windows,
MacCatalyst
}
// MyHybridApp.Maui/Services/MauiPlatformService.cs
namespace MyHybridApp.Maui.Services;
public class MauiPlatformService : IPlatformService
{
public PlatformType CurrentPlatform =>
#if ANDROID
PlatformType.Android;
#elif IOS
PlatformType.iOS;
#elif WINDOWS
PlatformType.Windows;
#elif MACCATALYST
PlatformType.MacCatalyst;
#else
PlatformType.Web;
#endif
public bool IsNativeApp => true;
public bool IsWeb => false;
public string AppVersion => AppInfo.Current.VersionString;
}
// MyHybridApp.Web/Services/WebPlatformService.cs
namespace MyHybridApp.Web.Services;
public class WebPlatformService : IPlatformService
{
public PlatformType CurrentPlatform => PlatformType.Web;
public bool IsNativeApp => false;
public bool IsWeb => true;
public string AppVersion => "1.0.0"; // อ่านจาก config
}
จากนั้นเอาไปใช้ใน Razor component เพื่อปรับ UI ตามแพลตฟอร์มได้เลย:
@* MyHybridApp.Shared/Components/Layout/MainLayout.razor *@
@inherits LayoutComponentBase
@inject IPlatformService PlatformService
<div class="app-container @GetPlatformClass()">
<aside class="sidebar @(PlatformService.IsNativeApp ? "native-sidebar" : "web-sidebar")">
<NavMenu />
</aside>
<main class="main-content">
<div class="top-bar">
@if (PlatformService.IsWeb)
{
<!-- แสดง breadcrumb เฉพาะบนเว็บ -->
<nav class="breadcrumb">
<BreadcrumbTrail />
</nav>
}
@if (PlatformService.IsNativeApp)
{
<!-- แสดงสถานะการเชื่อมต่อเฉพาะบน native app -->
<ConnectionStatus />
}
</div>
<article class="content">
@Body
</article>
</main>
</div>
@code {
private string GetPlatformClass() => PlatformService.CurrentPlatform switch
{
PlatformType.Android => "platform-android",
PlatformType.iOS => "platform-ios",
PlatformType.Windows => "platform-windows",
PlatformType.MacCatalyst => "platform-mac",
_ => "platform-web"
};
}
การใช้ Abstractions สำหรับ Navigation
เรื่อง Navigation ต้องระวังนิดนึงครับ เพราะ behavior อาจต่างกันระหว่างเว็บกับมือถือ ข่าวดีคือ NavigationManager ของ Blazor ทำงานได้ทั้งบน BlazorWebView และ browser โดยไม่ต้องแก้อะไร แต่ถ้าต้องจัดการ deep linking หรือ back navigation ที่ซับซ้อนขึ้น ก็ควรสร้าง abstraction layer เพิ่ม
เทคนิคการเพิ่มประสิทธิภาพสำหรับ Blazor Hybrid Apps
แม้ Blazor Hybrid จะเร็วกว่า Blazor WebAssembly อยู่แล้ว (เพราะรันบน native .NET runtime) แต่ยังมีเทคนิคหลายอย่างที่ช่วยให้แอปลื่นไหลขึ้นอีก ผมรวบรวมเทคนิคที่ใช้บ่อยมาให้
1. Lazy Loading ด้วย Virtualization
สำหรับรายการข้อมูลขนาดใหญ่ ใช้ Virtualize component แทนการ render ทั้งหมดพร้อมกัน ความแตกต่างนี้ค่อนข้างชัดเจนเลย:
@* แทนที่จะ render ทั้งหมด *@
@* BAD: foreach loop ที่ render ทุก item *@
@foreach (var item in allItems)
{
<ItemCard Item="@item" />
}
@* GOOD: ใช้ Virtualize เพื่อ render เฉพาะ item ที่มองเห็น *@
<Virtualize Items="@allItems" Context="item" OverscanCount="5">
<ItemCard Item="@item" />
</Virtualize>
@* สำหรับข้อมูลจำนวนมากที่ต้องโหลดแบบ pagination *@
<Virtualize Context="item"
ItemsProvider="@LoadItems"
ItemSize="60"
OverscanCount="10">
<ItemContent>
<ItemCard Item="@item" />
</ItemContent>
<Placeholder>
<div class="item-skeleton" style="height: 60px;">
กำลังโหลด...
</div>
</Placeholder>
</Virtualize>
@code {
private async ValueTask<ItemsProviderResult<ItemModel>> LoadItems(
ItemsProviderRequest request)
{
var result = await DataService.GetItemsAsync(
request.StartIndex,
request.Count,
request.CancellationToken);
return new ItemsProviderResult<ItemModel>(
result.Items,
result.TotalCount);
}
}
2. AOT Compilation สำหรับ Performance สูงสุด
ใน .NET 9/10 คุณเปิด Native AOT compilation ได้สำหรับ Android และ iOS เพื่อลด startup time ตรงนี้ช่วยได้เยอะเลยนะครับ โดยเฉพาะบนอุปกรณ์ที่ไม่ได้แรงมาก:
<!-- เพิ่มใน .csproj สำหรับ Release builds -->
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<!-- สำหรับ Android -->
<RunAOTCompilation>true</RunAOTCompilation>
<AndroidEnableProfiledAot>true</AndroidEnableProfiledAot>
<!-- สำหรับ iOS -->
<MtouchInterpreter>-all</MtouchInterpreter>
<!-- Trimming เพื่อลดขนาดแอป -->
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>link</TrimMode>
<!-- เปิด NativeAOT สำหรับ Windows/macOS -->
<PublishAot>true</PublishAot>
</PropertyGroup>
3. ลดการใช้ JavaScript Interop ให้น้อยที่สุด
JavaScript interop มี overhead เพราะต้อง serialize/deserialize ข้อมูลข้าม WebView boundary ถ้าจำเป็นต้องใช้จริงๆ ลองทำตามนี้:
- Batch calls — รวมหลาย JS calls เป็นครั้งเดียว อย่าเรียกทีละอัน
- ใช้
[JSImport]/[JSExport]— มีประสิทธิภาพดีกว่าIJSRuntime.InvokeAsyncแบบเดิมๆ - Cache ผลลัพธ์ — ถ้าข้อมูลจาก JS ไม่เปลี่ยนบ่อย ให้ cache ไว้ฝั่ง C#
- ใช้
StreamReference— สำหรับข้อมูลขนาดใหญ่ เลี่ยงการ serialize เป็น JSON
4. StateHasChanged อย่างชาญฉลาด
เรื่องนี้หลายคนพลาดครับ อย่าเรียก StateHasChanged() บ่อยเกินไป มันจะทำให้ component re-render โดยไม่จำเป็น ใช้ ShouldRender() เพื่อควบคุมการ render และใช้ @key directive เพื่อช่วยให้ Blazor diffing algorithm ทำงานได้ดีขึ้น
5. ใช้ IMemoryCache สำหรับข้อมูลที่เรียกบ่อย
ข้อมูลที่ต้องเรียกบ่อยแต่ไม่ได้เปลี่ยนทันที ให้ใช้ memory cache ลดการเรียก API ซ้ำ:
// MyHybridApp.Shared/Services/CachedWeatherService.cs
using Microsoft.Extensions.Caching.Memory;
namespace MyHybridApp.Shared.Services;
public class CachedWeatherService : IWeatherService
{
private readonly IWeatherService _inner;
private readonly IMemoryCache _cache;
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
public CachedWeatherService(
IWeatherService inner,
IMemoryCache cache)
{
_inner = inner;
_cache = cache;
}
public async Task<List<WeatherForecast>> GetForecastsAsync()
{
return await _cache.GetOrCreateAsync(
"weather_forecasts",
async entry =>
{
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
return await _inner.GetForecastsAsync();
}) ?? new List<WeatherForecast>();
}
}
// ลงทะเบียนด้วย Decorator pattern ใน MauiProgram.cs
builder.Services.AddSingleton<MauiWeatherService>();
builder.Services.AddSingleton<IWeatherService>(sp =>
new CachedWeatherService(
sp.GetRequiredService<MauiWeatherService>(),
sp.GetRequiredService<IMemoryCache>()
));
builder.Services.AddMemoryCache();
HybridWebView กับ BlazorWebView: เมื่อไหร่ควรใช้อะไร?
ใน .NET 9 มี HybridWebView เพิ่มเข้ามาเป็นทางเลือกใหม่ หลายคนสับสนว่าควรเลือกใช้อันไหน ผมจะอธิบายให้ชัดเจนเลยครับ
BlazorWebView
- วัตถุประสงค์: โฮสต์ Blazor Razor components ภายใน native WebView
- Rendering: ใช้ Blazor renderer เพื่อ render Razor components เป็น HTML
- การเขียนโค้ด: ใช้ Razor syntax เต็มรูปแบบ component lifecycle, data binding, event handling ทุกอย่าง
- เหมาะสำหรับ: แอปที่สร้าง UI ใหม่ด้วย Blazor และต้องการแชร์ components กับเว็บ
- ข้อดี: ได้ full Blazor ecosystem, component libraries, routing system
HybridWebView
- วัตถุประสงค์: โฮสต์เว็บแอปที่มีอยู่แล้ว (HTML/CSS/JS) ภายใน native WebView พร้อม bridge สำหรับสื่อสารกับ C#
- Rendering: ใช้ browser engine ของ WebView โดยตรง ไม่มี Blazor renderer เข้ามาเกี่ยว
- การเขียนโค้ด: เขียน HTML/CSS/JS ตามปกติ ใช้
HybridWebView.SendRawMessageAsync()สื่อสารกับ C# - เหมาะสำหรับ: การ migrate เว็บแอปที่มีอยู่ (React, Vue, Angular) เข้า native app
- ข้อดี: ยืดหยุ่นมาก ใช้ JS framework อะไรก็ได้ ไม่ผูกกับ Blazor
สรุปสั้นๆ
ใช้ BlazorWebView เมื่อสร้างแอปใหม่ตั้งแต่ต้น หรือต้องการแชร์ UI components กับ Blazor Web App หรือทีมถนัด C# กับ Razor syntax
ใช้ HybridWebView เมื่อมีเว็บแอป React/Vue/Angular อยู่แล้วและต้องการนำมาใส่ native app หรือต้องการทำ progressive migration
และจริงๆ แล้ว คุณใช้ทั้งสองร่วมกันในแอปเดียวก็ได้นะครับ เช่น ใช้ BlazorWebView สำหรับหน้าที่สร้างใหม่ และ HybridWebView สำหรับหน้าที่ port มาจากเว็บแอปเดิม
กลยุทธ์การทดสอบสำหรับ Hybrid Apps
เอาจริงๆ นะครับ เรื่องการทดสอบ Blazor Hybrid apps ค่อนข้างท้าทาย เพราะต้องครอบคลุมทั้ง UI logic, business logic, platform-specific code และ integration ระหว่างทุกส่วน แต่ถ้าวางแผนดี มันก็ไม่ได้ยากอย่างที่คิด
1. Unit Testing สำหรับ Razor Components ด้วย bUnit
bUnit เป็นเฟรมเวิร์กยอดนิยมสำหรับทดสอบ Blazor components โดยไม่ต้องมี browser จริง ผมใช้มาตลอดและชอบมากครับ:
// MyHybridApp.Tests/Components/DataGridTests.cs
using Bunit;
using MyHybridApp.Shared.Components.Shared;
using Xunit;
namespace MyHybridApp.Tests.Components;
public class DataGridTests : TestContext
{
[Fact]
public void DataGrid_ShowsLoadingState_WhenIsLoadingIsTrue()
{
// Arrange & Act
var cut = RenderComponent<DataGrid<string>>(parameters =>
parameters
.Add(p => p.IsLoading, true)
.Add(p => p.RowTemplate,
item => builder => builder.AddContent(0, item))
);
// Assert
cut.Find(".loading-overlay").MarkupMatches(
@"<div class=""loading-overlay"">
<div class=""spinner""></div>
<p>กำลังโหลดข้อมูล...</p>
</div>");
}
[Fact]
public void DataGrid_ShowsEmptyMessage_WhenNoItems()
{
// Arrange & Act
var cut = RenderComponent<DataGrid<string>>(parameters =>
parameters
.Add(p => p.Items, new List<string>())
.Add(p => p.EmptyMessage, "ไม่มีข้อมูล")
.Add(p => p.RowTemplate,
item => builder => builder.AddContent(0, item))
);
// Assert
Assert.Contains("ไม่มีข้อมูล", cut.Markup);
}
[Fact]
public void DataGrid_PaginatesCorrectly()
{
// Arrange
var items = Enumerable.Range(1, 50)
.Select(i => $"Item {i}")
.ToList();
var cut = RenderComponent<DataGrid<string>>(parameters =>
parameters
.Add(p => p.Items, items)
.Add(p => p.PageSize, 10)
.Add(p => p.EnablePagination, true)
.Add(p => p.RowTemplate,
item => builder =>
{
builder.OpenElement(0, "td");
builder.AddContent(1, item);
builder.CloseElement();
})
);
// Assert - ตรวจสอบว่าแสดง 10 rows ต่อหน้า
var rows = cut.FindAll("tbody tr");
Assert.Equal(10, rows.Count);
// Act - กดปุ่มถัดไป
cut.Find("button:last-child").Click();
// Assert - ตรวจสอบว่าแสดงหน้า 2
Assert.Contains("หน้า 2 / 5", cut.Markup);
}
[Fact]
public void DataGrid_RaisesOnRowSelected_WhenRowClicked()
{
// Arrange
var items = new List<string> { "Item 1", "Item 2", "Item 3" };
string? selectedItem = null;
var cut = RenderComponent<DataGrid<string>>(parameters =>
parameters
.Add(p => p.Items, items)
.Add(p => p.OnRowSelected,
EventCallback.Factory.Create<string>(
this, item => selectedItem = item))
.Add(p => p.RowTemplate,
item => builder =>
{
builder.OpenElement(0, "td");
builder.AddContent(1, item);
builder.CloseElement();
})
);
// Act - คลิกแถวที่สอง
cut.FindAll("tbody tr")[1].Click();
// Assert
Assert.Equal("Item 2", selectedItem);
}
}
2. Integration Testing สำหรับ Services
ทดสอบ service layer โดยใช้ mock objects ครับ ตรงนี้สำคัญมาก เพราะช่วยให้มั่นใจว่า component กับ service ทำงานร่วมกันได้ถูกต้อง:
// MyHybridApp.Tests/Services/WeatherPageIntegrationTests.cs
using Bunit;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using MyHybridApp.Shared.Components.Pages;
using MyHybridApp.Shared.Models;
using MyHybridApp.Shared.Services;
using Xunit;
namespace MyHybridApp.Tests.Services;
public class WeatherPageIntegrationTests : TestContext
{
[Fact]
public async Task WeatherPage_DisplaysForecasts_FromService()
{
// Arrange
var mockWeatherService = new Mock<IWeatherService>();
mockWeatherService
.Setup(s => s.GetForecastsAsync())
.ReturnsAsync(new List<WeatherForecast>
{
new()
{
Date = new DateTime(2025, 1, 15),
TemperatureC = 28,
Summary = "อากาศร้อน"
},
new()
{
Date = new DateTime(2025, 1, 16),
TemperatureC = 25,
Summary = "มีเมฆบางส่วน"
}
});
Services.AddSingleton(mockWeatherService.Object);
// Act
var cut = RenderComponent<Weather>();
// รอให้ OnInitializedAsync ทำงานเสร็จ
cut.WaitForState(() =>
cut.FindAll("tbody tr").Count > 0);
// Assert
Assert.Contains("อากาศร้อน", cut.Markup);
Assert.Contains("มีเมฆบางส่วน", cut.Markup);
var rows = cut.FindAll("tbody tr");
Assert.Equal(2, rows.Count);
}
}
3. Platform-specific Testing
สำหรับ platform-specific code ให้ใช้ device test runners ของ .NET MAUI หรือทดสอบ E2E ด้วย Appium:
- Unit tests: ใช้ bUnit สำหรับ Razor components และ xUnit/NUnit สำหรับ services
- Integration tests: ใช้ bUnit กับ mock services ทดสอบ component-service interaction
- UI tests: ใช้ Appium หรือ .NET MAUI DeviceTests ทดสอบบนอุปกรณ์จริงหรือ emulator
- Performance tests: ใช้ BenchmarkDotNet สำหรับ benchmarking critical paths
กลยุทธ์ที่ผมแนะนำคือเขียนโค้ดส่วนใหญ่ให้อยู่ใน Shared library แล้วใช้ interface abstraction เพื่อให้ mock ได้ง่าย แบบนี้จะเขียน unit tests ครอบคลุมได้มากโดยไม่ต้องพึ่งอุปกรณ์จริง
แนวทางปฏิบัติที่ดีที่สุดและข้อผิดพลาดที่พบบ่อย
ส่วนนี้สำคัญมากครับ รวบรวมบทเรียนจากการทำงานจริงกับ Blazor Hybrid apps:
แนวทางปฏิบัติที่ดี
- ใช้ Interface Abstraction เสมอ: อย่าเรียก platform-specific code ตรงๆ จาก Razor components ให้กำหนด interface ใน Shared library แล้ว implement แยกในแต่ละ platform project ไม่ใช่แค่ช่วยเรื่อง code sharing แต่ทำให้ทดสอบง่ายมากด้วย
- จัดโครงสร้าง CSS อย่างเป็นระบบ: ใช้ CSS isolation สำหรับ component-specific styles เก็บ global styles ใน RCL และใช้ CSS custom properties สำหรับ theming เพื่อปรับ look and feel ตามแพลตฟอร์มได้ง่าย
- จัดการ State อย่างมีโครงสร้าง: ใช้ state container pattern หรือ Fluxor แทนที่จะพึ่ง component state อย่างเดียว โดยเฉพาะเมื่อต้องแชร์ state ข้ามหลาย components
- Handle ทุก Exception: ใน Blazor Hybrid ถ้า unhandled exception เกิดขึ้นตอน render แอปจะค้างเลย ใช้
ErrorBoundaryครอบทุกส่วนที่อาจเกิด error และ implement global error handling ผ่านILogger - ทดสอบบนทุกแพลตฟอร์มตั้งแต่เนิ่นๆ: อย่ารอจนใกล้ release! WebView behavior อาจต่างกันบ้างในแต่ละแพลตฟอร์ม ทดสอบบน Android, iOS และ Windows อย่างสม่ำเสมอตลอดการพัฒนา
- ใช้
@keyสำหรับ List Rendering: เมื่อ render list ใช้@keyเสมอ มันช่วยให้ Blazor diffing algorithm ระบุ element ได้ถูกต้อง ป้องกันบั๊กเรื่องลำดับสับสนและเพิ่มประสิทธิภาพ
ข้อผิดพลาดที่พบบ่อย
- ไม่จัดการ async operations อย่างถูกต้อง: Native API calls เป็น async ที่อาจใช้เวลานาน ต้องแสดง loading state เสมอ และจัดการ cancellation อย่างเหมาะสม โดยเฉพาะเมื่อผู้ใช้ navigate ออกระหว่างที่ operation ยังทำงานอยู่
- ใช้ JavaScript Interop มากเกินไป: หลายคนเคยชินจาก Blazor WASM ที่ต้องพึ่ง JS interop แต่ใน Blazor Hybrid เข้าถึง native API ได้ตรงๆ ผ่าน .NET MAUI APIs ไม่ต้องผ่าน JavaScript
- ไม่ขอ Permission ก่อนเข้าถึง Native API: ทุกครั้งที่เข้าถึง API ที่ต้องใช้ permission (กล้อง, GPS, ไฟล์) ต้องตรวจสอบและขอก่อนเสมอ ไม่งั้นแอป crash แน่นอน
- ไม่คำนึงถึงขนาด WebView: บนมือถือ viewport เล็กกว่า desktop มาก ต้อง design UI ให้ responsive และทดสอบบนหน้าจอหลายขนาด
- Memory leak จาก Event Handlers: เมื่อ subscribe event จาก services ต้อง unsubscribe ใน
Dispose()เสมอ ไม่งั้น component จะไม่ถูก garbage collect - ไม่ใช้ CancellationToken: สำหรับ long-running operations ให้ใช้
CancellationTokenที่ถูก cancel ในDispose()เพื่อป้องกัน resource leak
ตัวอย่างการจัดการ Lifecycle ที่ถูกต้อง
ตัวอย่างนี้รวมทุก best practice ที่พูดถึงไว้ — ErrorBoundary, CancellationToken, proper disposal ครบถ้วน:
@page "/realtime-data"
@implements IAsyncDisposable
@inject IDataStreamService DataStream
@inject ILogger<RealtimeData> Logger
<h3>ข้อมูลเรียลไทม์</h3>
<ErrorBoundary @ref="errorBoundary">
<ChildContent>
@if (latestData is not null)
{
<div class="data-card">
<p>ค่าล่าสุด: @latestData.Value</p>
<p>อัปเดตเมื่อ: @latestData.Timestamp.ToString("HH:mm:ss")</p>
</div>
}
</ChildContent>
<ErrorContent Context="ex">
<div class="alert alert-danger">
<p>เกิดข้อผิดพลาดในการแสดงผล</p>
<button @onclick="() => errorBoundary?.Recover()">ลองใหม่</button>
</div>
</ErrorContent>
</ErrorBoundary>
@code {
private ErrorBoundary? errorBoundary;
private DataPoint? latestData;
private CancellationTokenSource? cts;
protected override async Task OnInitializedAsync()
{
cts = new CancellationTokenSource();
try
{
// subscribe ไปยัง data stream
await foreach (var data in DataStream
.GetStreamAsync(cts.Token))
{
latestData = data;
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
// ปกติเมื่อ component ถูก dispose
Logger.LogDebug("Data stream cancelled");
}
catch (Exception ex)
{
Logger.LogError(ex,
"เกิดข้อผิดพลาดในการรับข้อมูล realtime");
}
}
public async ValueTask DisposeAsync()
{
if (cts is not null)
{
await cts.CancelAsync();
cts.Dispose();
}
}
}
การจัดการ Offline Support
สำหรับแอปมือถือ การรองรับ offline ถือเป็นเรื่องสำคัญมาก Blazor Hybrid มีข้อได้เปรียบตรงที่โค้ดทั้งหมดรันบน native process อยู่แล้ว ไม่ต้องดาวน์โหลด WebAssembly หรือเชื่อมต่อกับ server เหมือน Blazor Server แอปจึงทำงานได้แม้ไม่มีเน็ต ตราบใดที่ข้อมูลถูก cache ไว้ในเครื่อง
สิ่งที่ต้องจัดการเพิ่มก็คือ data synchronization ระหว่าง local storage กับ remote API เมื่อกลับมาออนไลน์ ซึ่งสามารถใช้ SQLite ผ่าน Microsoft.EntityFrameworkCore.Sqlite เป็น local database แล้วสร้าง sync service ตรวจสอบ connectivity และซิงก์ข้อมูลอัตโนมัติ
การ Deploy และ Publish
เมื่อแอปพร้อมแล้ว ขั้นตอนสุดท้ายก็คือ publish ไปยังแต่ละ platform store ครับ:
- Android: ใช้
dotnet publish -f net9.0-android -c Releaseจะได้ไฟล์ AAB สำหรับอัปโหลดขึ้น Google Play Store - iOS: ใช้
dotnet publish -f net9.0-ios -c Releaseบน macOS ที่ติดตั้ง Xcode แล้วอัปโหลดผ่าน Transporter ไป App Store Connect - Windows: publish เป็น MSIX package สำหรับ Microsoft Store หรือ sideloading
- Web: publish โปรเจกต์ Blazor Web App ตามปกติไปยัง Azure, AWS หรือ hosting provider อื่นๆ
แนะนำให้ใช้ CI/CD pipeline อย่าง GitHub Actions หรือ Azure DevOps ช่วยอัตโนมัติกระบวนการ build, test และ publish สำหรับทุกแพลตฟอร์มพร้อมกัน จะช่วยประหยัดเวลาได้เยอะมาก
สรุป
.NET MAUI Blazor Hybrid เป็นเทคโนโลยีที่ทรงพลังจริงๆ สำหรับการพัฒนาแอปข้ามแพลตฟอร์มด้วย C# และ Razor syntax แชร์โค้ด UI กับ business logic ได้มากถึง 80-90% ระหว่างเว็บ, Android, iOS, Windows และ macOS
จุดแข็งหลักๆ ของ Blazor Hybrid:
- เข้าถึง Native API อย่างเต็มรูปแบบ — ผ่าน .NET MAUI APIs ไม่ต้องพึ่ง JavaScript bridges
- ประสิทธิภาพสูง — C# รันบน native .NET runtime ไม่ใช่ WebAssembly
- Code Sharing ที่แท้จริง — ผ่าน Razor Class Library ใช้ได้ทั้งเว็บและมือถือ
- ระบบนิเวศที่สมบูรณ์ — NuGet packages, Blazor component libraries, .NET tooling ที่ mature
กุญแจสำคัญคือ: ออกแบบ architecture ที่แยก concerns ชัดเจน ใช้ interface abstraction สำหรับ platform-specific code เก็บโค้ดส่วนใหญ่ไว้ใน Shared library เขียนเทสต์ให้ครอบคลุม และทดสอบบนทุกแพลตฟอร์มเป้าหมายอย่างสม่ำเสมอ
ด้วย .NET 9 และ .NET 10 ที่เพิ่มคุณสมบัติใหม่ๆ อย่างต่อเนื่อง ทั้ง HybridWebView, AOT compilation ที่ดีขึ้น และ tooling ที่ปรับปรุงมาเรื่อยๆ Blazor Hybrid กำลังกลายเป็นตัวเลือกอันดับต้นๆ สำหรับทีม .NET ที่ต้องการสร้างแอปข้ามแพลตฟอร์มคุณภาพสูงจาก codebase เดียว ถ้าคุณเป็นนักพัฒนา .NET ที่อยากขยายไป mobile development โดยไม่ต้องทิ้งทักษะที่มี Blazor Hybrid น่าจะเป็นเส้นทางที่คุ้มค่าที่สุดครับ