前言

WinUI 基于 Windows App SDK(就是头文件、库和工具集),使用 XAML (前端)和 C# 或 C++ (后端)创建出符合 Windows 用户期望 Fluent Design 外观和风格的应用。

介于这是聪明的巨硬又一次做出的愚蠢决定,以后很可能被抛弃,学习纯属娱乐。

本文为作者跟着官方教程制作一个NotesApp(WinUINotes)所记录的笔记(放心吧,你可以不用去看官方文档了,我把文档内容中译中了方便想阅读本文的人理解,一起学习)

我已经跟着微软的教程成功新建并构建了项目,如果想要使用本文入门WinUI,需要先查看此教程快速入门:设置环境并创建 WinUI 3 project,通过此教程学习如何利用 Visual Studio 创建项目并构建项目。

其实学习 Visual Studio 并不是错误的选择,这个IDE自带开发环境,帮忙自动配置好,非常方便。

新建项目

打开 Visual Studio ,点击右侧的新建项目按钮

在这里搜索“winui”模板

可以发现有两个模板,简单来说,本文选择 Blank App, Packaged (WinUI 3 in Desktop)(WinUI空白应用(已打包))

  • 选择“Blank App, Packaged (WinUI 3 in Desktop)”单项目,应用和打包配置在一起。
  • 选择“Blank App, Packaged with Windows Application Packaging Project”双项目,应用和打包器分开。

打包器就是把复杂项目所有东西放到一起,统一签名和打包,生成 .msix 或 .msixbundle 安装包。

根据下方图片输入"WinUINotes",然后点击“ 创建

项目文件用途

点击菜单栏的"视图 - 解决方案管理器"

在解决方案资源管理器中可以看到 WinUI 项目的文件

Assets 文件夹:可以存放图片等资源文件

App.xaml 和 App.xaml.cs程序入口点(程序的启动逻辑),可以控制全局样式,启动逻辑等。

MainWindow.xaml 和 MainWindow.xaml.cs应用的主窗口(用户看到的第一个界面),用界面层(XAML 标记语言写)和 逻辑层(C#写)

Package.appxmanifest :Visual Studio 自动生成的清单文件,包项目配置文件(这个不重要)

修改界面

由于我之前写过 HTML ,所以这里用标签指代 <object> 方便理解。

打开 MainWindow.xaml

可以看到默认是这样的👇

<!--?xml version="1.0" encoding="utf-8"?-->
<window x:class="WinUINotes.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:WinUINotes" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:ignorable="d" title="WinUINotes">

    <window.systembackdrop>
        <micabackdrop>
    </micabackdrop></window.systembackdrop>

    <grid>

    </grid>
</window>

在 MainWindow.xaml ,可以控制窗口的内容,就是图中的这片区域:

窗口的顶部区域由 Windows 系统控制,它包括标题栏,标题控件(最小/最大/关闭按钮)、应用图标、标题和拖动区域。 它还包括包围窗口外侧的框架。

XAML入门

众所不周知,HTML实际就是面向对象编程,因为每个元素都可以添加修改属性(

在XAML里面,这也是一样的。既然没学过XML,那就直接理解XAML好了,不用看两者区别了。

这里与HTML及其相似,元素在XAML中叫做标记大多数标签是成对出现的,即一个开标签(如 <div>)和一个闭标签(如 </div>),自闭合也是可以用的,例如 <Canvas />,它们之间的内容就是元素(标记)的内容。

一个 XAML 元素标签就代表创建一个对象

<!-- 创建一个按钮对象 -->
<Button />

<!-- 创建一个文本框对象 -->
<TextBox />

可以给对象设置属性

XAML 也可以在标签里添加属性,例如简单的属性语法 <object attribute="value"> 也就是 属性名="值" ,如果属性过于复杂或者想要让格式更加整齐,可以使用复杂的属性语法。形式为 <类型.属性名> 嵌套在标签内:

<object>
    <object.property>
        value
    </object.property>
</object>

先不要在语法这里浪费时间与热情,等下边做边学,会讲到的awa

给界面添加一些内容

<Window.. > 标签的内容替换为以下代码:

<window.systembackdrop>
    <micabackdrop kind="Base">
</micabackdrop></window.systembackdrop>

<grid>
    <grid.rowdefinitions>
        <!-- Title Bar -->
        <rowdefinition height="Auto">
        <!-- App Content -->
        <rowdefinition height="*">     
    </rowdefinition></rowdefinition></grid.rowdefinitions>
    <titlebar x:name="AppTitleBar" title="WinUI Notes">
        <titlebar.iconsource>
            <fonticonsource glyph="">
        </fonticonsource></titlebar.iconsource>
    </titlebar>

    <!-- App content -->
    
</grid>

现在看起来是这样的:

按 Ctrl + S 或点击工具栏中的“保存”图标都可以保存文件。

保存后点击顶部的编译按钮

可以看到成功构建了窗口,窗口也显示了 TitleBar 元素,就是标题栏下方的图标和"WinUI Notes"文字

去掉自带的标题栏

其实,我们是想把这个原本的难看的系统标题栏替换掉,换成 TitleBar 元素内容。

需要在 MainWindow.xaml.cs 写一些代码来替换掉系统标题栏。

打开 MainWindow.xaml.cs .....?这个文件被隐藏了,需要在 MainWindow.xaml 代码编辑窗口中按下 F7 ,或者在编辑窗口右键并选择“查看代码”也是可以的。

其实在解决方案资源管理器展开 MainWindow.xaml 也可以看到 MainWindow.xaml.cs qwq

可以看到 MainWindow.xaml.cs 默认内容是这样的:

using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Navigation;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Foundation;
using Windows.Foundation.Collections;

// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.

namespace WinUINotes
{
    /// <summary>
    /// An empty window that can be used on its own or navigated to within a Frame.
    /// </summary>
    public sealed partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }
    }
}

