From 20a69dca95aa461eccda3f7325ca9608a9b18e1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Sua=CC=81rez=20Ruiz?= Date: Mon, 23 Mar 2026 12:16:39 +0100 Subject: [PATCH 1/2] Apply optimizations around Avalonia Android --- .../Avalonia.Android/AndroidDispatcherImpl.cs | 21 +- .../Avalonia.Android/AvaloniaAccessHelper.cs | 17 +- .../Platform/SkiaPlatform/TopLevelImpl.cs | 15 +- .../Helpers/AndroidKeyboardEventsHelper.cs | 25 ++- .../Rendering/AndroidRenderingBenchmarks.cs | 194 ++++++++++++++++++ 5 files changed, 247 insertions(+), 25 deletions(-) create mode 100644 tests/Avalonia.Benchmarks/Rendering/AndroidRenderingBenchmarks.cs diff --git a/src/Android/Avalonia.Android/AndroidDispatcherImpl.cs b/src/Android/Avalonia.Android/AndroidDispatcherImpl.cs index 8ee5f2a8f0..ea23b64a42 100644 --- a/src/Android/Avalonia.Android/AndroidDispatcherImpl.cs +++ b/src/Android/Avalonia.Android/AndroidDispatcherImpl.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.Threading; using Android.OS; using Avalonia.Controls.Documents; using Avalonia.Threading; @@ -19,8 +20,7 @@ namespace Avalonia.Android private readonly Runnable _timerSignaler; private readonly Runnable _wakeupSignaler; private readonly MessageQueue _queue; - private readonly object _lock = new(); - private bool _signaled; + private int _signaled; private bool _backgroundProcessingRequested; @@ -46,8 +46,7 @@ namespace Avalonia.Android public event Action? Signaled; private void OnSignaled() { - lock (_lock) - _signaled = false; + Interlocked.Exchange(ref _signaled, 0); Signaled?.Invoke(); } @@ -68,13 +67,11 @@ namespace Avalonia.Android public void Signal() { - lock (_lock) + if (Interlocked.CompareExchange(ref _signaled, 1, 0) != 0) { - if(_signaled) - return; - _signaled = true; - _handler.Post(_signaler); + return; } + _handler.Post(_signaler); } readonly Stopwatch _clock = Stopwatch.StartNew(); @@ -133,11 +130,9 @@ namespace Avalonia.Android // "background" jobs not being processed // So we need to examine the queue state to prevent that scenario - lock (_lock) + if (Volatile.Read(ref _signaled) != 0) { - // There are higher priority jobs enqueued, we'll be called again - if (_signaled) - return; + return; } if (CanQueryPendingInput) diff --git a/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs b/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs index ca06998dbc..6d5128ece2 100644 --- a/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs +++ b/src/Android/Avalonia.Android/AvaloniaAccessHelper.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using System.Linq; + using Android.OS; using AndroidX.Core.View.Accessibility; using AndroidX.CustomView.Widget; @@ -134,9 +134,18 @@ namespace Avalonia.Android protected override bool OnPerformActionForVirtualView(int virtualViewId, int action, Bundle? arguments) { - return (GetNodeInfoProvidersFromVirtualViewId(virtualViewId) ?? []) - .Select(x => TryPerformNodeAction(x, action, arguments)) - .Aggregate(false, (a, b) => a | b); + var providers = GetNodeInfoProvidersFromVirtualViewId(virtualViewId); + if (providers == null) + { + return false; + } + + var result = false; + foreach (var provider in providers) + { + result |= TryPerformNodeAction(provider, action, arguments); + } + return result; } private static bool TryPerformNodeAction(INodeInfoProvider nodeInfoProvider, int action, Bundle? arguments) diff --git a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs index 20284906be..8264dc6744 100644 --- a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs +++ b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs @@ -144,6 +144,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform private readonly TopLevelImpl _tl; private Size _oldSize; private double _oldScaling; + private Paint? _clearPaint; public SurfaceViewImpl(Context context, TopLevelImpl tl, bool placeOnTop) : base(context) { @@ -159,11 +160,13 @@ namespace Avalonia.Android.Platform.SkiaPlatform // can be seen below, but it does not. if (OperatingSystem.IsAndroidVersionAtLeast(29)) { - // Android 10+ does this (BlendMode was new) - var paint = new Paint(); - paint.SetColor(0); - paint.BlendMode = BlendMode.Clear; - canvas.DrawRect(0, 0, Width, Height, paint); + if (_clearPaint == null) + { + _clearPaint = new Paint(); + _clearPaint.SetColor(0); + _clearPaint.BlendMode = BlendMode.Clear; + } + canvas.DrawRect(0, 0, Width, Height, _clearPaint); } else { @@ -384,7 +387,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform { if(Input != null) { - var args = new RawTextInputEventArgs(AndroidKeyboardDevice.Instance!, (ulong)DateTime.Now.Ticks, InputRoot!, text); + var args = new RawTextInputEventArgs(AndroidKeyboardDevice.Instance!, (ulong)Environment.TickCount64, InputRoot!, text); Input(args); } diff --git a/src/Android/Avalonia.Android/Platform/Specific/Helpers/AndroidKeyboardEventsHelper.cs b/src/Android/Avalonia.Android/Platform/Specific/Helpers/AndroidKeyboardEventsHelper.cs index 03be7c2153..59b1bc9899 100644 --- a/src/Android/Avalonia.Android/Platform/Specific/Helpers/AndroidKeyboardEventsHelper.cs +++ b/src/Android/Avalonia.Android/Platform/Specific/Helpers/AndroidKeyboardEventsHelper.cs @@ -10,6 +10,18 @@ namespace Avalonia.Android.Platform.Specific.Helpers { internal class AndroidKeyboardEventsHelper : IDisposable where TView : TopLevelImpl { + private static readonly string[] s_asciiStringCache = InitAsciiStringCache(); + + private static string[] InitAsciiStringCache() + { + var cache = new string[128]; + for (int i = 0; i < 128; i++) + { + cache[i] = ((char)i).ToString(); + } + return cache; + } + private readonly TView _view; public bool HandleEvents { get; set; } @@ -79,7 +91,7 @@ namespace Avalonia.Android.Platform.Specific.Helpers AndroidKeyboardDevice.Instance!, Convert.ToUInt64(e.EventTime), inputRoot, - unicodeTextInput ?? Convert.ToChar(e.UnicodeChar).ToString() + unicodeTextInput ?? CharToString(e.UnicodeChar) ); _view.Input?.Invoke(rawTextEvent); @@ -107,6 +119,15 @@ namespace Avalonia.Android.Platform.Specific.Helpers return rv; } + private static string CharToString(int unicodeChar) + { + if (unicodeChar >= 0 && unicodeChar < s_asciiStringCache.Length) + { + return s_asciiStringCache[unicodeChar]; + } + return char.ConvertFromUtf32(unicodeChar); + } + private static string? GetKeySymbol(int unicodeChar, PhysicalKey physicalKey) { // Handle a very limited set of control characters so that we're consistent with other platforms @@ -126,7 +147,7 @@ namespace Avalonia.Android.Platform.Specific.Helpers if (unicodeChar <= 0x7F) { var asciiChar = (char)unicodeChar; - return KeySymbolHelper.IsAllowedAsciiKeySymbol(asciiChar) ? asciiChar.ToString() : null; + return KeySymbolHelper.IsAllowedAsciiKeySymbol(asciiChar) ? s_asciiStringCache[asciiChar] : null; } return char.ConvertFromUtf32(unicodeChar); } diff --git a/tests/Avalonia.Benchmarks/Rendering/AndroidRenderingBenchmarks.cs b/tests/Avalonia.Benchmarks/Rendering/AndroidRenderingBenchmarks.cs new file mode 100644 index 0000000000..bb6176f086 --- /dev/null +++ b/tests/Avalonia.Benchmarks/Rendering/AndroidRenderingBenchmarks.cs @@ -0,0 +1,194 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using BenchmarkDotNet.Attributes; + +namespace Avalonia.Benchmarks.Rendering +{ + /// + /// Benchmarks for Avalonia.Android hot path patterns. + /// Run with: dotnet run -c Release -- --filter *AndroidRenderingBenchmarks* + /// + [MemoryDiagnoser] + public class AndroidRenderingBenchmarks + { + private const int FrameCount = 1000; + + // DispatchDraw: new Paint() every frame vs cached (TopLevelImpl.cs) + + [Benchmark] + public object? Current_DispatchDraw_NewObjectPerFrame() + { + object? last = null; + for (int i = 0; i < FrameCount; i++) + { + var obj = new DispatchDrawState(); + obj.Color = 0; + obj.Mode = 1; + last = obj; + } + return last; + } + + [Benchmark] + public object? Optimized_DispatchDraw_CachedObject() + { + DispatchDrawState? cached = null; + for (int i = 0; i < FrameCount; i++) + { + if (cached == null) + { + cached = new DispatchDrawState(); + cached.Color = 0; + cached.Mode = 1; + } + } + return cached; + } + + private sealed class DispatchDrawState + { + public int Color; + public int Mode; + } + + // Dispatcher.Signal(): lock vs Interlocked (AndroidDispatcherImpl.cs) + + private readonly object _lock = new(); + + [Benchmark] + public int Current_DispatcherSignal_Lock() + { + int signaled = 0; + bool flag = false; + for (int i = 0; i < FrameCount; i++) + { + lock (_lock) + { + if (!flag) + { + flag = true; + signaled++; + } + } + lock (_lock) + { + flag = false; + } + } + return signaled; + } + + [Benchmark] + public int Optimized_DispatcherSignal_Interlocked() + { + int signaled = 0; + int flag = 0; + for (int i = 0; i < FrameCount; i++) + { + if (Interlocked.CompareExchange(ref flag, 1, 0) == 0) + { + signaled++; + } + Interlocked.Exchange(ref flag, 0); + } + return signaled; + } + + // TextInput timestamp: DateTime.Now vs TickCount64 (TopLevelImpl.cs) + + [Benchmark] + public ulong Current_TextInput_DateTimeNowTicks() + { + ulong result = 0; + for (int i = 0; i < FrameCount; i++) + { + result = (ulong)DateTime.Now.Ticks; + } + return result; + } + + [Benchmark] + public long Optimized_TextInput_EnvironmentTickCount64() + { + long result = 0; + for (int i = 0; i < FrameCount; i++) + { + result = Environment.TickCount64; + } + return result; + } + + // Accessibility action: LINQ vs loop (AvaloniaAccessHelper.cs) + + private readonly List _providers = new() { 1, 2, 3 }; + + [Benchmark] + public bool Current_AccessAction_Linq() + { + bool result = false; + for (int i = 0; i < FrameCount; i++) + { + result = _providers + .Select(x => x > 1) + .Aggregate(false, (a, b) => a | b); + } + return result; + } + + [Benchmark] + public bool Optimized_AccessAction_Loop() + { + bool result = false; + for (int i = 0; i < FrameCount; i++) + { + foreach (var p in _providers) + { + result |= p > 1; + } + } + return result; + } + + // Keyboard char.ToString() vs cached string (AndroidKeyboardEventsHelper.cs) + + private static readonly string[] s_asciiStringCache = CreateAsciiCache(); + + private static string[] CreateAsciiCache() + { + var cache = new string[128]; + for (int i = 0; i < 128; i++) + { + cache[i] = ((char)i).ToString(); + } + return cache; + } + + [Benchmark] + public string? Current_KeySymbol_CharToString() + { + string? last = null; + for (int i = 0; i < FrameCount; i++) + { + char c = (char)(32 + (i % 95)); + last = c.ToString(); + } + return last; + } + + [Benchmark] + public string? Optimized_KeySymbol_CachedString() + { + string? last = null; + for (int i = 0; i < FrameCount; i++) + { + int code = 32 + (i % 95); + last = s_asciiStringCache[code]; + } + return last; + } + + } +} From 6dd2453958655d20144c3ed962ae4fcf3a0d23a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Sua=CC=81rez=20Ruiz?= Date: Mon, 23 Mar 2026 13:09:21 +0100 Subject: [PATCH 2/2] More changes --- .../Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs | 3 ++- .../Rendering/AndroidRenderingBenchmarks.cs | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs index 8264dc6744..da128cec96 100644 --- a/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs +++ b/src/Android/Avalonia.Android/Platform/SkiaPlatform/TopLevelImpl.cs @@ -4,6 +4,7 @@ using Android.App; using Android.Content; using Android.Graphics; using Android.Graphics.Drawables; +using Android.OS; using Android.Runtime; using Android.Views; using AndroidX.AppCompat.App; @@ -387,7 +388,7 @@ namespace Avalonia.Android.Platform.SkiaPlatform { if(Input != null) { - var args = new RawTextInputEventArgs(AndroidKeyboardDevice.Instance!, (ulong)Environment.TickCount64, InputRoot!, text); + var args = new RawTextInputEventArgs(AndroidKeyboardDevice.Instance!, (ulong)SystemClock.UptimeMillis(), InputRoot!, text); Input(args); } diff --git a/tests/Avalonia.Benchmarks/Rendering/AndroidRenderingBenchmarks.cs b/tests/Avalonia.Benchmarks/Rendering/AndroidRenderingBenchmarks.cs index bb6176f086..b671505b5d 100644 --- a/tests/Avalonia.Benchmarks/Rendering/AndroidRenderingBenchmarks.cs +++ b/tests/Avalonia.Benchmarks/Rendering/AndroidRenderingBenchmarks.cs @@ -97,7 +97,7 @@ namespace Avalonia.Benchmarks.Rendering return signaled; } - // TextInput timestamp: DateTime.Now vs TickCount64 (TopLevelImpl.cs) + // TextInput timestamp: DateTime.Now vs monotonic clock (TopLevelImpl.cs) [Benchmark] public ulong Current_TextInput_DateTimeNowTicks() @@ -111,12 +111,12 @@ namespace Avalonia.Benchmarks.Rendering } [Benchmark] - public long Optimized_TextInput_EnvironmentTickCount64() + public long Optimized_TextInput_StopwatchTimestamp() { long result = 0; for (int i = 0; i < FrameCount; i++) { - result = Environment.TickCount64; + result = System.Diagnostics.Stopwatch.GetTimestamp(); } return result; }