Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds a rendering performance test #24762

Merged
merged 1 commit into from
Sep 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?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:issues="clr-namespace:Maui.Controls.Sample.Issues"
x:Class="Maui.Controls.Sample.Issues.RenderingPerformance"
Title="RenderingPerformance">

<Grid Padding="24" RowDefinitions="Auto,Auto,*" RowSpacing="8">
<Button x:Name="StartButton" Clicked="ButtonClicked" Text="Start" AutomationId="StartButton" />
<Label Grid.Row="1" Text="Do not press 'Start' more than once, just wait." />

<ScrollView Grid.Row="2">
<ContentView>
<VerticalStackLayout x:Name="BindableContainer" BindableLayout.ItemsSource="{Binding Models}">
<BindableLayout.ItemTemplate>
<DataTemplate>
<Border Margin="0,16" Padding="8" StrokeShape="RoundRectangle 8" Background="LightBlue">
<Grid RowDefinitions="Auto,Auto,Auto">
<Label Text="{Binding Header}" />
<Label Text="{Binding Content}" Grid.Row="1" />
<VerticalStackLayout Grid.Row="2" BindableLayout.ItemsSource="{Binding SubModels}" Margin="16,0">
<BindableLayout.ItemTemplate>
<DataTemplate>
<ContentView>
<VerticalStackLayout Margin="0,8">
<Label Text="{Binding Header}" />
<issues:MeasuredLabel IsMeasured="{Binding IsMeasured}" Text="{Binding Content}" />
</VerticalStackLayout>
</ContentView>
</DataTemplate>
</BindableLayout.ItemTemplate>
</VerticalStackLayout>
</Grid>
</Border>
</DataTemplate>
</BindableLayout.ItemTemplate>
</VerticalStackLayout>
</ContentView>
</ScrollView>
</Grid>
</ContentPage>
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
using System;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using Microsoft.Maui;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Xaml;
using ILayout = Microsoft.Maui.ILayout;

namespace Maui.Controls.Sample.Issues;

public class MeasuredLabel : Label
{
private static readonly TimeSpan ArrangedThreshold = TimeSpan.FromSeconds(1);
public static readonly BindableProperty IsMeasuredProperty = BindableProperty.Create(nameof(IsMeasured), typeof(bool), typeof(MeasuredLabel), false);

public bool IsMeasured
{
get => (bool)GetValue(IsMeasuredProperty);
set => SetValue(IsMeasuredProperty, value);
}

public long? LastArrangedTicks { get; set; }

public long? GetArrangeTicks() {
if (LastArrangedTicks is { } ticks)
{
var elapsed = Stopwatch.GetElapsedTime(ticks);
if (elapsed > ArrangedThreshold)
{
return ticks;
}
}

return null;
}
}

public static class RenderingPerformanceExtensions
{
public static MauiAppBuilder RenderingPerformanceAddMappers(this MauiAppBuilder builder)
{
builder.ConfigureMauiHandlers(handlers =>
{
Microsoft.Maui.Handlers.LabelHandler.CommandMapper.AppendToMapping(nameof(IView.Frame), (handler, view, arg) =>
{
if (view is MeasuredLabel { IsMeasured: true } measuredLabel)
{
measuredLabel.LastArrangedTicks = Stopwatch.GetTimestamp();
}
});
});

return builder;
}
}

