.NET MAUI SQLite: สร้างแอป Offline-First พร้อม CRUD, Sync และ EF Core

เรียนรู้การสร้างแอปมือถือ Offline-First ด้วย .NET MAUI กับ SQLite เปรียบเทียบ sqlite-net-pcl vs EF Core พร้อมตัวอย่าง CRUD, ระบบ sync ข้อมูล และเทคนิค optimize ประสิทธิภาพ รองรับ .NET MAUI 10 (2026)

ทำไมแอปมือถือต้องรองรับ Offline-First?

พูดตรงๆ เลยนะครับ — เรามักจะคิดว่าอินเทอร์เน็ตมันอยู่ทุกที่ แต่ในชีวิตจริง ผู้ใช้แอปมือถือเจอสถานการณ์ไม่มีสัญญาณอยู่บ่อยกว่าที่คิด ในลิฟต์, ใต้ดิน, ต่างจังหวัด, หรือแม้แต่บนเครื่องบิน แอปที่พังทันทีที่ขาดเน็ตมันไม่ใช่แอปที่ดีเลย

ดังนั้นสถาปัตยกรรม Offline-First จึงไม่ใช่แค่ทางเลือกอีกต่อไป แต่เป็นสิ่งจำเป็น

Offline-First หมายความว่าแอปออกแบบมาให้ทำงานได้โดยไม่ต้องเชื่อมต่ออินเทอร์เน็ตเป็นหลัก ข้อมูลจะถูกเก็บใน local database บนอุปกรณ์ก่อน แล้วค่อย sync ขึ้น server เมื่อมีสัญญาณ ซึ่ง SQLite เป็นตัวเลือกอันดับหนึ่งสำหรับ local database ใน .NET MAUI เพราะมันเบา เสถียร และรองรับทุกแพลตฟอร์มที่ .NET MAUI ทำงานได้

ในบทความนี้ผมจะพาคุณผ่านทุกขั้นตอนของการสร้างแอป Offline-First ด้วย .NET MAUI กับ SQLite ตั้งแต่การเลือกไลบรารี, ตั้งค่าโปรเจกต์, ทำ CRUD operations, ใช้ Entity Framework Core, sync ข้อมูลกับ backend API ไปจนถึงเทคนิคเพิ่มประสิทธิภาพ ทั้งหมดนี้รองรับ .NET MAUI 10 (LTS) ที่เป็นเวอร์ชันล่าสุดในปี 2026 ครับ

sqlite-net-pcl vs Entity Framework Core: เลือกอะไรดี?

ก่อนจะลงมือเขียนโค้ด คุณต้องตัดสินใจก่อนว่าจะใช้ไลบรารีตัวไหนในการจัดการ SQLite มีสองตัวเลือกหลักครับ

sqlite-net-pcl — เบาและเรียบง่าย

sqlite-net-pcl เป็น lightweight ORM ที่ได้รับความนิยมสูงมากในหมู่นักพัฒนา .NET MAUI ใช้ attribute-based mapping กำหนด table schema ได้ง่ายๆ ด้วย [Table], [PrimaryKey], [AutoIncrement] และอื่นๆ

  • ข้อดี: ติดตั้งง่าย, เบา, เรียนรู้เร็ว, overhead น้อย เหมาะกับแอปขนาดเล็ก-กลาง
  • ข้อเสีย: ไม่รองรับ relationships (one-to-many, many-to-many) ได้ดีนัก ต้องพึ่ง SQLiteNetExtensions ซึ่งไม่ค่อยได้อัปเดตแล้ว, ไม่มีระบบ migration

Entity Framework Core — เต็มพิกัดพร้อม Migration

EF Core เป็น full-featured ORM จาก Microsoft รองรับ relationships, LINQ queries, code-first migrations และ change tracking ครบวงจร

  • ข้อดี: รองรับ relationships ครบ, มี migration system, ใช้ LINQ ได้เต็มที่ ถ้าเคยใช้ EF Core ใน web project มาแล้วก็แชร์โค้ดได้เลย
  • ข้อเสีย: overhead สูงกว่า, ขนาดแอปใหญ่ขึ้น, มีความซับซ้อนมากกว่า

