为什么移动应用需要离线优先架构?
做过移动端开发的人应该都深有体会——网络环境这东西,从来就没靠谱过。用户可能在地铁里信号忽有忽无,可能在飞机上彻底断网,也可能只是从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();
}
}
整个架构的数据流其实很清晰:
- 用户操作 → ViewModel接收命令
- 数据写入 → 通过Repository写入本地SQLite,同时记录到离线操作队列
- 网络监听 → NetworkService持续监测网络状态
- 自动同步 → 网络恢复时,SyncService自动处理操作队列
- 冲突处理 → 根据预设策略自动解决,或提交给用户裁决
- 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完全兼容,迁移成本真的很低。