.NET MAUI离线优先架构完全指南:从SQLite数据持久化到云端同步实战

全面解析.NET MAUI离线优先架构的设计与实现,涵盖SQLite(sqlite-net-pcl与EF Core)数据持久化、Repository模式、网络状态监听、离线操作队列、数据同步与冲突解决策略,附完整可运行代码示例和性能优化实践。

MAUI离线优先架构指南2026:SQLite+云同步

为什么移动应用需要离线优先架构?

做过移动端开发的人应该都深有体会——网络环境这东西,从来就没靠谱过。用户可能在地铁里信号忽有忽无,可能在飞机上彻底断网,也可能只是从Wi-Fi切到蜂窝网络的那几秒钟就卡住了。如果你的应用百分百依赖网络才能用,那用户体验基本上就是灾难。

离线优先(Offline-First)架构的核心理念说白了就一句话:让应用在本地就能完整运行,网络只是个同步数据的通道。用户的每次操作先写到本地存储,有网了再自动同步到服务器。这样不管网络啥状况,应用都能跑得顺顺当当。

在.NET MAUI里实现离线优先架构,你需要搞定几个关键技术点:本地数据持久化(SQLite、Preferences、Secure Storage)、网络状态监听、数据同步策略,还有冲突解决机制。这篇文章会从头到尾带你把整个流程走一遍,代码都是可以直接拿去用的。

.NET MAUI中的数据持久化方案全景

在动手写代码之前,先把.NET MAUI提供的几种数据持久化方案搞清楚。选对了方案事半功倍,选错了后面返工会很痛苦——相信我,我踩过这个坑。

Preferences:轻量级键值对存储

Preferences是最简单的持久化方式,适合存用户设置、应用配置这类小量非敏感数据。它底层用的是各平台的原生实现:Android是SharedPreferences,iOS是NSUserDefaults,Windows是ApplicationDataContainer。

// 保存数据
Preferences.Default.Set("username", "张三");
Preferences.Default.Set("theme_mode", "dark");
Preferences.Default.Set("font_size", 16);
Preferences.Default.Set("notifications_enabled", true);
Preferences.Default.Set("last_sync_time", DateTime.UtcNow.ToString("O"));

// 读取数据(需要提供默认值)
string username = Preferences.Default.Get("username", "访客");
string theme = Preferences.Default.Get("theme_mode", "light");
int fontSize = Preferences.Default.Get("font_size", 14);
bool notificationsEnabled = Preferences.Default.Get("notifications_enabled", true);

// 检查某个键是否存在
bool hasUsername = Preferences.Default.ContainsKey("username");

// 删除特定键
Preferences.Default.Remove("username");

// 清除所有Preferences
Preferences.Default.Clear();

注意:Preferences不带任何加密保护,千万别用它存密码、Token之类的敏感信息。另外它也不适合存大量数据或复杂对象——如果你要存的东西超过几十个键值对,那就该上SQLite了。

Secure Storage:加密存储敏感数据

需要安全保护的数据,比如API Token、用户凭证、加密密钥什么的,得用Secure Storage。它在各平台上的加密机制不同:Android用EncryptedSharedPreferences(AES-256 GCM加密),iOS走Keychain,Windows则是Data Protection API。

// 安全存储Token
public async Task SaveAuthTokenAsync(string token)
{
    try
    {
        await SecureStorage.Default.SetAsync("auth_token", token);
        await SecureStorage.Default.SetAsync("token_expiry",
            DateTime.UtcNow.AddHours(24).ToString("O"));
    }
    catch (Exception ex)
    {
        // 某些设备可能不支持安全存储
        // 需要有降级方案
        System.Diagnostics.Debug.WriteLine($"安全存储失败: {ex.Message}");
    }
}

// 读取Token
public async Task<string?> GetAuthTokenAsync()
{
    try
    {
        string? token = await SecureStorage.Default.GetAsync("auth_token");
        string? expiry = await SecureStorage.Default.GetAsync("token_expiry");

        if (token != null && expiry != null)
        {
            var expiryDate = DateTime.Parse(expiry);
            if (DateTime.UtcNow < expiryDate)
                return token;

            // Token已过期,清除
            SecureStorage.Default.Remove("auth_token");
            SecureStorage.Default.Remove("token_expiry");
        }
        return null;
    }
    catch (Exception ex)
    {
        System.Diagnostics.Debug.WriteLine($"读取安全存储失败: {ex.Message}");
        return null;
    }
}

// 清除所有安全存储的数据(比如用户登出时)
public void ClearSecureData()
{
    SecureStorage.Default.RemoveAll();
}

Secure Storage的读写操作全是异步的,而且因为要加密解密所以有性能开销,别频繁调用。如果有大量敏感数据需要存储,更好的做法是把数据加密后丢进SQLite,只把加密密钥放在Secure Storage里。

