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

Changing Translation of an element causes Maui in iOS to constantly run Measure & ArrangeChildren (likely cause of brutal iOS Layout performance issues) #24996

Open
jonmdev opened this issue Sep 30, 2024 · 16 comments · May be fixed by #26629 or #25664
Labels
area-layout StackLayout, GridLayout, ContentView, AbsoluteLayout, FlexLayout, ContentPresenter platform/iOS 🍎 s/triaged Issue has been reviewed s/verified Verified / Reproducible Issue ready for Engineering Triage t/bug Something isn't working t/perf The issue affects performance (runtime speed, memory usage, startup time, etc.) (sub: perf)

Comments

@jonmdev
Copy link

jonmdev commented Sep 30, 2024

Description

Maui provides very poor Layout performance on iOS during translations (eg. custom animations, scrolling, or collection view type systems), and I believe I have now found the cause. Hopefully there is an easy workaround or solution. I am open to any suggestions.

This is likely also the cause of the generally poor scrolling and CollectionView performance in iOS as both will also likely depend on translation changes to function, and thus also be subject to these endless re-measures and re-arranges.

PROBLEM

In a working system, adjusting the translation of an element should NEVER provoke a re-measure/re-layout/re-arrange of the object or all its children. That is the whole point of translation. It is supposed to be a fast and efficient way to adjust the position of something WITHOUT re-doing every measurement and layout.

This is working in Android/Windows. Adjusting the translation of an element does NOT provoke a re-layout/re-arrange of the object or all children. No re-measure or arrange occurs on translation change.

However, in iOS ArrangeChildren is being run on every single translation change. And Measure is being run extremely often as well provoked by only translation changes (though not quite as often).

This is brutalizing the performance in iOS as any translation change leads to continuous re-layouts and re-measures of all children of the translated object starting from its parent and propagating down through the entire child hierarchy.

DEMONSTRATION PROJECT

This is demonstrated through the bug project here: https://github.com/jonmdev/iOSTranslationArrangeBug

The project is created in only two files:

CAbsoluteLayout.cs - This is a derived class of AbsoluteLayout to implement a custom layout manager so we can see the layouts on debug when they happen through the overrides of Measure and ArrangeChildren. The custom layout manager is just a simplified copy and paste from Maui's AbsoluteLayoutManager.cs. No special changes.

App.xaml.cs - This is just a simple C# project that adds a few of these Custom Absolute Layout elements to the screen and starts oscillating the position of one. It creates a hierarchy of, by styleId names:

"Scroll Window" (AbsoluteLayout)
==="Scroll Content" (AbsoluteLayout)
======Child Abs 0 (AbsoluteLayout)
=========Border
======Child Abs 1 (AbsoluteLayout)
=========Border
======Child Abs 2 (AbsoluteLayout)
=========Border

"Scroll Content" is the moved object. In iOS, we will see how arranges and measures are spammed as its translation changes from its parent ("Scroll Window") all the way down through the hierarchy.

RESULT

One can see if you run this in Windows/Android there are NO ongoing lines showing Measure or ArrangeChildren are being run (as expected). You will get on project load only once or twice something like:

[0:] MEASURE CHILDREN OF: Scroll Window NUM CHILDREN 1
[0:] MEASURE CHILDREN OF: Scroll Content NUM CHILDREN 3
[0:] MEASURE CHILDREN OF: Child Abs 0 NUM CHILDREN 1
[0:] MEASURE CHILDREN OF: Child Abs 1 NUM CHILDREN 1
[0:] MEASURE CHILDREN OF: Child Abs 2 NUM CHILDREN 1
[0:] ARRANGE CHILDREN OF: Scroll Window NUM CHILDREN 1
[0:] ARRANGE CHILDREN OF: Scroll Content NUM CHILDREN 3
[0:] ARRANGE CHILDREN OF: Child Abs 0 NUM CHILDREN 1
[0:] ARRANGE CHILDREN OF: Child Abs 1 NUM CHILDREN 1
[0:] ARRANGE CHILDREN OF: Child Abs 2 NUM CHILDREN 1