ตารางเปรียบเทียบ

คุณสมบัติ sqlite-net-pcl EF Core + SQLite
ความซับซ้อนในการติดตั้งต่ำปานกลาง-สูง
ORMLightweightFull-featured
Relationshipsจำกัดครบถ้วน
Migrationไม่รองรับรองรับ
LINQพื้นฐานเต็มรูปแบบ
Overheadน้อยมากมากกว่า
เหมาะกับแอปเล็ก-กลางแอปกลาง-ใหญ่

คำแนะนำจากประสบการณ์ผม: ถ้าแอปของคุณมี data model ไม่ซับซ้อนมากและไม่ต้องการ migration ใช้ sqlite-net-pcl จะง่ายและเร็วกว่าเยอะ แต่ถ้า data model มัน complex มี relationships เยอะ หรือต้องแชร์ data layer กับ web project ให้ไป EF Core เลยครับ

วิธีที่ 1: ใช้ sqlite-net-pcl สำหรับ Local Database

ติดตั้ง NuGet Package

เริ่มต้นด้วยการติดตั้ง package ที่จำเป็นกันก่อน:

dotnet add package sqlite-net-pcl
dotnet add package SQLitePCLRaw.bundle_green

SQLitePCLRaw.bundle_green เป็น native SQLite library ที่จำเป็นสำหรับทุกแพลตฟอร์ม ต้องติดตั้งคู่กันเสมอนะครับ อย่าลืม!

กำหนด Constants สำหรับ Database Path

namespace MyApp.Data;

public static class DatabaseConstants
{
    public const string DatabaseFilename = "myapp.db3";

    public const SQLite.SQLiteOpenFlags Flags =
        SQLite.SQLiteOpenFlags.ReadWrite |
        SQLite.SQLiteOpenFlags.Create |
        SQLite.SQLiteOpenFlags.SharedCache;

    public static string DatabasePath =>
        Path.Combine(
            FileSystem.AppDataDirectory,
            DatabaseFilename);
}

สังเกตว่าเราใช้ FileSystem.AppDataDirectory ซึ่งเป็น API ของ .NET MAUI ที่จะชี้ไปยัง app-specific directory ที่ปลอดภัยบนทุกแพลตฟอร์ม ตรงนี้สำคัญมากเพราะถ้าใช้ path ผิด iOS อาจลบข้อมูลเราทิ้งได้

สร้าง Model

using SQLite;

namespace MyApp.Models;

[Table("tasks")]
public class TaskItem
{
    [PrimaryKey, AutoIncrement]
    public int Id { get; set; }

    [MaxLength(200), NotNull]
    public string Title { get; set; } = string.Empty;

    public string? Description { get; set; }

    public bool IsCompleted { get; set; }

    [Indexed]
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    public DateTime? CompletedAt { get; set; }

    // สำหรับ sync กับ server
    public string? ServerId { get; set; }
    public DateTime LastModified { get; set; } = DateTime.UtcNow;
    public bool IsSynced { get; set; }
}

สร้าง Database Service

ตรงนี้เป็นหัวใจของการทำงานกับ SQLite เลยครับ ผมจะสร้าง service ที่จัดการ connection และ CRUD operations ทั้งหมด:

using SQLite;
using MyApp.Models;

namespace MyApp.Data;

public class TaskDatabase
{
    private SQLiteAsyncConnection? _database;

    private async Task<SQLiteAsyncConnection> GetConnectionAsync()
    {
        if (_database is not null)
            return _database;

        _database = new SQLiteAsyncConnection(
            DatabaseConstants.DatabasePath,
            DatabaseConstants.Flags);

        // เปิด WAL mode เพื่อประสิทธิภาพที่ดีขึ้น
        await _database.ExecuteAsync("PRAGMA journal_mode=WAL;");

        await _database.CreateTableAsync<TaskItem>();
        return _database;
    }

