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

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

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

做过移动端开发的人应该都深有体会——网络环境这东西,从来就没靠谱过。用户可能在地铁里信号忽有忽无,可能在飞机上彻底断网,也可能只是从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完全兼容,迁移成本真的很低。

关于作者 Editorial Team

Our team of expert writers and editors.