Bootstrap

.NET MAUI学习笔记——3.创建第一个程序_初级篇

一、引言

上篇文介绍了如何构建一个程序(Build your first app),浅浅地描述了构建过程。
这篇来学习一下如何创建一个.NET MAUI程序(Creat a .NET MAUI app),并在创建过程中,对细节方面作较详细介绍。
要说构建与创建之间有什么区别,构建就是将默认代码示例跑起来;创建则是从无到有,在程序中加入一些自己的东西。

那本文就以官方的笔记/记事本应用程序(Note app)为例来介绍如何从零创建一个程序,使用该笔记app,用户可以创建、保存和加载多个笔记。

通过学习该app的开发过程,开发者应该就能自己按照该过程独立开发其他app了。


二、开发步骤

1. 创建项目

1.1. 创建项目

创建项目,在构建程序中介绍过了,这边再针对其中几个点详细讲下。
创建项目时,会遇到以下的设置项(settings):

  • 项目名
    出于官方示例原因,该项目名得设置为Notes。若项目名不一致,后面从教程中复制粘贴示例的代码也许会导致构建错误。
  • 将解决方案与项目放在同一目录中
    该项不勾选。
    在这里插入图片描述

1.2. 选择目标设备

.NET MAUI程序被设计用于多操作系统和设备上运行的。因此,在测试和调试时,你需要选择程序运行的目标设备(平台)(即Android、Windows、iOS和macOS)。

在VS工具栏中,将调试目标(Debug Target)设置为你想要测试和调试的设备。下面步骤演示了如何将调试目标设为安卓:
在这里插入图片描述

  1. 选择调试目标下拉按钮(绿色的▶)
  2. 选择Android Emulator
  3. 选择Emulator的设备项

2. 自定义app shell

没接触过的人应该不知道app shell是啥。
其实我也不知道,不过在这里应该可以暂时理解为一种UI框架(或UI结构)。可以按下图理解,
在这里插入图片描述

当VS创建.NET MAUI项目时,会生成4个重要的代码文件。可以在解决方案管理器(Solution Explorer)中看到它们:
在这里插入图片描述
这些文件用于配置和运行.NET MAUI应用程序。每个文件都有不同的用途,它们的描述如下:

  • MauiProgram.cs
    这是一个用于引导应用程序的代码文件。里面的代码作为应用程序的跨平台入口点,用于配置和启动应用程序。模板启动代码指向App.xaml定义的App类。
    在这里插入图片描述
  • App.xamlApp.xaml.cs
    简单起见,这两个文件一般认为是一个文件。通常,任何一个XAML文件都包含两个文件,.xaml文件本身,以及一个对应的代码文件(代码文件在解决方案管理器中是子项,点击XAML文件可以展开看到
    在这里插入图片描述
    )。.XMAL文件包含XAML标记,代码文件包含用户创建的用于与XAML标记交互的代码(一般认为,XAML是写界面元素的,cs代码文件是写后台的)。
    App.xaml文件包含应用程序范围内的XAML资源,如颜色、样式和模板。App.xaml.cs通常包含实例化Shell应用程序的代码。在该项目中,它指向AppShell类。
  • AppShell.xamlAppShell.xaml.cs
    该文件定义了AppShell类,它用于定义应用程序的可视化层次结构。
  • MainPage.xamlMainPage.xaml.cs
    这是应用程序显示的启动页面。MainPage.xaml文件定义了页面的UI。MainPage.xaml.cs包含了XAML的后台代码,比如按钮点击事件的代码。

2.1. 添加一个“关于”页面

定制化操作要做的第一步是向项目添加一个页面。它是一个关于页面,呈现了应用程序相关的信息,比如作者、版本,可能还有一个链接来获取更多信息。

  1. 在VS的解决方案资源管理器窗格中,右键单击Notes项目>添加>新建项…
    在这里插入图片描述
  2. 添加新项对话框中,在窗口左侧的模板列表中选择.NET MAUI。接着,选择.NET MAUI ContentPage(XAML)模板。将文件命名为AboutPage.xaml,然后选择添加
    在这里插入图片描述
  3. AboutPage.xaml文件会打开一个新的文档选项卡,显示代表页面UI的所有XAML标记(这个标记是指该.xaml文件中的代码,因为xaml本身是xml系的标记语言,所以代码元素也能叫标记)。用以下标记代码替换原代码并保存:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="Notes.AboutPage">
    <VerticalStackLayout Spacing="10" Margin="10">
        <HorizontalStackLayout Spacing="10">
            <Image Source="dotnet_bot.png"
                   SemanticProperties.Description="The dot net bot waving hello!"
                   HeightRequest="64" />
            <Label FontSize="22" FontAttributes="Bold" Text="Notes" VerticalOptions="End" />
            <Label FontSize="22" Text="v1.0" VerticalOptions="End" />
        </HorizontalStackLayout>

        <Label Text="This app is written in XAML and C# with .NET MAUI." />
        <Button Text="Learn more..." Clicked="LearnMore_Clicked" />
    </VerticalStackLayout>
</ContentPage>

