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

第十九章:实战案例:构建一个跨平台云笔记应用

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

第十九章:实战案例:构建一个跨平台云笔记应用

本章导读:烹饪学校的毕业考试从来不是回答选择题,而是让学生走进厨房,用学到的技艺完成一道完整的菜品。学习编程亦是如此——你可以背诵所有的语法规则,阅读无数的架构文章,但只有当你亲手构建一个完整的应用时,那些碎片化的知识才会真正融会贯通,形成你的"肌肉记忆"。本章将带你完成这样一次"毕业考试":我们将从前八章学到的所有知识中汲取营养,构建一个名为 NoteUno 的跨平台云笔记应用。这不仅仅是一个演示项目,而是一个具备完整功能、可以在生产环境中使用的真实应用。

🎯 19.1 项目目标:NoteUno

NoteUno 是一个跨平台的云笔记应用,它的设计目标是展示 Uno Platform 在处理复杂业务场景时的综合能力。这个项目涵盖了我们之前学习的所有核心主题:MVVM 架构、数据绑定、响应式布局、原生功能集成、离线存储、状态管理、以及安全认证。

📋 19.1.1 核心功能需求分析

让我们从产品需求的角度来定义 NoteUno 应该具备的功能。这些需求的设计考虑了实际用户的痛点,同时也覆盖了我们想要展示的技术要点。

笔记管理是应用的核心功能。用户应该能够创建新笔记、编辑现有笔记、删除不需要的笔记,以及通过关键词搜索笔记内容。这个看似简单的 CRUD(增删改查)操作,在跨平台环境下需要考虑很多细节:如何在离线时处理编辑?如何处理并发冲突?如何保证数据一致性?

分类系统帮助用户组织大量的笔记。NoteUno 支持通过标签(Tag)和文件夹(Folder)两种方式来分类笔记。标签适合跨主题的灵活分类,比如同时给一篇笔记打上"工作"和"重要"两个标签;文件夹则适合层级化的组织,比如"工作/项目A/会议记录"。

Markdown 支持是现代笔记应用的标配功能。用户可以使用 Markdown 语法来格式化笔记内容,应用会实时渲染出预览效果。为了实现这个功能,我们需要集成第三方的 Markdown 解析库,并在 UI 层正确显示渲染结果。

离线优先是移动应用的重要特性。用户可能在飞机上、地铁里、或者网络信号不好的地方使用应用。NoteUno 的设计原则是:所有操作优先在本地完成,然后在有网络时自动同步到云端。这需要精心设计的数据同步策略和冲突解决机制。

跨平台同步是"云笔记"的核心价值。用户应该能够在 Windows 电脑上记录一篇笔记,然后在 iPhone 上查看和编辑,所有设备上的数据保持一致。这涉及到用户身份认证、数据加密传输、增量同步等技术挑战。

第一性原理:为什么我们需要在实战项目中学习?认知科学告诉我们,知识的深度理解来自于"迁移应用"——将抽象的概念应用到具体的情境中。阅读关于 MVVM 的文章时,你理解的是概念;但当你亲手创建一个 ViewModel、编写数据绑定、处理属性变更通知时,你对 MVVM 的理解就从"知道"层面上升到了"掌握"层面。

🏗️ 19.2 架构设计

优秀的架构是复杂项目成功的基础。NoteUno 采用分层架构设计,将不同的关注点分离到不同的层次中,使得每一层都可以独立开发、测试和维护。

📊 19.2.1 整体架构分层

Model 层定义了应用的核心数据实体。这些实体是对现实世界概念的抽象表达:Note 表示一篇笔记,Tag 表示一个标签,Folder 表示一个文件夹,User 表示一个用户。这些实体类通常是简单的 POCO(Plain Old CLR Object),主要职责是承载数据,不包含复杂的业务逻辑。

Service 层封装了所有的业务逻辑和外部交互。INoteService 负责笔记的存储、检索和同步;IAuthService 负责用户身份认证;ISyncService 负责处理设备间的数据同步。通过接口定义这些服务,我们可以在测试环境中轻松替换为模拟实现,大大提高了代码的可测试性。