将 MainWindow 函数的代码替换为:

public MainWindow()
{
    this.InitializeComponent();

    // ↓ 添加了这些内容 ↓
    // 隐藏默认系统标题栏
    ExtendsContentIntoTitleBar = true;
    // 用 WinUI TitleBar 替换系统标题栏
    SetTitleBar(AppTitleBar);
    // ↑ 添加了这些内容 ↑
}

这里的 AppTitleBar 对应了之前在 MainWindow.xaml 写的 TitleBar 的参数。

按下 F5 可以直接调试编译()

保存并编译一下,可以看到成功替换了标题栏:

学到这里很厉害啦,可以休息一下了,等会再来学习后面的内容..

创建一个新的页面

现在,我们将创建一个页面,允许用户编辑笔记,然后编写代码以保存或删除笔记。

在解决方案资源管理器的项目名"WinUINotes"处右键点击,在菜单中选择"添加 - 新建项"

点击左下角的"显示所有模板"

选择"空白页"模板,修改名称为 NotePage.xaml,点击添加

给新的页面添加内容

打开 NotePage.xaml ,可以看到默认内容是这样的👇

<?xml version="1.0" encoding="utf-8"?>
<Page
    x:Class="WinUINotes.NotePage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:WinUINotes"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d">

    <Grid>

    </Grid>
</Page>

 将 <Grid> ... </Grid> 元素中的内容替换为以下代码:

<Grid Padding="16" RowSpacing="8">
    <Grid.RowDefinitions>
        <RowDefinition Height="*"/>
        <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*"/>
        <ColumnDefinition Width="400"/>
        <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>

    <TextBox x:Name="NoteEditor"
         AcceptsReturn="True"
         TextWrapping="Wrap"
         PlaceholderText="Enter your note"
         Header="New note"
         ScrollViewer.VerticalScrollBarVisibility="Auto"
         Width="400"
         Grid.Column="1"/>

    <StackPanel Orientation="Horizontal"
            HorizontalAlignment="Right"
            Spacing="4"
            Grid.Row="1" Grid.Column="1">
        <Button Content="Save" Style="{StaticResource AccentButtonStyle}"/>
        <Button Content="Delete"/>
    </StackPanel>
</Grid>

现在看起来应该是这样的:

回到 MainWindow.xaml,将 Frame 的内容修改为

<Frame x:Name="rootFrame" Grid.Row="1" SourcePageType="local:NotePage"/>
  • Frame:框架

Frame 本身不显示内容,而是负责在多个 Page(页面)之间切换。

现在来编译一下看看效果 :)

现在我们可以来研究一下这个页面的构成了。

XAML基础

