Loading...
正在加载...
请稍候

第十二章:状态管理与数据持久化策略

✨步子哥 (steper) 2026年02月17日 05:28
# 第十二章:状态管理与数据持久化策略 > **本章导读**:想象你正在阅读一本精彩的小说,读到一半时,电话铃响了,你不得不放下书本去接电话。当你回来继续阅读时,你希望能够准确地从刚才停下的地方继续——而不是从第一页重新开始。应用的状态管理就是这样一个"书签"系统。在移动设备和 Web 浏览器中,系统随时可能因为内存不足而终止你的应用后台。如何在被"杀掉"后优雅地恢复?如何让用户感觉应用从未离开过?本章将揭示这些问题的答案,带你构建真正具有"记忆"的应用。 --- ## 🔄 12.1 应用生命周期:理解"幸存"的艺术 在深入技术细节之前,我们需要先理解移动应用与桌面应用的根本差异。在传统的 Windows 桌面应用中,用户决定何时关闭应用——应用可以一直运行,直到用户点击关闭按钮。但在移动世界和 Web 世界中,这个主动权被操作系统夺走了。 当用户按下 Home 键切换到另一个应用时,你的应用并不会立即终止,而是进入"后台"状态。如果系统内存充足,它可能会在后台保持一段时间。但当用户打开更多应用、系统内存紧张时,操作系统会毫不留情地终止你的应用进程——而且不会给你任何警告。这就是为什么移动应用必须具备"断点续传"的能力。 > **第一性原理**:为什么移动操作系统要如此"残忍"地杀掉后台应用?答案在于**资源约束**。移动设备的内存和电池都是有限的。如果所有应用都在后台持续运行,设备很快就会变得缓慢甚至死机,电池也会在几小时内耗尽。操作系统必须做出取舍:优先保证前台应用的流畅体验,牺牲后台应用的存活。这是一种"残酷但必要"的资源调度策略。 ### 📊 12.1.1 生命周期状态图 Uno Platform 映射了 WinUI 的生命周期事件,让开发者能够在关键时刻保存和恢复状态。让我们通过一个状态图来理解应用的整个生命周期。 ``` ┌─────────────┐ │ NotRunning │ 应用尚未启动或已被终止 └──────┬──────┘ │ 用户点击应用图标 ▼ ┌─────────────┐ │ Running │ 应用在前台运行,与用户交互 └──────┬──────┘ │ 用户按 Home 键或切换到其他应用 ▼ ┌─────────────┐ │ Suspending │ 应用即将进入后台(约5秒窗口) └──────┬──────┘ │ 保存状态完成 ▼ ┌─────────────┐ │ Suspended │ 应用在后台,进程可能仍存在 └──────┬──────┘ │ 用户返回应用 或 系统终止进程 ▼ ┌─────────────┐ │ Resuming │ 应用从后台恢复(如果进程仍存在) └──────┬──────┘ │ ▼ ┌─────────────┐ │ Running │ 继续运行 └─────────────┘ ``` ### 🎯 12.1.2 处理生命周期事件 ```csharp // 文件位置:App.xaml.cs using Microsoft.UI.Xaml; using Windows.ApplicationModel.Activation; using Windows.Storage; public sealed partial class App : Application { /// <summary> /// 应用级别的状态容器 /// 用于在挂起和恢复之间保存临时数据 /// </summary> private readonly AppState _appState = new AppState(); public App() { this.InitializeComponent(); // 订阅应用生命周期事件 // Suspending: 应用即将进入后台时的最后通知 // 通常你有约 5 秒的时间完成保存操作 this.Suspending += OnSuspending; // Resuming: 应用从后台恢复 // 注意:只有当进程没有被终止时才会触发 // 如果进程被终止后重新启动,会走正常的启动流程 this.Resuming += OnResuming; } /// <summary> /// 应用即将挂起时的处理 /// 这是保存临时状态的最后机会 /// </summary> private async void OnSuspending(object sender, Windows.ApplicationModel.SuspendingEventArgs e) { // 获取一个 Deferral,表示我们需要异步操作 // 在 Deferral 完成之前,系统不会挂起应用 var deferral = e.SuspendingOperation.GetDeferral(); try { // 保存应用状态到本地存储 // 包括:当前页面、用户输入的表单数据、滚动位置等 await SaveAppStateAsync(); // 如果使用了 SQLite 数据库,确保所有待写入的数据已刷新到磁盘 await DatabaseService.FlushAsync(); // 记录挂起时间,用于恢复时判断数据是否过期 var settings = ApplicationData.Current.LocalSettings; settings.Values["LastSuspendTime"] = DateTime.Now.ToString("o"); } catch (Exception ex) { // 即使保存失败,也要记录日志 // 但不要抛出异常,否则可能导致应用崩溃 System.Diagnostics.Debug.WriteLine($"保存状态失败: {ex.Message}"); } finally { // 必须调用 Complete,否则应用会被系统强制终止 deferral.Complete(); } } /// <summary> /// 应用从后台恢复时的处理 /// </summary> private void OnResuming(object sender, object e) { // 检查数据是否需要刷新 // 例如:如果应用在后台超过1小时,可能需要重新获取最新数据 var settings = ApplicationData.Current.LocalSettings; if (settings.Values["LastSuspendTime"] is string lastSuspendStr) { var lastSuspend = DateTime.Parse(lastSuspendStr); var timeSinceSuspend = DateTime.Now - lastSuspend; if (timeSinceSuspend.TotalMinutes > 60) { // 后台时间过长,刷新关键数据 _ = RefreshDataAsync(); } } // 通知各个 ViewModel 应用已恢复 // 它们可能需要重新连接信号、刷新通知等 ViewModelLocator.NotifyResuming(); } /// <summary> /// 保存应用状态到本地存储 /// </summary> private async Task SaveAppStateAsync() { // 收集需要保存的状态 var stateData = new AppStateData { CurrentPage = _appState.CurrentPage, NavigationStack = _appState.NavigationStack, FormData = _appState.FormData, ScrollPositions = _appState.ScrollPositions, Timestamp = DateTime.Now }; // 序列化为 JSON string json = JsonSerializer.Serialize(stateData, new JsonSerializerOptions { WriteIndented = false, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); // 保存到文件 StorageFolder localFolder = ApplicationData.Current.LocalFolder; StorageFile stateFile = await localFolder.CreateFileAsync( "app_state.json", CreationCollisionOption.ReplaceExisting ); await FileIO.WriteTextAsync(stateFile, json); } /// <summary> /// 从本地存储恢复应用状态 /// </summary> private async Task<AppStateData> LoadAppStateAsync() { try { StorageFolder localFolder = ApplicationData.Current.LocalFolder; StorageFile stateFile = await localFolder.GetFileAsync("app_state.json"); string json = await FileIO.ReadTextAsync(stateFile); return JsonSerializer.Deserialize<AppStateData>(json); } catch (FileNotFoundException) { return null; // 没有保存的状态 } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"加载状态失败: {ex.Message}"); return null; } } protected override async void OnLaunched(LaunchActivatedEventArgs args) { // 检查是否有之前保存的状态 var savedState = await LoadAppStateAsync(); if (savedState != null) { // 从保存的状态恢复 // 注意:需要验证状态的时效性 var stateAge = DateTime.Now - savedState.Timestamp; if (stateAge.TotalHours < 24) // 状态在24小时内有效 { RestoreFromState(savedState); } } // 正常的窗口创建和导航 var window = new Window(); // ... 导航到初始页面 } } /// <summary> /// 应用状态数据结构 /// </summary> public class AppStateData { public string CurrentPage { get; set; } public List<string> NavigationStack { get; set; } public Dictionary<string, string> FormData { get; set; } public Dictionary<string, double> ScrollPositions { get; set; } public DateTime Timestamp { get; set; } } ``` > **费曼技巧提问**:为什么 `Suspending` 事件处理器需要使用 Deferral?想象你在餐厅用餐,突然接到电话需要离开。你告诉服务员"请稍等,我需要打包"。服务员会等你打包完成,而不是立即收走你的盘子。Deferral 就是那个"请稍等"的信号——它告诉操作系统"我还有事情要做,请等我完成再挂起"。 --- ## ⚙️ 12.2 轻量级配置:ApplicationData 应用数据容器 对于简单的配置信息——比如用户的主题偏好、通知设置、首次运行标识——你不需要数据库,也不需要复杂的序列化。Uno Platform 提供了极其简单的键值对存储机制:`ApplicationData`。 ### 📦 12.2.1 本地设置(LocalSettings) `LocalSettings` 是一个轻量级的字典存储,数据保存在设备本地,不会跨设备同步。它适合存储设备相关的偏好设置。 ```csharp using Windows.Storage; public class SettingsService { // 获取本地设置容器 // 这是一个持久化的键值对存储 private ApplicationDataContainer LocalSettings => ApplicationData.Current.LocalSettings; /// <summary> /// 用户主题偏好 /// </summary> public ElementTheme ThemePreference { get { // 尝试从设置中读取 // ?? 右边的值是默认值(当设置不存在时使用) if (LocalSettings.Values["ThemePreference"] is string themeStr) { return Enum.Parse<ElementTheme>(themeStr); } return ElementTheme.Default; } set { // 保存到设置 // 枚举值存储为字符串,便于调试和版本兼容 LocalSettings.Values["ThemePreference"] = value.ToString(); } } /// <summary> /// 是否首次运行 /// </summary> public bool IsFirstRun { get => (bool)(LocalSettings.Values["IsFirstRun"] ?? true); set => LocalSettings.Values["IsFirstRun"] = value; } /// <summary> /// 上次同步时间 /// </summary> public DateTime? LastSyncTime { get { if (LocalSettings.Values["LastSyncTime"] is string timeStr) { return DateTime.Parse(timeStr); } return null; } set { if (value.HasValue) { // 使用 ISO 8601 格式存储日期时间 // "o" 格式保证时区信息的完整性 LocalSettings.Values["LastSyncTime"] = value.Value.ToString("o"); } else { LocalSettings.Values.Remove("LastSyncTime"); } } } /// <summary> /// 字体大小偏好 /// </summary> public int FontSize { get => (int)(LocalSettings.Values["FontSize"] ?? 14); set => LocalSettings.Values["FontSize"] = value; } /// <summary> /// 复合设置:用户档案 /// 展示如何存储更复杂的数据 /// </summary> public UserProfile UserProfile { get { if (LocalSettings.Values["UserProfile"] is string json) { return JsonSerializer.Deserialize<UserProfile>(json); } return null; } set { if (value != null) { string json = JsonSerializer.Serialize(value); LocalSettings.Values["UserProfile"] = json; } else { LocalSettings.Values.Remove("UserProfile"); } } } /// <summary> /// 清除所有设置(用于退出登录) /// </summary> public void ClearAll() { LocalSettings.Values.Clear(); } } public enum ElementTheme { Default, Light, Dark } public class UserProfile { public string UserId { get; set; } public string DisplayName { get; set; } public string Email { get; set; } } ``` ### ☁️ 12.2.2 漫游设置(RoamingSettings) 漫游设置是一种特殊的设置容器,它会在用户的所有设备之间自动同步。在 Windows 平台上,这是通过微软账户实现的。在 Android 和 iOS 上,Uno Platform 目前将其映射为本地存储,但未来的版本可能会集成 iCloud 和 Google Drive 同步。 ```csharp public class RoamingSettingsService { // 获取漫游设置容器 private ApplicationDataContainer RoamingSettings => ApplicationData.Current.RoamingSettings; /// <summary> /// 同步用户的阅读偏好 /// 这些偏好会在用户的所有设备上同步 /// </summary> public bool AutoPlayVideos { get => (bool)(RoamingSettings.Values["AutoPlayVideos"] ?? true); set => RoamingSettings.Values["AutoPlayVideos"] = value; } /// <summary> /// 监听漫游设置变化 /// 当设置从其他设备同步过来时,会触发此事件 /// </summary> public void Initialize() { ApplicationData.Current.DataChanged += OnDataChanged; } private void OnDataChanged(ApplicationData sender, object args) { // 漫游数据已更新 // 在这里刷新 UI 或通知相关组件 System.Diagnostics.Debug.WriteLine("漫游设置已同步"); } } ``` > **技术术语**:**Roaming(漫游)** 在计算机领域指的是数据随用户移动的能力。就像你的手机漫游到国外时仍然可以使用一样,漫游设置让你的应用配置在用户的任何设备上都保持一致。想象你在办公室的电脑上设置了深色主题,回到家后打开平板,主题设置自动同步——这就是漫游的魅力。 --- ## 🗄️ 12.3 跨平台数据库:SQLite 深度实战 当应用需要处理成千上万条结构化数据时,键值对存储就显得力不从心了。这时,你需要一个真正的数据库。在跨平台开发领域,**SQLite** 是无可争议的王者——它轻量、快速、零配置,而且完全开源。 ### 📦 12.3.1 为什么选择 SQLite? SQLite 的设计哲学可以用一句话概括:**"零配置、无服务器、自包含"**。与 MySQL 或 PostgreSQL 不同,SQLite 不需要一个单独的服务器进程。整个数据库就是一个普通的文件,存储在你的应用沙盒中。这意味着: 数据库文件随着应用卸载而自动清理,不会留下残留。读写操作直接在文件系统层面进行,没有网络开销。跨平台兼容性极佳——同一个数据库文件可以在 Android、iOS、Windows 和 WASM 上读写。 > **第一性原理**:为什么 SQLite 能够成为跨平台数据库的标准?因为它的核心是一个纯 C 语言实现的库,几乎可以在任何有 C 编译器的平台上运行。而且 SQLite 团队对向后兼容性的承诺极其坚定——"永远不破坏兼容性"是他们的核心原则。这意味着你可以放心地使用 SQLite,不用担心未来的版本会导致你的应用崩溃。 ### 🔧 12.3.2 安装与配置 在开始使用 SQLite 之前,你需要安装几个 NuGet 包。打开你的 Shared 项目,添加以下依赖: ```xml <!-- 在 Shared 项目的 .csproj 文件中 --> <ItemGroup> <!-- SQLite ORM 库 --> <PackageReference Include="sqlite-net-pcl" Version="1.8.116" /> <!-- SQLite 原生库(提供各平台的 SQLite 实现) --> <PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.4" /> </ItemGroup> ``` ### 💻 12.3.3 数据库服务实现 让我们创建一个完整的数据库服务,展示 SQLite 在 Uno Platform 中的最佳实践: ```csharp // 文件位置:Services/DatabaseService.cs using SQLite; using Windows.Storage; public class DatabaseService { private SQLiteAsyncConnection _database; private readonly string _databaseFileName = "MyAppData.db3"; /// <summary> /// 初始化数据库 /// 应该在应用启动时调用 /// </summary> public async Task InitializeAsync() { if (_database != null) return; // 获取数据库文件路径 // ApplicationData.Current.LocalFolder 在各平台上都指向应用的私有存储目录 string dbPath = Path.Combine( ApplicationData.Current.LocalFolder.Path, _databaseFileName ); // 创建数据库连接 // SQLiteAsyncConnection 提供异步 API,不会阻塞 UI 线程 _database = new SQLiteAsyncConnection(dbPath); // 创建所有表(如果不存在) // CreateTableAsync 是幂等的——重复调用不会有副作用 await _database.CreateTableAsync<TodoItem>(); await _database.CreateTableAsync<Category>(); await _database.CreateTableAsync<Tag>(); System.Diagnostics.Debug.WriteLine($"数据库初始化完成: {dbPath}"); } /// <summary> /// 确保所有挂起的写入操作已完成 /// 在应用挂起前调用 /// </summary> public Task FlushAsync() { // SQLite 会自动处理数据持久化 // 但在某些情况下,显式检查点可以确保数据安全 return Task.CompletedTask; } /// <summary> /// 获取数据库实例(供高级查询使用) /// </summary> public SQLiteAsyncConnection GetConnection() => _database; } ``` ### 📝 12.3.4 实体模型定义 使用 `sqlite-net-pcl` 库,你可以用 C# 类来定义数据库表结构。属性(Attribute)用于配置表名、列名、索引等。 ```csharp // 文件位置:Models/TodoItem.cs using SQLite; /// <summary> /// 待办事项实体 /// 演示 SQLite 的实体映射特性 /// </summary> [Table("todo_items")] // 自定义表名 public class TodoItem { /// <summary> /// 主键 /// AutoIncrement 表示自增 /// </summary> [PrimaryKey, AutoIncrement] public int Id { get; set; } /// <summary> /// 待办事项标题 /// MaxLength 限制字符串长度 /// NotNull 表示不允许为空 /// </summary> [MaxLength(200), NotNull] public string Title { get; set; } /// <summary> /// 详细描述 /// </summary> [MaxLength(2000)] public string Description { get; set; } /// <summary> /// 是否已完成 /// </summary> public bool IsCompleted { get; set; } /// <summary> /// 优先级 (1-5) /// </summary> public int Priority { get; set; } /// <summary> /// 截止日期 /// </summary> public DateTime? DueDate { get; set; } /// <summary> /// 所属分类 ID /// </summary> [Indexed] // 为此列创建索引,加速查询 public int CategoryId { get; set; } /// <summary> /// 创建时间 /// </summary> public DateTime CreatedAt { get; set; } = DateTime.Now; /// <summary> /// 最后更新时间 /// </summary> public DateTime UpdatedAt { get; set; } = DateTime.Now; } /// <summary> /// 分类实体 /// </summary> [Table("categories")] public class Category { [PrimaryKey, AutoIncrement] public int Id { get; set; } [MaxLength(50), NotNull, Unique] // Unique 表示唯一约束 public string Name { get; set; } public string Color { get; set; } // 存储十六进制颜色值,如 "#FF5722" } /// <summary> /// 标签实体 /// </summary> [Table("tags")] public class Tag { [PrimaryKey, AutoIncrement] public int Id { get; set; } [MaxLength(30), NotNull] public string Name { get; set; } } ``` ### 📊 12.3.5 数据访问层:Repository 模式 为了保持代码的整洁和可测试性,我们使用 Repository 模式来封装数据访问逻辑: ```csharp // 文件位置:Repositories/TodoItemRepository.cs using SQLite; using System.Linq.Expressions; public interface ITodoItemRepository { Task<List<TodoItem>> GetAllAsync(); Task<TodoItem> GetByIdAsync(int id); Task<List<TodoItem>> GetByCategoryAsync(int categoryId); Task<List<TodoItem>> GetCompletedAsync(); Task<List<TodoItem>> GetPendingAsync(); Task<List<TodoItem>> SearchAsync(string keyword); Task<int> SaveAsync(TodoItem item); Task<int> DeleteAsync(TodoItem item); Task<int> CountAsync(Expression<Func<TodoItem, bool>> predicate = null); } public class TodoItemRepository : ITodoItemRepository { private readonly SQLiteAsyncConnection _database; public TodoItemRepository(DatabaseService databaseService) { _database = databaseService.GetConnection(); } /// <summary> /// 获取所有待办事项 /// </summary> public async Task<List<TodoItem>> GetAllAsync() { // Table<T>() 返回一个可查询的表对象 // ToListAsync() 异步执行查询并返回结果列表 return await _database.Table<TodoItem>() .OrderByDescending(t => t.Priority) .ThenBy(t => t.DueDate) .ToListAsync(); } /// <summary> /// 根据 ID 获取单个待办事项 /// </summary> public async Task<TodoItem> GetByIdAsync(int id) { // FindAsync 是最高效的主键查询方式 return await _database.FindAsync<TodoItem>(id); } /// <summary> /// 获取指定分类的所有待办事项 /// </summary> public async Task<List<TodoItem>> GetByCategoryAsync(int categoryId) { return await _database.Table<TodoItem>() .Where(t => t.CategoryId == categoryId) .ToListAsync(); } /// <summary> /// 获取所有已完成的待办事项 /// </summary> public async Task<List<TodoItem>> GetCompletedAsync() { return await _database.Table<TodoItem>() .Where(t => t.IsCompleted) .ToListAsync(); } /// <summary> /// 获取所有待处理的待办事项 /// </summary> public async Task<List<TodoItem>> GetPendingAsync() { return await _database.Table<TodoItem>() .Where(t => !t.IsCompleted) .OrderBy(t => t.DueDate) .ToListAsync(); } /// <summary> /// 搜索待办事项 /// </summary> public async Task<List<TodoItem>> SearchAsync(string keyword) { if (string.IsNullOrWhiteSpace(keyword)) return await GetAllAsync(); // 使用 LINQ 进行模糊搜索 string lowerKeyword = keyword.ToLower(); return await _database.Table<TodoItem>() .Where(t => t.Title.ToLower().Contains(lowerKeyword) || (t.Description != null && t.Description.ToLower().Contains(lowerKeyword))) .ToListAsync(); } /// <summary> /// 保存(插入或更新)待办事项 /// </summary> public async Task<int> SaveAsync(TodoItem item) { // 更新时间戳 item.UpdatedAt = DateTime.Now; if (item.Id == 0) { // 新项目,执行插入 return await _database.InsertAsync(item); } else { // 已有项目,执行更新 return await _database.UpdateAsync(item); } } /// <summary> /// 删除待办事项 /// </summary> public async Task<int> DeleteAsync(TodoItem item) { return await _database.DeleteAsync(item); } /// <summary> /// 统计满足条件的待办事项数量 /// </summary> public async Task<int> CountAsync(Expression<Func<TodoItem, bool>> predicate = null) { if (predicate == null) { return await _database.Table<TodoItem>().CountAsync(); } return await _database.Table<TodoItem>().Where(predicate).CountAsync(); } } ``` --- ## 🔐 12.4 安全存储:保护敏感数据 在应用开发中,有一条铁律:**永远不要以明文形式存储敏感数据**。密码、API 密钥、令牌、银行卡号——这些信息必须被加密存储。Uno Platform 通过 `PasswordVault` 类提供了跨平台的安全存储解决方案。 ### 🛡️ 12.4.1 PasswordVault:平台原生安全存储 `PasswordVault` 会调用各平台的底层安全设施来保护你的数据。这意味着你不需要自己实现加密算法——平台已经为你提供了经过安全审计的解决方案。 ```csharp // 文件位置:Services/SecureStorageService.cs using Windows.Security.Credentials; public class SecureStorageService { private readonly PasswordVault _vault = new PasswordVault(); /// <summary> /// 保存凭据 /// </summary> /// <param name="resource">资源标识符(如服务名称)</param> /// <param name="userName">用户名或键</param> /// <param name="password">密码或值</param> public void SaveCredential(string resource, string userName, string password) { // 创建凭据对象 var credential = new PasswordCredential(resource, userName, password); // 添加到安全存储 // 在不同平台上,这会调用: // - Windows: 凭据管理器 (Credential Manager) // - iOS: Keychain // - Android: EncryptedSharedPreferences / KeyStore _vault.Add(credential); System.Diagnostics.Debug.WriteLine($"凭据已保存: {resource}/{userName}"); } /// <summary> /// 获取凭据 /// </summary> public PasswordCredential GetCredential(string resource, string userName) { try { // 从安全存储中检索凭据 var credential = _vault.Retrieve(resource, userName); // 需要调用 RetrievePassword 才能获取实际的密码值 credential.RetrievePassword(); return credential; } catch (Exception ex) { // 凭据不存在或访问失败 System.Diagnostics.Debug.WriteLine($"获取凭据失败: {ex.Message}"); return null; } } /// <summary> /// 获取指定资源的所有凭据 /// </summary> public IReadOnlyList<PasswordCredential> GetAllCredentials(string resource) { try { return _vault.FindAllByResource(resource); } catch { return new List<PasswordCredential>(); } } /// <summary> /// 删除凭据 /// </summary> public void RemoveCredential(string resource, string userName) { try { var credential = _vault.Retrieve(resource, userName); _vault.Remove(credential); } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"删除凭据失败: {ex.Message}"); } } /// <summary> /// 检查凭据是否存在 /// </summary> public bool HasCredential(string resource, string userName) { try { _vault.Retrieve(resource, userName); return true; } catch { return false; } } } ``` ### 💡 12.4.2 实际应用示例:OAuth 令牌存储 ```csharp // 文件位置:Services/AuthService.cs public class AuthService { private const string TOKEN_RESOURCE = "MyApp_OAuth"; private const string ACCESS_TOKEN_KEY = "access_token"; private const string REFRESH_TOKEN_KEY = "refresh_token"; private readonly SecureStorageService _secureStorage; public AuthService() { _secureStorage = new SecureStorageService(); } /// <summary> /// 保存认证令牌 /// </summary> public void SaveTokens(string accessToken, string refreshToken) { // 分别保存访问令牌和刷新令牌 _secureStorage.SaveCredential(TOKEN_RESOURCE, ACCESS_TOKEN_KEY, accessToken); _secureStorage.SaveCredential(TOKEN_RESOURCE, REFRESH_TOKEN_KEY, refreshToken); } /// <summary> /// 获取访问令牌 /// </summary> public string GetAccessToken() { var credential = _secureStorage.GetCredential(TOKEN_RESOURCE, ACCESS_TOKEN_KEY); return credential?.Password; } /// <summary> /// 获取刷新令牌 /// </summary> public string GetRefreshToken() { var credential = _secureStorage.GetCredential(TOKEN_RESOURCE, REFRESH_TOKEN_KEY); return credential?.Password; } /// <summary> /// 检查用户是否已登录 /// </summary> public bool IsLoggedIn() { return _secureStorage.HasCredential(TOKEN_RESOURCE, ACCESS_TOKEN_KEY); } /// <summary> /// 登出:清除所有令牌 /// </summary> public void Logout() { _secureStorage.RemoveCredential(TOKEN_RESOURCE, ACCESS_TOKEN_KEY); _secureStorage.RemoveCredential(TOKEN_RESOURCE, REFRESH_TOKEN_KEY); } } ``` > **技术术语**:**Keychain**(iOS)和 **KeyStore**(Android)是操作系统级别的安全存储系统。它们使用硬件支持的加密模块(如 iPhone 的 Secure Enclave)来保护敏感数据。即使攻击者获得了设备的 root 权限,也很难从这些安全存储中提取明文数据。这就是为什么你应该始终使用 `PasswordVault` 而不是自己实现加密。 --- ## 📡 12.5 现代缓存策略:离线优先架构 如果你正在构建一个严重依赖网络 API 的应用,你需要考虑离线场景。用户可能在地铁里、飞机上,或者只是网络连接不稳定。一个优秀的应用应该能够在离线时继续工作,并在联网后自动同步。 ### 🔄 12.5.1 缓存层次结构 一个成熟的缓存系统通常包含多个层次,每一层都有不同的特点和用途。 ``` ┌─────────────────────────────────────────────┐ │ Memory Cache │ 速度:最快 │ (内存中的对象缓存) │ 持久性:应用生命周期 └─────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────┐ │ Disk Cache │ 速度:快 │ (本地文件或 SQLite 缓存) │ 持久性:应用安装期间 └─────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────┐ │ Network │ 速度:取决于网络 │ (远程 API 请求) │ 持久性:无(实时数据) └─────────────────────────────────────────────┘ ``` ### 📦 12.5.2 实现离线优先的数据服务 ```csharp // 文件位置:Services/CachedDataService.cs using Windows.Storage; public class CachedDataService { private readonly HttpClient _httpClient; private readonly SemaphoreSlim _cacheLock = new SemaphoreSlim(1, 1); // 内存缓存 private readonly Dictionary<string, (object Data, DateTime Expiry)> _memoryCache = new(); /// <summary> /// 获取数据(优先从缓存读取) /// </summary> /// <typeparam name="T">数据类型</typeparam> /// <param name="cacheKey">缓存键</param> /// <param name="apiUrl">API 地址</param> /// <param name="cacheDuration">缓存有效期</param> /// <param name="forceRefresh">是否强制刷新</param> public async Task<T> GetDataAsync<T>( string cacheKey, string apiUrl, TimeSpan cacheDuration, bool forceRefresh = false) where T : class { // 1. 检查内存缓存 if (!forceRefresh && TryGetFromMemoryCache<T>(cacheKey, out var memoryData)) { System.Diagnostics.Debug.WriteLine($"从内存缓存获取: {cacheKey}"); return memoryData; } // 2. 检查磁盘缓存 if (!forceRefresh && await TryGetFromDiskCacheAsync<T>(cacheKey, out var diskData)) { // 同时更新内存缓存 SetMemoryCache(cacheKey, diskData, cacheDuration); System.Diagnostics.Debug.WriteLine($"从磁盘缓存获取: {cacheKey}"); return diskData; } // 3. 从网络获取 try { var networkData = await FetchFromNetworkAsync<T>(apiUrl); // 更新所有缓存层 SetMemoryCache(cacheKey, networkData, cacheDuration); await SaveToDiskCacheAsync(cacheKey, networkData); System.Diagnostics.Debug.WriteLine($"从网络获取: {cacheKey}"); return networkData; } catch (HttpRequestException ex) { // 网络请求失败 System.Diagnostics.Debug.WriteLine($"网络请求失败: {ex.Message}"); // 如果有过期的磁盘缓存,即使过期也返回(离线模式) if (await TryGetFromDiskCacheAsync<T>(cacheKey, out var staleData, ignoreExpiry: true)) { System.Diagnostics.Debug.WriteLine($"使用过期缓存(离线模式): {cacheKey}"); return staleData; } throw; // 没有任何可用数据,抛出异常 } } private bool TryGetFromMemoryCache<T>(string key, out T data) where T : class { if (_memoryCache.TryGetValue(key, out var entry)) { if (DateTime.Now < entry.Expiry) { data = entry.Data as T; return data != null; } else { _memoryCache.Remove(key); // 清除过期条目 } } data = null; return false; } private void SetMemoryCache<T>(string key, T data, TimeSpan duration) { _memoryCache[key] = (data, DateTime.Now.Add(duration)); } private async Task<bool> TryGetFromDiskCacheAsync<T>( string key, out T data, bool ignoreExpiry = false) where T : class { await _cacheLock.WaitAsync(); try { StorageFolder cacheFolder = await GetCacheFolderAsync(); StorageFile cacheFile = await cacheFolder.TryGetItemAsync($"{key}.json") as StorageFile; if (cacheFile == null) { data = null; return false; } string json = await FileIO.ReadTextAsync(cacheFile); var cacheEntry = JsonSerializer.Deserialize<CacheEntry<T>>(json); if (!ignoreExpiry && DateTime.Now > cacheEntry.Expiry) { await cacheFile.DeleteAsync(); // 清除过期文件 data = null; return false; } data = cacheEntry.Data; return data != null; } catch { data = null; return false; } finally { _cacheLock.Release(); } } private async Task SaveToDiskCacheAsync<T>(string key, T data) { await _cacheLock.WaitAsync(); try { StorageFolder cacheFolder = await GetCacheFolderAsync(); StorageFile cacheFile = await cacheFolder.CreateFileAsync( $"{key}.json", CreationCollisionOption.ReplaceExisting ); var cacheEntry = new CacheEntry<T> { Data = data, CachedAt = DateTime.Now, Expiry = DateTime.Now.AddDays(7) // 磁盘缓存保留7天 }; string json = JsonSerializer.Serialize(cacheEntry); await FileIO.WriteTextAsync(cacheFile, json); } finally { _cacheLock.Release(); } } private async Task<T> FetchFromNetworkAsync<T>(string url) { var response = await _httpClient.GetAsync(url); response.EnsureSuccessStatusCode(); string json = await response.Content.ReadAsStringAsync(); return JsonSerializer.Deserialize<T>(json); } private async Task<StorageFolder> GetCacheFolderAsync() { StorageFolder localFolder = ApplicationData.Current.LocalFolder; return await localFolder.CreateFolderAsync("cache", CreationCollisionOption.OpenIfExists); } /// <summary> /// 清除所有缓存 /// </summary> public async Task ClearCacheAsync() { _memoryCache.Clear(); StorageFolder cacheFolder = await GetCacheFolderAsync(); await cacheFolder.DeleteAsync(); } } /// <summary> /// 缓存条目结构 /// </summary> public class CacheEntry<T> { public T Data { get; set; } public DateTime CachedAt { get; set; } public DateTime Expiry { get; set; } } ``` --- ## 📝 本章小结 状态管理是应用的"记忆系统",决定了用户在应用切换、设备重启、网络中断等场景下的体验。通过本章的学习,你已经掌握了构建健壮应用状态管理系统的核心技能。 让我们回顾本章的关键要点: 第一,理解应用生命周期是状态管理的基础。在移动平台和 Web 上,应用随时可能被系统终止。你必须在 `Suspending` 事件中保存状态,并在恢复时正确还原。 第二,`ApplicationData` 提供了简单高效的键值存储。对于配置信息和用户偏好这类轻量级数据,它是最佳选择。漫游设置还能实现跨设备同步。 第三,SQLite 是跨平台结构化数据存储的标准方案。通过 ORM 库如 `sqlite-net-pcl`,你可以用 C# 对象来操作数据库,无需手写 SQL。 第四,敏感数据必须使用 `PasswordVault` 安全存储。它利用各平台的原生安全设施,为密码、令牌等敏感信息提供硬件级别的保护。 第五,离线优先架构通过多级缓存策略实现。内存缓存提供最快访问,磁盘缓存确保持久性,网络获取最新数据。在网络不可用时,过期缓存也能作为最后的备选方案。 在下一章中,我们将进入企业级开发的关键领域——**身份验证与安全**。你将学习如何在 Uno Platform 中实现安全的登录流程,集成 OAuth2 认证,以及保护 API 通信。 --- > **动手实验**: > 1. **生命周期感知应用**:创建一个显示生命周期状态的应用。在屏幕上实时显示应用当前处于 Running、Suspended 还是 Resuming 状态。使用 `Suspending` 事件保存当前时间,在恢复时显示应用被挂起了多长时间。 > 2. **待办事项管理器**:使用 SQLite 实现一个完整的待办事项应用。支持添加、编辑、删除、标记完成功能。按优先级和截止日期排序显示。确保应用在被终止后能够恢复所有数据。 > 3. **离线新闻阅读器**:实现一个简单的新闻阅读器,支持离线阅读。首次加载时从网络获取数据并缓存到本地。后续启动时优先显示缓存数据,同时在后台检查更新。在网络不可用时,显示"离线模式"提示。

讨论回复

0 条回复

还没有人回复,快来发表你的看法吧!