现在,让我们来分析一下页面上的XAML控件的关键部分:

  • <ContentPage> 是 AboutPage类的根对象。
  • <VerticalStackLayout> 是ContentPage唯一的子对象。ContentPage只能有一个子对象。VerticalStackLayout类可以有多个子类。该布局控件将其子控件一个接一个垂直排列。
  • <HorizontalStackLayout> 效果和 VerticalStackLayout差不多,除了它的子元素是水平排列的。
  • <Image> 会显示一张图片,本例中,它使用的是每个.NET MAUI项目都附带的dotnet_bot.png图片。
  • <Label> 控件显示一段文本。
  • <Button> 控件是一个按钮,被用户按压时,会引发Clicked事件。你可以在Clicked事件的响应代码中添加自己的逻辑。
  • Clicked=“LearnMore_Clicked” ,按钮的Clicked事件被分配给在代码后台文件(.cs)中定义的LearnMore_Clicked事件处理程序。下一小节会介绍该部分代码。

2.2. 处理Clicked事件

接下来,往按钮的Clicked事件中添加代码。

  1. 打开XAML编辑器后,右键单击(right-click)LearnMore_Clicked文本,选择跳转到定义:
    在这里插入图片描述
  2. 代码编辑器会打开后台代码,AboutPage.xaml.cs,并为Clicked事件生成一个空的事件处理器(event handler),
	private void LearnMore_Clicked(object sender, EventArgs e)
	{

	}
  1. 用以下代码替换事件处理器,该代码会打开系统浏览器并跳转到指定的URL:
private async void LearnMore_Clicked(object sender, EventArgs e)
{
    // Navigate to the specified URL in the system browser.
    await Launcher.Default.OpenAsync("https://aka.ms/maui");
}

注意,方法声明中加上了async关键字,这允许在打开系统浏览器时使用await关键字。

现在,AboutPage的XAML和后台代码已经完成,接着让它在app中显示。

2.3. 添加图片资源

有些控件能使用图片,这可以增强用户与app的交互体验。本节中,你需要添加两张图片到你的app中(可以是网上下载的,也可以自己简单画一下,主要就是两个图标)。

  • About图标:
    在这里插入图片描述
    该图片用作“关于”页面的切换图标。
  • Notes图标:
    在这里插入图片描述
    此图像用作将在教程下一部分中创建的“笔记”页面的图标。

在你下载了这些图像后,将它们移至项目的Resources\Images文件夹中。此文件夹中的任何文件都会自动作为MauiImage资源包含在项目中。你还可以使用VS将图像加入到项目中。如果你手动移动图像,可以跳过以下步骤。

2.3.1. 用VS来移动图片
  1. 在VS的解决方案资源管理器窗格中,展开Resources文件夹,可以看到Images文件夹。

💡提示
你可以使用文件资源管理器(File Explorer)来将图像文件直接拖拽进解决方案资源管理器中。这会自动将文件移入文件夹,并将它们包含在项目中。如果你使用了拖放的方式,可以忽略此过程的其余部分。

  1. Images上右键单击并选择添加>现有项
  2. 导航到包含所需图像文件的目录,选中所需图像文件,点击添加

❗警告

  1. 添加进项目的图像文件名得小写,不然会报下面错误:
    在这里插入图片描述
  2. 有时候会报以下错误,
    建议把项目下的 bin和obj文件夹删了,重新构建项目试试,应该就行了(初次接触,原因尚不明确),
    在这里插入图片描述
  3. 图片得是常规格式的(.png之类),命名得与下面Icon引用一致,不需要加格式后缀。若不显示重新构建试试。

2.4. 修改app shell

正如本文开头提到的,AppShell类定义了app的可视层次结构,即用于创建用于创建程序UI的XAML标记。更新XAML,添加一个TabBar控件:

  1. 解决方案资源管理器中双击AppShell.xaml文件打开XAML编辑器。用以下XAML代码替换并保存:
<?xml version="1.0" encoding="UTF-8" ?>
<Shell
    x:Class="Notes.AppShell"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:local="clr-namespace:Notes"
    Shell.FlyoutBehavior="Disabled">

    <TabBar>
        <ShellContent
            Title="Notes"
            ContentTemplate="{DataTemplate local:MainPage}"
            Icon="icon_notes" />

        <ShellContent
            Title="About"
            ContentTemplate="{DataTemplate local:AboutPage}"
            Icon="icon_about" />
    </TabBar>

</Shell>

分析一下XAML的关键部分:

  • <Shell>是XAML标记的根对象。
  • <TabBar>是Shell的内容。
  • <TabBar>里有两个<ShellContent>对象。在你替换模板代码前,里面只有一个<ShellContent>对象,指向了MainPage页面。

TabBar及其子元素不表示任何UI元素,而是app的可视层次结构的组织。Shell会接受这些对象,并为内容生成UI。

每个<ShellContent>对象指向一个显示的页面。这是由ContentTemplate属性设置的。

2.5. 运行app

点击VS顶部的运行按钮或按下F5来运行app。
在这里插入图片描述

你将看到有两个选项卡:NotesAbout。按下About标签,app会导航到AboutPage。按下**Learn More… **按钮以打开网页浏览器。
在这里插入图片描述
关闭app回到VS。如果你使用的是Android emulator,在 虚拟设备中终止程序(就像手机里按菜单键弹出后台程序视图,然后从后台程序视图中滑掉程序一样)或 按VS顶部的停止按钮(如下图):
在这里插入图片描述