[XamlCompilation(XamlCompilationOptions.Compile)]
[Issue(IssueTracker.None, 0, "Rendering performance", PlatformAffected.All)]
public partial class RenderingPerformance : ContentPage
{
bool _firstRun = true;

public List<ViewModelStub> Models { get; set; }

public RenderingPerformance()
{
Models = GenerateMeasuredItem();
BindingContext = this;
InitializeComponent();
}

private async void ButtonClicked(object sender, EventArgs e)
{
var capturedTimes = new List<int[]>();

// Generate view models so that only the last NestedViewModelStub of the last ViewModelStub is measured
// First time we generate 40 * 10 + 1 = 401 items
// This causes the creation of (40 * 5) + (40 * 10 * 4) + (1 * 5) + (1 * 4) = ~1800 platform views
var test1Models = GenerateItems(40, "First");
// Second time we generate 20 * 10 + 1 = 201 items
// This causes (20 * 5) + (20 * 10 * 4) = ~900 binding context changes
// and other ~900 platform views removals
var test2Models = GenerateItems(20, "Second");
// Third time we manually clear the BindableContainer and reset the models to the initial state (1 measured item)
var resetModel = GenerateMeasuredItem();

// This enables us to measure the time it takes to:
// - Create platform views
// - Bind the new view models
// - Remove platform views
// - Clear platform views

// Views include frequently used components like `ContentView` (legacy layout), `Border`, `VerticalStackLayout`, `Grid`, `Label`.
// Measurement happens by tracking IView.Frame mapping which happens right after the platform has arranged the view in the container view.

// Clear the first measure (happened while rendering the page for the first time)
if (_firstRun)
{
_firstRun = false;
await GetArrangeTicksAsync();
}

for (var i = 0; i < 5; i++)
{
await Task.Delay(200);

Models = test1Models;
var startTicks = Stopwatch.GetTimestamp();
OnPropertyChanged(nameof(Models));
var endTicks = await Task.Run(GetArrangeTicksAsync);
var t1 = (int)Stopwatch.GetElapsedTime(startTicks, endTicks).TotalMilliseconds;

await Task.Delay(200);

Models = test2Models;
startTicks = Stopwatch.GetTimestamp();
OnPropertyChanged(nameof(Models));
endTicks = await Task.Run(GetArrangeTicksAsync);
var t2 = (int)Stopwatch.GetElapsedTime(startTicks, endTicks).TotalMilliseconds;

await Task.Delay(200);

startTicks = Stopwatch.GetTimestamp();
BindableContainer.Clear();
Models = resetModel;
OnPropertyChanged(nameof(Models));
endTicks = await Task.Run(GetArrangeTicksAsync);
var t3 = (int)Stopwatch.GetElapsedTime(startTicks, endTicks).TotalMilliseconds;

capturedTimes.Add([t1, t2, t3]);
}

var avg1 = (int)capturedTimes.Average(t => t[0]);
var avg2 = (int)capturedTimes.Average(t => t[1]);
var avg3 = (int)capturedTimes.Average(t => t[2]);
StartButton.Text = $"{avg1},{avg2},{avg3}";
}

/// <summary>
/// Traverse the visual tree to find the last MeasuredLabel and return its arrange ticks when found
/// </summary>
/// <returns></returns>
async Task<long> GetArrangeTicksAsync()
{
while (true)
{
await Task.Delay(100);
IView view = BindableContainer;
while (true)
{
if (view is ILayout { Count: > 0 } layout)
{
view = layout[^1];
}
else if (view is IContentView contentView)
{
view = (IView)contentView.Content;
}
else
{
break;
}
}

if (view is MeasuredLabel measuredLabel && measuredLabel.GetArrangeTicks() is { } arrangeTicks)
{
measuredLabel.LastArrangedTicks = null;
return arrangeTicks;
}
}
}

static List<ViewModelStub> GenerateItems(int count, string prefix)
{
return
[
..Enumerable.Range(0, count).Select(i => new ViewModelStub
{
Content = $"{prefix} Content {i}",
Header = $"Header {i}",
SubModels = Enumerable.Range(0, 10).Select(j => new NestedViewModelStub
{
Content = $"{prefix} SubContent {j}", Header = $"{prefix} SubHeader {j}"
}).ToArray()
}),
..GenerateMeasuredItem()
];
}

static List<ViewModelStub> GenerateMeasuredItem()
{
return
[
new ViewModelStub
{
Content = "Measured Content",
Header = "Measured Header",
SubModels =
[
new NestedViewModelStub { Content = "Measured SubContent", Header = "Measured SubHeader", IsMeasured = true }
]
}
];
}

public class ViewModelStub
{
public string Header { get; set; }
public string Content { get; set; }
public NestedViewModelStub[] SubModels { get; set; }
}

public class NestedViewModelStub
{
public string Header { get; set; }
public string Content { get; set; }
public bool IsMeasured { get; set; }
}
}
1 change: 1 addition & 0 deletions src/Controls/tests/TestCases.HostApp/MauiProgram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public static MauiApp CreateMauiApp()
fonts.AddFont("FontAwesome.ttf", "FA");
fonts.AddFont("ionicons.ttf", "Ion");
})
.RenderingPerformanceAddMappers()
.Issue21109AddMappers()
.Issue18720AddMappers()
.Issue18720EditorAddMappers()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using NUnit.Framework;
using NUnit.Framework.Legacy;
using UITest.Appium;
using UITest.Core;

namespace Microsoft.Maui.TestCases.Tests.Issues
{
public class RenderingPerformance : _IssuesUITest
{
public RenderingPerformance(TestDevice device) : base(device) { }

public override string Issue => "Rendering performance";

[Test]
[Category(UITestCategories.Performance)]
public async Task RenderingPerformanceRun()
{
const string automationId = "StartButton";

var button = App.WaitForElement(automationId);
App.Tap(automationId);

var timeout = TimeSpan.FromMinutes(5); // MACCATALYST takes a long time to run this test
App.WaitForTextToBePresentInElement(automationId, ",", timeout);

var times = button.GetText()?.Split(',') ?? throw new ArgumentNullException("StartButton text is null");

var logMessage = @$"RenderingPerformance: [{times[0]}, {times[1]}, {times[2]}]";
TestContext.WriteLine(logMessage);

// Write the log to a file and attach it to the test results for ADO
var logFile = Path.Combine(Path.GetTempPath(), "RenderingPerformance.log");
await File.WriteAllTextAsync(logFile, logMessage);
TestContext.AddTestAttachment(logFile, "RenderingPerformance.log");
}
}
}
9 changes: 4 additions & 5 deletions src/TestUtils/src/UITest.Appium/HelperExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -401,11 +401,10 @@ public static void WaitForNoElement(
Wait(query, i => i is null, timeoutMessage, timeout, retryFrequency);
}

public static bool WaitForTextToBePresentInElement(this IApp app, string automationId, string text)
public static bool WaitForTextToBePresentInElement(this IApp app, string automationId, string text, TimeSpan? timeout = null)
{
TimeSpan timeout = DefaultTimeout;
timeout ??= DefaultTimeout;
TimeSpan retryFrequency = TimeSpan.FromMilliseconds(500);
string timeoutMessage = $"Timed out on {nameof(WaitForTextToBePresentInElement)}.";

DateTime start = DateTime.Now;

Expand All @@ -418,9 +417,9 @@ public static bool WaitForTextToBePresentInElement(this IApp app, string automat
}

long elapsed = DateTime.Now.Subtract(start).Ticks;
if (elapsed >= timeout.Ticks)
if (elapsed >= timeout.Value.Ticks)
{
Debug.WriteLine($">>>>> {elapsed} ticks elapsed, timeout value is {timeout.Ticks}");
Debug.WriteLine($">>>>> {elapsed} ticks elapsed, timeout value is {timeout.Value.Ticks}");

return false;
}
Expand Down
Loading