提高应用性能

应用性能不佳表现在许多方面。 这会使应用看起来无响应,导致滚动缓慢,还可缩短设备电池寿命。 但是,优化性能不止需要实现高效的代码。 还必须考虑用户对应用性能的体验。 例如,确保操作执行不会妨碍用户执行其他活动,这有助于改进用户的体验。

有许多方法可以提高 .NET Multi-platform App UI (.NET MAUI) 应用的性能和感知性能。 这些方法一起可以极大地降低 CPU 执行的工作量和应用消耗的内存量。

使用探查器

开发应用时,请务必先分析代码,再尝试对代码进行优化。 分析是一种确定代码优化对减少性能问题的哪方面影响最大的方法。 探查器跟踪应用的内存使用率,并记录应用中方法的运行时间。 此数据有助于浏览应用的执行路径和代码执行开销,以便发现最佳优化时机。

可以在 Android、iOS 和 Mac 上使用 dotnet-trace 对 .NET MAUI 应用进行分析,也可以在 Windows 上使用 PerfView 进行分析。 有关详细信息,请参阅分析 .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

第二种方法是删除不需要的元素。 例如,以下显示了包含多个 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,应该使用初始屏幕在激活时段中告知用户进度,让用户确信应用没有挂起。 可通过进度栏或类似控件提供此确信通知。

激活期间,应用会执行激活逻辑,通常包括加载和处理资源。 通过确保所需资源已打包在应用内,而不用远程检索,可缩短激活期限。 例如,某些情况可能适合在激活期间加载存储在本地的占位符数据。 然后,显示初始 UI 后,用户便可与应用交互,占位符数据可逐渐替换为远程源。 此外,应用的激活逻辑仅应执行让用户开始使用应用所需的工作。 这在激活逻辑延迟加载其他程序集时可起到帮助,因为程序集在首次使用时需完成加载。

仔细选择依赖关系注入容器

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

作为替代方案,通过使用工厂手动实现依赖关系注入,可以使依赖关系注入更加高效。

创建 Shell 应用

.NET MAUI Shell 应用提供了基于浮出控件和选项卡的固定导航体验。 如果应用用户体验可以通过 Shell 实现,那么这样做很有用。 Shell 应用有助于避免糟糕的启动体验,因为页面是应导航所需而创建的,不是在应用启动时创建,而在使用 TabbedPage 的应用中会出现这种情况。 有关详细信息,请参阅 Shell 概述

优化 ListView 性能

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

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

ListView 控件需要使用应用来提供数据和单元格模板。 实现此目标的方法会对该控件的性能产生很大影响。 有关详细信息,请参阅缓存数据

使用异步编程

通过使用异步编程,可增强应用的总体响应能力,而且通常可避免性能瓶颈。 在 .NET 中,基于任务的异步模式 (TAP) 是异步操作的推荐设计模式。 但如果 TAP 使用不当,则可能会导致应用性能不佳。

基础

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

  • 了解由 TaskStatus 枚举表示的任务生命周期。 有关详细信息,请参阅 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 上的 Async 构造函数
  • 使用惰性任务模式,以避免在应用启动过程中要等待异步操作完成。 有关详细信息,请参阅 AsyncLazy
  • 通过创建 TaskCompletionSource<T> 对象,为不使用 TAP 的现有异步操作创建任务包装器。 这些对象获得 Task 可编程性的优点,并使你能够控制关联 Task 的生存期和完成。 有关详细信息,请参阅 TaskCompletionSource 的性质
  • 当无需处理异步操作的结果时,返回 Task 对象,而不是返回等待的 Task 对象。 由于执行的上下文切换较少,因此性能更高。
  • 在数据可用时处理数据,或在你有多个必须以异步方式彼此通信的操作等场景下,使用任务并行库 (TPL) 数据流库。 有关详细信息,请参阅数据流(任务并行库)

UI

在配合 UI 控件使用 TAP 时,应遵循以下准则:

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

  • 使用 UI 线程上异步操作中的数据更新 UI 元素,以避免引发异常。 但是,对 ListView.ItemsSource 属性的更新将自动被封送到该 UI 线程。 要了解如何确定代码是否在 UI 线程上运行,请参阅在 UI 线程上创建线程

    重要

    通过数据绑定更新的控件属性都将自动被封送到该 UI 线程。

错误处理

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

  • 了解异步异常处理。 由异步运行的用户代码引发的未处理异常会传播回调用线程(某些情况除外)。 有关详细信息,请参阅异常处理(任务并行库)
  • 不要创建 async void 方法,而是创建 async Task 方法。 这些方法更便于实现错误处理、可组合性和可测试性。 此指导原则的例外情况是异步事件处理程序,这类处理程序必须返回 void。 有关详细信息,请参阅避免 Async Void
  • 请勿通过调用 Task.WaitTask.ResultGetAwaiter().GetResult 方法将阻止代码和异步代码混杂在一起,因为它们会导致死锁的发生。 但是,如果必须违反此准则,则首选的方法是调用 GetAwaiter().GetResult 方法,因为它将保留任务异常。 有关详细信息,请参阅始终使用 Async.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 实现来释放资源。 可通过两种方法来实现此目的:

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

在 using 语句中包装 IDisposable 对象

以下示例展示了如何在 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 实例上的引用数。 出现此情况时,.NET iOS 运行时会创建 GCHandle 实例以使 MyView 对象在托管代码中处于活动状态,因为无法保证所有托管对象都保留对该对象的引用。 从托管代码的角度看,如果 MyView 对象不适用于 GCHandle,则将在 AddSubview(UIView) 调用后将该对象回收。

未托管的 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),可以直接在类中实现接口并替代方法,再向 this 分配 DataSource 属性,而不是创建子类。

释放包含强引用的对象

如果存在强引用且难以删除依赖项,请使用 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;
    }
}