3. 创建“笔记”页面

示例app是一个笔记程序,所以接下来要创建一个笔记页面也就是笔记功能的页面)。

3.1. 创建“笔记”页面

现在app包含了MainPage(主页面)和AboutPage(关于页面),现在可以创建app剩下的部分了。首先,你要创建一个页面,用户可以在该页面创建和显示笔记(note);接着,你将写代码来加载和保存笔记。

笔记页面用于显示笔记,并且你可以保存和删除笔记。首先,在项目中添加一个新页面:

  1. 在VS的解决方案资源管理器中,右键单击 Notes项目> 添加 > 新建项
  2. 添加新项对话框中,选择模板列表中的 .NET MAUI。接着,选中 .NET MAUI ContentPage (XAML) 模板。将文件命名为NotePage.xaml,然后点击 添加
  3. NotePage.xaml文件会打开新的选项卡,显示页面UI的XAML标记。将其XAML标记替换为下面标记,并且保存:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="Notes.NotePage"
             Title="Note">
    <VerticalStackLayout Spacing="10" Margin="5">
        <Editor x:Name="TextEditor"
                Placeholder="Enter your note"
                HeightRequest="100" />

        <Grid ColumnDefinitions="*,*" ColumnSpacing="4">
            <Button Text="Save"
                    Clicked="SaveButton_Clicked" />

            <Button Grid.Column="1"
                    Text="Delete"
                    Clicked="DeleteButton_Clicked" />
        </Grid>
    </VerticalStackLayout>
</ContentPage>

和前面“关于”页面一样,我们来简单分析一下“笔记”页面的结构:

  • <VerticalStackLayout> 将其子元素一个个垂直排列。
  • <Editor> 是多行文本编辑器空间,它是VerticalStackLayout里的第一个控件。
  • <Grid> 是一个布局控件,它是VerticalStackLayout中的第二个控件。
    该布局控件可以定义行和列,行列中创建单元格。列是由宽度来定义的, 值“*”(一般读作“星”)用于指定列要尽可能地填充空间。上面代码示例定义了两个列,并且都使用了“*”值,使它们有尽可能多的空间,不过由于两列都是“*”值,所以在空间中会均匀地分配列:ColumnDefinitions=“*,*”
    。两列的宽度用字符“,”(逗号)分隔。
    Grid定义的行和列的索引都是从0开始的。第一列的索引是0,第二列索引是1,以此类推。
  • <Grid>中有两个<Button>控件,有一个被指定了列号。如果子控件没有定义分配列号,它会自动被赋到第一列。在此标记代码中,第一个按钮是“Save”按钮,并自动分配给了第一列,即第0列。第二个按钮是“Delete”按钮,分配给第二列,即列1(column 1)。
    注意,这两个按钮都处理了Clicked事件(对Clicked事件都有对应处理器)。下一节中,我们会介绍这些事件处理器的代码。

3.2. 加载并保存笔记

先打开NotePage.xaml.cs后台代码。通常,有三种方式可以打开NotePage.xaml的后台代码:

  • NotePage.xaml已经打开并且文档被激活正处于编辑状态,按下F7。
  • NotePage.xaml已经打开并且文档被激活正处于编辑状态,右键单击文本编辑器,选中查看代码
  • 解决方案资源管理器中展开NotePage.xaml项,暴露出NotePage.xaml.cs文件后,双击即可。

当新添加一个XAML文件,它后台的构造函数中只包含一行代码,对InitializeComponent方法的调用:

namespace Notes;

public partial class NotePage : ContentPage
{
    public NotePage()
    {
        InitializeComponent();
    }
}

InitializeComponent方法读取XAML标记并初始化标记定义的所有对象。对象以父子关系连接,代码中定义的事件处理器会附加到XAML中设置的事件上。

现在你应该对后台代码更加了解了,接下来向NotePage.xaml.cs添加加载和保存笔记的代码:

  1. 当笔记被创建时,它会以文本文件的形式保存到设备中。文件的名称由 _fileName 变量表示。向NotePage类添加以下字符串变量声明:
public partial class NotePage : ContentPage
{
	// 下划线前缀小写首字母是C#中的私有变量的命名习惯 _lowerCase
    string _fileName = Path.Combine(FileSystem.AppDataDirectory, "notes.txt");

上面代码会构造一个文件路径,将其存储在app的本地数据(data)目录中。文件名为“notes.txt”。

  1. 在类的构造函数中,InitializeComponent方法被调用后,从设备中读取文件并将其内容存储到TextEditor 控件的Text属性中。
public NotePage()
{
    InitializeComponent();

    if (File.Exists(_fileName))
        TextEditor.Text = File.ReadAllText(_fileName);
}
  1. 接着,添加代码来处理XAML中定义的Clicked事件:
private void SaveButton_Clicked(object sender, EventArgs e)
{
    // Save the file.
    File.WriteAllText(_fileName, TextEditor.Text);
}

private void DeleteButton_Clicked(object sender, EventArgs e)
{
    // Delete the file.
    if (File.Exists(_fileName))
        File.Delete(_fileName);

    TextEditor.Text = string.Empty;
}

SaveButton_Clicked方法将编辑器控件(Editor)中的文本写入 _fileName表示的文件中。
DeleteButton_Clicked 方法首先检查_fileName文件是否存在,若存在则删除它。接着,清除编辑器控件中的文本。