    // CREATE
    public async Task<int> SaveTaskAsync(TaskItem task)
    {
        var db = await GetConnectionAsync();
        task.LastModified = DateTime.UtcNow;
        task.IsSynced = false;

        if (task.Id != 0)
            return await db.UpdateAsync(task);
        else
            return await db.InsertAsync(task);
    }

    // READ - ทั้งหมด
    public async Task<List<TaskItem>> GetTasksAsync()
    {
        var db = await GetConnectionAsync();
        return await db.Table<TaskItem>()
            .OrderByDescending(t => t.CreatedAt)
            .ToListAsync();
    }

    // READ - เฉพาะที่ยังไม่เสร็จ
    public async Task<List<TaskItem>> GetPendingTasksAsync()
    {
        var db = await GetConnectionAsync();
        return await db.Table<TaskItem>()
            .Where(t => !t.IsCompleted)
            .OrderByDescending(t => t.CreatedAt)
            .ToListAsync();
    }

    // READ - ตาม Id
    public async Task<TaskItem?> GetTaskByIdAsync(int id)
    {
        var db = await GetConnectionAsync();
        return await db.Table<TaskItem>()
            .FirstOrDefaultAsync(t => t.Id == id);
    }

    // DELETE
    public async Task<int> DeleteTaskAsync(TaskItem task)
    {
        var db = await GetConnectionAsync();
        return await db.DeleteAsync(task);
    }

    // สำหรับ Sync: ดึงรายการที่ยังไม่ได้ sync
    public async Task<List<TaskItem>> GetUnsyncedTasksAsync()
    {
        var db = await GetConnectionAsync();
        return await db.Table<TaskItem>()
            .Where(t => !t.IsSynced)
            .ToListAsync();
    }

    // สำหรับ Sync: อัปเดตสถานะ sync
    public async Task MarkAsSyncedAsync(int id, string serverId)
    {
        var db = await GetConnectionAsync();
        var task = await GetTaskByIdAsync(id);
        if (task is not null)
        {
            task.IsSynced = true;
            task.ServerId = serverId;
            await db.UpdateAsync(task);
        }
    }
}

ลงทะเบียนใน DI Container

ขั้นตอนนี้ลืมไม่ได้เลยครับ ต้อง register service ใน DI:

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

    // ลงทะเบียน Database Service เป็น Singleton
    builder.Services.AddSingleton<TaskDatabase>();

    // ลงทะเบียน ViewModels
    builder.Services.AddTransient<TaskListViewModel>();
    builder.Services.AddTransient<TaskDetailViewModel>();

    // ลงทะเบียน Pages
    builder.Services.AddTransient<TaskListPage>();
    builder.Services.AddTransient<TaskDetailPage>();

    return builder.Build();
}

ใช้งานใน ViewModel ด้วย CommunityToolkit.Mvvm

ผมแนะนำให้ใช้ CommunityToolkit.Mvvm เพราะช่วยลด boilerplate code ได้เยอะมาก (จริงๆ ถ้าไม่ใช้จะเขียน INotifyPropertyChanged เองจนมือล้าเลย):

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MyApp.Data;
using MyApp.Models;
using System.Collections.ObjectModel;

namespace MyApp.ViewModels;

public partial class TaskListViewModel : ObservableObject
{
    private readonly TaskDatabase _database;

    public TaskListViewModel(TaskDatabase database)
    {
        _database = database;
    }

    [ObservableProperty]
    private ObservableCollection<TaskItem> tasks = new();

    [ObservableProperty]
    private bool isRefreshing;

    [RelayCommand]
    private async Task LoadTasksAsync()
    {
        try
        {
            IsRefreshing = true;
            var items = await _database.GetTasksAsync();
            Tasks = new ObservableCollection<TaskItem>(items);
        }
        finally
        {
            IsRefreshing = false;
        }
    }

    [RelayCommand]
    private async Task ToggleCompletedAsync(TaskItem task)
    {
        task.IsCompleted = !task.IsCompleted;
        task.CompletedAt = task.IsCompleted ? DateTime.UtcNow : null;
        await _database.SaveTaskAsync(task);
        await LoadTasksAsync();
    }

