您正在查看静态缓存页面 · 查看完整动态版本 · 登录 参与讨论

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

✨步子哥 (steper) 2026年02月17日 05:28 0 次浏览

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

本章导读:想象你正在阅读一本精彩的小说,读到一半时,电话铃响了,你不得不放下书本去接电话。当你回来继续阅读时,你希望能够准确地从刚才停下的地方继续——而不是从第一页重新开始。应用的状态管理就是这样一个"书签"系统。在移动设备和 Web 浏览器中,系统随时可能因为内存不足而终止你的应用后台。如何在被"杀掉"后优雅地恢复?如何让用户感觉应用从未离开过?本章将揭示这些问题的答案,带你构建真正具有"记忆"的应用。

🔄 12.1 应用生命周期:理解"幸存"的艺术

在深入技术细节之前,我们需要先理解移动应用与桌面应用的根本差异。在传统的 Windows 桌面应用中,用户决定何时关闭应用——应用可以一直运行,直到用户点击关闭按钮。但在移动世界和 Web 世界中,这个主动权被操作系统夺走了。

当用户按下 Home 键切换到另一个应用时,你的应用并不会立即终止,而是进入"后台"状态。如果系统内存充足,它可能会在后台保持一段时间。但当用户打开更多应用、系统内存紧张时,操作系统会毫不留情地终止你的应用进程——而且不会给你任何警告。这就是为什么移动应用必须具备"断点续传"的能力。

第一性原理:为什么移动操作系统要如此"残忍"地杀掉后台应用?答案在于资源约束。移动设备的内存和电池都是有限的。如果所有应用都在后台持续运行,设备很快就会变得缓慢甚至死机,电池也会在几小时内耗尽。操作系统必须做出取舍:优先保证前台应用的流畅体验,牺牲后台应用的存活。这是一种"残酷但必要"的资源调度策略。

📊 12.1.1 生命周期状态图

Uno Platform 映射了 WinUI 的生命周期事件,让开发者能够在关键时刻保存和恢复状态。让我们通过一个状态图来理解应用的整个生命周期。

┌─────────────┐
│  NotRunning │  应用尚未启动或已被终止
└──────┬──────┘
       │ 用户点击应用图标
       ▼
┌─────────────┐
│  Running    │  应用在前台运行,与用户交互
└──────┬──────┘
       │ 用户按 Home 键或切换到其他应用
       ▼
┌─────────────┐
│ Suspending  │  应用即将进入后台(约5秒窗口)
└──────┬──────┘
       │ 保存状态完成
       ▼
┌─────────────┐
│ Suspended   │  应用在后台,进程可能仍存在
└──────┬──────┘
       │ 用户返回应用 或 系统终止进程
       ▼
┌─────────────┐
│ Resuming    │  应用从后台恢复(如果进程仍存在)
└──────┬──────┘
       │
       ▼
┌─────────────┐
│  Running    │  继续运行
└─────────────┘

🎯 12.1.2 处理生命周期事件

// 文件位置: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 是一个轻量级的字典存储,数据保存在设备本地,不会跨设备同步。它适合存储设备相关的偏好设置。

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 同步。

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 项目,添加以下依赖:

<!-- 在 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 中的最佳实践:

// 文件位置: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)用于配置表名、列名、索引等。

// 文件位置: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 模式来封装数据访问逻辑:

// 文件位置: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 会调用各平台的底层安全设施来保护你的数据。这意味着你不需要自己实现加密算法——平台已经为你提供了经过安全审计的解决方案。

// 文件位置: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 令牌存储

// 文件位置: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 实现离线优先的数据服务

// 文件位置: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 条回复

还没有人回复