回顾我们在 MainWindow.xaml 写的代码,可以注意到我们把控件写在了 Grid (网格)元素中:

  • Grid网格(布局容器,用于按行和列排列子元素)
  • RowDefinition行定义(定义网格中某一行的属性,如高度)

可以看到,我们把网格的每一行的属性写在了 <Grid.RowDefinitions> 元素里面,有两个 <RowDefinitions>也就是创建了2个行

行是从0开始数的,在子元素中通过 Grid.Row="哪一行" 属性来指定该元素所处的行。可以看看下面代码的注释,方便理解~

    <Grid>
        <!-- Grid.RowDefinitions 定义网格的行 -->
        <Grid.RowDefinitions>
            <!-- 标题栏行:高度自适应(Auto) -->
            <!-- 从上往下定义,现在规定第0行的高度 -->
            <RowDefinition Height="Auto" />
            <!-- 内容行:占满剩余的所有空间(*) -->
            <!-- 从上往下定义,现在规定第1行的高度 -->
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>
        
        <!-- 以下为子元素(也就是内容) -->
        <!-- 现在是第一个子元素,没写Grid.Row属性默认放在第0行 -->
        <TitleBar x:Name="AppTitleBar"
              Title="WinUI Notes">
            <TitleBar.IconSource>
                <FontIconSource Glyph="&#xF4AA;"/>
            </TitleBar.IconSource>
        </TitleBar>

        <!-- 内容 -->
        <!-- 现在是第二个子元素,Grid.Row属性为1放在第1行 -->
        <Frame x:Name="rootFrame" Grid.Row="1" SourcePageType="local:NotePage"/>
    </Grid>

现在再查看 NotePage.xaml 中的代码,同样可以看到我们把所有子元素写在了 Grid (网格)元素中:

  • RowDefinition行定义(定义网格中某一行的属性,如高度)
  • Grid.ColumnDefinitions列定义(定义网格中某一列的属性,如宽度)

可以数一下,有两个 <RowDefinitions> ,三个 <ColumnDefinitions>,所以这个网格规定了 2 行 3 列

我们给 <RowDefinitions>定义)设置了 Height="*" (高度="占满剩余空间")以及 Height="Auto" (高度="高度自适应")属性,给 <ColumnDefinitions>定义)设置了 Width="*" (宽度="占满剩余空间")以及 Width="400" (宽度="400")属性。

你可能注意到了,宽度="400"是什么...?

其实这里代表宽度为 400epx,在 XAML 不用 px(像素)来表达布局尺寸和间距,而是使用 epx (有效像素),这个 epx 单位是一个虚拟的度量单位(而 px 是实际存在的物理的像素单位),它会自适应调整间距,只要你设置一个合适的值即可。

通过这张图也可以清晰看到网格的行列,以及每个格子。(顶部的一行是在 MainWindow.xaml 规定的,内容区域的两行是在 NotePage.xaml 规定的)

我们继续研究 NotePage.xaml 。可以发现界面是英文的,如何把每个元素的文本改为中文呢?

其实看一下也能理解这几个控件了,还是简单介绍一下吧:

  • TextBox:文本框(用于接收用户输入或显示文本的控件,支持单行或多行)
  • Button:按钮(用于触发操作或命令的控件,用户单击时执行逻辑)

通过对比,可以发现注释编辑器的标题是 Header="" 属性在控制,灰色提示文字是 PlaceholderText="" 属性在控制;两个按钮显示的文本是 Content="" 属性在控制

将其中的英文替换为中文,就像这样👇

<!-- 注释编辑器 -->
<TextBox x:Name="NoteEditor"
 AcceptsReturn="True"
 TextWrapping="Wrap"
 PlaceholderText="请输入注释文字"
 Header="新建笔记"
 ScrollViewer.VerticalScrollBarVisibility="Auto"
 Width="400"
 Grid.Column="1"/>
<!-- 操作按钮 -->
<StackPanel Orientation="Horizontal" 
    HorizontalAlignment="Right"
    Spacing="4"
    Grid.Row="1" Grid.Column="1">
    <!-- 保存按钮 -->
    <Button Content="保存" Style="{StaticResource AccentButtonStyle}"/>
    <!-- 删除按钮 -->
    <Button Content="删除"/>
</StackPanel>

保存后编译,看看效果:

你可能也注意到了,正常情况下页面是竖向排列,可是按钮区域却是横向排列子元素