ViewModel 层是连接 Model 和 View 的桥梁。MainViewModel 处理笔记列表的展示和筛选;EditViewModel 处理单篇笔记的编辑和保存;SettingsViewModel 处理用户设置的读取和修改。ViewModel 不直接引用 UI 控件,而是通过数据绑定和命令来与 View 交互。

View 层使用 XAML 定义用户界面的结构和外观。针对不同的设备形态(手机、平板、桌面),我们设计不同的布局策略,但共享相同的 ViewModel。通过响应式布局和视觉状态管理,同一个 XAML 文件可以自适应各种屏幕尺寸。

// NoteUno 的项目结构
// 这个结构展示了典型的 Uno Platform 企业级应用的文件组织方式

NoteUno/
├── NoteUno.Core/                    # 共享的核心项目(非平台特定)
│   ├── Models/                      # 数据模型
│   │   ├── Note.cs                  # 笔记实体
│   │   ├── Tag.cs                   # 标签实体
│   │   ├── Folder.cs                # 文件夹实体
│   │   └── User.cs                  # 用户实体
│   ├── Services/                    # 服务接口和实现
│   │   ├── Interfaces/
│   │   │   ├── INoteService.cs      # 笔记服务接口
│   │   │   ├── IAuthService.cs      # 认证服务接口
│   │   │   └── ISyncService.cs      # 同步服务接口
│   │   └── Implementations/
│   │       ├── NoteService.cs       # 笔记服务实现
│   │       ├── AuthService.cs       # 认证服务实现
│   │       └── SyncService.cs       # 同步服务实现
│   ├── ViewModels/                  # 视图模型
│   │   ├── MainViewModel.cs         # 主页视图模型
│   │   ├── EditViewModel.cs         # 编辑页视图模型
│   │   ├── SettingsViewModel.cs     # 设置页视图模型
│   │   └── ShellViewModel.cs        # 外壳视图模型
│   └── Converters/                  # 值转换器
│       ├── BoolToVisibilityConverter.cs
│       └── DateTimeToStringConverter.cs
├── NoteUno.Shared/                  # 共享的 UI 资源
│   ├── Styles/                      # 全局样式
│   │   ├── Colors.xaml              # 颜色定义
│   │   └── Typography.xaml          # 字体定义
│   └── Templates/                   # 数据模板
│       └── NoteListItemTemplate.xaml
├── NoteUno.Mobile/                  # 移动端项目(iOS + Android)
│   ├── Views/
│   │   ├── MainPage.xaml
│   │   └── EditPage.xaml
│   └── Platforms/                   # 平台特定代码
│       ├── Android/
│       └── iOS/
├── NoteUno.Wasm/                    # WebAssembly 项目
│   ├── wwwroot/
│   │   └── index.html
│   └── Views/
└── NoteUno.Windows/                 # Windows 桌面项目
    └── Views/
费曼技巧提问:为什么要把代码分成这么多层,而不是全部写在一起?想象你在整理一个图书馆。如果所有书籍都堆在一个房间里,找一本书可能需要翻遍整个房间。但如果按主题分类放在不同的书架上,找到一本书只需要几分钟。代码分层也是同样的道理:当你需要修改某个功能时,明确的分层结构让你能够快速定位到相关的代码,而不需要在数千行代码中大海捞针。

💾 19.3 数据建模与存储

数据是应用的核心资产,良好的数据模型设计直接影响应用的性能和可维护性。NoteUno 使用 SQLite 作为本地数据库,它是一个轻量级的嵌入式数据库,无需独立的服务器进程,非常适合移动应用。

📝 19.3.1 实体模型定义

// Models/Note.cs - 笔记实体类
// 使用 sqlite-net-pcl 的特性来映射数据库表

using SQLite;
using System;

namespace NoteUno.Core.Models;

/// <summary>
/// 笔记实体类
/// 对应数据库中的 Notes 表
/// </summary>
[Table("Notes")]  // 指定数据库表名
public class Note
{
    /// <summary>
    /// 笔记的唯一标识符
    /// 由数据库自动生成
    /// </summary>
    [PrimaryKey]              // 标记为主键
    [AutoIncrement]           // 标记为自增
    [Column("id")]            // 指定数据库列名
    public int Id { get; set; }