  1. 保存上述改动(CTRL + S或鼠标右击文件选项卡保存)。

后台文件中最终代码如下:

namespace Notes;

public partial class NotePage : ContentPage
{
    string _fileName = Path.Combine(FileSystem.AppDataDirectory, "notes.txt");

    public NotePage()
    {
        InitializeComponent();

        if (File.Exists(_fileName))
            TextEditor.Text = File.ReadAllText(_fileName);
    }

    private void SaveButton_Clicked(object sender, EventArgs e)
    {
        // Save the file.
        File.WriteAllText(_fileName, TextEditor.Text);
    }

    private void DeleteButton_Clicked(object sender, EventArgs e)
    {
        // Delete the file.
        if (File.Exists(_fileName))
            File.Delete(_fileName);

        TextEditor.Text = string.Empty;
    }
}

3.3. 测试笔记

现在,笔记页面已完成,你需要以一种方式将它呈现给用户。打开AppShell.xaml文件,并更改第一个ShellContent 条目,使之指向NotePage而不再是MainPage

<?xml version="1.0" encoding="UTF-8" ?>
<Shell
    x:Class="Notes.AppShell"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:local="clr-namespace:Notes"
    Shell.FlyoutBehavior="Disabled">

    <TabBar>
        <ShellContent
            Title="Notes"
            ContentTemplate="{DataTemplate local:NotePage}"
            Icon="icon_notes" />

        <ShellContent
            Title="About"
            ContentTemplate="{DataTemplate local:AboutPage}"
            Icon="icon_about" />
    </TabBar>

</Shell>

保存后,运行app。尝试在输入框中输入并按下Save按钮。关闭app,然后重新打开它。你输入的笔记会从设备存储中加载出来。
在这里插入图片描述

4. 绑定数据至UI和导航页面

本节介绍视图(view)、模型(model)和app中的导航的概念。

前面的教程中,已经向项目添加了两个页面:NotePageAboutPage。页面表示的是数据的视图(view of data)。NotePage是显示“笔记数据”的视图。AboutPage是显示“应用程序信息数据”的视图。这两个视图都有硬编码的(hardcoded)数据模型(或者说数据模型嵌入在视图中),你需要将数据模型从视图中分离出来。

将模型与视图分离的好处是什么?
它可以让你设计视图来表示模型的任何部分并与之交互,而不必担心模型的实际代码(简单讲,就是view部分设计与model部分设计分离)。这是通过使用数据绑定(data binding)来实现的,本教程后面将介绍数据绑定,不过现在,我们先重新构建该项目。

4.1. 分离视图与模型

重构(refactor)现有代码,使模型与视图分离。接下来几步操作将组织代码,以使视图与模型彼此独立定义。

  1. 将项目中的MainPage.xamlMainPage.xaml.cs删除。在解决方案资源管理器中,找到MainPage.xaml条目,右键单击并选择删除。

💡提示
通常删除MainPage.xaml条目的同时也会删掉MainPage.xaml.cs条目。若MainPage.xaml.cs没有被删除,就右键单击它,然后选择删除。

  1. 右键单击Notes项目并选择 添加 > 新建文件夹。将文件夹命名为Models
  2. 按上一步再新建一个文件夹,命名为Views
  3. 找到NotePage.xaml项,把它拖进Views文件夹。NotePage.xaml.cs应该也会被一起拖入。

❗重要
当你移动文件时,VS通常会发出一个警告,提示移动操作可能会花费较长时间。通常,这并不会带来什么问题,点击确定即可。
VS可能还会询问你是否要调整移动文件的命名空间。选择否即可,因为下一步我们会更改命名空间。

  1. 找到AboutPage.xaml项,将它拖入Views文件夹。
4.1.1. 更新视图命名空间

现在视图(上节中的.xaml文件及其附属xaml.cs)已经移动到Views文件夹下,你需要更改其命名空间使之匹配。页面的XAML和后台代码文件的命名空间之前是Notes,现在需要更新为Notes.Views(其实很直白,和项目中的文件结构对应)。

  1. 解决方案资源管理器中,展开NotePage.xamlAboutPage.xaml,使它们的后台代码文件暴露:
    在这里插入图片描述
  2. 双击NotePage.xaml.cs打开它的代码编辑器。将命名空间改成Notes.Views
namespace Notes.Views;
  1. AboutPage.xaml.cs也做上述更改。
  2. 双击NotePage.xaml,打开其XAML编辑器。旧的命名空间是通过x:Class属性(attribute,或许叫特性好点)引用的,该属性定义了XAML的后台代码中的类型。该条目不仅是命名空间,还是带有类型的命名空间。将x:Class的值更改为Notes.Views.NotePage
x:Class="Notes.Views.NotePage"
  1. 重复上一步,将AboutPage.xamlx:Class更改为Notes.Views.AboutPage
4.1.2. 在Shell中修复命名空间引用

AppShell.xaml定义了两个标签,一个用于NotesPage,另一个用于AboutPage。现在,这两个页面已经移至新的命名空间,XAML中的类型映射已经失效。在解决方案资源管理器中,双击AppShell.xaml,在XAML编辑器中打开它。它的代码应该是以下模样:

<?xml version="1.0" encoding="UTF-8" ?>
<Shell
    x:Class="Notes.AppShell"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:local="clr-namespace:Notes"
    Shell.FlyoutBehavior="Disabled">