文件系统存储:处理非结构化数据

图片、PDF、日志文件、缓存的JSON响应——这些非结构化数据直接存文件系统就行。.NET MAUI提供了FileSystem类来获取各平台的安全目录路径:

public class FileStorageService
{
    // 获取应用数据目录(数据持久保存)
    private string AppDataPath => FileSystem.AppDataDirectory;

    // 获取缓存目录(系统可能在存储不足时清除)
    private string CachePath => FileSystem.CacheDirectory;

    public async Task SaveJsonCacheAsync(string key, object data)
    {
        string filePath = Path.Combine(CachePath, $"{key}.json");
        string json = System.Text.Json.JsonSerializer.Serialize(data);
        await File.WriteAllTextAsync(filePath, json);
    }

    public async Task<T?> LoadJsonCacheAsync<T>(string key)
    {
        string filePath = Path.Combine(CachePath, $"{key}.json");
        if (!File.Exists(filePath))
            return default;

        string json = await File.ReadAllTextAsync(filePath);
        return System.Text.Json.JsonSerializer.Deserialize<T>(json);
    }

    public async Task SaveImageAsync(string fileName, byte[] imageData)
    {
        string filePath = Path.Combine(AppDataPath, "images", fileName);
        Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
        await File.WriteAllBytesAsync(filePath, imageData);
    }
}

SQLite集成深度实战:sqlite-net-pcl vs Entity Framework Core

说到移动应用的本地数据库,SQLite基本上就是唯一的正经选择。轻量、不用单独跑服务器进程、支持ACID事务,跨平台兼容性也没得说。在.NET MAUI里,目前有两种主流的SQLite集成方案,各有各的好。

方案一:sqlite-net-pcl —— 轻量直接

sqlite-net-pcl是社区用得最广泛的SQLite库,API简洁明了,性能也不错。说实话,大多数移动应用场景用它就完全够了。

先装NuGet包:

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

然后定义数据模型:

using SQLite;

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

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

    [MaxLength(1000)]
    public string? Description { get; set; }

    public bool IsCompleted { get; set; }

    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    public DateTime? CompletedAt { get; set; }

    // 同步状态追踪
    public bool IsSynced { get; set; } = false;

    public DateTime? LastSyncedAt { get; set; }

    [MaxLength(50)]
    public string? ServerSideId { get; set; }

    // 用于冲突检测
    public long Version { get; set; } = 0;
}

接下来创建数据库服务。这里我用单例模式,保证整个应用共享一个数据库连接(多个连接在移动端容易出问题):

using SQLite;

public class DatabaseService
{
    private SQLiteAsyncConnection? _database;
    private readonly string _dbPath;

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

    private async Task<SQLiteAsyncConnection> GetDatabaseAsync()
    {
        if (_database != null)
            return _database;

        _database = new SQLiteAsyncConnection(_dbPath,
            SQLiteOpenFlags.ReadWrite |
            SQLiteOpenFlags.Create |
            SQLiteOpenFlags.SharedCache);

        // 启用WAL模式提升并发性能
        await _database.ExecuteAsync("PRAGMA journal_mode=WAL;");

        // 创建表
        await _database.CreateTableAsync<TodoTask>();

        return _database;
    }

    // 获取所有任务
    public async Task<List<TodoTask>> GetTasksAsync()
    {
        var db = await GetDatabaseAsync();
        return await db.Table<TodoTask>()
            .OrderByDescending(t => t.CreatedAt)
            .ToListAsync();
    }

    // 获取未同步的任务
    public async Task<List<TodoTask>> GetUnsyncedTasksAsync()
    {
        var db = await GetDatabaseAsync();
        return await db.Table<TodoTask>()
            .Where(t => !t.IsSynced)
            .ToListAsync();
    }

    // 添加或更新任务
    public async Task<int> SaveTaskAsync(TodoTask task)
    {
        var db = await GetDatabaseAsync();
        task.IsSynced = false; // 标记为需要同步
        task.Version++;

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

    // 删除任务
    public async Task<int> DeleteTaskAsync(TodoTask task)
    {
        var db = await GetDatabaseAsync();
        return await db.DeleteAsync(task);
    }

    // 批量标记为已同步
    public async Task MarkAsSyncedAsync(IEnumerable<int> taskIds)
    {
        var db = await GetDatabaseAsync();
        var now = DateTime.UtcNow;
        foreach (var id in taskIds)
        {
            await db.ExecuteAsync(
                "UPDATE tasks SET IsSynced = 1, LastSyncedAt = ? WHERE Id = ?",
                now, id);
        }
    }
}

方案二:Entity Framework Core —— ORM的力量

如果你团队更习惯EF Core的开发模式,或者业务逻辑比较复杂需要用到迁移功能,EF Core也完全可以胜任。它在.NET MAUI中的支持现在已经相当成熟了。

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

定义DbContext:

using Microsoft.EntityFrameworkCore;

public class AppDbContext : DbContext
{
    public DbSet<TodoTask> Tasks { get; set; }
    public DbSet<SyncLog> SyncLogs { get; set; }