观察按钮区域的代码,可以发现按钮在 <StackPanel Orientation="Horizontal" ... > ... </StackPanel>

<!-- 操作按钮 -->
<StackPanel Orientation="Horizontal" 
    HorizontalAlignment="Right"
    Spacing="4"
    Grid.Row="1" Grid.Column="1">
    <!-- 保存按钮 -->
    <Button Content="保存" Style="{StaticResource AccentButtonStyle}"/>
    <!-- 删除按钮 -->
    <Button Content="删除"/>
</StackPanel>
  • StackPanel:堆叠面板(将子元素放在同一行列,可水平或垂直排列,默认垂直排列)

左边的按钮是蓝色的,这是因为它添加了 Style="{StaticResource AccentButtonStyle}" 属性,这就很像 HTML 中的 Class 属性

实现点击按钮功能

打开 NotePage.xaml.cs 文件,我们要开始写后端啦

这个文件默认是这样的:

using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Navigation;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Foundation;
using Windows.Foundation.Collections;

// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.

namespace WinUINotes
{
    /// 
    /// An empty page that can be used on its own or navigated to within a Frame.
    /// 
    public sealed partial class NotePage : Page
    {
        public NotePage()
        {
            InitializeComponent();
        }
    }
}

InitializeComponent(); 就是触发解析 XAML 的函数。触发后就可以把内容渲染到窗口中了

