提高应用性能

应用性能不佳以多种方式呈现。 它可以使应用看起来无响应,可能会导致滚动缓慢,并可以减少设备电池使用时间。 但是,优化性能不仅仅是实现高效的代码。 还必须考虑用户的应用性能体验。 例如,确保执行操作而不阻止用户执行其他活动有助于改善用户体验。

有许多技术可用于提高 .NET 多平台应用 UI (.NET MAUI) 应用的性能和感知性能。 这些技术可以极大地减少 CPU 执行的工作量,以及应用消耗的内存量。

使用性能分析器

在开发应用程序时,只有在对其进行了分析之后,才应该尝试优化代码。 性能分析是一种技术,用于确定代码优化在哪些地方能最有效地提升性能。 探查器跟踪应用的内存使用情况,并记录应用中方法的运行时间。 此数据有助于浏览应用的执行路径以及代码的执行成本,以便发现优化的最佳机会。

可以在 Android、iOS、Mac 和 Windows 上使用 dotnet-trace,并通过 Windows 上的 PerfView 分析 .NET MAUI 应用。 有关详细信息,请参阅 分析 .NET MAUI 应用

分析应用时建议使用以下最佳做法:

  • 避免在模拟器中分析应用,因为模拟器可能会扭曲应用性能。
  • 理想情况下,应在各种设备上执行分析,因为对一台设备执行性能度量并不总是显示其他设备的性能特征。 在具有最低预计规格的设备上,至少应执行性能分析。
  • 关闭所有其他应用,以确保测量到的是被分析应用的全部影响,而不是其他应用的影响。

使用编译的绑定

编译的绑定通过在编译时(而不是在运行时使用反射)解析绑定表达式来提高 .NET MAUI 应用中的数据绑定性能。 编译绑定表达式会生成编译的代码,该代码通常比使用经典绑定快 8-20 倍解析绑定。 有关更多信息,请参阅 编译绑定

减少不必要的绑定

不要对可以轻松静态设置的内容使用绑定。 不需要绑定的数据没有绑定的优势,因为绑定成本不划算。 例如,设置 Button.Text = "Accept" 的开销比将 Button.Text 绑定到具有值“Accept”的 viewmodel string 属性要小。

选择正确的布局

能够显示多个子级的布局,但只有一个子级,这是浪费的。 例如,以下示例显示了具有单个子项的 VerticalStackLayout

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MyMauiApp.MainPage">
    <VerticalStackLayout>
        <Image Source="waterfront.jpg" />
    </VerticalStackLayout>
</ContentPage>

这是浪费的,应删除 VerticalStackLayout 元素,如以下示例所示:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MyMauiApp.MainPage">
    <Image Source="waterfront.jpg" />
</ContentPage>

此外,不要尝试通过使用其他布局的组合来重现特定布局的外观,因为这样可以执行不必要的布局计算。 例如,不要尝试使用 HorizontalStackLayout 元素的组合来重现 Grid 布局。 以下示例演示了这种不良做法的示例:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MyMauiApp.MainPage">
    <VerticalStackLayout>
        <HorizontalStackLayout>
            <Label Text="Name:" />
            <Entry Placeholder="Enter your name" />
        </HorizontalStackLayout>
        <HorizontalStackLayout>
            <Label Text="Age:" />
            <Entry Placeholder="Enter your age" />
        </HorizontalStackLayout>
        <HorizontalStackLayout>
            <Label Text="Occupation:" />
            <Entry Placeholder="Enter your occupation" />
        </HorizontalStackLayout>
        <HorizontalStackLayout>
            <Label Text="Address:" />
            <Entry Placeholder="Enter your address" />
        </HorizontalStackLayout>
    </VerticalStackLayout>
</ContentPage>