    [RelayCommand]
    private async Task DeleteTaskAsync(TaskItem task)
    {
        await _database.DeleteTaskAsync(task);
        Tasks.Remove(task);
    }
}

วิธีที่ 2: ใช้ Entity Framework Core + SQLite

ถ้าแอปของคุณต้องการ relationships ที่ซับซ้อนกว่า หรือคุณคุ้นเคยกับ EF Core อยู่แล้ว มาดูวิธีนี้กันครับ

ติดตั้ง NuGet Packages

dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Design

สร้าง Entity Models

สิ่งที่ต่างจาก sqlite-net-pcl อย่างชัดเจนคือ EF Core จัดการ navigation properties ให้เราอย่างสวยงาม:

namespace MyApp.Entities;

public class Project
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string? Description { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    // Navigation property — EF Core จัดการ relationships ให้
    public ICollection<TaskItem> Tasks { get; set; } = new List<TaskItem>();
}

public class TaskItem
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string? Description { get; set; }
    public bool IsCompleted { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public DateTime? CompletedAt { get; set; }
    public DateTime LastModified { get; set; } = DateTime.UtcNow;
    public bool IsSynced { get; set; }

    // Foreign key
    public int ProjectId { get; set; }
    public Project Project { get; set; } = null!;
}

สร้าง DbContext

using Microsoft.EntityFrameworkCore;
using MyApp.Entities;

namespace MyApp.Data;

public class AppDbContext : DbContext
{
    public DbSet<Project> Projects => Set<Project>();
    public DbSet<TaskItem> Tasks => Set<TaskItem>();

    private readonly string _dbPath;

    public AppDbContext()
    {
        _dbPath = Path.Combine(
            FileSystem.AppDataDirectory, "myapp_ef.db3");
    }

    protected override void OnConfiguring(
        DbContextOptionsBuilder optionsBuilder)
    {
        // สำคัญมาก: ต้อง Init สำหรับ iOS
        SQLitePCL.Batteries_V2.Init();

        optionsBuilder.UseSqlite($"Data Source={_dbPath}");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // กำหนด index เพื่อประสิทธิภาพ
        modelBuilder.Entity<TaskItem>()
            .HasIndex(t => t.IsCompleted);

        modelBuilder.Entity<TaskItem>()
            .HasIndex(t => t.IsSynced);

        modelBuilder.Entity<TaskItem>()
            .HasIndex(t => t.CreatedAt);

        // กำหนด relationship
        modelBuilder.Entity<Project>()
            .HasMany(p => p.Tasks)
            .WithOne(t => t.Project)
            .HasForeignKey(t => t.ProjectId)
            .OnDelete(DeleteBehavior.Cascade);
    }
}

จัดการ Migration บน Mobile

ตรงนี้เป็นจุดที่นักพัฒนาหลายคนสับสนครับ เพราะการใช้ EF Core migrations บนแอปมือถือมันต่างจาก web app พอสมควร เราไม่สามารถรัน dotnet ef database update บนอุปกรณ์ได้ วิธีที่ผมแนะนำคือใช้ EnsureCreated หรือ apply migrations ตอนแอปเปิด:

// DatabaseInitializer.cs
namespace MyApp.Data;

public class DatabaseInitializer
{
    private readonly AppDbContext _context;

    public DatabaseInitializer(AppDbContext context)
    {
        _context = context;
    }

    public async Task InitializeAsync()
    {
        // วิธีที่ 1: EnsureCreated — ง่าย แต่ไม่รองรับ migration
        // await _context.Database.EnsureCreatedAsync();

        // วิธีที่ 2: Apply migrations — แนะนำสำหรับ production
        await _context.Database.MigrateAsync();
    }

    public async Task SeedDataAsync()
    {
        if (!await _context.Projects.AnyAsync())
        {
            _context.Projects.Add(new Entities.Project
            {
                Name = "โปรเจกต์ตัวอย่าง",
                Description = "โปรเจกต์เริ่มต้นสำหรับทดสอบ"
            });
            await _context.SaveChangesAsync();
        }
    }
}

