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

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

✨步子哥 (steper) 2026年02月17日 05:29
# 第十九章:实战案例:构建一个跨平台云笔记应用 > **本章导读**:烹饪学校的毕业考试从来不是回答选择题,而是让学生走进厨房,用学到的技艺完成一道完整的菜品。学习编程亦是如此——你可以背诵所有的语法规则,阅读无数的架构文章,但只有当你亲手构建一个完整的应用时,那些碎片化的知识才会真正融会贯通,形成你的"肌肉记忆"。本章将带你完成这样一次"毕业考试":我们将从前八章学到的所有知识中汲取营养,构建一个名为 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 文件可以自适应各种屏幕尺寸。 ```csharp // 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 实体模型定义 ```csharp // 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; } } ``` ```csharp // 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 数据服务实现 ```csharp // 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 主页面布局 ```xml <!-- 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。 ```csharp // 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 条回复

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