    <TabBar>
        <ShellContent
            Title="Notes"
            ContentTemplate="{DataTemplate local:NotePage}"
            Icon="icon_notes" />

        <ShellContent
            Title="About"
            ContentTemplate="{DataTemplate local:AboutPage}"
            Icon="icon_about" />
    </TabBar>

</Shell>

通过XAML命名空间声明将.NET命名空间导入XAML。在上面的XAML标记中,它是根元素<Shell>中的xmlns:local="clr-namespace:Notes属性。声明XML命名空间以在同一程序集中导入.NET命名空间的格式为:

xmlns:{XML namespace name}="clr-namespace:{.NET namespace}"

前面的声明将local XML命名空间(local是你取的名)映射到Notes .NET命名空间。通常做法是将local命名空间映射到项目的根命名空间。

现在删除local XML命名空间并添加一个新的命名空间。这个新的XML命名空间将映射到Notes.Views .NET命名空间,命名为views。它的声明代码为:

xmlns:views="clr-namespace:Notes.Views"

ShellContent. ContentTemplate属性使用local XML命名空间,将它们更改成views。你的XAML现在看起来应该是以下模样:

<?xml version="1.0" encoding="UTF-8" ?>
<Shell
    x:Class="Notes.AppShell"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:views="clr-namespace:Notes.Views"
    Shell.FlyoutBehavior="Disabled">

    <TabBar>
        <ShellContent
            Title="Notes"
            ContentTemplate="{DataTemplate views:NotePage}"
            Icon="icon_notes" />

        <ShellContent
            Title="About"
            ContentTemplate="{DataTemplate views:AboutPage}"
            Icon="icon_about" />
    </TabBar>

</Shell>

现在,你应该就能在没有任何编译错误的情况下运行app了,并且运行效果和之前一样。

4.2. 定义模型

模型(model)是嵌入在笔记中,和视图(views)相关的数据。
在这里插入图片描述
如上图是笔记页面(这个笔记页面也可以认为是笔记的视图),文本编辑器中的文本就是笔记页面中的数据。
我们会创建一个新的类来表示这些数据。首先,是表示笔记页面的数据:

  1. 解决方案资源管理器中,右键单击Models文件夹,选择添加>
  2. 将其命名为Note.cs,然后添加
  3. 打开Note.cs,用以下代码替换其内容并保存:
namespace Notes.Models;

internal class Note
{
    public string Filename { get; set; }
    public string Text { get; set; }
    public DateTime Date { get; set; }
}

接下来,创建关于页面的模型:

  1. 解决方案资源管理器中,右键单击Models文件夹,新添加一个类。
  2. 将其命名为About.cs并添加。
  3. About.cs中内容做以下替换并保存:
namespace Notes.Models;

internal class About
{
    public string Title => AppInfo.Name;
    public string Version => AppInfo.VersionString;
    public string MoreInfoUrl => "https://aka.ms/maui";
    public string Message => "This app is written in XAML and C# with .NET MAUI.";
}

4.3. 更新关于页面

关于页面是最容易改造的页面。改完后,你马上能运行app,查看它如何从模型中加载数据。

  1. 解决方案资源管理器中,打开Views\AboutPage.xaml文件。
  2. 用以下代码替换其内容:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:models="clr-namespace:Notes.Models"
             x:Class="Notes.Views.AboutPage">
    <ContentPage.BindingContext>
        <models:About />
    </ContentPage.BindingContext>
    <VerticalStackLayout Spacing="10" Margin="10">
        <HorizontalStackLayout Spacing="10">
            <Image Source="dotnet_bot.png"
                   SemanticProperties.Description="The dot net bot waving hello!"
                   HeightRequest="64" />
            <Label FontSize="22" FontAttributes="Bold" Text="{Binding Title}" VerticalOptions="End" />
            <Label FontSize="22" Text="{Binding Version}" VerticalOptions="End" />
        </HorizontalStackLayout>

        <Label Text="{Binding Message}" />
        <Button Text="Learn more..." Clicked="LearnMore_Clicked" />
    </VerticalStackLayout>

</ContentPage>

我们来简单分析一下更改后的代码:

  • xmlns:models=“clr-namespace:Notes.Models”
    这行将 Notes.Models .NET命名空间映射到了 models XML命名空间。
  • ContentPage的BindingContext属性被设置为了Notes.Models.About类的一个实例,使用models:About的XML命名空间和对象。这种写法是属性元素语法(property element syntax),而不是传统的XML特性设置(XML attribute)。
1. 属性元素语法(property element syntax)
<标签>
	<标签.属性></标签.属性>
<标签/>
2. XML特性设置
<标签 属性=>  

❗重要
目前为止,属性都是由XML特性设置的。这种写法对于简单的值(如FontSize、Text)非常高效。但如果属性值很复杂,则必须使用属性元素语法来创建对象。考虑下面例子,创建一个label,并设置它的FontSize属性:

<Label FontSize="22" />

改成属性元素语法,代码如下:

<Label>
   <Label.FontSize>
       22
   </Label.FontSize>
</Label>
  • 3个<Label>控件原本硬编码的文本属性值改成了绑定语法:{Binding PATH}。
    {Binding}语法在运行时处理,允许绑定返回值是动态的。{Binding PATH}的PATH部分是要绑定的属性路径(其实就是绑定的那个最终属性,比如我要绑定的是Text,那Path=Text)。该属性来自当前控件的BindingContext。<Label>控件的BindingContext未设置。当控件的上下文(Context)未设置时,上下文会从父对象继承。本例中,父对象的上下文设置值是根对象:ContentPage。
    BindingContext中的对象是About模型的一个实例。其中一个标签的绑定路径是将Label.Text绑定到About.Title属性。
    <Label FontSize="22" FontAttributes="Bold" Text="{Binding Title}" VerticalOptions="End" />
    
    关于页面的最后一个更改是更新打开网页的按钮点击。URL在后台代码中是硬编码的,但是URI应该来自BindingContext属性中的模型。
    1. 解决方案资源管理器中,打开Views\AboutPage.xaml.cs文件。

    2. 用以下代码替换LearnMore_Clicked方法:

      private async void LearnMore_Clicked(object sender, EventArgs e)
      {
          if (BindingContext is Models.About about)	// 标注
          {
              // Navigate to the specified URL in the system browser.
              await Launcher.Default.OpenAsync(about.MoreInfoUrl);
          }
      }
      

查看上面代码的标注行,代码将检查BindingContext是否为Models.About类,如果是,则将它赋值给about变量。if语句中的下一行将浏览器打开,并跳转到about.MoreInfoUrl属性提供的URL上。

运行应用程序,你可以看到它的运行效果和之前一样。你可以尝试更改about模型的值,看看浏览器打开的UI和URL会发生什么变化。

4.4. 更新笔记页面

上一节将关于页面视图(about page view)绑定到关于模型(about model),现在你将执行同样的操作,将笔记视图绑定到笔记模型。不过,在本例中,模型不会在XAML中创建,而是通过下面几步在后台代码中提供。

  1. 解决方案资源管理器中,打开Views\NotePage.xaml文件。

  2. 给<Editor>控件添加Text属性。并将该属性绑定到Text属性上,就像这样:<Editor … Text=“{Binding Text}”

    <?xml version="1.0" encoding="utf-8" ?>
    <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
                 xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
                 x:Class="Notes.Views.NotePage"
                 Title="Note">
        <VerticalStackLayout Spacing="10" Margin="5">
            <Editor x:Name="TextEditor"
                    Placeholder="Enter your note"
                    Text="{Binding Text}"
                    HeightRequest="100" />
    
            <Grid ColumnDefinitions="*,*" ColumnSpacing="4">
                <Button Text="Save"
                        Clicked="SaveButton_Clicked" />
    
                <Button Grid.Column="1"
                        Text="Delete"
                        Clicked="DeleteButton_Clicked" />
            </Grid>
        </VerticalStackLayout>
    </ContentPage>
    

对后台代码的修改远比这复杂。当前的代码会在构造函数中加载文本内容,然后将读出的文本内容设置到TextEditor.Text属性。代码如下:

public NotePage()
{
    InitializeComponent();

    if (File.Exists(_fileName))
        TextEditor.Text = File.ReadAllText(_fileName);
}

这种在构造函数中加载笔记内容的方法并不好,我们现在创建一个新的LoadNote方法,此方法将执行以下操作:

  • 接受一个文件名参数。
  • 创建一个新的笔记模型并设置文件名。
  • 若文件存在,则加载文件内容至模型。
  • 若文件存在,则用文件创建的日期更新模型。
  • 将页面的BindingContext设置成新模型。
  1. 解决方案资源管理器中,打开Views\NotePage.xaml.cs文件。

  2. 将下面代码添加进类中:

    private void LoadNote(string fileName)
    {
        Models.Note noteModel = new Models.Note();
        noteModel.Filename = fileName;
    
        if (File.Exists(fileName))
        {
            noteModel.Date = File.GetCreationTime(fileName);
            noteModel.Text = File.ReadAllText(fileName);
        }
    
        BindingContext = noteModel;
    }
    
  3. 更新类构造函数,使之调用LoadNote。笔记的文件名是app的本地数据目录中创建的随机生成的名称。

    public NotePage()
    {
        InitializeComponent();
    
        string appDataPath = FileSystem.AppDataDirectory;
        string randomFileName = $"{Path.GetRandomFileName()}.notes.txt";
    
        LoadNote(Path.Combine(appDataPath, randomFileName));
    }
    

4.5. 多个笔记与导航

现在的笔记视图只显示一个笔记,没有办法表示多个笔记。要显示多个笔记,需要创建新视图与新模型:AllNotes