这是浪费的,因为进行了不必要的布局计算。 相反,可以使用 Grid更好地实现所需的布局,如以下示例所示:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MyMauiApp.MainPage">
    <Grid ColumnDefinitions="100,*"
          RowDefinitions="30,30,30,30">
        <Label Text="Name:" />
        <Entry Grid.Column="1"
               Placeholder="Enter your name" />
        <Label Grid.Row="1"
               Text="Age:" />
        <Entry Grid.Row="1"
               Grid.Column="1"
               Placeholder="Enter your age" />
        <Label Grid.Row="2"
               Text="Occupation:" />
        <Entry Grid.Row="2"
               Grid.Column="1"
               Placeholder="Enter your occupation" />
        <Label Grid.Row="3"
               Text="Address:" />
        <Entry Grid.Row="3"
               Grid.Column="1"
               Placeholder="Enter your address" />
    </Grid>
</ContentPage>

优化图像资源

图像是应用使用的一些最昂贵的资源,通常以高分辨率捕获。 虽然这会创建充满细节的充满活力的图像,但显示此类图像的应用通常需要更多的 CPU 使用率来解码图像,以及存储解码图像的更多内存。 当高分辨率图像将在内存中被缩小显示时,解码它是浪费的。 而是通过创建接近预测显示大小的存储映像版本来减少 CPU 使用率和内存占用量。 例如,在列表视图中显示的图像很可能比全屏显示的图像的分辨率更低。

此外,仅在必要时才应创建图像,并且在应用不再需要它们时应尽快释放。 例如,如果应用通过流读取数据来显示图像,请确保仅在需要时创建流,并在不再需要时释放它。 这可以通过创建页面时创建流,或者在触发 Page.Appearing 事件时创建流,然后在触发 Page.Disappearing 事件时释放流来实现此目的。

使用 ImageSource.FromUri(Uri) 方法下载要显示的图像时,请确保下载的图像缓存适当时间。 有关详细信息,请参阅 映像缓存

减少页面上的元素数

减少页面上的元素数将使页面呈现更快。 实现此目标的主要技术有两种。 第一个是隐藏不可见的元素。 每个元素的 IsVisible 属性确定该元素是否应在屏幕上可见。 如果元素不可见,因为它隐藏在其他元素后面,请删除该元素或将其 IsVisible 属性设置为 false。 将元素上的 IsVisible 属性设置为 false 会保留可视化树中的元素,但将其排除在呈现和布局计算中。

第二种方法是删除不必要的元素。 例如,下面显示了包含多个 Label 元素的页面布局:

<VerticalStackLayout>
    <VerticalStackLayout Padding="20,20,0,0">
        <Label Text="Hello" />
    </VerticalStackLayout>
    <VerticalStackLayout Padding="20,20,0,0">
        <Label Text="Welcome to the App!" />
    </VerticalStackLayout>
    <VerticalStackLayout Padding="20,20,0,0">
        <Label Text="Downloading Data..." />
    </VerticalStackLayout>
</VerticalStackLayout>

可以使用减少的元素计数维护相同的页面布局,如以下示例所示:

<VerticalStackLayout Padding="20,35,20,20"
                     Spacing="25">
    <Label Text="Hello" />
    <Label Text="Welcome to the App!" />
    <Label Text="Downloading Data..." />
</VerticalStackLayout>

减小应用程序资源字典大小

在整个应用中使用的任何资源都应存储在应用的资源字典中,以避免重复。 这有助于减少在整个应用中必须分析的 XAML 数量。 以下示例显示了 HeadingLabelStyle 资源,该资源适用于整个应用,因此在应用的资源字典中定义。

<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MyMauiApp.App">
     <Application.Resources>
        <Style x:Key="HeadingLabelStyle"
               TargetType="Label">
            <Setter Property="HorizontalOptions"
                    Value="Center" />
            <Setter Property="FontSize"
                    Value="Large" />
            <Setter Property="TextColor"
                    Value="Red" />
        </Style>
     </Application.Resources>
</Application>