    private readonly string _dbPath;

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

    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        options.UseSqlite($"Data Source={_dbPath}");
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<TodoTask>(entity =>
        {
            entity.ToTable("tasks");
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Title).HasMaxLength(200).IsRequired();
            entity.Property(e => e.Description).HasMaxLength(1000);
            entity.HasIndex(e => e.IsSynced);
            entity.HasIndex(e => e.CreatedAt);
        });

        modelBuilder.Entity<SyncLog>(entity =>
        {
            entity.ToTable("sync_logs");
            entity.HasKey(e => e.Id);
        });
    }
}

public class SyncLog
{
    public int Id { get; set; }
    public string EntityType { get; set; } = string.Empty;
    public int EntityId { get; set; }
    public string Operation { get; set; } = string.Empty; // Insert, Update, Delete
    public string? Payload { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public bool IsProcessed { get; set; } = false;
}

在MauiProgram.cs里注册一下:

builder.Services.AddDbContext<AppDbContext>();

// 确保数据库和表已创建
var dbContext = builder.Services.BuildServiceProvider()
    .GetRequiredService<AppDbContext>();
dbContext.Database.EnsureCreated();

那这两种方案怎么选呢?我个人的经验是:数据模型简单、表不多(十个以内),用sqlite-net-pcl就够了,它启动快、包体积也小。但如果你的应用有复杂的关联查询、需要正经的数据库迁移管理,或者团队已经对EF Core驾轻就熟,那就直接上EF Core,别纠结。

使用Repository模式构建数据访问层

直接在ViewModel里写数据库操作代码?这不是个好习惯。它会让代码紧耦合,测试和维护都很头疼。Repository模式可以很好地解决这个问题——虽然多了一层抽象,但绝对值得。

// 通用Repository接口
public interface IRepository<T> where T : class, new()
{
    Task<List<T>> GetAllAsync();
    Task<T?> GetByIdAsync(int id);
    Task<int> AddAsync(T entity);
    Task<int> UpdateAsync(T entity);
    Task<int> DeleteAsync(T entity);
}

// SQLite实现
public class SqliteRepository<T> : IRepository<T> where T : class, new()
{
    private readonly SQLiteAsyncConnection _db;

    public SqliteRepository(SQLiteAsyncConnection db)
    {
        _db = db;
        _db.CreateTableAsync<T>().Wait();
    }

    public async Task<List<T>> GetAllAsync()
        => await _db.Table<T>().ToListAsync();

    public async Task<T?> GetByIdAsync(int id)
        => await _db.FindAsync<T>(id);

    public async Task<int> AddAsync(T entity)
        => await _db.InsertAsync(entity);

    public async Task<int> UpdateAsync(T entity)
        => await _db.UpdateAsync(entity);

    public async Task<int> DeleteAsync(T entity)
        => await _db.DeleteAsync(entity);
}

// 带离线追踪功能的扩展Repository
public interface IOfflineRepository<T> : IRepository<T> where T : class, new()
{
    Task<List<T>> GetUnsyncedAsync();
    Task MarkSyncedAsync(int id);
    Task<int> GetUnsyncedCountAsync();
}

public class OfflineTaskRepository : SqliteRepository<TodoTask>, IOfflineRepository<TodoTask>
{
    private readonly SQLiteAsyncConnection _db;

    public OfflineTaskRepository(SQLiteAsyncConnection db) : base(db)
    {
        _db = db;
    }

    public async Task<List<TodoTask>> GetUnsyncedAsync()
        => await _db.Table<TodoTask>()
            .Where(t => !t.IsSynced)
            .ToListAsync();

    public async Task MarkSyncedAsync(int id)
    {
        var task = await _db.FindAsync<TodoTask>(id);
        if (task != null)
        {
            task.IsSynced = true;
            task.LastSyncedAt = DateTime.UtcNow;
            await _db.UpdateAsync(task);
        }
    }

    public async Task<int> GetUnsyncedCountAsync()
        => await _db.Table<TodoTask>()
            .Where(t => !t.IsSynced)
            .CountAsync();
}

然后在依赖注入容器中注册:

// MauiProgram.cs
builder.Services.AddSingleton<SQLiteAsyncConnection>(provider =>
{
    var dbPath = Path.Combine(FileSystem.AppDataDirectory, "app_data.db3");
    return new SQLiteAsyncConnection(dbPath,
        SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.SharedCache);
});

builder.Services.AddSingleton<IOfflineRepository<TodoTask>, OfflineTaskRepository>();
builder.Services.AddSingleton<ISyncService, SyncService>();

网络状态监听与连接管理

离线优先架构的根基在于准确感知网络状态变化。好在.NET MAUI通过IConnectivity接口把这件事做得挺简单的。

public class NetworkService : IDisposable
{
    private readonly IConnectivity _connectivity;