ลงทะเบียนใน DI Container

// MauiProgram.cs
builder.Services.AddDbContext<AppDbContext>();
builder.Services.AddTransient<DatabaseInitializer>();

// ใน App.xaml.cs — เรียก initialize ตอนแอปเปิด
public partial class App : Application
{
    public App(DatabaseInitializer initializer)
    {
        InitializeComponent();

        Task.Run(async () =>
        {
            await initializer.InitializeAsync();
            await initializer.SeedDataAsync();
        });
    }
}

CRUD ด้วย EF Core ใน Repository Pattern

สำหรับคนที่ชอบใช้ Repository Pattern (ซึ่งผมว่าเหมาะกับแอปที่ต้อง sync ข้อมูลมาก เพราะแยก concern ได้ชัดเจน) นี่คือตัวอย่างครับ:

using Microsoft.EntityFrameworkCore;
using MyApp.Entities;

namespace MyApp.Data;

public class TaskRepository
{
    private readonly AppDbContext _context;

    public TaskRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<List<TaskItem>> GetAllAsync(int projectId)
    {
        return await _context.Tasks
            .Where(t => t.ProjectId == projectId)
            .OrderByDescending(t => t.CreatedAt)
            .ToListAsync();
    }

    public async Task<TaskItem?> GetByIdAsync(int id)
    {
        return await _context.Tasks
            .Include(t => t.Project)
            .FirstOrDefaultAsync(t => t.Id == id);
    }

    public async Task<TaskItem> CreateAsync(TaskItem task)
    {
        task.LastModified = DateTime.UtcNow;
        task.IsSynced = false;
        _context.Tasks.Add(task);
        await _context.SaveChangesAsync();
        return task;
    }

    public async Task UpdateAsync(TaskItem task)
    {
        task.LastModified = DateTime.UtcNow;
        task.IsSynced = false;
        _context.Tasks.Update(task);
        await _context.SaveChangesAsync();
    }

    public async Task DeleteAsync(int id)
    {
        var task = await _context.Tasks.FindAsync(id);
        if (task is not null)
        {
            _context.Tasks.Remove(task);
            await _context.SaveChangesAsync();
        }
    }

    public async Task<List<TaskItem>> GetUnsyncedAsync()
    {
        return await _context.Tasks
            .Where(t => !t.IsSynced)
            .ToListAsync();
    }
}

การสร้างระบบ Sync กับ Backend API

มาถึงส่วนที่สำคัญที่สุดแล้วครับ หัวใจของ Offline-First ก็คือระบบ sync ที่เชื่อถือได้นั่นเอง ผมจะสร้าง sync service ที่จัดการทั้งการ push ข้อมูลจาก local ไป server และ pull ข้อมูลใหม่กลับมา

ออกแบบ Sync Service

using System.Net.Http.Json;
using MyApp.Models;
using MyApp.Data;

namespace MyApp.Services;

public class SyncService
{
    private readonly TaskDatabase _localDb;
    private readonly HttpClient _httpClient;
    private readonly IConnectivity _connectivity;

    public SyncService(
        TaskDatabase localDb,
        HttpClient httpClient,
        IConnectivity connectivity)
    {
        _localDb = localDb;
        _httpClient = httpClient;
        _connectivity = connectivity;
    }

    public bool IsOnline =>
        _connectivity.NetworkAccess == NetworkAccess.Internet;

    public async Task<SyncResult> SyncAsync()
    {
        if (!IsOnline)
            return new SyncResult(false, "ไม่มีการเชื่อมต่ออินเทอร์เน็ต");

        var result = new SyncResult(true);

        try
        {
            // ขั้นตอน 1: Push — ส่งข้อมูลที่ยังไม่ได้ sync ไป server
            await PushLocalChangesAsync(result);

            // ขั้นตอน 2: Pull — ดึงข้อมูลใหม่จาก server
            await PullRemoteChangesAsync(result);
        }
        catch (Exception ex)
        {
            result.IsSuccess = false;
            result.Message = $"Sync ล้มเหลว: {ex.Message}";
        }

        return result;
    }