但是,特定于页面的 XAML 不应包含在应用的资源字典中,因为资源将在应用启动时进行分析,而不是当页面需要时。 如果某个资源由不是启动页的页面使用,则应将其放置在该页面的资源字典中,因此有助于减少应用启动时分析的 XAML。 以下示例显示了只存在于单个页面上的 HeadingLabelStyle 资源,因此需在页面的资源字典中定义该资源。

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MyMauiApp.MainPage">
    <ContentPage.Resources>
        <Style x:Key="HeadingLabelStyle"
                TargetType="Label">
            <Setter Property="HorizontalOptions"
                    Value="Center" />
            <Setter Property="FontSize"
                    Value="Large" />
            <Setter Property="TextColor"
                    Value="Red" />
        </Style>
    </ContentPage.Resources>
    ...
</ContentPage>

有关应用资源的详细信息,请参阅 使用 XAML 样式化应用程序

减小应用的大小

.NET MAUI 生成应用时,可以使用名为 ILLink 的链接器来减小应用的整体大小。 ILLink 通过分析编译器生成的中间代码来减小大小。 它删除未使用的方法、属性、字段、事件、结构和类,以生成仅包含运行应用所需的代码和程序集依赖项的应用。

有关配置链接器行为的详细信息,请参阅 链接 Android 应用链接 iOS 应用,以及 链接 Mac Catalyst 应用

减少应用激活期

所有应用都有一个 激活期,即应用启动时间与应用可供使用的时间。 此激活期为用户提供了应用的第一印象,因此必须减少激活期和用户对其的看法,以便用户获得对应用的有利第一印象。

在应用显示其初始 UI 之前,它应提供初始屏幕以向用户指示应用正在启动。 如果应用程序无法快速显示其初始界面,则应使用启动屏幕通知用户激活期间的进度,以此向用户保证应用程序没有卡住。 这种提示可能是进度条或类似的控件。

在激活期间,应用执行激活逻辑,这通常包括资源的加载和处理。 通过确保所需的资源打包在应用中,而不是远程检索,可以减少激活期。 例如,在某些情况下,可能需要在激活期间加载本地存储的占位符数据。 然后,显示初始 UI 并且用户能够与应用交互后,可以从远程源逐步替换占位符数据。 此外,应用的激活逻辑应仅执行允许用户开始使用应用所需的工作。 这有助于延迟加载其他程序集,因为程序集是在首次使用时加载的。

仔细选择依赖项注入容器

依赖项注入容器在移动应用中引入了额外的性能约束。 使用容器注册和解析类型具有性能成本,因为容器使用反射来创建每种类型,尤其是在为应用中每个页面导航重新构造依赖项时。 如果存在许多或深层依赖项,则创建成本可能会显著增加。 此外,通常发生在应用启动期间的类型注册可能会对启动时间产生明显影响,具体取决于所使用的容器。 有关 .NET MAUI 应用中的依赖项注入的详细信息,请参阅 依赖项注入

作为替代方法,可以通过使用工厂手动实现依赖项注入来更具性能。

创建 Shell 应用

.NET MAUI Shell 应用基于浮出控件和选项卡提供有意见的导航体验。 如果应用用户体验可以通过 Shell 实现,则这样做是有益的。 Shell 应用程序有助于避免不佳的启动体验,因为页面是在导航时按需创建的,而不是在应用程序启动时创建,这与使用 TabbedPage的应用程序不同。 有关详细信息,请参阅 Shell 概述

优化 ListView 性能

使用 ListView时,应优化许多用户体验:

  • 初始化 – 从控件创建时开始,到在屏幕上显示项目时结束的时间间隔。
  • 滚动 – 滚动浏览列表并确保 UI 不会滞后于触摸手势。
  • 用于添加、删除和选择项的交互

ListView 控件要求应用提供数据和单元格模板。 实现此目的的方式将对控件的性能产生重大影响。 有关详细信息,请参阅 缓存数据

使用异步编程

应用的整体响应能力可以通过使用异步编程来增强,并且通常会避免性能瓶颈。 在 .NET 中,基于任务的异步模式(TAP) 是异步操作的建议设计模式。 但是,使用 TAP 不正确可能会导致应用表现不佳。

基础