    /// <summary>
    /// 笔记标题
    /// 不能为空
    /// </summary>
    [MaxLength(200)]          // 最大长度限制
    [NotNull]                 // 不能为空
    [Column("title")]
    public string Title { get; set; } = string.Empty;

    /// <summary>
    /// 笔记内容
    /// 支持 Markdown 格式
    /// </summary>
    [Column("content")]
    public string Content { get; set; } = string.Empty;

    /// <summary>
    /// 笔记的纯文本摘要
    /// 用于列表预览和搜索
    /// </summary>
    [MaxLength(500)]
    [Column("preview")]
    public string Preview { get; set; } = string.Empty;

    /// <summary>
    /// 创建时间
    /// 默认值为当前时间
    /// </summary>
    [Column("created_at")]
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

    /// <summary>
    /// 最后修改时间
    /// 每次保存时更新
    /// </summary>
    [Column("updated_at")]
    public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;

    /// <summary>
    /// 笔记所属文件夹的 ID
    /// 为 null 表示根目录
    /// </summary>
    [Column("folder_id")]
    public int? FolderId { get; set; }

    /// <summary>
    /// 是否已同步到云端
    /// 用于离线同步逻辑
    /// </summary>
    [Column("is_synced")]
    public bool IsSynced { get; set; } = false;

    /// <summary>
    /// 是否已删除(软删除)
    /// 删除的笔记保留一段时间后才真正删除
    /// </summary>
    [Column("is_deleted")]
    public bool IsDeleted { get; set; } = false;

    /// <summary>
    /// 云端同步 ID
    /// 用于匹配本地和云端记录
    /// </summary>
    [Column("cloud_id")]
    public string? CloudId { get; set; }

    /// <summary>
    /// 笔记的标签列表
    /// 这是一个导航属性,不存储在 Notes 表中
    /// 通过多对多关系表关联
    /// </summary>
    [Ignore]  // SQLite 忽略此属性
    public List<Tag> Tags { get; set; } = new();

    /// <summary>
    /// 从内容中提取纯文本预览
    /// 去除 Markdown 格式符号
    /// </summary>
    public void UpdatePreview()
    {
        if (string.IsNullOrEmpty(Content))
        {
            Preview = string.Empty;
            return;
        }

        // 简单的 Markdown 清理:移除常见的格式符号
        // 生产环境中可以使用更完善的 Markdown 解析器
        var plainText = Content
            .Replace("#", "")           // 移除标题标记
            .Replace("*", "")           // 移除加粗/斜体标记
            .Replace("_", "")           // 移除斜体标记
            .Replace("`", "")           // 移除代码标记
            .Replace("[", "")           // 移除链接标记
            .Replace("]", "")
            .Replace("(", "")
            .Replace(")", "")
            .Trim();

        // 截取前 200 个字符作为预览
        Preview = plainText.Length > 200
            ? plainText.Substring(0, 200) + "..."
            : plainText;
    }
}
// Models/Tag.cs - 标签实体类

using SQLite;

namespace NoteUno.Core.Models;

/// <summary>
/// 标签实体类
/// 用于对笔记进行分类
/// </summary>
[Table("Tags")]
public class Tag
{
    [PrimaryKey]
    [AutoIncrement]
    public int Id { get; set; }

    /// <summary>
    /// 标签名称
    /// 如"工作"、"个人"、"重要"等
    /// </summary>
    [MaxLength(50)]
    [NotNull]
    [Unique]  // 标签名唯一
    public string Name { get; set; } = string.Empty;

    /// <summary>
    /// 标签颜色(十六进制)
    /// 用于 UI 显示
    /// </summary>
    [MaxLength(7)]
    public string Color { get; set; } = "#0078D4";  // 默认蓝色

    /// <summary>
    /// 使用此标签的笔记数量
    /// 冗余存储,用于优化列表显示
    /// </summary>
    public int NoteCount { get; set; } = 0;
}

🔧 19.3.2 数据服务实现

// Services/Implementations/NoteService.cs - 笔记服务实现

using SQLite;
using NoteUno.Core.Models;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace NoteUno.Core.Services;