    private async Task PushLocalChangesAsync(SyncResult result)
    {
        var unsyncedItems = await _localDb.GetUnsyncedTasksAsync();

        foreach (var item in unsyncedItems)
        {
            try
            {
                HttpResponseMessage response;

                if (string.IsNullOrEmpty(item.ServerId))
                {
                    // สร้างใหม่บน server
                    response = await _httpClient.PostAsJsonAsync(
                        "api/tasks", item);
                }
                else
                {
                    // อัปเดตบน server
                    response = await _httpClient.PutAsJsonAsync(
                        $"api/tasks/{item.ServerId}", item);
                }

                if (response.IsSuccessStatusCode)
                {
                    var serverItem = await response.Content
                        .ReadFromJsonAsync<TaskItem>();
                    if (serverItem is not null)
                    {
                        await _localDb.MarkAsSyncedAsync(
                            item.Id, serverItem.ServerId!);
                        result.PushedCount++;
                    }
                }
            }
            catch (Exception ex)
            {
                result.Errors.Add(
                    $"Push ล้มเหลวสำหรับ item {item.Id}: {ex.Message}");
            }
        }
    }

    private async Task PullRemoteChangesAsync(SyncResult result)
    {
        try
        {
            var lastSync = Preferences.Get("last_sync",
                DateTime.MinValue.ToString("O"));

            var remoteItems = await _httpClient
                .GetFromJsonAsync<List<TaskItem>>(
                    $"api/tasks?modifiedAfter={lastSync}");

            if (remoteItems is not null)
            {
                foreach (var remoteItem in remoteItems)
                {
                    remoteItem.IsSynced = true;
                    await _localDb.SaveTaskAsync(remoteItem);
                    result.PulledCount++;
                }
            }

            Preferences.Set("last_sync",
                DateTime.UtcNow.ToString("O"));
        }
        catch (Exception ex)
        {
            result.Errors.Add($"Pull ล้มเหลว: {ex.Message}");
        }
    }
}

public class SyncResult
{
    public bool IsSuccess { get; set; }
    public string? Message { get; set; }
    public int PushedCount { get; set; }
    public int PulledCount { get; set; }
    public List<string> Errors { get; set; } = new();

    public SyncResult(bool isSuccess, string? message = null)
    {
        IsSuccess = isSuccess;
        Message = message;
    }
}

ตั้งค่า Background Sync อัตโนมัติ

อันนี้เป็นฟีเจอร์ที่ผมว่าขาดไม่ได้เลยสำหรับแอป Offline-First ครับ เราจะใช้ IConnectivity ของ .NET MAUI เพื่อตรวจจับเมื่อเน็ตกลับมา แล้ว trigger sync อัตโนมัติ:

// ConnectivityMonitor.cs
namespace MyApp.Services;

public class ConnectivityMonitor : IDisposable
{
    private readonly IConnectivity _connectivity;
    private readonly SyncService _syncService;

    public ConnectivityMonitor(
        IConnectivity connectivity,
        SyncService syncService)
    {
        _connectivity = connectivity;
        _syncService = syncService;
        _connectivity.ConnectivityChanged += OnConnectivityChanged;
    }

    private async void OnConnectivityChanged(
        object? sender, ConnectivityChangedEventArgs e)
    {
        if (e.NetworkAccess == NetworkAccess.Internet)
        {
            // เน็ตกลับมาแล้ว — sync ทันที
            await _syncService.SyncAsync();
        }
    }

    public void Dispose()
    {
        _connectivity.ConnectivityChanged -= OnConnectivityChanged;
    }
}

เทคนิคเพิ่มประสิทธิภาพ SQLite บนมือถือ

SQLite บนมือถือมีข้อจำกัดเรื่องทรัพยากรมากกว่า server อยู่พอสมควร ดังนั้นการ optimize จึงสำคัญมากครับ ผมรวมเทคนิคที่ใช้จริงมาให้

1. เปิด WAL (Write-Ahead Logging)