使用 TAP 时,应遵循以下一般准则:

  • 了解任务生命周期,该生命周期由 TaskStatus 枚举表示。 有关详细信息,请参阅 任务状态意义任务状态
  • 使用 Task.WhenAll 方法来异步等待多个异步操作的完成,而不是分别对一系列异步操作执行 await。 有关详细信息,请参阅 Task.WhenAll
  • 使用 Task.WhenAny 方法异步等待多个异步操作之一完成。 有关详细信息,请参阅 Task.WhenAny
  • 使用 Task.Delay 方法生成在指定时间后完成的 Task 对象。 这对于轮询数据以及延迟处理预先确定时间的用户输入等方案非常有用。 有关详细信息,请参阅 Task.Delay
  • 使用 Task.Run 方法在线程池上执行密集型同步 CPU 操作。 此方法是 TaskFactory.StartNew 方法的快捷方式,已设置最优参数。 有关详细信息,请参阅 Task.Run
  • 避免尝试创建异步构造函数。 请改用生命周期事件或单独的初始化逻辑来正确 await 任何初始化。 有关详细信息,请参阅 blog.stephencleary.com 上的 异步构造函数
  • 使用延迟任务模式可以避免在应用启动时等待异步操作的完成。 有关详细信息,请参阅 AsyncLazy
  • 通过创建 TaskCompletionSource<T> 对象,为不使用 TAP 的现有异步操作创建任务包装器。 这些对象利用 Task 的可编程性优势,使您能够管理关联的 Task的寿命和完成情况。 有关详细信息,请参阅 TaskCompletionSource 的性质
  • 当无需处理异步操作的结果时,返回 Task 对象,而不是返回等待的 Task 对象。 上下文切换较少,因此性能更加出色。
  • 在某些场景中,比如当需要处理可用数据时,或当您有多个必须彼此异步通信的操作时,可以使用任务并行库(TPL)数据流库。 有关详细信息,请参阅 数据流(任务并行库)

用户界面

将 TAP 与 UI 控件结合使用时,应遵循以下准则:

  • 调用 API 的异步版本(如果可用)。 这将保持 UI 线程的畅通,从而有助于改善用户使用应用程序的体验。

  • 使用 UI 线程上的异步操作中的数据更新 UI 元素,以避免引发异常。 但是,ListView.ItemsSource 属性的更新将自动传递到 UI 线程。 有关确定代码是否在 UI 线程上运行的信息,请参阅 在 UI 线程上创建线程

    重要

    通过数据绑定更新的任何控件属性将自动调度到 UI 线程。

错误处理

使用 TAP 时,应遵循以下错误处理准则:

  • 了解异步异常处理。 异步运行的代码引发的未经处理的异常将传播回调用线程,但在某些情况下除外。 有关详细信息,请参阅 异常处理(任务并行库)
  • 避免创建 async void 方法,而是创建 async Task 方法。 这些方法可简化错误处理、可组合性和可测试性。 此准则的例外是异步事件处理程序,必须返回 void。 有关详细信息,请参阅 避免异步 Void
  • 不要通过调用 Task.WaitTask.ResultGetAwaiter().GetResult 方法来混合阻塞和异步代码,因为它们可能会导致死锁。 但是,如果必须违反此准则,首选方法是调用 GetAwaiter().GetResult 方法,因为它会保留任务异常。 有关详细信息,请参阅 全程异步.NET 4.5中的任务异常处理。
  • 尽可能使用 ConfigureAwait 方法创建无上下文代码。 无上下文的代码在移动应用中具有更好的性能,并且是处理部分异步代码库时避免死锁的有用技术。 有关详细信息,请参阅 配置上下文
  • 延续任务 用于处理上一个异步操作抛出的异常,并在启动之前或正在运行时取消延续等功能。 有关详细信息,请参阅 使用连续任务链接任务。
  • 在从 ICommand调用异步操作时,请使用 ICommand 的异步实现。 这可确保可以处理异步命令逻辑中的任何异常。 有关详细信息,请参阅 异步编程:异步 MVVM 应用程序的模式:命令

延迟创建对象的成本

惰性初始化可用于推迟对象的创建,直到首次使用它为止。 此方法主要用于提高性能、避免计算并减少内存需求。