/// <summary>
/// 笔记服务的实现
/// 封装所有与笔记相关的数据库操作
/// </summary>
public class NoteService : INoteService
{
    private readonly SQLiteAsyncConnection _database;
    private readonly ILogger<NoteService> _logger;

    /// <summary>
    /// 构造函数
    /// 接收已初始化的数据库连接
    /// </summary>
    public NoteService(SQLiteAsyncConnection database, ILogger<NoteService> logger)
    {
        _database = database;
        _logger = logger;
    }

    /// <summary>
    /// 初始化数据库表结构
    /// 在应用启动时调用
    /// </summary>
    public async Task InitializeAsync()
    {
        try
        {
            // 创建所有表(如果不存在)
            await _database.CreateTableAsync<Note>();
            await _database.CreateTableAsync<Tag>();
            await _database.CreateTableAsync<NoteTag>();  // 多对多关联表
            await _database.CreateTableAsync<Folder>();

            _logger.LogInformation("数据库初始化完成");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "数据库初始化失败");
            throw;
        }
    }

    /// <summary>
    /// 获取所有笔记(不包括已删除的)
    /// </summary>
    public async Task<List<Note>> GetAllNotesAsync()
    {
        return await _database.Table<Note>()
            .Where(n => !n.IsDeleted)
            .OrderByDescending(n => n.UpdatedAt)
            .ToListAsync();
    }

    /// <summary>
    /// 根据关键词搜索笔记
    /// 在标题和内容中搜索
    /// </summary>
    public async Task<List<Note>> SearchNotesAsync(string keyword)
    {
        if (string.IsNullOrWhiteSpace(keyword))
        {
            return await GetAllNotesAsync();
        }

        // 使用 LIKE 进行模糊搜索
        // 注意:SQLite 的 LIKE 默认不区分大小写(对于 ASCII 字符)
        var searchTerm = $"%{keyword}%";

        return await _database.Table<Note>()
            .Where(n => !n.IsDeleted &&
                       (n.Title.Contains(keyword) || n.Content.Contains(keyword)))
            .OrderByDescending(n => n.UpdatedAt)
            .ToListAsync();
    }

    /// <summary>
    /// 保存笔记(新增或更新)
    /// </summary>
    public async Task<int> SaveNoteAsync(Note note)
    {
        note.UpdatedAt = DateTime.UtcNow;
        note.IsSynced = false;  // 标记为未同步
        note.UpdatePreview();   // 更新预览文本

        if (note.Id == 0)
        {
            // 新笔记:插入
            note.CreatedAt = DateTime.UtcNow;
            await _database.InsertAsync(note);
            _logger.LogInformation("创建新笔记:{Title}", note.Title);
        }
        else
        {
            // 已有笔记:更新
            await _database.UpdateAsync(note);
            _logger.LogInformation("更新笔记:{Id}", note.Id);
        }

        return note.Id;
    }

    /// <summary>
    /// 软删除笔记
    /// 笔记不会立即删除,而是标记为已删除
    /// 在下次同步时才真正删除
    /// </summary>
    public async Task DeleteNoteAsync(int noteId)
    {
        var note = await _database.FindAsync<Note>(noteId);
        if (note != null)
        {
            note.IsDeleted = true;
            note.UpdatedAt = DateTime.UtcNow;
            await _database.UpdateAsync(note);

            _logger.LogInformation("软删除笔记:{Id}", noteId);
        }
    }

    /// <summary>
    /// 获取未同步的笔记
    /// 用于云端同步
    /// </summary>
    public async Task<List<Note>> GetUnsyncedNotesAsync()
    {
        return await _database.Table<Note>()
            .Where(n => !n.IsSynced || n.IsDeleted)
            .ToListAsync();
    }

    /// <summary>
    /// 标记笔记为已同步
    /// </summary>
    public async Task MarkAsSyncedAsync(int noteId, string cloudId)
    {
        var note = await _database.FindAsync<Note>(noteId);
        if (note != null)
        {
            note.IsSynced = true;
            note.CloudId = cloudId;

            // 如果是已删除的笔记,同步后可以真正删除
            if (note.IsDeleted)
            {
                await _database.DeleteAsync(note);
                _logger.LogInformation("永久删除已同步的笔记:{Id}", noteId);
            }
            else
            {
                await _database.UpdateAsync(note);
            }
        }
    }
}
技术术语软删除(Soft Delete) 是一种数据删除策略,它不真正从数据库中删除记录,而是将记录标记为"已删除"。这种策略有几个好处:1)支持"撤销删除"功能;2)保留数据用于审计;3)支持离线同步——删除操作可以在网络可用时同步到服务器。软删除的代价是需要在查询时过滤掉已删除的记录,并且需要定期清理真正不需要的数据。