This is normal behavior in Android/Windows.

However, in iOS, these outputs are spammed on a continuous basis as the translation keeps changing. You will get an infinite number of such re-measurements and re-arranges. The arranges occur on literally every single Translation change while the measures occur only at certain points of the oscillation (unclear why).

In general, manipulating the translations in iOS of anything in the real world with complex hierarchies is extremely costly and nothing runs as smoothly as it should even on high end iOS devices. This is likely why.

We cannot afford to be re-arranging the whole hierarchy every time a translation occurs and this should not be happening.

CAUSE / SOLUTION?

Is there any obvious cause or solution that comes to mind?

Any workaround or solution (or ideas) would be very appreciated.

Steps to Reproduce

  1. Load the bug project.
  2. Run in Windows/Android. See there is no ArrangeChildren or Measure being run on an ongoing basis.
  3. Run in iOS and see these functions are running on a continuous basis as the translation is changed.

Link to public reproduction project repository

https://github.com/jonmdev/iOSTranslationArrangeBug

Version with bug

8.0.91 SR9.1

Is this a regression from previous behavior?

No, this is something new

Affected platforms

iOS

@jonmdev jonmdev added the t/bug Something isn't working label Sep 30, 2024
@jonmdev jonmdev changed the title Changing Translation of an element causes Maui in iOS to constantly run Measure & ArrangeChildren on parent & all children (likely cause of brutal Layout performance issues on iOS) Changing Translation of an element causes Maui in iOS to constantly run Measure & ArrangeChildren (likely cause of brutal iOS Layout performance issues) Sep 30, 2024
@ninachen03 ninachen03 added platform/iOS 🍎 s/verified Verified / Reproducible Issue ready for Engineering Triage s/triaged Issue has been reviewed labels Sep 30, 2024
@ninachen03
Copy link

This issue has been verified using Visual Studio 17.12.0 Preview 2.0(8.0.91 & 8.0.3). Can repro this issue at ios platform.
measure

@jfversluis jfversluis added t/perf The issue affects performance (runtime speed, memory usage, startup time, etc.) (sub: perf) area-layout StackLayout, GridLayout, ContentView, AbsoluteLayout, FlexLayout, ContentPresenter labels Sep 30, 2024
@jsuarezruiz jsuarezruiz added this to the Backlog milestone Oct 15, 2024
@albyrock87
Copy link
Contributor

@jonmdev a follow-up from my comment on the other issue.
I see you're not using legacy layouts inside, but unfortunately ScrollView is a legacy layout.
May you try to replace that with a Grid and see if the problem is gone?

You can find me on Discord in case you wanna chat.

@albyrock87
Copy link
Contributor

albyrock87 commented Oct 20, 2024

Also, keeping the ScrollView, may you try to install this version of MAUI from my NuGet GitHub feed and see if the issue is gone?
https://github.com/nalu-development/maui-custom/pkgs/nuget/Microsoft.Maui.Core

Note: This custom build supports Android and iOS only.

@jonmdev
Copy link
Author

jonmdev commented Oct 20, 2024

@jonmdev a follow-up from my comment on the other issue. I see you're not using legacy layouts inside, but unfortunately ScrollView is a legacy layout. May you try to replace that with a Grid and see if the problem is gone?

You can find me on Discord in case you wanna chat.

I am not sure what you are referring to. I am not using any ScrollView. Just translating an AbsoluteLayout. This should not cause these remeasure and rearranges. There is a fundamental Layout failure. Do you see what I mean?

You can see as I linked above the only minimal project code is here:

The "scroll window" and "scroll content" of my project are just AbsoluteLayouts with a custom LayoutManager so we can see all the bizarre behavior debugged out from it:

        CAbsoluteLayout scrollWindow;
        CAbsoluteLayout scrollContent;

I am not sure if you would call AbsoluteLayout a "legacy layout" but it is still very much needed. We need a simple method for laying out objects where we are controlling their arrangement or translation and need to add multiple children to it and arrange them for example in layers without something like "VerticalStackLayout" taking over and forcing a position.