请考虑在以下情况下,对创建成本高昂的对象使用延迟初始化:

  • 应用可能不使用该对象。
  • 创建对象之前,必须完成其他成本高昂的操作。

Lazy<T> 类用于定义延迟初始化的类型,如以下示例所示:

void ProcessData(bool dataRequired = false)
{
    Lazy<double> data = new Lazy<double>(() =>
    {
        return ParallelEnumerable.Range(0, 1000)
                     .Select(d => Compute(d))
                     .Aggregate((x, y) => x + y);
    });

    if (dataRequired)
    {
        if (data.Value > 90)
        {
            ...
        }
    }
}

double Compute(double x)
{
    ...
}

延迟初始化是在首次访问 Lazy<T>.Value 属性时发生的。 包装类型在首次访问时创建并返回,并存储以供将来访问。

有关延迟初始化的详细信息,请参阅 延迟初始化

发布 IDisposable 资源

IDisposable 接口提供释放资源的机制。 它提供了一个 Dispose 方法,该方法应实现以显式释放资源。 IDisposable 不是析构函数,只能在以下情况下实现:

  • 当类拥有非托管资源时。 需要释放的典型非托管资源包括文件、流和网络连接。
  • 当类拥有被系统托管的 IDisposable 资源时。

然后,当不再需要实例时,类型使用者可以调用 IDisposable.Dispose 实现来释放资源。 实现此目的有两种方法:

  • IDisposable 对象包装在 using 语句中。
  • 通过在 try/finally 块中封装对 IDisposable.Dispose 的调用。

将 IDisposable 对象包装在 using 语句中

以下示例演示如何在 using 语句中包装 IDisposable 对象:

public void ReadText(string filename)
{
    string text;
    using (StreamReader reader = new StreamReader(filename))
    {
        text = reader.ReadToEnd();
    }
    ...
}

StreamReader 类实现 IDisposableusing 语句提供了一种方便的语法,用于在 StreamReader 对象上调用 StreamReader.Dispose 方法,然后再将其排除在范围外。 在 using 块中,StreamReader 对象是只读的,无法重新分配。 using 语句还可确保即使发生异常,也调用 Dispose 方法,因为编译器为 try/finally 块实现中间语言(IL)。

在 try/finally 块中封装对 IDisposable.Dispose 的调用

以下示例演示如何在 try/finally 块中包装对 IDisposable.Dispose 的调用:

public void ReadText(string filename)
{
    string text;
    StreamReader reader = null;
    try
    {
        reader = new StreamReader(filename);
        text = reader.ReadToEnd();
    }
    finally
    {
        if (reader != null)
            reader.Dispose();
    }
    ...
}

StreamReader 类实现 IDisposablefinally 块调用 StreamReader.Dispose 方法来释放资源。 有关详细信息,请参阅 IDisposable 接口

取消订阅活动

为了防止内存泄漏,应在释放订阅对象之前取消对事件的订阅。 在取消订阅事件之前,发布对象中的事件委托对象保持对封装订阅者事件处理程序的委托的引用。 只要发布对象保存此引用,垃圾回收将不会回收订阅服务器对象内存。

以下示例演示如何取消事件订阅:

public class Publisher
{
    public event EventHandler MyEvent;

    public void OnMyEventFires()
    {
        if (MyEvent != null)
            MyEvent(this, EventArgs.Empty);
    }
}

public class Subscriber : IDisposable
{
    readonly Publisher _publisher;

    public Subscriber(Publisher publish)
    {
        _publisher = publish;
        _publisher.MyEvent += OnMyEventFires;
    }

    void OnMyEventFires(object sender, EventArgs e)
    {
        Debug.WriteLine("The publisher notified the subscriber of an event");
    }

    public void Dispose()
    {
        _publisher.MyEvent -= OnMyEventFires;
    }
}

Subscriber 类在其 Dispose 方法中取消订阅该事件。

使用事件处理程序和 lambda 语法时,还可以发生引用周期,因为 lambda 表达式可以引用和保持对象活动状态。 因此,可以将对匿名方法的引用存储在一个字段中,并用于取消对事件的订阅,如下示例所示:

