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

Improve shadow rendering on Android, fix shadow clipping on iOS #26789

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from

Conversation

albyrock87
Copy link
Contributor

@albyrock87 albyrock87 commented Dec 23, 2024

Android Shadow Rendering

Description of Change

  • move the shadow computation to android level with an improved algorithm
    • use Glide's LruBitmapPool to reduce the memory pressure on Java
    • prepare Paint only when shadow changes
  • when we know we're going to render a solid shape we can use Canvas API to speedup the shadow rendering performance by a lot
  • remove unneeded saveLayer call from platform interop: it's a very expensive operation done for no reasons here
  • fix shadow rendering on Android which was still broken (despite Shadows not rendering as expected on Android and iOS #24414) under some conditions (like negative offsets)

Benchmark

Here I've executed the testing host app with return new ShadowBenchmark(); as main page and scrolling the CV once.

Before

image

After (with transparency)

You can see this is a bit faster, and for sure it is creating less GC/allocation work due to the bitmap pool.
Still timings are not good enough.

image

After (with solid background)

This time no need to create bitmaps and render descendants, we can simply draw the shadow.
And this is exactly the speed we want.

image

Notes

To collect those speedscopes I've temporary re-added:

protected override void DrawShadow(Canvas canvas, int viewWidth, int viewHeight)
{
	base.DrawShadow(canvas, viewWidth, viewHeight);
}

iOS Shadow Rendering

  • Removes unneeded shadow sublayer from wrapper view => we can simply apply the shadow to the WrapperView's main Layer considering it's the parent of the clipped content.
  • Make clear that the layer where we apply clipping is the content's main Layer
  • Ensure that when the clip is removed, we also clear the content's Layer's mask

Issues Fixed

Fixes #27156
Fixes #23630
Fixes #20518
Fixes #18202
Fixes #17886
Fixes #13981
Fixes #9539
Fixes #4106

Relates to #17881
Relates to #17257
Relates to #10401

@albyrock87 albyrock87 requested a review from a team as a code owner December 23, 2024 16:46
@albyrock87 albyrock87 marked this pull request as draft December 23, 2024 16:46
@dotnet-policy-service dotnet-policy-service bot added the community ✨ Community Contribution label Dec 23, 2024
@PureWeen
Copy link
Member

/azp run

Copy link

Azure Pipelines successfully started running 3 pipeline(s).

@PureWeen PureWeen added this to the .NET 9 SR4 milestone Dec 23, 2024
@PureWeen
Copy link
Member

/rebase

@PureWeen
Copy link
Member

/azp run

Copy link

Azure Pipelines successfully started running 3 pipeline(s).

@albyrock87 albyrock87 force-pushed the android-shadow branch 5 times, most recently from 2f0b86f to eccf37e Compare December 24, 2024 13:45
@AmrAlSayed0
Copy link
Contributor

I'm late to the party but an attempt to improve shadow's performance was made before here #10523. Moving the code to the Java side might help but I think what would benefit the most is a shadow cache. All views that have the same size and the same shadow properties (very common scenario in CollectionView items for example) should use the same bitmap. This will eliminate a huge number of calls including C# to Java ones. There is an implementation of the shadow cache in the PR.

@albyrock87
Copy link
Contributor Author

@AmrAlSayed0 thanks for sharing, I'll check it out.
This is still a very early WIP.
I also intend using Glide's bitmap pool for this, which should improve the performance by avoiding continuous allocations of the bitmaps.

@albyrock87 albyrock87 force-pushed the android-shadow branch 11 times, most recently from d40a883 to 5cb0404 Compare December 26, 2024 11:35
src/Core/src/Platform/Android/WrapperView.cs Outdated Show resolved Hide resolved
Comment on lines +19 to +20
// Use application context to avoid memory leaks
Context applicationContext = context.getApplicationContext();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have any details why to use getApplicationContext?

It looks like the ShadowBitmapPool is used once per PlatformWrapperView, which would have the same lifetime as an activity anyway?

If we used a single ShadowBitmapPool for the entire application, seems like getApplicationContext would be appropriate in that case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment below: we use only one pool.

public PlatformWrapperView(Context context) {
super(context);
this.viewBounds = new Rect();
this.bitmapPool = ShadowBitmapPool.get(context);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, we use 1 pool per PlatformWrapperView.

Can we use 1 pool for the entire application? That would allow many views to share the pool.

Copy link
Contributor Author

@albyrock87 albyrock87 Jan 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually that static method returns one pool for the entire application, so exactly what you're asking :)

@PureWeen
Copy link
Member

/azp run

Copy link

Azure Pipelines successfully started running 3 pipeline(s).

@PureWeen
Copy link
Member

/azp run

Copy link

Azure Pipelines successfully started running 3 pipeline(s).

@AmrAlSayed0
Copy link
Contributor

Correct me if I'm wrong but what this is doing is using a pool not a cache. Which means that no 2 views will ever use the same bitmap, it's just that that bitmaps will be reused and recycled instead of being created each time it's needed to better conserve memory.

I was thinking of a cache for when, for example, 200 items in a CollectionView request the same exact bitmap cuz they have MeasureFirstItem set (hence same size) and have the same template (hence the same shadow properties) and the same bitmap can be set for both.

I know this PR is already doing too much so this maybe an optimization for a later time ..

@albyrock87
Copy link
Contributor Author

albyrock87 commented Jan 13, 2025

@AmrAlSayed0 there's a small "cache" at the view's level which reuses the bitmap content when not shadowInvalidated.

But from my analysis, this is probably ineffective as every time something changes on the descendants (requestLayout) the shadow is being invalidated.

The reason for this invalidation is that the shadow is based on content pixels.
For example, you may have a collection view containing simple Labels with a shadow.
Now every label may have the same height, but the shadow will be different considering the text is different.

This is really a pain as Android doesn't offer a better way to draw a shadow based on the content.

What I've done here though, is detecting if you have a shadow applied to a solid rectangle, or a solid Border.
If that's the case the shadow will be drawn based on the shape directly on the rendering Canvas: this is super fast and we don't have to worry about bitmaps at all.

Unfortunately there is an edge case not covered, so I'm discussing with the MAUI team what to do.

Thanks for your input on this!

@PureWeen
Copy link
Member

/azp run

Copy link

Azure Pipelines successfully started running 3 pipeline(s).

@@ -83,19 +54,55 @@ public override bool DispatchTouchEvent(MotionEvent e)

partial void ClipChanged()
{
_invalidateClip = _invalidateShadow = true;
_invalidateClip = true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you change the clipping of a View at runtime, and it has a shadow, should it be invalidated, would this still happen?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it will, and this is also covered in the new 24114 UI test.
The invalidation happens on Java side.

float[] positions;
float[] bounds;

switch (shadowPaint)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice one, but we don't have gradient shadows implemented on other platforms. After merging this, we should check if we can implement it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was already implemented android-only.
This can't be done on other platforms, for example iOS doesn't support this, it only takes a Color parameter.

_backgroundColor = new AColor(color);
var backgroundColor = new AColor(color);
_backgroundColor = backgroundColor;
_isBackgroundSolid = backgroundColor.A is 255;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be an extension method in the ColorExtensions class.

@@ -224,6 +234,7 @@ public void SetBorderBrush(RadialGradientPaint radialGradientPaint)

_borderColor = null;
_stroke = radialGradientPaint;
_isBorderSolid = radialGradientPaint.GradientStops.All(s => s.Color.Alpha == 1);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like with Color, could be a nice addition to the PaintExtensions class.

shadowPaint = null;
shadowCanvas = null;
if (shadowBitmap != null) {
bitmapPool.put(shadowBitmap);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good improvement!


private int getRadiusSafeSpace() {
if (radius <= 0) {
return 4;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use this specific value?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I forgot to remove this, thanks!

UpdateLabel();
}

private void OnTapGestureRecognizerTapped(object sender, EventArgs e)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could also update the sample adding another Button to remove/add the shadows at runtime?

@@ -16,8 +16,34 @@ public Issue24414(TestDevice device)
[Category(UITestCategories.Visual)]
public void Issue24414Test()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test is failing in all the platforms by differences in the shadows:
image
Maybe commits 14eecfe after include the snapshots had an impact?

Could you review the tests? Let me know if needs help with it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I have to capture all the shapshots because I changed the UI test.
I will on next commit+azp.

@@ -16,8 +16,34 @@ public Issue24414(TestDevice device)
[Category(UITestCategories.Visual)]
public void Issue24414Test()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Android, could use

public static IList<object> GetPerformanceData(this IApp app, string performanceDataType)
to get memory info and validate never exceed a value, and avoid detecting degradation in memory consumption due to any change

@albyrock87
Copy link
Contributor Author

albyrock87 commented Jan 15, 2025

@jsuarezruiz I should have addressed all your feedbacks (except for screenshots which will be updated after the next AZP)

# Conflicts:
#	src/TestUtils/src/UITest.Appium/HelperExtensions.cs
@albyrock87 albyrock87 changed the title Android shadow performance Improve shadow rendering on Android, fix shadow clipping on iOS Jan 15, 2025
@PureWeen
Copy link
Member

/azp run

Copy link

Azure Pipelines successfully started running 3 pipeline(s).

@PureWeen
Copy link
Member

/azp run

Copy link

Azure Pipelines successfully started running 3 pipeline(s).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-drawing Shapes, Borders, Shadows, Graphics, BoxView, custom drawing community ✨ Community Contribution
Projects
Status: Changes Requested
5 participants