🎨 19.4 构建响应式 UI

NoteUno 需要在从手机到桌面的各种设备上提供优秀的用户体验。我们采用主从架构(Master-Detail)作为核心布局模式:在宽屏设备上,左侧显示笔记列表,右侧显示选中笔记的详情;在窄屏设备上,列表和详情分屏显示,用户通过导航在两者之间切换。

📱 19.4.1 主页面布局

<!-- Views/MainPage.xaml - 主页面布局 -->
<!-- 使用响应式设计适配不同屏幕尺寸 -->

<Page x:Class="NoteUno.Views.MainPage"
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
      xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
      xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
      xmlns:vm="using:NoteUno.Core.ViewModels"
      mc:Ignorable="d">

    <Page.DataContext>
        <vm:MainViewModel />
    </Page.DataContext>

    <Grid x:Name="RootGrid">
        <Grid.ColumnDefinitions>
            <!-- 列表列:在窄屏时占满宽度,宽屏时占 1/3 -->
            <ColumnDefinition x:Name="MasterColumn" Width="*" />
            <!-- 详情列:在窄屏时宽度为 0,宽屏时占 2/3 -->
            <ColumnDefinition x:Name="DetailColumn" Width="0" />
        </Grid.ColumnDefinitions>

        <!-- ==================== 主列表区域 ==================== -->
        <Grid Grid.Column="0"
              Background="{ThemeResource LayerFillColorDefaultBrush}">
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto" />  <!-- 标题栏 -->
                <RowDefinition Height="Auto" />  <!-- 搜索框 -->
                <RowDefinition Height="*" />     <!-- 列表 -->
            </Grid.RowDefinitions>

            <!-- 标题栏 -->
            <Grid Grid.Row="0"
                  Padding="16,12"
                  Background="{ThemeResource LayerFillColorAltBrush}">
                <TextBlock Text="我的笔记"
                          Style="{StaticResource TitleTextBlockStyle}" />

                <!-- 新建笔记按钮 -->
                <Button HorizontalAlignment="Right"
                        Command="{x:Bind ViewModel.CreateNewNoteCommand}">
                    <StackPanel Orientation="Horizontal" Spacing="8">
                        <FontIcon Glyph="&#xE710;" FontSize="16" />
                        <TextBlock Text="新建" />
                    </StackPanel>
                </Button>
            </Grid>

            <!-- 搜索框 -->
            <TextBox Grid.Row="1"
                     Margin="16,8"
                     PlaceholderText="搜索笔记..."
                     Text="{x:Bind ViewModel.SearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />

            <!-- 笔记列表 -->
            <ListView Grid.Row="2"
                      ItemsSource="{x:Bind ViewModel.Notes}"
                      SelectedItem="{x:Bind ViewModel.SelectedNote, Mode=TwoWay}"
                      SelectionMode="Single">

                <!-- 列表项模板 -->
                <ListView.ItemTemplate>
                    <DataTemplate x:DataType="vm:NoteListItemViewModel">
                        <Grid Padding="16,12"
                              ColumnSpacing="12">
                            <Grid.ColumnDefinitions>
                                <ColumnDefinition Width="*" />
                                <ColumnDefinition Width="Auto" />
                            </Grid.ColumnDefinitions>

                            <StackPanel Grid.Column="0"
                                       Spacing="4">
                                <!-- 笔记标题 -->
                                <TextBlock Text="{x:Bind Title}"
                                          Style="{StaticResource BodyStrongTextBlockStyle}"
                                          TextTrimming="CharacterEllipsis"
                                          MaxLines="1" />

                                <!-- 预览文本 -->
                                <TextBlock Text="{x:Bind Preview}"
                                          Foreground="{ThemeResource TextFillColorSecondaryBrush}"
                                          TextTrimming="CharacterEllipsis"
                                          MaxLines="2" />
                            </StackPanel>

                            <!-- 更新时间 -->
                            <TextBlock Grid.Column="1"
                                      Text="{x:Bind UpdatedAtDisplay}"
                                      Foreground="{ThemeResource TextFillColorTertiaryBrush}"
                                      VerticalAlignment="Top"
                                      FontSize="12" />
                        </Grid>
                    </DataTemplate>
                </ListView.ItemTemplate>
            </ListView>
        </Grid>

        <!-- ==================== 详情编辑区域 ==================== -->
        <!-- 在窄屏模式下隐藏(宽度为 0) -->
        <Grid Grid.Column="1"
              x:Name="DetailArea"
              Background="{ThemeResource LayerFillColorDefaultBrush}"
              BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
              BorderThickness="1,0,0,0">
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto" />  <!-- 工具栏 -->
                <RowDefinition Height="*" />     <!-- 编辑器 -->
            </Grid.RowDefinitions>

            <!-- 工具栏 -->
            <Grid Grid.Row="0"
                  Padding="16,8"
                  Background="{ThemeResource LayerFillColorAltBrush}">
                <StackPanel Orientation="Horizontal" Spacing="8">
                    <!-- 返回按钮(仅在窄屏模式下显示) -->
                    <Button x:Name="BackButton"
                            Visibility="Collapsed"
                            Command="{x:Bind ViewModel.GoBackCommand}">
                        <FontIcon Glyph="&#xE72B;" />
                    </Button>

                    <TextBlock Text="{x:Bind ViewModel.SelectedNote.Title, Mode=OneWay}"
                              Style="{StaticResource SubtitleTextBlockStyle}"
                              VerticalAlignment="Center" />
                </StackPanel>

                <!-- 操作按钮 -->
                <StackPanel Orientation="Horizontal"
                           HorizontalAlignment="Right"
                           Spacing="8">
                    <Button ToolTipService.ToolTip="保存"
                            Command="{x:Bind ViewModel.SaveNoteCommand}">
                        <FontIcon Glyph="&#xE74E;" />
                    </Button>
                    <Button ToolTipService.ToolTip="删除"
                            Command="{x:Bind ViewModel.DeleteNoteCommand}">
                        <FontIcon Glyph="&#xE74D;" />
                    </Button>
                </StackPanel>
            </Grid>

            <!-- 编辑区域 -->
            <Grid Grid.Row="1">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto" />  <!-- 标题输入 -->
                    <RowDefinition Height="*" />     <!-- 内容编辑 -->
                </Grid.RowDefinitions>

                <!-- 标题输入框 -->
                <TextBox Grid.Row="0"
                         Margin="16"
                         PlaceholderText="笔记标题"
                         Text="{x:Bind ViewModel.SelectedNote.Title, Mode=TwoWay}"
                         FontSize="24"
                         FontWeight="SemiBold"
                         Background="Transparent"
                         BorderThickness="0" />

                <!-- 内容编辑框 -->
                <TextBox Grid.Row="1"
                         Margin="16,0,16,16"
                         PlaceholderText="开始写笔记...&#x0a;&#x0a;支持 Markdown 格式"
                         Text="{x:Bind ViewModel.SelectedNote.Content, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
                         AcceptsReturn="True"
                         TextWrapping="Wrap"
                         ScrollViewer.VerticalScrollBarVisibility="Auto"
                         Background="Transparent"
                         BorderThickness="0" />
            </Grid>
        </Grid>

        <!-- ==================== 响应式视觉状态 ==================== -->
        <VisualStateManager.VisualStateGroups>
            <VisualStateGroup x:Name="WidthStates">
                <!-- 窄屏状态(手机):列表占满宽度 -->
                <VisualState x:Name="NarrowState">
                    <VisualState.StateTriggers>
                        <AdaptiveTrigger MinWindowWidth="0" />
                    </VisualState.StateTriggers>
                    <VisualState.Setters>
                        <Setter Target="MasterColumn.Width" Value="*" />
                        <Setter Target="DetailColumn.Width" Value="0" />
                        <Setter Target="DetailArea.Visibility" Value="Collapsed" />
                        <Setter Target="BackButton.Visibility" Value="Collapsed" />
                    </VisualState.Setters>
                </VisualState>

                <!-- 宽屏状态(平板/桌面):主从布局 -->
                <VisualState x:Name="WideState">
                    <VisualState.StateTriggers>
                        <AdaptiveTrigger MinWindowWidth="800" />
                    </VisualState.StateTriggers>
                    <VisualState.Setters>
                        <Setter Target="MasterColumn.Width" Value="320" />
                        <Setter Target="DetailColumn.Width" Value="*" />
                        <Setter Target="DetailArea.Visibility" Value="Visible" />
                    </VisualState.Setters>
                </VisualState>
            </VisualStateGroup>

            <!-- 详情显示状态(用于窄屏模式下的导航) -->
            <VisualStateGroup x:Name="DetailStates">
                <VisualState x:Name="ListState" />
                <VisualState x:Name="DetailState">
                    <VisualState.Setters>
                        <Setter Target="MasterColumn.Width" Value="0" />
                        <Setter Target="DetailColumn.Width" Value="*" />
                        <Setter Target="DetailArea.Visibility" Value="Visible" />
                        <Setter Target="BackButton.Visibility" Value="Visible" />
                    </VisualState.Setters>
                </VisualState>
            </VisualStateGroup>
        </VisualStateManager.VisualStateGroups>
    </Grid>