public class Subscriber : IDisposable
{
    readonly Publisher _publisher;
    EventHandler _handler;

    public Subscriber(Publisher publish)
    {
        _publisher = publish;
        _handler = (sender, e) =>
        {
            Debug.WriteLine("The publisher notified the subscriber of an event");
        };
        _publisher.MyEvent += _handler;
    }

    public void Dispose()
    {
        _publisher.MyEvent -= _handler;
    }
}

_handler 字段维护对匿名方法的引用,并用于事件订阅和取消订阅。

避免 iOS 和 Mac Catalyst 上的强循环引用

在某些情况下,可以创建强引用环,这可能阻止对象的内存被垃圾回收器回收。 例如,假设 NSObject派生子类(例如继承自 UIView的类)添加到 NSObject派生容器,并且从 Objective-C进行强引用,如以下示例所示:

class Container : UIView
{
    public void Poke()
    {
        // Call this method to poke this object
    }
}

class MyView : UIView
{
    Container _parent;

    public MyView(Container parent)
    {
        _parent = parent;
    }

    void PokeParent()
    {
        _parent.Poke();
    }
}

var container = new Container();
container.AddSubview(new MyView(container));

当此代码创建 Container 实例时,C# 对象将具有对 Objective-C 对象的强引用。 同样,MyView 实例也将具有对 Objective-C 对象的强引用。

此外,对 container.AddSubview 的调用将增加非托管 MyView 实例的引用计数。 发生这种情况时,适用于 iOS 运行时的 .NET 会创建一个 GCHandle 实例,以将 MyView 对象保留在托管代码中,因为不能保证任何托管对象都将保留对它的引用。 从托管代码的角度来看,如果没有 GCHandle,在 AddSubview(UIView) 调用之后,MyView 对象将会被回收。

非托管 MyView 对象将有一个 GCHandle 指向托管对象的链接,这称为 强链接。 托管对象将包含对 Container 实例的引用。 反过来,Container 实例将具有对 MyView 对象的托管引用。

如果包含的对象保留与其容器的链接,可以使用多种选项来处理循环引用:

  • 通过保留对容器的弱引用来避免循环引用。
  • 对对象调用 Dispose
  • 通过将指向容器的链接设置为 null来手动中断周期。
  • 从容器中手动删除包含的对象。

使用弱引用

防止循环引用的一种方法是使用从子级到父级的弱引用。 例如,上述代码可能如以下示例所示:

class Container : UIView
{
    public void Poke()
    {
        // Call this method to poke this object
    }
}

class MyView : UIView
{
    WeakReference<Container> _weakParent;

    public MyView(Container parent)
    {
        _weakParent = new WeakReference<Container>(parent);
    }

    void PokeParent()
    {
        if (weakParent.TryGetTarget (out var parent))
            parent.Poke();
    }
}

var container = new Container();
container.AddSubview(new MyView container));

在这里,包含的对象不会使父对象保持活动状态。 然而,父进程通过调用 container.AddSubView保持子进程存活。

这也发生在使用委托或数据源模式的 iOS API 中,其中对等类包含实现。 例如,在 UITableView 类中设置 Delegate 属性或 DataSource 时。

对于纯粹为了实现协议而创建的类(例如 IUITableViewDataSource,可以执行的操作不是创建子类,而是在类中实现接口并重写方法,并将 DataSource 属性分配给 this

销毁具有强引用的对象

如果存在强引用并且很难删除依赖项,请使 Dispose 方法清除父指针。

对于容器,请重写 Dispose 方法以删除所包含的对象,如以下示例所示:

class MyContainer : UIView
{
    public override void Dispose()
    {
        // Brute force, remove everything
        foreach (var view in Subviews)
        {
              view.RemoveFromSuperview();
        }
        base.Dispose();
    }
}

对于保持对其父对象强引用的子对象,请在 Dispose 实现中清除对父对象的引用:

class MyChild : UIView
{
    MyContainer _container;

    public MyChild(MyContainer container)
    {
        _container = container;
    }

    public override void Dispose()
    {
        _container = null;
    }
}