本章导读:烹饪学校的毕业考试从来不是回答选择题,而是让学生走进厨房,用学到的技艺完成一道完整的菜品。学习编程亦是如此——你可以背诵所有的语法规则,阅读无数的架构文章,但只有当你亲手构建一个完整的应用时,那些碎片化的知识才会真正融会贯通,形成你的"肌肉记忆"。本章将带你完成这样一次"毕业考试":我们将从前八章学到的所有知识中汲取营养,构建一个名为 NoteUno 的跨平台云笔记应用。这不仅仅是一个演示项目,而是一个具备完整功能、可以在生产环境中使用的真实应用。
NoteUno 是一个跨平台的云笔记应用,它的设计目标是展示 Uno Platform 在处理复杂业务场景时的综合能力。这个项目涵盖了我们之前学习的所有核心主题:MVVM 架构、数据绑定、响应式布局、原生功能集成、离线存储、状态管理、以及安全认证。
让我们从产品需求的角度来定义 NoteUno 应该具备的功能。这些需求的设计考虑了实际用户的痛点,同时也覆盖了我们想要展示的技术要点。
笔记管理是应用的核心功能。用户应该能够创建新笔记、编辑现有笔记、删除不需要的笔记,以及通过关键词搜索笔记内容。这个看似简单的 CRUD(增删改查)操作,在跨平台环境下需要考虑很多细节:如何在离线时处理编辑?如何处理并发冲突?如何保证数据一致性?
分类系统帮助用户组织大量的笔记。NoteUno 支持通过标签(Tag)和文件夹(Folder)两种方式来分类笔记。标签适合跨主题的灵活分类,比如同时给一篇笔记打上"工作"和"重要"两个标签;文件夹则适合层级化的组织,比如"工作/项目A/会议记录"。
Markdown 支持是现代笔记应用的标配功能。用户可以使用 Markdown 语法来格式化笔记内容,应用会实时渲染出预览效果。为了实现这个功能,我们需要集成第三方的 Markdown 解析库,并在 UI 层正确显示渲染结果。
离线优先是移动应用的重要特性。用户可能在飞机上、地铁里、或者网络信号不好的地方使用应用。NoteUno 的设计原则是:所有操作优先在本地完成,然后在有网络时自动同步到云端。这需要精心设计的数据同步策略和冲突解决机制。
跨平台同步是"云笔记"的核心价值。用户应该能够在 Windows 电脑上记录一篇笔记,然后在 iPhone 上查看和编辑,所有设备上的数据保持一致。这涉及到用户身份认证、数据加密传输、增量同步等技术挑战。
第一性原理:为什么我们需要在实战项目中学习?认知科学告诉我们,知识的深度理解来自于"迁移应用"——将抽象的概念应用到具体的情境中。阅读关于 MVVM 的文章时,你理解的是概念;但当你亲手创建一个 ViewModel、编写数据绑定、处理属性变更通知时,你对 MVVM 的理解就从"知道"层面上升到了"掌握"层面。
优秀的架构是复杂项目成功的基础。NoteUno 采用分层架构设计,将不同的关注点分离到不同的层次中,使得每一层都可以独立开发、测试和维护。
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/
费曼技巧提问:为什么要把代码分成这么多层,而不是全部写在一起?想象你在整理一个图书馆。如果所有书籍都堆在一个房间里,找一本书可能需要翻遍整个房间。但如果按主题分类放在不同的书架上,找到一本书只需要几分钟。代码分层也是同样的道理:当你需要修改某个功能时,明确的分层结构让你能够快速定位到相关的代码,而不需要在数千行代码中大海捞针。
数据是应用的核心资产,良好的数据模型设计直接影响应用的性能和可维护性。NoteUno 使用 SQLite 作为本地数据库,它是一个轻量级的嵌入式数据库,无需独立的服务器进程,非常适合移动应用。
// 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;
}
// 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)支持离线同步——删除操作可以在网络可用时同步到服务器。软删除的代价是需要在查询时过滤掉已删除的记录,并且需要定期清理真正不需要的数据。
NoteUno 需要在从手机到桌面的各种设备上提供优秀的用户体验。我们采用主从架构(Master-Detail)作为核心布局模式:在宽屏设备上,左侧显示笔记列表,右侧显示选中笔记的详情;在窄屏设备上,列表和详情分屏显示,用户通过导航在两者之间切换。
<!-- 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="" 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="" />
</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="" />
</Button>
<Button ToolTipService.ToolTip="删除"
Command="{x:Bind ViewModel.DeleteNoteCommand}">
<FontIcon Glyph="" />
</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="开始写笔记...

支持 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>
第一性原理:为什么响应式设计如此重要?答案在于"用户期望的一致性"。用户不关心你的应用是在手机上还是平板上运行,他们只关心能否高效地完成任务。响应式设计的核心是用同一套代码满足不同设备上的用户期望,这既降低了开发成本,又保证了体验的一致性。
现代笔记应用几乎都支持 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 生态的演进方向,展望这个技术栈的未来发展。
动手实验:
- 完成 NoteUno 的基础功能实现:创建项目结构,实现
Note实体类和NoteService,确保能够在本地的 SQLite 数据库中存储和读取笔记。- 实现响应式主界面:使用 VisualStateManager 创建在窄屏和宽屏下表现不同的布局。在手机模拟器和桌面环境中分别测试,验证布局切换效果。
- 集成 Markdown 编辑功能:使用 Markdig 库解析 Markdown,在
WebView控件中显示渲染结果。实现编辑/预览模式的切换功能。
还没有人回复