WAL mode ช่วยให้อ่านและเขียนพร้อมกันได้ ลด lock contention ไปได้เยอะมาก:

// สำหรับ sqlite-net-pcl
await database.ExecuteAsync("PRAGMA journal_mode=WAL;");

// สำหรับ EF Core — เพิ่มใน OnConfiguring
optionsBuilder.UseSqlite($"Data Source={_dbPath};Cache=Shared");

2. สร้าง Index สำหรับ Column ที่ค้นหาบ่อย

อันนี้หลายคนมองข้าม แต่มันทำให้ query เร็วขึ้นอย่างเห็นได้ชัด:

// sqlite-net-pcl — ใช้ attribute
[Indexed]
public DateTime CreatedAt { get; set; }

// EF Core — ใช้ Fluent API
modelBuilder.Entity<TaskItem>()
    .HasIndex(t => t.CreatedAt);

// Composite index สำหรับ query ที่กรองหลาย column
modelBuilder.Entity<TaskItem>()
    .HasIndex(t => new { t.ProjectId, t.IsCompleted });

3. ใช้ Batch Operations สำหรับข้อมูลจำนวนมาก

// sqlite-net-pcl — InsertAll ใน transaction
var db = await GetConnectionAsync();
await db.RunInTransactionAsync(conn =>
{
    conn.InsertAll(newTasks);
});

// EF Core — AddRange แล้ว SaveChanges ครั้งเดียว
_context.Tasks.AddRange(newTasks);
await _context.SaveChangesAsync();

4. Pagination สำหรับข้อมูลจำนวนมาก

อย่าดึงข้อมูลทั้งหมดมาในครั้งเดียวเด็ดขาดครับ โดยเฉพาะถ้ามีข้อมูลเป็นพันเป็นหมื่น record ให้ใช้ pagination แทน:

// sqlite-net-pcl
var page = await db.Table<TaskItem>()
    .OrderByDescending(t => t.CreatedAt)
    .Skip(pageIndex * pageSize)
    .Take(pageSize)
    .ToListAsync();

// EF Core
var page = await _context.Tasks
    .OrderByDescending(t => t.CreatedAt)
    .Skip(pageIndex * pageSize)
    .Take(pageSize)
    .ToListAsync();

5. หลีกเลี่ยง N+1 Query (EF Core)

ปัญหา classic ที่เจอกันบ่อยมาก ถ้าใช้ EF Core ต้องระวังเรื่องนี้ให้ดี:

// ❌ ไม่ดี — ทำให้เกิด N+1 queries
var projects = await _context.Projects.ToListAsync();
foreach (var p in projects)
{
    var tasks = p.Tasks; // lazy load ทีละ project
}

// ✅ ดี — ใช้ Include เพื่อ eager load
var projects = await _context.Projects
    .Include(p => p.Tasks)
    .ToListAsync();

โครงสร้างโปรเจกต์ที่แนะนำ

สำหรับแอป Offline-First ที่มีระบบ sync ผมแนะนำให้จัดโครงสร้างประมาณนี้ครับ เพราะแยก layer ได้ชัดเจนและ maintain ง่าย:

MyApp/
├── MyApp.sln
├── MyApp/
│   ├── App.xaml.cs
│   ├── MauiProgram.cs
│   ├── Data/
│   │   ├── DatabaseConstants.cs
│   │   ├── TaskDatabase.cs          # sqlite-net-pcl service
│   │   ├── AppDbContext.cs           # EF Core context (ถ้าใช้)
│   │   └── DatabaseInitializer.cs
│   ├── Models/
│   │   ├── TaskItem.cs
│   │   └── SyncResult.cs
│   ├── Services/
│   │   ├── SyncService.cs
│   │   └── ConnectivityMonitor.cs
│   ├── ViewModels/
│   │   ├── TaskListViewModel.cs
│   │   └── TaskDetailViewModel.cs
│   └── Views/
│       ├── TaskListPage.xaml
│       └── TaskDetailPage.xaml

ข้อควรระวังสำหรับแต่ละแพลตฟอร์ม