  1. 解决方案资源管理器中,右键单击Views文件夹并选择添加>新建项
  2. 新建项对话框中,选中 .NET MAUI。然后选择 .NET MAUI ContentPage(XAML) 模板。将文件命名为AllNotesPage.xaml,接着添加。
  3. 解决方案资源管理器中,右键单击Models文件夹并选择添加>
  4. 将文件命名为AllNotes.cs并添加。
4.5.1. 编写AllNotes模型

新模型表示多个笔记要显示的数据。该数据是一个属性,用来表示多个笔记。集合是一个ObservableCollection,该集合类型专门用于模型中的集合。当一个能列出多个项的控件(一般称为集合/条目控件,如ListView)被绑定到ObservableCollection时,控件会与集合协同工作,会自动保持项列表与集合同步。若列表添加了一个项,则更新集合;若集合添加了一个项,则控件将自动更新新项。

  1. 解决方案资源管理器中,打开Models\AllNotes.cs文件。
  2. 将内容替换成以下代码:
using System.Collections.ObjectModel;

namespace Notes.Models;

internal class AllNotes
{
    public ObservableCollection<Note> Notes { get; set; } = new ObservableCollection<Note>();

    public AllNotes() =>
        LoadNotes();

    public void LoadNotes()
    {
        Notes.Clear();

        // Get the folder where the notes are stored.
        string appDataPath = FileSystem.AppDataDirectory;

        // Use Linq extensions to load the *.notes.txt files.
        IEnumerable<Note> notes = Directory

                                    // Select the file names from the directory
                                    .EnumerateFiles(appDataPath, "*.notes.txt")

                                    // Each file name is used to create a new Note
                                    .Select(filename => new Note()
                                    {
                                        Filename = filename,
                                        Text = File.ReadAllText(filename),
                                        Date = File.GetCreationTime(filename)
                                    })

                                    // With the final collection of notes, order them by date
                                    .OrderBy(note => note.Date);

        // Add each note into the ObservableCollection
        foreach (Note note in notes)
            Notes.Add(note);
    }
}

上述代码声明了一个集合,命名为Notes,并使用了LoadNotes方法从设备中加载笔记。该方法使用LINQ拓展加载、转换和排序数据,并最终添加到Notes集合。

4.5.2. 设计AllNotes页面

接下来,需要设计支持AllNotes模型的视图。

  1. 解决方案资源管理器中,打开Views\AllNotesPage.xaml文件。
  2. 用以下代码替换:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="Notes.Views.AllNotesPage"
             Title="Your Notes">
    <!-- Add an item to the toolbar -->
    <ContentPage.ToolbarItems>
        <ToolbarItem Text="Add" Clicked="Add_Clicked" IconImageSource="{FontImage Glyph='+', Color=White, Size=22}" />
    </ContentPage.ToolbarItems>

    <!-- Display notes in a list -->
    <CollectionView x:Name="notesCollection"
                        ItemsSource="{Binding Notes}"
                        Margin="20"
                        SelectionMode="Single"
                        SelectionChanged="notesCollection_SelectionChanged">

        <!-- Designate how the collection of items are laid out -->
        <CollectionView.ItemsLayout>
            <LinearItemsLayout Orientation="Vertical" ItemSpacing="10" />
        </CollectionView.ItemsLayout>

        <!-- Define the appearance of each item in the list -->
        <CollectionView.ItemTemplate>
            <DataTemplate>
                <StackLayout>
                    <Label Text="{Binding Text}" FontSize="22"/>
                    <Label Text="{Binding Date}" FontSize="14" TextColor="Silver"/>
                </StackLayout>
            </DataTemplate>
        </CollectionView.ItemTemplate>
    </CollectionView>
</ContentPage>

上面的XAML引入了一些新概念:

  • ContentPage.ToolbarItems属性包含了一个ToolbarItem。这边定义的按钮通常显示在app的顶部,沿着页面标题。不过,根据平台的不同,它可能位于不同的位置。当按下其中一个按钮时,将引发Clicked事件,就像普通按钮一样。

    ToolbarItem.IconImageSource属性会在按钮上设置一个icon(图标)并显示。icon可以是项目定义的任何图像资源,但是,在本例中使用的试试FontImageFontImage可以使用来自字体的雕文(字形)来作图像。

  • CollectionView控件显示多个项(正如它的名字一样,集合视图,用于显示条目/项的集合),在本例中,它被绑定到模型的Notes属性。集合视图显示每个项的方式都是通过CollectionView.ItemsLayoutCollectionView.ItemTemplate属性来设置的。

    集合中的每个项,CollectionView.ItemTemplate会生成声明好的XAML。该XAML的BindingContext会变成集合项本身,在本例中,是每个单独的笔记。笔记的模板会用到两个label,它们分别被绑定到每个笔记(Note)的TextDate属性。

  • CollectionView处理SelectionChanged事件,当集合视图中的项被选中时会引发该事件。

还需要编写视图的后台代码来加载笔记和处理事件。

  1. 解决方案资源管理器中,打开Views/AllNotesPage.xaml.cs文件。
  2. 用下面代码替换:
namespace Notes.Views;

public partial class AllNotesPage : ContentPage
{
    public AllNotesPage()
    {
        InitializeComponent();

        BindingContext = new Models.AllNotes();
    }

    protected override void OnAppearing()
    {
        ((Models.AllNotes)BindingContext).LoadNotes();
    }