</Page>
第一性原理:为什么响应式设计如此重要?答案在于"用户期望的一致性"。用户不关心你的应用是在手机上还是平板上运行,他们只关心能否高效地完成任务。响应式设计的核心是用同一套代码满足不同设备上的用户期望,这既降低了开发成本,又保证了体验的一致性。

✍️ 19.5 进阶功能:Markdown 编辑器

现代笔记应用几乎都支持 Markdown 格式,它让用户可以用简单的纯文本语法实现丰富的格式效果。NoteUno 集成了 Markdig 库来解析 Markdown,并在 UI 层渲染为 HTML。

// ViewModels/EditViewModel.cs - Markdown 编辑功能

using Markdig;
using NoteUno.Core.Models;
using NoteUno.Core.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace NoteUno.Core.ViewModels;

/// <summary>
/// 笔记编辑视图模型
/// 包含 Markdown 渲染功能
/// </summary>
public partial class EditViewModel : ObservableObject
{
    private readonly INoteService _noteService;
    private readonly MarkdownPipeline _markdownPipeline;

    // 当前编辑的笔记
    [ObservableProperty]
    private Note _currentNote;

    // 渲染后的 HTML 内容
    [ObservableProperty]
    private string _renderedHtml = string.Empty;

    // 是否显示预览(vs 编辑模式)
    [ObservableProperty]
    private bool _isPreviewMode;