    public event EventHandler<bool>? ConnectivityChanged;

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

    public NetworkService(IConnectivity connectivity)
    {
        _connectivity = connectivity;
        _connectivity.ConnectivityChanged += OnConnectivityChanged;
    }

    private void OnConnectivityChanged(object? sender,
        ConnectivityChangedEventArgs e)
    {
        bool isConnected = e.NetworkAccess == NetworkAccess.Internet;
        ConnectivityChanged?.Invoke(this, isConnected);

        System.Diagnostics.Debug.WriteLine(
            $"网络状态变化: {e.NetworkAccess}");
        System.Diagnostics.Debug.WriteLine(
            $"连接类型: {string.Join(", ", e.ConnectionProfiles)}");
    }

    public ConnectionProfile GetConnectionType()
    {
        var profiles = _connectivity.ConnectionProfiles;

        if (profiles.Contains(ConnectionProfile.WiFi))
            return ConnectionProfile.WiFi;
        if (profiles.Contains(ConnectionProfile.Cellular))
            return ConnectionProfile.Cellular;
        if (profiles.Contains(ConnectionProfile.Ethernet))
            return ConnectionProfile.Ethernet;

        return ConnectionProfile.Unknown;
    }

    // 检查是否应该执行大数据量同步
    // 只在Wi-Fi下进行大量数据传输
    public bool ShouldSyncLargeData()
    {
        return IsConnected &&
            GetConnectionType() == ConnectionProfile.WiFi;
    }

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

离线操作队列:确保数据不丢失

当用户在没网的时候做了各种操作,我们不能让这些操作凭空消失。核心思路就是:把操作记下来排好队,等网络恢复了再按顺序一个个执行。这就是离线操作队列。

public class OfflineAction
{
    [PrimaryKey, AutoIncrement]
    public int Id { get; set; }

    public string EntityType { get; set; } = string.Empty;

    public string ActionType { get; set; } = string.Empty; // Create, Update, Delete

    public string Payload { get; set; } = string.Empty; // JSON序列化的数据

    public int RetryCount { get; set; } = 0;

    public int MaxRetries { get; set; } = 5;

    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    public DateTime? ProcessedAt { get; set; }

    public bool IsProcessed { get; set; } = false;

    public string? ErrorMessage { get; set; }
}

public class OfflineActionQueue
{
    private readonly SQLiteAsyncConnection _db;
    private readonly SemaphoreSlim _semaphore = new(1, 1);

    public OfflineActionQueue(SQLiteAsyncConnection db)
    {
        _db = db;
        _db.CreateTableAsync<OfflineAction>().Wait();
    }

    // 入队一个离线操作
    public async Task EnqueueAsync<T>(string actionType, T entity)
    {
        var action = new OfflineAction
        {
            EntityType = typeof(T).Name,
            ActionType = actionType,
            Payload = System.Text.Json.JsonSerializer.Serialize(entity),
            CreatedAt = DateTime.UtcNow
        };

        await _db.InsertAsync(action);
    }

    // 获取所有待处理的操作(按创建时间排序)
    public async Task<List<OfflineAction>> GetPendingActionsAsync()
    {
        return await _db.Table<OfflineAction>()
            .Where(a => !a.IsProcessed && a.RetryCount < a.MaxRetries)
            .OrderBy(a => a.CreatedAt)
            .ToListAsync();
    }

    // 处理队列中的操作
    public async Task ProcessQueueAsync(
        Func<OfflineAction, Task<bool>> processor)
    {
        await _semaphore.WaitAsync();
        try
        {
            var pendingActions = await GetPendingActionsAsync();

            foreach (var action in pendingActions)
            {
                try
                {
                    bool success = await processor(action);
                    if (success)
                    {
                        action.IsProcessed = true;
                        action.ProcessedAt = DateTime.UtcNow;
                    }
                    else
                    {
                        action.RetryCount++;
                        action.ErrorMessage = "处理失败,将重试";
                    }
                }
                catch (Exception ex)
                {
                    action.RetryCount++;
                    action.ErrorMessage = ex.Message;
                }

                await _db.UpdateAsync(action);
            }
        }
        finally
        {
            _semaphore.Release();
        }
    }