I updated my original post for better clarity on the object types (only AbsoluteLayout and Border in this project at all at any point).

Thanks for any help as always.

@albyrock87
Copy link
Contributor

I'm sorry, I got tricked by the name "scrollWindow".
Anyway, I'll look into this later in the day/tomorrow.

@albyrock87
Copy link
Contributor

@jonmdev here's what I tried to do within the MAUI repo to reproduce:

namespace Maui.Controls.Sample.Issues;

[Issue(IssueTracker.Github, 24996, "Translation causes multiple layout passes", PlatformAffected.All)]
public partial class Issue24996 : ContentPage
{
	public Issue24996()
	{
		InitializeComponent();
	}

	public async void OnTapped(object sender, EventArgs e)
	{
		Lvl2.TranslationY = Random.Shared.Next(0, 400);
		await Task.Delay(500);
		Stats.Text = $"Root: {Root.MeasurePasses}->{Root.ArrangePasses} / Lvl1: {Lvl1.MeasurePasses}->{Lvl1.ArrangePasses} / Lvl2: {Lvl2.MeasurePasses}->{Lvl2.ArrangePasses} / Lvl3: {Lvl3.MeasurePasses}->{Lvl3.ArrangePasses}";
	}
}

public class AbsoluteLayout24996 : AbsoluteLayout
{
	public int MeasurePasses { get; private set; }
	public int ArrangePasses { get; private set; }

	protected override Size MeasureOverride(double widthConstraint, double heightConstraint)
	{
		MeasurePasses++;
		return base.MeasureOverride(widthConstraint, heightConstraint);
	}

	protected override Size ArrangeOverride(Rect bounds)
	{
		ArrangePasses++;
		return base.ArrangeOverride(bounds);
	}
}
<?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.Issue24996"
             Title="Issue24996">
  <issues:AbsoluteLayout24996 x:Name="Root">
    <issues:AbsoluteLayout24996.GestureRecognizers>
      <TapGestureRecognizer Tapped="OnTapped" />
    </issues:AbsoluteLayout24996.GestureRecognizers>
    <issues:AbsoluteLayout24996 x:Name="Lvl1" BackgroundColor="DarkSlateBlue">
      <issues:AbsoluteLayout24996 x:Name="Lvl2" BackgroundColor="AliceBlue">
        <issues:AbsoluteLayout24996 x:Name="Lvl3" HeightRequest="100" WidthRequest="100" BackgroundColor="Aqua">
          <Border BackgroundColor="GreenYellow" HeightRequest="100" WidthRequest="100" />
        </issues:AbsoluteLayout24996>
      </issues:AbsoluteLayout24996>
    </issues:AbsoluteLayout24996>
    <Label x:Name="Stats" 
           issues:AbsoluteLayout24996.LayoutFlags="PositionProportional"
           issues:AbsoluteLayout24996.LayoutBounds="0,1,-1,-1"
           BackgroundColor="#222222"
           TextColor="White"/>
  </issues:AbsoluteLayout24996>
</ContentPage>

As you can see from this recording, the number of measure / arrange passes matches the number of taps (translations).

Image

Why are you doing this?

mainPage.SizeChanged += delegate {
                if (mainPage.Width > 0) {
                    scrollWindow.WidthRequest = mainPage.Width;
                    scrollWindow.HeightRequest = mainPage.Height;
                    scrollContent.WidthRequest = mainPage.Width;
                    scrollContent.HeightRequest = mainPage.Height * 0.78;
                }
            };

Anyway, even if I add this after InitializeComponent I'm still not able to reproduce.
What could be the difference?

@jonmdev
Copy link
Author

jonmdev commented Oct 21, 2024

Why are you doing this?