    public EditViewModel(INoteService noteService)
    {
        _noteService = noteService;

        // 配置 Markdown 解析管线
        // 启用常用的扩展功能
        _markdownPipeline = new MarkdownPipelineBuilder()
            .UseAdvancedExtensions()      // 启用高级扩展(表格、任务列表等)
            .UseAutoLinks()               // 自动识别链接
            .UseTaskLists()               // 任务列表支持
            .UsePipeTables()              // 管道表格
            .Build();
    }

    /// <summary>
    /// 加载指定 ID 的笔记
    /// </summary>
    public async Task LoadNoteAsync(int noteId)
    {
        var notes = await _noteService.GetAllNotesAsync();
        CurrentNote = notes.FirstOrDefault(n => n.Id == noteId)
                      ?? new Note { Title = "新笔记" };

        UpdateRenderedHtml();
    }

    /// <summary>
    /// 当笔记内容变化时,更新渲染的 HTML
    /// </summary>
    partial void OnCurrentNoteChanged(Note value)
    {
        UpdateRenderedHtml();
    }

    /// <summary>
    /// 将 Markdown 内容转换为 HTML
    /// </summary>
    private void UpdateRenderedHtml()
    {
        if (CurrentNote == null || string.IsNullOrEmpty(CurrentNote.Content))
        {
            RenderedHtml = "<p style='color:gray;'>开始输入内容...</p>";
            return;
        }

        try
        {
            // 使用 Markdig 将 Markdown 转换为 HTML
            var html = Markdown.ToHtml(CurrentNote.Content, _markdownPipeline);

            // 添加基础样式,使 HTML 在 WebView 中显示更美观
            var styledHtml = $@"
                <!DOCTYPE html>
                <html>
                <head>
                    <meta charset='utf-8'>
                    <meta name='viewport' content='width=device-width, initial-scale=1.0'>
                    <style>
                        body {{
                            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
                            line-height: 1.6;
                            padding: 16px;
                            color: #333;
                        }}
                        h1, h2, h3 {{ margin-top: 1.5em; }}
                        code {{
                            background: #f4f4f4;
                            padding: 2px 6px;
                            border-radius: 4px;
                        }}
                        pre {{
                            background: #f4f4f4;
                            padding: 12px;
                            border-radius: 8px;
                            overflow-x: auto;
                        }}
                        blockquote {{
                            border-left: 4px solid #ddd;
                            margin-left: 0;
                            padding-left: 16px;
                            color: #666;
                        }}
                        table {{
                            border-collapse: collapse;
                            width: 100%;
                        }}
                        th, td {{
                            border: 1px solid #ddd;
                            padding: 8px;
                            text-align: left;
                        }}
                    </style>
                </head>
                <body>
                    {html}
                </body>
                </html>";

            RenderedHtml = styledHtml;
        }
        catch (Exception ex)
        {
            RenderedHtml = $"<p style='color:red;'>渲染错误:{ex.Message}</p>";
        }
    }