    // 获取队列状态
    public async Task<(int pending, int failed, int processed)> GetQueueStatsAsync()
    {
        var all = await _db.Table<OfflineAction>().ToListAsync();
        return (
            pending: all.Count(a => !a.IsProcessed && a.RetryCount < a.MaxRetries),
            failed: all.Count(a => !a.IsProcessed && a.RetryCount >= a.MaxRetries),
            processed: all.Count(a => a.IsProcessed)
        );
    }
}

数据同步服务:打通本地与云端

好了,有了离线操作队列和网络状态监听,接下来就是把它们串起来,实现自动数据同步。这部分是整个架构的核心,代码稍微多一点,但逻辑其实不复杂。

public interface ISyncService
{
    Task<SyncResult> SyncAsync();
    Task StartAutoSyncAsync(TimeSpan interval);
    void StopAutoSync();
    event EventHandler<SyncResult>? SyncCompleted;
}

public class SyncResult
{
    public bool IsSuccess { get; set; }
    public int ItemsPushed { get; set; }
    public int ItemsPulled { get; set; }
    public int Conflicts { get; set; }
    public List<string> Errors { get; set; } = new();
    public DateTime SyncTime { get; set; } = DateTime.UtcNow;
}

public class SyncService : ISyncService, IDisposable
{
    private readonly IOfflineRepository<TodoTask> _taskRepo;
    private readonly OfflineActionQueue _actionQueue;
    private readonly NetworkService _networkService;
    private readonly HttpClient _httpClient;
    private CancellationTokenSource? _autoSyncCts;

    public event EventHandler<SyncResult>? SyncCompleted;

    public SyncService(
        IOfflineRepository<TodoTask> taskRepo,
        OfflineActionQueue actionQueue,
        NetworkService networkService,
        HttpClient httpClient)
    {
        _taskRepo = taskRepo;
        _actionQueue = actionQueue;
        _networkService = networkService;
        _httpClient = httpClient;

        // 网络恢复时自动触发同步
        _networkService.ConnectivityChanged += async (s, isConnected) =>
        {
            if (isConnected)
            {
                await SyncAsync();
            }
        };
    }

    public async Task<SyncResult> SyncAsync()
    {
        var result = new SyncResult();

        if (!_networkService.IsConnected)
        {
            result.IsSuccess = false;
            result.Errors.Add("无网络连接");
            return result;
        }

        try
        {
            // 第一步:推送本地变更到服务器
            await PushChangesAsync(result);

            // 第二步:从服务器拉取最新数据
            await PullChangesAsync(result);

            result.IsSuccess = result.Errors.Count == 0;
        }
        catch (Exception ex)
        {
            result.IsSuccess = false;
            result.Errors.Add($"同步异常: {ex.Message}");
        }

        SyncCompleted?.Invoke(this, result);
        return result;
    }

    private async Task PushChangesAsync(SyncResult result)
    {
        await _actionQueue.ProcessQueueAsync(async action =>
        {
            var response = await _httpClient.PostAsJsonAsync(
                "/api/sync/push", new
                {
                    action.EntityType,
                    action.ActionType,
                    action.Payload,
                    ClientTimestamp = action.CreatedAt
                });

            if (response.IsSuccessStatusCode)
            {
                result.ItemsPushed++;
                return true;
            }

            if (response.StatusCode ==
                System.Net.HttpStatusCode.Conflict)
            {
                result.Conflicts++;
                // 冲突处理逻辑(见下一节)
                return await HandleConflictAsync(action, response);
            }

            return false;
        });
    }

    private async Task PullChangesAsync(SyncResult result)
    {
        // 获取上次同步的时间戳
        string? lastSync = Preferences.Default.Get(
            "last_pull_sync", (string?)null);

        var url = lastSync != null
            ? $"/api/sync/pull?since={Uri.EscapeDataString(lastSync)}"
            : "/api/sync/pull";

        var response = await _httpClient.GetAsync(url);

        if (response.IsSuccessStatusCode)
        {
            var serverTasks = await response.Content
                .ReadFromJsonAsync<List<TodoTask>>();

            if (serverTasks != null)
            {
                foreach (var serverTask in serverTasks)
                {
                    await MergeServerTaskAsync(serverTask);
                    result.ItemsPulled++;
                }
            }

            Preferences.Default.Set("last_pull_sync",
                DateTime.UtcNow.ToString("O"));
        }
    }

    private async Task MergeServerTaskAsync(TodoTask serverTask)
    {
        // 查找本地是否已有这条记录
        var allTasks = await _taskRepo.GetAllAsync();
        var localTask = allTasks.FirstOrDefault(
            t => t.ServerSideId == serverTask.ServerSideId);

        if (localTask == null)
        {
            // 本地不存在,直接插入
            serverTask.IsSynced = true;
            serverTask.LastSyncedAt = DateTime.UtcNow;
            await _taskRepo.AddAsync(serverTask);
        }
        else if (serverTask.Version > localTask.Version)
        {
            // 服务器版本更新,用服务器的覆盖本地
            serverTask.Id = localTask.Id;
            serverTask.IsSynced = true;
            serverTask.LastSyncedAt = DateTime.UtcNow;
            await _taskRepo.UpdateAsync(serverTask);
        }
        // 如果本地版本更新,保留本地的(会在下次Push时上传)
    }

