# 第十二章:状态管理与数据持久化策略
> **本章导读**:想象你正在阅读一本精彩的小说,读到一半时,电话铃响了,你不得不放下书本去接电话。当你回来继续阅读时,你希望能够准确地从刚才停下的地方继续——而不是从第一页重新开始。应用的状态管理就是这样一个"书签"系统。在移动设备和 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 条回复还没有人回复,快来发表你的看法吧!