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

Android shadow performance #26789

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

Conversation

albyrock87
Copy link
Contributor

@albyrock87 albyrock87 commented Dec 23, 2024

Description of Change

Apparently DrawShadow has an absurd impact on the performance.
Here's a speedscope from our app sorted by self time descending, also look at total time %!!!
image

This PR:

  • moves the shadow computation to android level with an improved algorithm
    • uses Glide's LruBitmapPool to reduce the memory pressure on Java
    • prepares 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
  • removes unneeded saveLayer call from platform interop: it's a very expensive operation done for no reasons here
  • fixes 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)

I tried as much as possible to not break signatures but given Java to C# bindings are automatically generated, there was no way completely avoid touching public APIs.

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

Before

DrawShadow takes 24%.

image
before.speedscope.json

image

After

DrawShadow is gone.

image
after.speedscope.json

image

Important notes about the benchmark

Now you may suppose that we've simply hidden the cost of drawShadow from the profiler.
That is in part true, this is why I've also measured this on a real app by looking at the root drawChild performance.
You can clearly see a 40% improvement (833ms vs 523ms).
image
image

Issues Fixed

@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
@PureWeen PureWeen added the area-drawing Shapes, Borders, Shadows, Graphics, BoxView, custom drawing label Dec 26, 2024
@albyrock87 albyrock87 force-pushed the android-shadow branch 3 times, most recently from bec5474 to 3178ee0 Compare December 29, 2024 18:02
@PureWeen
Copy link
Member

PureWeen commented Jan 9, 2025

/azp run

Copy link

Azure Pipelines successfully started running 3 pipeline(s).

@PureWeen
Copy link
Member

PureWeen commented Jan 9, 2025

/azp run

Copy link

Azure Pipelines successfully started running 3 pipeline(s).

@albyrock87 albyrock87 force-pushed the android-shadow branch 2 times, most recently from 6da5858 to d01b92c Compare January 10, 2025 11:38
Comment on lines +66 to +68
float radius = Context.ToPixels(Shadow.Radius);
float offsetX = Context.ToPixels(Shadow.Offset.X);
float offsetY = Context.ToPixels(Shadow.Offset.Y);
Copy link
Member

Choose a reason for hiding this comment

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

This calls the Context property three times in a row. This will call into Java each time, doing the bookkeeping to lookup the C# object.

Can you store Context in a local to reuse 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.

Good catch!

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

@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!

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: Todo
Development

Successfully merging this pull request may close these issues.

5 participants