    private async Task<bool> HandleConflictAsync(
        OfflineAction action,
        HttpResponseMessage response)
    {
        // 使用"最后写入胜出"策略
        // 也可以实现更复杂的合并策略
        var serverVersion = await response.Content
            .ReadFromJsonAsync<TodoTask>();

        if (serverVersion != null)
        {
            await MergeServerTaskAsync(serverVersion);
        }

        return true; // 标记冲突已处理
    }

    public async Task StartAutoSyncAsync(TimeSpan interval)
    {
        StopAutoSync();
        _autoSyncCts = new CancellationTokenSource();

        while (!_autoSyncCts.Token.IsCancellationRequested)
        {
            try
            {
                await Task.Delay(interval, _autoSyncCts.Token);
                if (_networkService.IsConnected)
                {
                    await SyncAsync();
                }
            }
            catch (TaskCanceledException)
            {
                break;
            }
        }
    }

    public void StopAutoSync()
    {
        _autoSyncCts?.Cancel();
        _autoSyncCts?.Dispose();
        _autoSyncCts = null;
    }

    public void Dispose()
    {
        StopAutoSync();
    }
}

冲突解决策略详解

数据同步最让人头疼的部分,老实说,不是写代码——而是设计冲突解决策略。当同一条数据在本地和服务器上都被改了,到底用哪个版本?这没有放之四海而皆准的答案,得看你的具体业务场景。

策略一:最后写入胜出(Last Write Wins)

最简单粗暴的方式——谁的时间戳新就用谁的。实现起来几行代码搞定,但可能丢数据。适合那些丢了也不心疼的场景,比如用户设置、浏览记录之类的。

public TodoTask ResolveByLastWriteWins(
    TodoTask local, TodoTask server)
{
    // 比较修改时间,取更新的那个
    if (local.CreatedAt > server.CreatedAt)
        return local;
    else
        return server;
}

策略二:字段级合并(Field-Level Merge)

更精细的玩法——逐个字段比较,把各自改过的字段合并到一起。像NubeSync之类的框架就是这么干的。虽然复杂了些,但能最大程度保留双方的修改。

public TodoTask ResolveByFieldMerge(
    TodoTask local, TodoTask server, TodoTask original)
{
    var merged = new TodoTask { Id = local.Id };

    // 对每个字段比较:谁修改了就用谁的
    merged.Title = local.Title != original.Title
        ? local.Title
        : server.Title;

    merged.Description = local.Description != original.Description
        ? local.Description
        : server.Description;

    merged.IsCompleted = local.IsCompleted != original.IsCompleted
        ? local.IsCompleted
        : server.IsCompleted;

    // 如果同一个字段两边都改了,需要额外的决策逻辑
    if (local.Title != original.Title &&
        server.Title != original.Title &&
        local.Title != server.Title)
    {
        // 双方都修改了同一字段且不同
        // 这里可以选择保留其中一个,或者提示用户手动解决
        merged.Title = server.Title; // 默认取服务器版本
    }

    merged.Version = Math.Max(local.Version, server.Version) + 1;
    return merged;
}

策略三:用户手动解决

对于真正重要的数据,最稳妥的方式就是把冲突摆到用户面前,让他们自己决定留哪个。多一步操作,但数据安全有保障。

public class ConflictItem
{
    public TodoTask LocalVersion { get; set; } = null!;
    public TodoTask ServerVersion { get; set; } = null!;
    public DateTime DetectedAt { get; set; } = DateTime.UtcNow;
}

public class ConflictResolver
{
    private readonly List<ConflictItem> _pendingConflicts = new();

    public void AddConflict(TodoTask local, TodoTask server)
    {
        _pendingConflicts.Add(new ConflictItem
        {
            LocalVersion = local,
            ServerVersion = server
        });
    }

    public List<ConflictItem> GetPendingConflicts()
        => _pendingConflicts.ToList();

    public async Task ResolveAsync(ConflictItem conflict,
        bool useLocalVersion,
        IOfflineRepository<TodoTask> repo)
    {
        var chosen = useLocalVersion
            ? conflict.LocalVersion
            : conflict.ServerVersion;

        chosen.IsSynced = false;
        chosen.Version++;
        await repo.UpdateAsync(chosen);
        _pendingConflicts.Remove(conflict);
    }
}

在ViewModel中整合离线功能

到这一步,所有的积木都准备好了。现在把它们在ViewModel里拼起来,用CommunityToolkit.Mvvm来简化MVVM的样板代码:

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;

public partial class TaskListViewModel : ObservableObject
{
    private readonly IOfflineRepository<TodoTask> _taskRepo;
    private readonly ISyncService _syncService;
    private readonly NetworkService _networkService;

    [ObservableProperty]
    private bool _isLoading;

    [ObservableProperty]
    private bool _isOnline;

    [ObservableProperty]
    private int _unsyncedCount;