This small snippet is just a simple way to resize the objects when the ContentPage changes size to full screen automatically. It runs once on initial project load. You can change it to:

            mainPage.SizeChanged += delegate {
                if (mainPage.Width > 0) {
                    Debug.WriteLine("MAIN PAGE CHANGED SIZE); //TO SEE WHEN IT OCCURS
                    scrollWindow.WidthRequest = mainPage.Width;
                    scrollWindow.HeightRequest = mainPage.Height;
                    scrollContent.WidthRequest = mainPage.Width;
                    scrollContent.HeightRequest = mainPage.Height * 0.78;
                }
            };

You will see it only runs once. It is not the culprit or any problem.

As you can see from this recording, the number of measure / arrange passes matches the number of taps (translations).

This is likely the same abnormal behavior, based on my project and expectations compared to Android/Windows and any other system I have worked in.

If you load my project in Windows/Android and run it, you will see there are ZERO measure or arrange passes being debugged out after the very initial project load, despite it continuously translating the object infinitely over time.

This is the correct behavior. Translation should NOT trigger measure/arrange passes. That is the whole point of the translation function. It is meant to be a cheap and simple way to move an object relative to another WITHOUT re-measuring or arranging anything. It only does these extra measures and arranges in iOS. It is a bug that is making smooth translation in iOS near impossible when there is a complex hierarchy.

If you have a very complex hierarchy and try to translate it, you can do this smoothly with ease in Windows/Android because they are not remeasuring/arranging anything every time (as expected). But in iOS it becomes crushing to remeasure/rearrange on every translation change which shouldn't be happening.

The concept of Translation is common to many UI systems and the Windows/Android behavior is the expected behavior. The iOS behavior is not.

Give my project a try in Android/Windows and you will see what I mean. I don't actually work at all in xaml (only C#) so I am not sure if there would be any other difference. But I suspect you will see the same thing in your project if you test it in Windows/Android to compare.

Thanks very much for your attention to this or any further ideas.

@PureWeen PureWeen modified the milestones: Backlog, .NET 9 Servicing Oct 21, 2024
@albyrock87
Copy link
Contributor

albyrock87 commented Oct 22, 2024

@jonmdev It took me a while to understand your point, I'm sorry! :D

So, this is what's happening:

  • When a transformation property is changed, it triggers
    public static void UpdateTransformation(this UIView platformView, IView? view)
    {
    CALayer? layer = platformView.Layer;
    CGPoint? originalAnchor = layer?.AnchorPoint;
    platformView.UpdateTransformation(view, layer, originalAnchor);
    }
  • This natively invalidates the view through SetNeedsLayout, which unfortunately is being overridden in MauiView which is the base class of LayoutView and other views like Border
    public override void SetNeedsLayout()
    {
    InvalidateConstraintsCache();
    base.SetNeedsLayout();
    TryToInvalidateSuperView(false);
    }
  • That will go up to the chain and cause each layout to be remeasured and rearranged

On other platforms this "bubble up" mechanism happens natively, while on iOS it does not.
This is why MAUI had to manually do this, but I've learnt that doing it this way leads to undesired behaviors / limitations.

I have a PR which moves this mechanism at handler mapper level

public static void InvalidateMeasure(this UIView platformView, IView view)
{
platformView.PropagateSetNeedsLayout();
}
internal static void PropagateSetNeedsLayout(this UIView? view)
{
// Bubble up the tree to the root view unless we reach a scrollable area
while (view != null)
{
view.SetNeedsLayout();
// We check for Window to avoid scenarios where an invalidate might propagate up the tree
// To a SuperView that's been disposed which will cause a crash when trying to access it
if (view.Window is null)
{
if (view is ISchedulesSetNeedsLayout schedulesSetNeedsLayout)
{
schedulesSetNeedsLayout.ScheduleSetNeedsLayoutPropagation();
}
return;
}
view = view.Superview;
// If we reached a scrollable area or the page, simply invalidate and stop here
if (view is ContentView { Tag: ContentView.PageTag } or UIScrollView { ScrollEnabled: true })
{
view.SetNeedsLayout();
return;
}
}
}

This way, even if the transformation happens at native level, it would only invalidate that native view, and not the entire chain.

Now, even if I wanted to create a PR targeting .NET8, there is no chance it'd be included as an SR10 because I've been told that only very small and very important PRs could be included there.

On the other hand I can propose a fix for this targeting .NET9, or I may even fix this and release it within my custom .NET8 MAUI build.

@albyrock87
Copy link
Contributor

albyrock87 commented Oct 22, 2024

@jonmdev let me add that this is only happening in a precise situation, I've updated my test code
main...albyrock87:issue-24996-translation

That said, my message above is still valid, but it's limited to this scenario.

SizeChanged += delegate {
	if (Width > 0) {
		// For some reason, constraining Lvl1 layout to a fixed size causes a `SetNeedsLayout` to be called
		// when translating the Lvl2 view (its child) outside the bottom boundary.
		// This causes a layout pass to be called on the Root, Lvl1, Lvl2, and Lvl3.
		Lvl1.WidthRequest = Width;
		Lvl1.HeightRequest = Height;
	}
};

Image

@OvrBtn

This comment has been minimized.

@albyrock87
Copy link
Contributor

@OvrBtn may you contact me via Discord (I have the same username there) so that we don't spam this issue? Thanks!

@albyrock87
Copy link
Contributor

I've prepared a branch (for now it's based on .NET8 so I'll have to rebase) with the fix.
main...albyrock87:issue-24996-translation

@jonmdev
Copy link
Author

jonmdev commented Oct 22, 2024

I've prepared a branch (for now it's based on .NET8 so I'll have to rebase) with the fix. main...albyrock87:issue-24996-translation

Hey, thanks so much for looking into this and trying to solve it. I would love to test this to see if it fixes my real world terrible iOS performance scenario I am facing which I believe is due to this bug. But I am not sure how. I have never built Maui and I am not sure how or what to do with it even if I do. I usually just add the nuget packages.

To me it is no different whether it is .NET 9 or .NET 8 as I am not in production yet (can't be, too many Maui issues) and we will all have to go to .NET 9 anyway at some point.

Any thoughts or suggestions? Thanks.

@albyrock87
Copy link
Contributor

Once I find the time to rebase onto net9 and create a PR, we can then run AZP and generate packages from there. :)

@albyrock87
Copy link
Contributor

@jonmdev you can find packages with my fix in artifacts here: https://dev.azure.com/xamarin/public/_build/results?buildId=126601&view=artifacts&pathAsName=false&type=publishedArtifacts

Obviously we're talking about MAUI 9 packages.

@jonmdev
Copy link
Author

jonmdev commented Nov 12, 2024

Alby! I have to say - Thank you so much! You have single handedly fixed so many of the terrible iOS problems now.

Now this appears fixed as well. I tested with your custom nuget package you sent me.

Before, I was getting ridiculous slow downs at certain points of translation due to all the extra measurements and layouts and arranges. Now that is smooth. This appears to have fixed what was driving me crazy. It looked horrible before. Now it looks quite normal.

I still have slow performance on my custom CollectionView type object. But that is I think a separate matter. Likely more over-draw or other issues like that again that will need to be proven separately.

It seems evident at this point that all these performance issues we are seeing are not just one issue but many issues. It will be easier to figure out the remaining ones now that this one is out of the way. With each problem fixed it is easier to see the remaining ones.

Hopefully this fix you made will be added soon to the official package for .NET 9. Then once that is the case, I will work on trying to narrow down the remaining iOS performance issues and let you know if I find anything else significant.

Thanks again for all your work. All the best.

albyrock87 added a commit to albyrock87/maui that referenced this issue Dec 14, 2024
@albyrock87 albyrock87 linked a pull request Dec 14, 2024 that will close this issue
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-layout StackLayout, GridLayout, ContentView, AbsoluteLayout, FlexLayout, ContentPresenter platform/iOS 🍎 s/triaged Issue has been reviewed s/verified Verified / Reproducible Issue ready for Engineering Triage t/bug Something isn't working t/perf The issue affects performance (runtime speed, memory usage, startup time, etc.) (sub: perf)
Projects
None yet
7 participants