    /// <summary>
    /// 切换预览/编辑模式
    /// </summary>
    [RelayCommand]
    private void TogglePreview()
    {
        IsPreviewMode = !IsPreviewMode;

        // 切换到预览模式时,更新渲染内容
        if (IsPreviewMode)
        {
            UpdateRenderedHtml();
        }
    }

    /// <summary>
    /// 保存当前笔记
    /// </summary>
    [RelayCommand]
    private async Task SaveAsync()
    {
        if (CurrentNote != null)
        {
            await _noteService.SaveNoteAsync(CurrentNote);
        }
    }
}

📝 本章小结

本章我们通过构建 NoteUno 云笔记应用,将前十八章学习的理论知识付诸实践。从需求分析到架构设计,从数据建模到 UI 实现,我们完整地走过了跨平台应用开发的每一个关键环节。

在这个过程中,有几个经验教训特别值得铭记。首先是性能优先原则:在处理长列表时,务必使用虚拟化技术(ItemsStackPanel),只渲染可见区域的项目,否则用户会遭遇严重的滚动卡顿。其次是防御性编程:跨平台文件 I/O 操作极易因权限问题或路径差异而失败,所有外部交互都应该包裹在 try-catch 中,并提供友好的错误提示。第三是状态保存意识:移动操作系统可能会在后台随时终止你的应用,务必在 OnNavigatedFrom 等生命周期方法中保存用户的编辑状态,防止数据丢失。

通过 NoteUno 这个项目,你应该已经深刻体会到 Uno Platform 带来的开发便利:同一套业务逻辑代码,能够在完全不同的设备形态上表现出一致的行为。这种"一次编写,处处运行"的能力,正是跨平台框架的核心价值所在。

在下一章——也是全书的最后一章——中,我们将跳出技术细节,从更高的视角探讨 Uno Platform 与 .NET 生态的演进方向,展望这个技术栈的未来发展。


动手实验
  1. 完成 NoteUno 的基础功能实现:创建项目结构,实现 Note 实体类和 NoteService,确保能够在本地的 SQLite 数据库中存储和读取笔记。
  2. 实现响应式主界面:使用 VisualStateManager 创建在窄屏和宽屏下表现不同的布局。在手机模拟器和桌面环境中分别测试,验证布局切换效果。
  3. 集成 Markdown 编辑功能:使用 Markdig 库解析 Markdown,在 WebView 控件中显示渲染结果。实现编辑/预览模式的切换功能。

讨论回复

0 条回复

还没有人回复