maui
maui copied to clipboard
AbsoluteLayout.SetLayoutBounds() causes all AbsoluteLayout children to be measured and layed out.
Description
Layout is very CPU intensive/inefficient when moving an AbsoluteLayout child using AbsoluteLayout.SetLayoutBounds() . In the attached sample, a timer calls AbsoluteLayout.SetLayoutBounds(boxview1,...) every second, which results in the following debug output:
[0:] OnSizeAllocated: boxview1 78.54545454545456,144.5818181818182
[0:] MeasureOverride: boxview1 78.54545454545456,144.5818181818182
[0:] MeasureOverride: boxview2 78.54545454545456,144.5818181818182
There are 2 problems here:
- boxview2 should not have any layout related calls as a result of changing boxview1. This is a major problem because if an AbsoluteLayout has lots of children this will needlessly consume a lot of CPU every 1 second.
- Even boxview1 should not have layout passes since we are not changing it's dimensions but only moving it within the AbsoluteLayout.
Steps to Reproduce
Run the attached sample on Android and see the debug output. I don't know if this is an issue on other platforms
Version with bug
Release Candidate 1 (current)
Last version that worked well
Unknown/Other
Affected platforms
Android
Affected platform versions
Android 11
Did you find any workaround?
no workaround
Relevant log output
No response
can see this issue when run above project on android 11.
We've moved this issue to the Backlog milestone. This means that it is not going to be worked on for the coming release. We will reassess the backlog following the current release and consider this item at that time. To learn more about our issue management process and to have better expectation regarding different types of issues you can read our Triage Process.
This issue is making our application very sluggish compared to XF when running on iOS (not releasable as-is).
Can someone take this out of the backlog?
@philipag Are you still seeing the same performance issue with VS 17.4 (and the most recent MAUI version)?
Yes @hartez
@hartez I examined the MAUI source code a bit and can see the problem. AbsoluteLayout.LayoutBoundsPropertyChanged() looks like this:
static void LayoutBoundsPropertyChanged(BindableObject bindable, object oldValue, object newValue)
{
if (bindable is View view && view.Parent is Maui.ILayout layout)
{
layout.InvalidateMeasure();
}
}
i.e. it invalidates the entire AbsoluteLayout control which is what causes all children to be laid out (instead of only the child in question as it should be).
Similarly, AbsoluteLayoutManager.ArrangeChildren() is the only method to calculate the child positioning of an AbsoluteLayout child, and it is designed to always recalculate layout for all children no matter which child had LayoutBounds or LayoutFlags changed.
So the solution to this bug is not that complicated it seems. The code just has to be refactored to deal with individual children on an as-needed basis. The current code explains why our app is soooo slow with MAUI as opposed to XF. There is an incredibly large amount of layout code being executed when just 1 child is repositioned.
Is this information enough to bring this out of the backlog and apply a fix. I would do a pull request but I'm not at all familiar with MAUI layout code which is somewhat complex it seems.
Here is an ugly kludge to work around this issue. It updates only the child for which LayoutBoundsPropertyChanged() is called, making an AbsoluterLayout with many children in its tree MUCH faster.
public class OurAbsoluteLayout : AbsoluteLayout
{
static OurAbsoluteLayout()
{
var prop = AbsoluteLayout.LayoutBoundsProperty.GetType().GetProperty("PropertyChanged", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.SetProperty);
prop.SetValue(LayoutBoundsProperty, new BindingPropertyChangedDelegate(OurLayoutBoundsPropertyChanged));
}
static void OurLayoutBoundsPropertyChanged(BindableObject bindable, object oldValue, object newValue)
{
if (bindable is View view && view.Parent is OurAbsoluteLayout layout)
{
var rect = (Microsoft.Maui.Graphics.Rect)newValue;
layout.ArrangeChild(view as IView, rect);
}
}
protected void ArrangeChild(IView child, Rect destination2)
{
if (child.Visibility != Visibility.Collapsed)
{
var padding = this.Padding;
double top = padding.Top + this.Bounds.Top;
double left = padding.Left + this.Bounds.Left;
double availableWidth = this.Bounds.Width - padding.HorizontalThickness;
double availableHeight = this.Bounds.Height - padding.VerticalThickness;
var destination = this.GetLayoutBounds(child);
var flags = this.GetLayoutFlags(child);
bool isWidthProportional = HasFlag(flags, AbsoluteLayoutFlags.WidthProportional);
bool isHeightProportional = HasFlag(flags, AbsoluteLayoutFlags.HeightProportional);
destination.Width = ResolveDimension(isWidthProportional, destination.Width, availableWidth, child.DesiredSize.Width);
destination.Height = ResolveDimension(isHeightProportional, destination.Height, availableHeight, child.DesiredSize.Height);
if (HasFlag(flags, AbsoluteLayoutFlags.XProportional))
destination.X = (availableWidth - destination.Width) * destination.X;
if (HasFlag(flags, AbsoluteLayoutFlags.YProportional))
destination.Y = (availableHeight - destination.Height) * destination.Y;
destination.X += left;
destination.Y += top;
child.InvalidateMeasure();
child.Arrange(destination);
}
}
static bool HasFlag(AbsoluteLayoutFlags a, AbsoluteLayoutFlags b)
{
// Avoiding Enum.HasFlag here for performance reasons; we don't need the type check
return (a & b) == b;
}
static double ResolveDimension(bool isProportional, double fromBounds, double available, double desired)
{
// By default, we use the absolute value from LayoutBounds
var value = fromBounds;
if (isProportional && !double.IsInfinity(available))
{
// If this dimension is marked proportional, then the value is a percentage of the available space
// Multiple it by the available space to figure out the final value
value *= available;
}
else if (value == AutoSize)
{
// No absolute or proportional value specified, so we use the measured value
if (desired != -1 && !double.IsNaN(desired))
value = desired;
}
return value;
}
}
Here is the simplified version of the kludge:
public class AbsoluteLayoutFix
{
static public void ApplyFix()
{
var prop = AbsoluteLayout.LayoutBoundsProperty.GetType().GetProperty("PropertyChanged", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.SetProperty);
prop.SetValue(AbsoluteLayout.LayoutBoundsProperty, new BindingPropertyChangedDelegate(OurLayoutBoundsPropertyChanged));
}
static void OurLayoutBoundsPropertyChanged(BindableObject bindable, object oldValue, object newValue)
{
if (bindable is View view && view.Parent is AbsoluteLayout layout)
ArrangeAbsChild(layout, view as IView);
}
static void ArrangeAbsChild(AbsoluteLayout layout, IView child)
{
if (child.Visibility != Visibility.Collapsed)
{
var padding = layout.Padding;
double top = padding.Top + layout.Bounds.Top;
double left = padding.Left + layout.Bounds.Left;
double availableWidth = layout.Bounds.Width - padding.HorizontalThickness;
double availableHeight = layout.Bounds.Height - padding.VerticalThickness;
var destination = layout.GetLayoutBounds(child);
var flags = layout.GetLayoutFlags(child);
bool isWidthProportional = HasFlag(flags, AbsoluteLayoutFlags.WidthProportional);
bool isHeightProportional = HasFlag(flags, AbsoluteLayoutFlags.HeightProportional);
destination.Width = ResolveDimension(isWidthProportional, destination.Width, availableWidth, child.DesiredSize.Width);
destination.Height = ResolveDimension(isHeightProportional, destination.Height, availableHeight, child.DesiredSize.Height);
if (HasFlag(flags, AbsoluteLayoutFlags.XProportional))
destination.X = (availableWidth - destination.Width) * destination.X;
if (HasFlag(flags, AbsoluteLayoutFlags.YProportional))
destination.Y = (availableHeight - destination.Height) * destination.Y;
destination.X += left;
destination.Y += top;
child.InvalidateMeasure();
child.Arrange(destination);
}
}
static bool HasFlag(AbsoluteLayoutFlags a, AbsoluteLayoutFlags b)
{
// Avoiding Enum.HasFlag here for performance reasons; we don't need the type check
return (a & b) == b;
}
static double ResolveDimension(bool isProportional, double fromBounds, double available, double desired)
{
// By default, we use the absolute value from LayoutBounds
var value = fromBounds;
if (isProportional && !double.IsInfinity(available))
{
// If this dimension is marked proportional, then the value is a percentage of the available space
// Multiple it by the available space to figure out the final value
value *= available;
}
else if (value == AbsoluteLayout.AutoSize)
{
// No absolute or proportional value specified, so we use the measured value
if (desired != -1 && !double.IsNaN(desired))
value = desired;
}
return value;
}
}
Just to be clear about the magnitude of this bug: this bug can essentially cause the entire visual tree to be re-measured each time an AboluteLayout child has its position changed!
Aside from projects which show a large number of measure calls, do we have a reproduction project which demonstrates slow, choppy, or janky layout performance?
My concern is that we're presuming that the problem is "too many measure calls", when what we really care about is "SetLayoutBounds updates the UI quickly". The way to achieve that might be to reduce the number of measure calls, but we need a measurement target other than "reduce the number of measure calls". We don't want to beg the question.
@hartez I'm not sure I follow. The problem is simple: In an absolute layout each child is layed out independently of other children. Therefore changing layout bounds of one child should not have any effect on other children - both computationally and visually.
If we say that it's ok to perform layout on all the children then one might as well say that it would be ok to perform layout on the entire visual tree every time any element layout is changed - which is obviously wrong and will result in all apps being super slow.
In an absolute layout each child is layed out independently of other children.
This isn't strictly true - since children can overlap and the z-indexing depends on the order in which the platform Arrange methods are called, one child changing its position can potentially require others to be Arranged again.
Aside from that detail, you're technically correct; at the cross-platform level, it's possible for us to know that changing the bounds of one child of the AbsoluteLayout does not affect the bounds of other children, and that we can possibly skip measuring and arranging those children (assuming they haven't been marked as requiring layout for some other reason).
But in terms of performance, that's not likely to get us much. The platform-level layout code is already checking to see if the Measre and Arrange values match the ones used previously; in those cases, it simply doesn't do anything. For example, consider your repro project when we run it on Android. One of the BoxViews is stationary - it always has the same layout bounds. After the first layout pass, every time the AbsoluteLayout makes a cross-platform Measure()
call on that BoxView and the underlying Android measure()
method is invoked, measure()
sees that the incoming measureSpec
values are the same and simply returns the previously measured width and height. The onMeasure()
method is never called. Same thing with Arrange()
and onLayout()
. In other words, the optimization you're asking for is already in place for the computationally expensive parts of this scenario.
One of the goals of the cross-platform layout code is to work with the underlying platforms and to take advantage of the optimziations already built into their layout systems. And the general guidance on all of the platforms is that custom layouts should measure and arrange all of their children. Ignoring this guidance or trying to second-guess the platform layout systems is generally a recipe for weird behavior and subtle layout problems, so we don't unless we've got a good reason.
There may well be a performance issue we need to address here. But assuming that the only/right way to solve it is to "reduce the number of measure calls" without having a better description/repro of the actual problem which we can observe prevents us from being sure that we're solving the right thing in the right way.
@hartez I can certainly understand that logic. The repro I provided only shows the issue and not the larger context. In my case the larger context is that the application has many absolutelayout children and when one of the children is animated (e.g. a pop-up sliding out), the performance is choppy because a lot of layout code is executing on each animation step of that one child.
For static layouts this overhead (although large) is not so much of a problem. But it is not possible to smoothly animate elements as-is. I filed a related bug #11891 for iOS. There the animations become so slow they are completely unusable - and such a simple thing should work flawlessly in a UI framework.
Note that both of these are regressions from XF which ran these types of animations smoothly. Also if I write equivalent native code for both platforms the animations are completely smooth as they should be. Under MAUI - whether technically correct or not - this is broken and makes for low-quality if not unusable apps. My feeling though is that this is not implemented correctly and much more code is executed than just passing the layout calls to the native controls (which handle this correctly). It's been a while since I filed this issue and the MAUI layout code is understandably complex, but that is what I remember from tracking down these issues in our app.
I recommend looking at #11891 first since the sample there shows a regression from XF which makes MAUI unusable.
#11891 is fixed. And that was an issue with unnecessary measuring; it was also a different type of layout, a different mechanism (transforms vs. changing layout bounds), and a different operating system. So we should be careful about conflating the two issues.
So let's get some more detail on your context:
In my case the larger context is that the application has many absolutelayout children and when one of the children is animated (e.g. a pop-up sliding out), the performance is choppy because a lot of layout code is executing on each animation step of that one child.
When you say that one of the children is "animated", are you talking about updating the layout bounds repeatedly (like you're doing in the repro for this issues)? Or are you talking about using the Animation extensions (like you were doing in #11891)?
And are you experiencing the choppiness on Android, iOS, or both?
@hartez I tried modifying this sample to use animations instead of AbsoluteLayout.SetLayoutBounds(), but then the problem does not happen. Unfortunately it's been too long since I filed this bug (almost 1 year) so I don't remember for sure whether animations also caused this problem. I THINK they did (if memory serves me, in the sample I used SetLayoutBounds() just because it was easier to debug MAUI code that way due to its synchronous nature) and now they don't, so it seems some other fixes have inadvertently partially addressed this issue as well.
Nevertheless I still think calling AbsoluteLayout.SetLayoutBounds() on an AbsoluteLayout child should also not cause all siblings and their entire visual trees to be layed out - for the same reason that animating the child size, rotation, and position apparently no longer causes siblings to be layed out (not even the affected control is layed out). Calling AbsoluteLayout.SetLayoutBounds() and only changing position (maintaining size) should not even cause the affected control to be layed out since that is identical to animating position.
Anyway, the reason I still think the current implementation is wrong is this: You say that it might be architecturally better to let the native controls handle this, but as-is MAUI is actually doing the exact opposite. Changing the native child control's boundary will not trigger a native layout cycle for all sibling visual trees. MAUI on the other hand IS causing a layout cycle for all sibling visual trees - the exact opposite of what the native control does. The bottom line is that MAUI has its own layout system which is based on knowledge of the underlying native controls. It already does not let all layout related tasks to be handled natively because it would not work.
I hope I made a convincing argument :) It seems the risk of implementing this fix would be pretty low?
@hartez I tried modifying this sample to use animations instead of AbsoluteLayout.SetLayoutBounds(), but then the problem does not happen. Unfortunately it's been too long since I filed this bug (almost 1 year) so I don't remember for sure whether animations also caused this problem. I THINK they did (if memory serves me, in the sample I used SetLayoutBounds() just because it was easier to debug MAUI code that way due to its synchronous nature) and now they don't,
SetLayoutBounds() is very different than using animations. Maybe this is causing some of the confusion.
so it seems some other fixes have inadvertently partially addressed this issue as well.
Or the performance issues were partially caused by those other issues which have been fixed, and were never due entirely to the number of Measure() calls. :)
Nevertheless I still think calling AbsoluteLayout.SetLayoutBounds() on an AbsoluteLayout child should also not cause all siblings and their entire visual trees to be layed out - for the same reason that animating the child size, rotation, and position apparently no longer causes siblings to be layed out (not even the affected control is layed out).
Again, the entire visual tree of a child with unchanged bounds is not laid out. Whether that happens or not is a platform-level decision, and the platforms generally don't do that.
Calling AbsoluteLayout.SetLayoutBounds() and only changing position (maintaining size) should not even cause the affected control to be layed out since that is identical to animating position.
It is not identical. The animation extensions are not changing the layout positions of the Views within their containers; they are changing the on-screen drawing locations of the Views post-layout. SetLayoutBounds() is changing the actual layout position of the View within the AbsoluteLayout.
I hope I made a convincing argument :)
A convincing argument would be to give me a scenario where the performance is poor so that I can measure it, determine where the performance issues are, fix them, and test against the scenario. As I said upthread - you might be right about the cause, but I need a test scenario that doesn't start from the presumption that you are right about the cause.
It seems the risk of implementing this fix would be pretty low?
We already have an implementation of what you are asking for (2 of them, in fact) in testing elsewhere for issues reported on another platform. But we don't know yet if those implementations actually fix the underlying issue.
And the risk of any change is high enough that we're not going to just merge it without verifying that it actually fixes an identifiable problem. So just tell us what you were trying to do on Android that did not work, so we can reproduce that and determine whether it still doesn't work (and what we can do to fix it), or determine that it's been addressed and close this issue.
@hartez To be specific, in this particular application there is a cursor which is moved during playback using AbsoluteLayout.SetLayoutBounds() with only a change in position. This should happen at 30 fps without using a lot of CPU (as it did in XF and as it would in a native implementation). Using animations is not practical because the cursor will jump depending on the data (it is not a smooth contiguous movement). The AbsoluteLayout has many other children and is a top level control. Each movement of the cursor causes a MAUI layout cycle of the entire application. This consumes a LOT of CPU and makes this animation and other aspects of the application run slowly.
Okay, I'll see if I can put together a repro on Monday that looks like what I'm imagining from that description.
Using animations is not practical because the cursor will jump depending on the data (it is not a smooth contiguous movement).
Just as an aside: you don't have to use interpolation with animations to make them smooth; you can just set those values to zero and make them jumpy. Or don't use animations at all, and just use the TranslationX and TranslationY properties.
Okay, I've started an attempt at reproducing the performance issue: https://github.com/hartez/6466Repro
I don't have a lot to go on, but you've mentioned a "cursor" moving during "playback", so I'm assuming something like a slider; I've got a track and slider which moves about randomly at the bottom of the screen. Tapping the button at the top of the screen will animate in a box with some controls in it; tapping the button in the box will animate it back out. (This is only for demonstration purposes; repeated SetLayoutBounds() is not a good way to animate things like this.) This all seems to work fine on a 2015 Nexus device. If more stuff should be moving around or there should be more controls on the screen, let me know.
I don't know what "playback" means here, but if you're using some sort of media element that would be helpful to know for reproduction purposes.
This should happen at 30 fps without using a lot of CPU
What fps are you seeing? How are you measuring it? How are you measuring CPU, on what sort of device, and how much is "a lot"?
Hi @philipag. We have added the "s/needs-info" label to this issue, which indicates that we have an open question for you before we can take further action. This issue will be closed automatically in 7 days if we do not hear back from you by then - please feel free to re-open it if you come back to this issue after that time.
@hartez are you creating a sample in order to verify that alwyas laying out all Absolutelayout children is a problem? The sample I provided already shows the issue but the performance in the sample does not suffer since the visual tree is super simple and the AbsoluteLayout only has 2 children.
To actually show performance degradation (stuttery UX) the visual tree will have to be complex since only then laying out the entire visual tree becomes an expensive operation.
In our app I did not bother measuring fps. The application simply became quite stuttery after migrating to MAUI and my kludge worked around the given issue with MAUI AbsoluteLayout by bringing back XF and native behaviour and made the app run smoothly again.
If you would like a sample which shows similar stutter issues it will require a real-world complex visual tree with AbsoluteLayout near the top. I can try to create such a sample but I won't have time to work on it at the moment.
Just to add to the discussion. I made an animation extension for AbsoluteLayout a while ago,
using System;
namespace meescan.Tools.AnimationExtensions
{
public static class LayoutBoundsToExtension
{
public static Task<bool> LayoutBoundsTo(this VisualElement self, Rect toLayoutBounds, uint length = 350, Easing? easing = null)
{
TaskCompletionSource<bool> taskCompletionSource = new();
Rect intiailLayoutBounds = AbsoluteLayout.GetLayoutBounds(self);
//property transformation. t is from 0 to 1
Func<double, Rect> transform = t =>
{
Rect convertedRect = new Rect
{
X = intiailLayoutBounds.X + t * (toLayoutBounds.X - intiailLayoutBounds.X),
Y = intiailLayoutBounds.Y + t * (toLayoutBounds.Y - intiailLayoutBounds.Y),
Width = intiailLayoutBounds.Width + t * (toLayoutBounds.Width - intiailLayoutBounds.Width),
Height = intiailLayoutBounds.Height + t * (toLayoutBounds.Height - intiailLayoutBounds.Height),
};
return convertedRect;
};
Action<Rect> postTransform = r =>
{
AbsoluteLayout.SetLayoutBounds(self, r);
};
self.Animate<Rect>(nameof(LayoutBoundsTo),transform, postTransform, 16, length, easing ?? Easing.Linear, (v, c) => taskCompletionSource.SetResult(c));
return taskCompletionSource.Task;
}
}
}
And I found that it was choppy, so I went back to using translationX and translationY. In my case I am assuming that SetLayoutBounds
call is expensive in the first place?
@danielftz SetLayoutBounds is definitely slower than animations but on MAUI it is much slower than it was with Xamarin Forms...
And I found that it was choppy, so I went back to using translationX and translationY. In my case I am assuming that SetLayoutBounds call is expensive in the first place?
Layout is definitely more expensive than using animations. Animations take advantage of transforms which use the native rendering/animation APIs. Even without a cross-platform layer involved, you really shouldn't use layout to do animation.
Whether or not SetLayoutBounds is "expensive" depends a lot on what it actually ends up doing, and how complex the things you're laying out are. Also, there may be other layout issues with other parts of MAUI that we need to address.
on MAUI it is much slower than it was with Xamarin Forms...
That may be, but it's not because of the AbsoluteLayout logic. It's essentially the same as it was on Forms.
I'm closing this issue; it's not a bug. The optimizations being asked for are not appropriate at the cross-platform level, will cause subtle layout bugs, and already exist at the native level (as documented elsewhere). If anyone disagrees and wants to do their own cross-platform optimizations, it's trivial to drop in your own custom layout manager; feel free.
@hartez ILayoutManagerFactory is a great addition I was not aware of. I will convert my project to using this instead of the above hack, thanks.
For future reference and since you have given this some thought could you list out some of the potential subtle bugs this might incur? I have not found any issues with it but for myself and others who might go down this path it would be good to be aware of them.