ตรงนี้เป็นสิ่งที่ผมเรียนรู้จากการทำโปรเจกต์จริงครับ แต่ละแพลตฟอร์มมีเรื่องที่ต้องระวังต่างกัน

iOS

  • ต้องเรียก SQLitePCL.Batteries_V2.Init() ก่อนใช้ SQLite เมื่อใช้ EF Core ไม่งั้นจะ crash ทันที
  • ระวังขนาด database — iOS อาจลบข้อมูลในกรณีที่เครื่องเก็บข้อมูลเต็ม ถ้าไฟล์อยู่ผิด directory
  • ใช้ FileSystem.AppDataDirectory เพื่อให้ database อยู่ใน directory ที่ iOS จะไม่ลบ

Android

  • Android มี SQLite ในตัวอยู่แล้ว แต่เวอร์ชันอาจเก่ามาก ควรใช้ bundled version ผ่าน SQLitePCLRaw.bundle_green จะปลอดภัยกว่า
  • ระวังเรื่อง file permissions — ใช้ FileSystem.AppDataDirectory เสมอ อย่าไปยุ่งกับ external storage

macOS

  • Database จะถูกเก็บใน ~/Library — ต้องมั่นใจว่าโครงสร้าง folder ถูกต้อง
  • ทดสอบเรื่อง App Sandbox permissions ให้ดีครับ ตรงนี้เจอบั๊กบ่อย

คำถามที่พบบ่อย (FAQ)

ใช้ sqlite-net-pcl กับ .NET MAUI 10 ได้ไหม?

ได้ครับ sqlite-net-pcl รองรับ .NET MAUI ทุกเวอร์ชันรวมถึง .NET 10 (LTS) แต่ต้องอย่าลืมติดตั้ง SQLitePCLRaw.bundle_green คู่กันด้วยเสมอ เพื่อให้มี native SQLite library บนทุกแพลตฟอร์ม

ควรใช้ sqlite-net-pcl หรือ EF Core ดีกว่ากัน?

ขึ้นอยู่กับความซับซ้อนของแอปครับ ถ้า data model ไม่ซับซ้อน ไม่มี relationships เยอะ และไม่ต้องการ migration ก็ sqlite-net-pcl เลย มันเบากว่าและเร็วกว่า แต่ถ้าแอปมี relationships หลายระดับ ต้องการ migration system หรือต้องแชร์ data layer กับ web project ให้ใช้ EF Core

ทำยังไงให้แอป sync ข้อมูลอัตโนมัติเมื่อเน็ตกลับมา?

ใช้ IConnectivity API ของ .NET MAUI ฟัง event ConnectivityChanged เมื่อ NetworkAccess เปลี่ยนเป็น Internet ก็ให้ trigger sync service ทันที ดูตัวอย่าง ConnectivityMonitor ในบทความนี้ได้เลยครับ

SQLite รองรับข้อมูลได้มากแค่ไหนบนมือถือ?

ตามทฤษฎี SQLite รองรับ database ขนาดได้ถึง 281 TB แต่สำหรับแอปมือถือจริงๆ ควรเก็บเฉพาะข้อมูลที่จำเป็นครับ หลีกเลี่ยงการเก็บไฟล์ขนาดใหญ่อย่างรูปภาพใน database โดยตรง ให้เก็บไฟล์ในระบบไฟล์แล้วเก็บแค่ path ใน database แทน จะดีกว่ามาก

จะจัดการ conflict เมื่อ sync ข้อมูลจากหลายอุปกรณ์ได้อย่างไร?

วิธีที่ง่ายที่สุดคือใช้ "last write wins" strategy ครับ — เปรียบเทียบ LastModified timestamp แล้วให้ข้อมูลที่แก้ล่าสุดชนะ สำหรับกรณีที่ต้องการความแม่นยำสูงกว่านั้น ก็ต้องไปดู field-level conflict resolution หรือ operational transformation แต่ผมบอกตรงๆ ว่ามันซับซ้อนขึ้นมาเยอะพอสมควรครับ

เกี่ยวกับผู้เขียน Editorial Team

Our team of expert writers and editors.