    private async void Add_Clicked(object sender, EventArgs e)
    {
        await Shell.Current.GoToAsync(nameof(NotePage));
    }

    private async void notesCollection_SelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        if (e.CurrentSelection.Count != 0)
        {
            // Get the note model
            var note = (Models.Note)e.CurrentSelection[0];

            // Should navigate to "NotePage?ItemId=path\on\device\XYZ.notes.txt"
            await Shell.Current.GoToAsync($"{nameof(NotePage)}?{nameof(NotePage.ItemId)}={note.Filename}");

            // Unselect the UI
            notesCollection.SelectedItem = null;
        }
    }
}

该代码使用构造函数将页面的BindingContext设置为模型。

OnAppearing方法从基类中重写。每当页面显示时,例如导航到页面时,会自动调用该方法。该代码会告诉模型去加载笔记。因为AllNotes视图中的 CollectionView 绑定到了 AllNotes模型中的 Notes属性,而该属性是一个ObservableCollection,所以当加载notes时,CollectionView会自动更新。

Add_Clicked处理程序引入了另一个新概念,导航(navigation)。因为app正在使用的是.NET MAUI Shell,你可以通过调用Shell.Current.GoToAsync方法导航到页面。注意,该处理程序是用async关键字声明的,它允许在导航时使用await关键字。该处理程序会导航到NotePage

上面代码中最后一段是notesCollection_SelectionChanged处理程序。该方法接受当前选择的项(Note模型),并使用它的信息导航到NotePageGoToAsync使用URI字符串进行导航。在本例中,构造了一个字符串,该字符串使用查询字符串参数在目标页面上设置属性。最后生成的字符串表示URI,看起来类似下面样子:

NotePage?ItemId=path\on\device\XYZ.notes.txt

*ItemId=*参数被设置成存储笔记的设备上的文件名。

VS也许会提示NotePage.ItemId属性不存在,实际上不是这样的。下一步是修改Note view,以根据你创建的ItemId参数加载模型。

4.5.3. 查询字符串参数

Note view需要支持查询字符串参数ItemId。现在创建它:

  1. 打开Views/NotePage.xaml.cs文件。

  2. class关键字添加QueryProperty 特性,提供查询字符串属性的名称,以及映射到的类属性。

    [QueryProperty(nameof(ItemId), nameof(ItemId))]
    public partial class NotePage : ContentPage
    
  3. 添加一个新的 string 属性,命名为ItemId。该属性调用LoadNote方法,传递该属性值,传递的值是笔记的文件名:

    public string ItemId
    {
        set { LoadNote(value); }
    }
    
  4. 用下面代码替换SaveButton_ClickedDeleteButton_Clicked处理程序内容:

    private async void SaveButton_Clicked(object sender, EventArgs e)
    {
        if (BindingContext is Models.Note note)
            File.WriteAllText(note.Filename, TextEditor.Text);
    
        await Shell.Current.GoToAsync("..");
    }
    
    private async void DeleteButton_Clicked(object sender, EventArgs e)
    {
        if (BindingContext is Models.Note note)
        {
            // Delete the file.
            if (File.Exists(note.Filename))
                File.Delete(note.Filename);
        }
    
        await Shell.Current.GoToAsync("..");
    }
    

    现在按钮是异步的。在被按下后,页面会使用 的URI导航回上一页。

  5. 删除 _fileName 变量,因为该类不再使用它。

4.5.4. 修改app的可视化树

AppShell现在仍然加载单个笔记页面,不过我们需要它加载AllPages view。打开AppShell.xaml文件,并更改第一个ShellContent条目以指向AllNotesPage而不是NotePage

<?xml version="1.0" encoding="UTF-8" ?>
<Shell
    x:Class="Notes.AppShell"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:views="clr-namespace:Notes.Views"
    Shell.FlyoutBehavior="Disabled">

    <TabBar>
        <ShellContent
            Title="Notes"
            ContentTemplate="{DataTemplate views:AllNotesPage}"
            Icon="icon_notes" />

        <ShellContent
            Title="About"
            ContentTemplate="{DataTemplate views:AboutPage}"
            Icon="icon_about" />
    </TabBar>

</Shell>

若你现在运行app,你会发现按下Add按钮,它就会崩溃,无法导航到NotesPage。每个能从其它页面导航到的页面,都要向导航系统注册。AllNotesPageAboutPage页面通过在TabBar中声明自动注册到导航系统。

NotesPage注册到导航系统:

  1. 打开Views/AppShell.xaml.cs文件。

  2. 在构造函数中添加一行来注册导航路由:

    namespace Notes;
    
    public partial class AppShell : Shell
    {
        public AppShell()
        {
            InitializeComponent();
    
            Routing.RegisterRoute(nameof(Views.NotePage), typeof(Views.NotePage));
        }
    }
    

Routing.RegisterRoute方法有两个参数:

  • 第一个参数是要注册的URI的字符串名称,在本例中,解析的名称是NotePage
  • 第二个参数是导航到NotePage时要加载的页面类型。

现在,可以运行app了。试着添加新的笔记,在笔记之间来回切换和删除笔记。

到此,你已经完成了.NET MAUI app的创建。


三、结语

MAUI总的开发体验来讲,和用了MVVM的WPF差不多。

;