XAML 本身只是一段文本,它不能直接渲染。必须由框架(WinUI / WPF / UWP)中的 XAML 解析器( C# 的函数实现)来读取、解析

如果学过类,那么可以轻松理解这里的知识点。在这里简单说一下:

类其实就是模板,类可以定义很多成员函数,每个成员函数都可以实现一个单独小功能,放到一个类里面就可以完整实现一个功能(就像Python的一个库那样子),这样就可以把程序的每一个部分分开维护,并且大大减少了代码量,通过调用自己写好的成员函数即可实现一个功能。(在C#中,所有代码必须属于类/结构,函数也是这样的,这就是面向对象编程)

之所以叫做成员函数,因为在类里面写的函数就是类的其中一员字面意思。也可以在类定义成员变量,这样这些变量可以通过类来访问

在类里面可以给每个成员声明 public(公共)或者 private(私有),私有的成员变量/函数只能被本类的成员调用;相反,公共的成员不再限于在本类调用,其他类的成员也可以调用

刚刚说了,类只是模板。可以通过构造将类变成对象,的每个对象都是独立的

例如:定义一个类,叫做笔记页面

public class 笔记页面模板 { ...很多与页面相关的功能写在里面了 } 

然后通过 new 创建(构造)一个页面(对象)

笔记页面模板 第一页 = new 笔记页面模板();

不知道你理解了吗(doge)

声明用到的函数

将下面的变量声明代码添加到 NotePage 类中

private StorageFolder storageFolder = ApplicationData.Current.LocalFolder;
private StorageFile? noteFile = null;
private string fileName = "note.txt";

就是放到这里:

public sealed partial class NotePage : Page
{
    // ↓ 添加了这些内容 ↓
    private StorageFolder storageFolder = ApplicationData.Current.LocalFolder;
    private StorageFile? noteFile = null;
    private string fileName = "note.txt";
    // ↑ 添加了这些内容 ↑
    public NotePage()
    {
        InitializeComponent();
    }
}

第 1 行:private StorageFolder storageFolder = ApplicationData.Current.LocalFolder;

定义了一个 private内部变量数据类型StorageFolder变量名storageFolderLocalFolder 这个对象。用来获取本地数据存储根目录,保存文件时会用到

第 2 行:private StorageFile? noteFile = null;

定义了一个 private内部变量数据类型StorageFolder?表示允许为null(空值),变量名noteFilenull 。用来临时存文件的,之后会用到

第 3 行:private string fileName = "note.txt";

定义了一个 private内部变量数据类型string变量名fileName (嗯对,就是字面意思),为 "note.txt"。就是字面意思,定义一个文件名变量(在C#中字符串必须双引号(单引号是字符))

添加加载文件事件函数

将下面的函数声明代码添加到 NotePage 类中

public NotePage()
{
    this.InitializeComponent();
    // ↓ 之前的内容是存在的,只需要添加这一行即可 ↓
    Loaded += NotePage_Loaded;
    // ↑ 之前的内容是存在的,只需要添加这一行即可 ↑
}

// ↓ 添加事件处理程序函数 ↓
private async void NotePage_Loaded(object sender, RoutedEventArgs e)
{
    noteFile = (StorageFile)await storageFolder.TryGetItemAsync(fileName);
    if (noteFile is not null)
    {
        NoteEditor.Text = await FileIO.ReadTextAsync(noteFile);
    }
}

这段代码的作用是在笔记页面(NotePage)加载完成后,自动从本地存储中读取一个 fileName 文本文件(对应之前写的 note.txt ),并将其内容显示在页面上的文本框(NoteEditor)的 Text 属性中

Loaded += NotePage_Loaded; 当页面完成布局并准备就绪时,会自动触发 Loaded 事件,从而执行这个 NotePage_Loaded 函数

可以注意到在声明成员函数(private async void NotePage_Loaded(object sender, RoutedEventArgs e))时使用了 async 关键字

async:在后台执行操作,也就是异步处理。这样,在读取文件期间,页面可以正常显示、用户可以进行滚动等操作,UI 界面不会出现“卡死”现象

await:等待本行代码异步操作完成,然后继续执行下面的代码

添加保存和删除按钮事件处理函数

回到 NotePage.xaml 文件

Click="" 添加到保存按钮中,此时会提示自动补全

点击 <新建事件处理程序> 选项,此时 Visual Studio 会自动帮我们创建好 Button_Click 函数

修改后的代码看起来就是这样的:

<!-- 保存按钮新增了属性 Click="Button_Click" -->
<Button Content="保存" Style="{StaticResource AccentButtonStyle}" Click="Button_Click"/>

再回到 NotePage.xaml.cs 文件,可以看到在我们的 NotePage 类下方出现了成员函数 private void Button_Click(object sender, RoutedEventArgs e)

Button_Click 函数名是自动生成的,我们需要改一个函数名

在 Button_Click 将光标移到函数名之前,就像这样:

输入将函数名称改为 saveButton_Click。这个函数可能在任何位置被调用,所以我们不仅需要修改这里,还需要修改

Visual Studio 可以同步修改所有位置的该函数名,只需要将鼠标移到函数名中,在弹出的提示框中点击显示可能的修补程序

选择 将"Button_Click"重命名为"saveButton_Click” 选项即可

回到 NotePage.xaml 文件中再次查看,可以看到成功被修改

再次重复对 “删除 ”按钮重复这些步骤,创建好删除按钮的成员函数

现在我们就可以写代码实现保存和删除笔记文件的功能了

在 SaveButton_Click 成员函数中添加以下代码实现保存文件的功能:

private void saveButton_Click(object sender, RoutedEventArgs e)
{
    if (noteFile is null)
    {
        noteFile = await storageFolder.CreateFileAsync(fileName, CreationCollisionOption.ReplaceExisting);
    }
    await FileIO.WriteTextAsync(noteFile, NoteEditor.Text);
}

粘贴进去之后 Visual Studio 直接就报错了,可以看到是异步处理的问题。回顾之前学习的内容,函数如果需要调用异步 API,声明函数时需要添加 async 关键字

将 async 添加到函数的声明中,这个报错就解决了,就像这样:

private async void saveButton_Click(object sender, RoutedEventArgs e)

在 deleteButton_Click 成员函数中添加以下代码实现删除文件的功能, 还需要注意在声明函数中添加 async 关键字

private async void deleteButton_Click(object sender, RoutedEventArgs e)
{
    if (noteFile is not null)
    {
        await noteFile.DeleteAsync();
        noteFile = null;
        NoteEditor.Text = string.Empty;
    }
}

最终的 NotePage.xaml.cs 文件中的 NotePage 类就是这样的了👇

public sealed partial class NotePage : Page
{
    // ↓ 添加了这些内容 ↓
    private StorageFolder storageFolder = ApplicationData.Current.LocalFolder;
    private StorageFile? noteFile = null;
    private string fileName = "note.txt";
    // ↑ 添加了这些内容 ↑
    public NotePage()
    {
        this.InitializeComponent();
        // ↓ 之前的内容是存在的,只需要添加这一行即可 ↓
        Loaded += NotePage_Loaded;
        // ↑ 之前的内容是存在的,只需要添加这一行即可 ↑
    }

    // ↓ 添加事件处理程序函数 ↓
    private async void NotePage_Loaded(object sender, RoutedEventArgs e)
    {
        noteFile = (StorageFile)await storageFolder.TryGetItemAsync(fileName);
        if (noteFile is not null)
        {
            NoteEditor.Text = await FileIO.ReadTextAsync(noteFile);
        }
    }

    private async void saveButton_Click(object sender, RoutedEventArgs e)
    {
        if (noteFile is null)
        {
            noteFile = await storageFolder.CreateFileAsync(fileName, CreationCollisionOption.ReplaceExisting);
        }
        await FileIO.WriteTextAsync(noteFile, NoteEditor.Text);
    }

    private async void deleteButton_Click(object sender, RoutedEventArgs e)
    {
        if (noteFile is not null)
        {
            await noteFile.DeleteAsync();
            noteFile = null;
            NoteEditor.Text = string.Empty;
        }
    }
}

保存,编译,查看效果:

恭喜恭喜可以再休息一下了捏(好厉害

重构项目结构

接下来重构现在的代码!!!

参考教程原文中的内容就是这样安排的。其实就是按照开发规范,将XAML页面以及对应的.cs文件放到页面文件夹,将页面的数据放到另一个文件夹。

如果你想看原文,可以展开看看

在本教程的前面步骤中,你向project添加了一个新页面,允许用户保存、编辑或删除单个笔记。 但是,由于应用需要处理多个笔记,因此需要添加另一个页面来显示所有备注(调用它 AllNotesPage)。 通过此页面,用户可以选择要在编辑器页中打开的备注,以便用户可以查看、编辑或删除它。 它还应该允许用户创建新笔记。

若要实现此目的, AllNotesPage 需要具有笔记集合和显示集合的方法。 这是应用遇到问题的地方,因为笔记数据紧密绑定到 NotePage 文件。 在 AllNotesPage中,只需显示列表或其他集合视图中的所有笔记,其中包含有关每个笔记的信息,如创建日期和文本预览。 由于笔记文本紧密绑定到TextBox控件,因此无法做到这一点。

重构现有代码以将模型与视图分开。 接下来的几个步骤将组织代码,以便分别定义视图和模型。

按照下图的提示,在解决方案资源管理器中新建两个文件夹

分别命名为 ModelsViewsModels 存放模块化的后端代码,Views 存放不同页面的前端代码

迁移前端

将 NotePage.xaml 拖到 Views 文件夹里

如果看到有关移动操作可能需要很长时间的警告,直接点击 即可

会看到下图这个弹窗,我们点击

点击确定按钮

命名空间可以再多一个分类层级,可以有两个不同命名空间里的 NotePage 类,互不干扰,需要时使用 using 即可引入命名空间。默认命名空间就是 “项目根命名空间 + 文件夹路径(用点分隔)”,所以是 WinUINotes.Views

现在 NotePage.xaml 已经移动到 Views 文件夹,接着需要更新命名空间

命名空间原本为 WinUINotes,现在要改为 WinUINotes.Views

打开 NotePage.xaml.cs,将找到下图这一行代码,改为 WinUINotes.Views

namespace WinUINotes.Views

打开 NotePage.xaml ,将找到下图这一行代码, 将 x:Class 属性的值改为 WinUINotes.Views.NotePage

x:Class="WinUINotes.Views.NotePage"

x:Class :后端代码绑定

"WinUINotes.Views.NotePage" 就是这个 XAML 文件的后端(C#)位于 WinUINotes.Views 命名空间下,类名为 NotePage

现在我们只修改了命名空间的映射(绑定后端文件),XAML 文件中还有其他代码引用了旧的命名空间,我们需要为其修改。

往下排查,还有这一行代码存在旧的命名空间,我们还需要修改命名空间的引用(将命名空间声明到本地),现在这行代码无效了。

xmlns:local : 命名空间引用,这个需要拆分来理解👇
  • xmlns 本身是 XML 的关键字,表示“声明一个命名空间”
  • : 分隔符,前面是关键字,后面是本地名称,在一起就变成了完整的 XML 属性。
  • local 给这个命名空间起一个别名(本地名称)

"using:WinUINotes" : 表示引用命名空间

之后在 XAML 里,用 local:某个命名空间 就表示“去 using:WinUINotes 里找这个命名空间

在本行命名空间的下方添加这个新的命名空间引用,将本地的 views 命名空间指向 WinUINotes.Views 👇

xmlns:views="using:WinUINotes.Views"

修改后看起来就是这样的👇

新的写好了,那旧的呢?)

其实旧的只是无效了,不删也不会报错

同时也要在 MainWindow.xaml 中的 xmlns:local 属性下添加这个新的命名空间引用。修改后是这样的👇

翻到页面底部,Frame 元素的 SourcePageType 属性还在引用旧的命名空间

修改这行代码为:

现在看起来是这样的👇

现在再保存编译程序就不会有任何报错了:)


迁移后端

按照下图的提示,在解决方案资源管理器中右键点击 Models 文件夹,点击"添加 - 类"

将名称改为 Note.cs,点击添加按钮

打开 Note.cs 文件

这文件默认是这样的:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace WinUINotes.Models
{
    class Note
    {
    }
}

将文件全部内容替换为以下代码:

using System;
using System.Threading.Tasks;
using Windows.Storage;

namespace WinUINotes.Models
{
    public class Note
    {
        private StorageFolder storageFolder = ApplicationData.Current.LocalFolder;
        // 属性
        public string Filename { get; set; } = string.Empty;
        public string Text { get; set; } = string.Empty;
        public DateTime Date { get; set; } = DateTime.Now;

        public Note()
        {
            Filename = "notes" + DateTime.Now.ToBinary().ToString() + ".txt";
        }

        public async Task SaveAsync()
        {
            // 将笔记保存到文件
            StorageFile noteFile = (StorageFile)await storageFolder.TryGetItemAsync(Filename);
            if (noteFile is null)
            {
                noteFile = await storageFolder.CreateFileAsync(Filename, CreationCollisionOption.ReplaceExisting);
            }
            await FileIO.WriteTextAsync(noteFile, Text);
        }

        public async Task DeleteAsync()
        {
            // 从文件系统中删除该笔记
            StorageFile noteFile = (StorageFile)await storageFolder.TryGetItemAsync(Filename);
            if (noteFile is not null)
            {
                await noteFile.DeleteAsync();
            }
        }
    }
}

属性 (Property):属性是类的成员,看起来很像在声明变量。它的主要作用是存放类的私有数据,但升级了。可以通过其名称后跟着自动属性{ get; set; }判断为属性。它可以实现复杂逻辑,例如在读写变量时实现各种功能,总之很强大就对了

欸你会发现 Filename 和 Text 成员变量变成了 public (公共)属性,并且添加了一个新属性 Date (存放本地日期时间)。现在,我们的App会支持多个笔记,笔记会保存到不同的文件中,所以 StorageFile 引用文件没了,现在变成了每次保存删除都重新打开一次指定的笔记文件,这样可以实现修改不同的笔记文件

为了实现每个笔记文件都是不同且唯一的,我们在构造函数中设置 Filename 属性。 可以使用 DateTime.ToBinary (64位整数时间)使文件名变成唯一的。 生成的文件名如下所示: notes-8584626598945870392.txt

具体可以看看全部代码的注释,自己理解👇

// 引入基础系统功能(如字符串、日期等)
using System;
// 引入异步任务支持(用于文件操作等耗时任务)
using System.Threading.Tasks;
// 引入Windows存储API(用于访问应用本地存储)
using Windows.Storage;

namespace WinUINotes.Models
{
    public class Note
    {
        // 存储笔记文件的位置
        private StorageFolder storageFolder = ApplicationData.Current.LocalFolder;
        // 以下为属性
        public string Filename { get; set; } = string.Empty;
        public string Text { get; set; } = string.Empty;
        public DateTime Date { get; set; } = DateTime.Now;
        // 生成唯一文件名:使用"notes"前缀 + 当前时间的二进制表示 + ".txt"后缀
        // DateTime.Now.ToBinary()将当前时间转换为64位整数,确保文件名唯一性
        public Note()
        {
            Filename = "notes" + DateTime.Now.ToBinary().ToString() + ".txt";
        }
        // 保存笔记文件
        public async Task SaveAsync()
        {
            // 尝试获取已存在的笔记文件(如果存在)
            StorageFile noteFile = (StorageFile)await storageFolder.TryGetItemAsync(Filename);
            // 如果文件不存在,则创建新文件
            if (noteFile is null)
            {
                // CreateFileAsync就是创建新文件,ReplaceExisting选项表示如果已存在则替换
                noteFile = await storageFolder.CreateFileAsync(Filename, CreationCollisionOption.ReplaceExisting);
            }
            // 将笔记文本内容写入文件
            await FileIO.WriteTextAsync(noteFile, Text);
        }
        // 删除该笔记文件
        public async Task DeleteAsync()
        {
            // 尝试获取要删除的笔记文件
            StorageFile noteFile = (StorageFile)await storageFolder.TryGetItemAsync(Filename);
            // 如果文件存在,则执行删除操作
            if (noteFile is not null)
            {
                await noteFile.DeleteAsync();
            }
        }
    }
}

删掉历史遗留代码

还记得我们之前在 NotePage.xaml.cs 实现了模块的内容了吗?现在我们需要把历史遗留代码清理掉

打开 Views 文件夹中的 NotePage.xaml.cs 文件

如下图所示,在页面顶部的最后一个 using 语句之后,添加一个新的 using 语句用来引用 Models 中的代码

using WinUINotes.Models;

把这些成员变量删掉

在原本成员变量所在的位置,添加一个名为 NotenoteModel 对象,现在交给 Note 模型来处理,页面只负责显示数据

private Note? noteModel;

删除整个 NotePage_Loaded 成员函数,因为现在数据会通过 noteModel 自动同步(绑定)到页面中,不需要手动加载了

saveButton_Click 成员函数中的所有代码更改为以下内容:

private async void saveButton_Click(object sender, RoutedEventArgs e)
{
    if (noteModel is not null)
    {
        await noteModel.SaveAsync();
    }
}

deleteButton_Click 成员函数中的所有代码更改为以下内容:

private async void deleteButton_Click(object sender, RoutedEventArgs e)
{
    if (noteModel is not null)
    {
        await noteModel.DeleteAsync();
    }
}

就是全部交给 Note.cs 中的函数去处理了,这里只是调用一下,起到中转的作用(

数据绑定

数据绑定就是在界面中显示数据,还可以让数据保持同步,自动更新。

打开 Views\NotePage.xaml 文件

将 TextBox 元素代码替换为:

<TextBox x:Name="NoteEditor"
 Text="{x:Bind noteModel.Text, Mode=TwoWay}"
 AcceptsReturn="True"
 TextWrapping="Wrap"
 PlaceholderText="请输入注释文字"
 Header="{x:Bind noteModel.Date.ToString()}"
 ScrollViewer.VerticalScrollBarVisibility="Auto"
 Width="400"
 Grid.Column="1"/>

其实就是新增了 TextBox 的 Text 属性,更新了 Header 属性,并且用到了 {x:Bind} (编译时绑定)

Text="{x:Bind noteModel.Text, Mode=TwoWay}"
  • x:Bind noteModel.Text:将 TextBox.Text 与后端的 noteModel.Text 绑定起来
  • Mode=TwoWay:双向同步。用户在输入框里打字 → noteModel.Text 自动更新;后端修改 noteModel.Text → 界面显示也会变

这样就简单多了,前后端数据自动保持一致

Header="{x:Bind noteModel.Date.ToString()}"
  • 直接在绑定路径里调用 .ToString() 方法,最终 Header 显示的就是格式化的日期字符串
  •  热知识:{x:Bind} 的默认模式是 OneTime(只在页面加载时赋值一次)

原本 Header 属性在这里负责显示上方的静态标题“新建笔记”,就像这样:

修改后标题动态显示当前笔记文件的创建日期

小结:MVVM

控件树就是字面意思,它们的关系像一棵树一样

Page (爷爷)
 └── Grid (爸爸)
      ├── TextBox (大儿子)
      └── Button (小儿子)

这种 一层套一层、单线继承 的结构,在计算机里就叫 树(Tree)

回顾一下数据绑定相关知识,我们用到了 Model-View-ViewModel(MVVM)这种 UI 体系结构设计模式,简单来说就是前后端分离。在 Web 开发中,看到的界面就是前端,在服务器的计算就是后端;而在 WinUI3 开发中,我们只做客户端,所以本文中所提到的前后端便是控件树(视图)与控件的状态与行为(逻辑)

// 吃完饭见bye

新建一个页面来显示所有Notes