    [ObservableProperty]
    private string _syncStatus = "未同步";

    public ObservableCollection<TodoTask> Tasks { get; } = new();

    public TaskListViewModel(
        IOfflineRepository<TodoTask> taskRepo,
        ISyncService syncService,
        NetworkService networkService)
    {
        _taskRepo = taskRepo;
        _syncService = syncService;
        _networkService = networkService;

        IsOnline = _networkService.IsConnected;

        _networkService.ConnectivityChanged += (s, connected) =>
        {
            IsOnline = connected;
            SyncStatus = connected ? "在线" : "离线模式";
        };

        _syncService.SyncCompleted += async (s, result) =>
        {
            SyncStatus = result.IsSuccess
                ? $"已同步 ({result.SyncTime:HH:mm})"
                : $"同步失败: {result.Errors.FirstOrDefault()}";
            await LoadTasksAsync();
        };
    }

    [RelayCommand]
    public async Task LoadTasksAsync()
    {
        IsLoading = true;
        try
        {
            var tasks = await _taskRepo.GetAllAsync();
            UnsyncedCount = await _taskRepo.GetUnsyncedCountAsync();

            Tasks.Clear();
            foreach (var task in tasks.OrderByDescending(t => t.CreatedAt))
                Tasks.Add(task);
        }
        finally
        {
            IsLoading = false;
        }
    }

    [RelayCommand]
    public async Task AddTaskAsync(string title)
    {
        var newTask = new TodoTask
        {
            Title = title,
            CreatedAt = DateTime.UtcNow
        };

        await _taskRepo.AddAsync(newTask);
        await LoadTasksAsync();

        // 如果在线,立即触发同步
        if (_networkService.IsConnected)
            await _syncService.SyncAsync();
    }

    [RelayCommand]
    public async Task ToggleCompleteAsync(TodoTask task)
    {
        task.IsCompleted = !task.IsCompleted;
        task.CompletedAt = task.IsCompleted ? DateTime.UtcNow : null;
        await _taskRepo.UpdateAsync(task);
        await LoadTasksAsync();
    }

    [RelayCommand]
    public async Task SyncNowAsync()
    {
        if (!_networkService.IsConnected)
        {
            SyncStatus = "无网络连接,无法同步";
            return;
        }

        SyncStatus = "正在同步...";
        await _syncService.SyncAsync();
    }
}

性能优化与最佳实践

到这里,基本的离线优先架构算是搭建完成了。不过要让它在生产环境里真正稳定跑起来,还有些优化技巧和注意事项值得聊聊。

SQLite性能调优

// 在数据库初始化时设置这些PRAGMA
await db.ExecuteAsync("PRAGMA journal_mode=WAL;");    // 启用WAL模式
await db.ExecuteAsync("PRAGMA synchronous=NORMAL;");  // 平衡安全性和性能
await db.ExecuteAsync("PRAGMA cache_size=-8000;");    // 8MB缓存
await db.ExecuteAsync("PRAGMA temp_store=MEMORY;");   // 临时表存内存

// 批量操作时使用事务
public async Task BatchInsertAsync(List<TodoTask> tasks)
{
    var db = await GetDatabaseAsync();
    await db.RunInTransactionAsync(conn =>
    {
        foreach (var task in tasks)
        {
            conn.Insert(task);
        }
    });
}

// 使用索引加速查询
await db.ExecuteAsync(
    "CREATE INDEX IF NOT EXISTS idx_tasks_synced ON tasks(IsSynced);");
await db.ExecuteAsync(
    "CREATE INDEX IF NOT EXISTS idx_tasks_created ON tasks(CreatedAt DESC);");

内存管理

移动设备内存有限,这点没得商量。处理大量数据的时候尤其要注意别一口气全加载到内存里:

// 分页加载数据,避免一次加载太多
public async Task<List<TodoTask>> GetTasksPagedAsync(
    int page, int pageSize = 20)
{
    var db = await GetDatabaseAsync();
    return await db.Table<TodoTask>()
        .OrderByDescending(t => t.CreatedAt)
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .ToListAsync();
}

// 同步时分批处理,避免内存峰值
public async Task SyncInBatchesAsync(int batchSize = 50)
{
    var unsynced = await _taskRepo.GetUnsyncedAsync();

    for (int i = 0; i < unsynced.Count; i += batchSize)
    {
        var batch = unsynced.Skip(i).Take(batchSize).ToList();
        await PushBatchAsync(batch);

        // 给GC一个回收的机会
        if (i % (batchSize * 5) == 0)
            GC.Collect(0, GCCollectionMode.Optimized);
    }
}

安全考虑

安全这块不能马虎,有几点特别需要注意:

  • 数据库加密:如果存了敏感数据,强烈建议用SQLCipher对整个数据库文件加密。装上sqlite-net-sqlcipher包,在连接字符串里指定密码就行,改动很小。
  • 传输安全:同步请求必须走HTTPS,请求头带上认证Token。Token当然得放在Secure Storage里。
  • 数据清理:用户登出的时候,除了清Secure Storage里的凭证,别忘了把本地数据库里的用户数据也清掉(或者加密)。这一步很多开发者会遗漏。
public async Task OnUserLogoutAsync()
{
    // 清除安全存储
    SecureStorage.Default.RemoveAll();

    // 清除同步相关的Preferences
    Preferences.Default.Remove("last_pull_sync");

    // 删除本地数据库文件
    var dbPath = Path.Combine(
        FileSystem.AppDataDirectory, "app_data.db3");
    if (File.Exists(dbPath))
        File.Delete(dbPath);

    // 清除缓存文件
    var cacheDir = FileSystem.CacheDirectory;
    if (Directory.Exists(cacheDir))
    {
        foreach (var file in Directory.GetFiles(cacheDir))
            File.Delete(file);
    }
}

实战架构总览:把所有组件串起来

最后来看看完整的服务注册代码,感受一下整个架构的全貌:

// MauiProgram.cs - 完整的服务注册
public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            });

        // 数据库连接
        var dbPath = Path.Combine(
            FileSystem.AppDataDirectory, "app_data.db3");
        var db = new SQLiteAsyncConnection(dbPath,
            SQLiteOpenFlags.ReadWrite |
            SQLiteOpenFlags.Create |
            SQLiteOpenFlags.SharedCache);

        builder.Services.AddSingleton(db);

        // 数据访问层
        builder.Services.AddSingleton<IOfflineRepository<TodoTask>,
            OfflineTaskRepository>();
        builder.Services.AddSingleton<OfflineActionQueue>();

        // 网络服务
        builder.Services.AddSingleton<IConnectivity>(
            Connectivity.Current);
        builder.Services.AddSingleton<NetworkService>();

        // 同步服务
        builder.Services.AddSingleton<HttpClient>(provider =>
        {
            var client = new HttpClient
            {
                BaseAddress = new Uri("https://your-api.com")
            };
            return client;
        });
        builder.Services.AddSingleton<ISyncService, SyncService>();

        // ViewModels
        builder.Services.AddTransient<TaskListViewModel>();

        return builder.Build();
    }
}

整个架构的数据流其实很清晰:

  1. 用户操作 → ViewModel接收命令
  2. 数据写入 → 通过Repository写入本地SQLite,同时记录到离线操作队列
  3. 网络监听 → NetworkService持续监测网络状态
  4. 自动同步 → 网络恢复时,SyncService自动处理操作队列
  5. 冲突处理 → 根据预设策略自动解决,或提交给用户裁决
  6. UI更新 → 同步完成后通知ViewModel刷新界面

总结与下一步

构建一个靠谱的离线优先架构确实不是一两天的事,但一旦做好了,你的应用就能在任何网络环境下都给用户提供丝滑体验。这篇文章覆盖的内容还是挺多的,快速回顾一下:

  • 数据持久化方案选择:Preferences存简单设置,Secure Storage存敏感数据,SQLite存结构化数据
  • SQLite两种集成方案:sqlite-net-pcl适合简单场景,EF Core适合复杂业务
  • Repository模式:解耦数据访问层,让代码更好测试、更好维护
  • 网络状态管理:IConnectivity实时感知网络变化
  • 离线操作队列:确保离线操作不丢失,按序执行
  • 数据同步服务:自动推拉数据,处理各种冲突
  • 性能优化:WAL模式、事务批处理、分页加载这些都是必备技巧

在实际项目中你可能还得考虑数据库迁移策略(应用升级时Schema怎么变)、更精细的同步频率控制(根据电量和网络类型来调整),以及端到端加密等进阶话题。这些我们后续再聊。

另外,如果你正从Xamarin迁移到.NET MAUI,有个好消息:本文中的大部分库和模式都可以直接复用。sqlite-net-pcl、CommunityToolkit.Mvvm这些关键依赖跟.NET MAUI完全兼容,迁移成本真的很低。

关于作者 Devika Ramaswamy

Devika spent four years on the Xamarin team at Microsoft before the transition to .NET MAUI, where she worked on the iOS handler layer and shipped fixes that landed in the .NET 7 and .NET 8 release notes. She left Redmond in 2023 to run mobile engineering at a Series B logistics startup, porting their 600k-line Xamarin.Forms codebase to MAUI over eleven months. She writes mostly about the unglamorous parts of cross-platform work: handler internals, AOT trimming on iOS, MSBuild target customization, and why your hot reload keeps breaking. She holds the .NET MAUI MVP award (2024, 2025) and has spoken at .NET Conf and Xamarin Expert Day. Based in Bengaluru, she still pushes the occasional PR to the dotnet/maui repo on weekends.