From 0962845eade871726aa0b1077396268974c3eb48 Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Sun, 13 Sep 2026 22:04:07 +0200 Subject: [PATCH] fix: startup speed --- LehrerApp.Desktop/App.axaml.cs | 58 ++++++++++++++----- LehrerApp.Desktop/AppBootstrapper.cs | 6 +- .../Services/AnnualPlanSyncService.cs | 4 +- .../Services/UntisSyncService.cs | 4 +- LehrerApp.Desktop/Views/SplashWindow.axaml | 21 +++++-- LehrerApp.Desktop/Views/SplashWindow.axaml.cs | 6 ++ TODO.md | 9 +++ 7 files changed, 86 insertions(+), 22 deletions(-) diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs index a5d40f2..4e3b2a0 100644 --- a/LehrerApp.Desktop/App.axaml.cs +++ b/LehrerApp.Desktop/App.axaml.cs @@ -26,6 +26,8 @@ public class App : Application public static IServiceProvider Services { get; private set; } = null!; private static ServiceProvider? _serviceProvider; private static bool _exitHandlerAttached; + private static Task _initialPolls = Task.CompletedTask; + private static readonly CancellationTokenSource StartupCancellation = new(); public override void Initialize() => AvaloniaXamlLoader.Load(this); @@ -65,7 +67,11 @@ public class App : Application promptVm.OnUnlocked = async password => { AppBootstrapper.DbPassword = password; - await StartMainAppAsync(desktop, promptWindow); + var unlockedSplash = new SplashWindow(); + desktop.MainWindow = unlockedSplash; + unlockedSplash.Show(); + promptWindow.Close(); + await StartMainAppAsync(desktop, unlockedSplash); }; desktop.MainWindow = promptWindow; promptWindow.Show(); @@ -79,14 +85,21 @@ public class App : Application private static async Task StartMainAppAsync( IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null) { - _serviceProvider = AppBootstrapper.BuildServices(); + var splash = windowToClose as SplashWindow; + var timer = System.Diagnostics.Stopwatch.StartNew(); + var progress = new Progress<(int Value, string Text)>(step => + splash?.SetProgress(step.Value, step.Text)); + _serviceProvider = await Task.Run(() => AppBootstrapper.BuildServices( + (value, text) => ((IProgress<(int, string)>)progress).Report((value, text)))); Services = _serviceProvider; Services.GetRequiredService().Info("Anwendung gestartet."); GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService()); // Papierkorb (14.3): Einträge älter als 30 Tage endgültig entfernen. Beim Start statt per // Timer - reicht für ein Werkzeug, das ohnehin nur "Fehlklick eben rückgängig machen" sein // soll, kein dauerhaftes Archiv. - Services.GetRequiredService().PurgeOlderThan(DateTime.UtcNow.AddDays(-30)); + splash?.SetProgress(60, "Papierkorb aufräumen …"); + await Task.Run(() => Services.GetRequiredService() + .PurgeOlderThan(DateTime.UtcNow.AddDays(-30))); if (!_exitHandlerAttached) { @@ -99,19 +112,13 @@ public class App : Application // Opt-in - ein Neustart der App richtet den Pipe-Listener neu ein. Services.GetRequiredService().Start(); + splash?.SetProgress(75, "Übersicht vorbereiten …"); + await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); var mainVm = Services.GetRequiredService(); WireCallbacks(mainVm); - // Beide optionalen Erstabgleiche noch unter dem Splashscreen abschließen. Ihre - // CPU-/Datenbankarbeit läuft innerhalb der Dienste im Threadpool; dadurch öffnet das - // Hauptfenster mit fertigem Datenstand und friert nicht kurz danach ein. Das Auflösen - // aktiviert zugleich die periodischen Timer. - var initialPolls = new List(); - if (Services.GetService() is { } untisSync) - initialPolls.Add(untisSync.PollAsync()); - if (Services.GetService() is { } annualPlanSync) - initialPolls.Add(annualPlanSync.PollAsync()); - await Task.WhenAll(initialPolls); + splash?.SetProgress(90, "Hauptfenster öffnen …"); + await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); var main = new MainWindow { DataContext = mainVm }; main.EnableWindowSizePersistence(Services.GetRequiredService()); @@ -119,7 +126,30 @@ public class App : Application main.EnableFinalSync(syncEngine); desktop.MainWindow = main; main.Show(); + splash?.SetProgress(100, "Bereit"); windowToClose?.Close(); + AppBootstrapper.Logger.Info($"Start: Hauptfenster nach {timer.ElapsedMilliseconds} ms geöffnet."); + + // Vorhandene lokale Daten sind sofort nutzbar; Netzwerkzugriffe blockieren den Start nicht. + var untis = Services.GetService(); + var annualPlan = Services.GetService(); + if (untis is not null) + untis.DataChanged += () => Dispatcher.UIThread.Post(() => + { + Services.GetRequiredService().Load(); + Services.GetRequiredService().RefreshCommand.Execute(null); + }); + _initialPolls = Task.Run(async () => + { + try + { + await Task.WhenAll( + untis?.PollAsync(StartupCancellation.Token) ?? Task.CompletedTask, + annualPlan?.PollAsync(StartupCancellation.Token) ?? Task.CompletedTask); + } + catch (OperationCanceledException) when (StartupCancellation.IsCancellationRequested) { } + catch (Exception ex) { AppBootstrapper.Logger.Error("Erstabgleich fehlgeschlagen.", ex); } + }); } /// Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den @@ -140,6 +170,8 @@ public class App : Application try { + StartupCancellation.Cancel(); + _initialPolls.GetAwaiter().GetResult(); serviceProvider.GetService()?.Checkpoint(); } catch (Exception ex) diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index b28bb9b..a432af7 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -101,7 +101,7 @@ public static class AppBootstrapper Environment.Exit(0); } - public static ServiceProvider BuildServices() + public static ServiceProvider BuildServices(Action? reportProgress = null) { var services = new ServiceCollection(); @@ -130,7 +130,9 @@ public static class AppBootstrapper // Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig. var backupSettings = new BackupSettingsService(appData); var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() }; + reportProgress?.Invoke(10, "Sicherung erstellen …"); var backupPath = backup.CreateBackup(DbPath); + reportProgress?.Invoke(25, "Sicherung prüfen …"); // Best-effort-Prüfung des automatischen Startbackups: nur geloggt, kein Blocker für den // Programmstart — ein beschädigtes Backup soll auffallen, nicht den Nutzer aufhalten. if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword)) @@ -363,7 +365,9 @@ public static class AppBootstrapper services.AddTransient(); services.AddTransient(); + reportProgress?.Invoke(40, "Datenbank öffnen und aktualisieren …"); var provider = services.BuildServiceProvider(); + provider.GetRequiredService(); // Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier, // außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden. diff --git a/LehrerApp.Desktop/Services/AnnualPlanSyncService.cs b/LehrerApp.Desktop/Services/AnnualPlanSyncService.cs index d1e3031..ba028eb 100644 --- a/LehrerApp.Desktop/Services/AnnualPlanSyncService.cs +++ b/LehrerApp.Desktop/Services/AnnualPlanSyncService.cs @@ -36,7 +36,7 @@ public sealed class AnnualPlanSyncService : IDisposable _timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval); } - public async Task PollAsync() + public async Task PollAsync(CancellationToken cancellationToken = default) { if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return; try @@ -47,7 +47,7 @@ public sealed class AnnualPlanSyncService : IDisposable string icsText; try { - icsText = await _http.GetStringAsync(url).ConfigureAwait(false); + icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { diff --git a/LehrerApp.Desktop/Services/UntisSyncService.cs b/LehrerApp.Desktop/Services/UntisSyncService.cs index cbdbd36..f170a70 100644 --- a/LehrerApp.Desktop/Services/UntisSyncService.cs +++ b/LehrerApp.Desktop/Services/UntisSyncService.cs @@ -65,7 +65,7 @@ public class UntisSyncService : IDisposable TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes)); } - public async Task PollAsync() + public async Task PollAsync(CancellationToken cancellationToken = default) { if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return; try @@ -76,7 +76,7 @@ public class UntisSyncService : IDisposable string icsText; try { - icsText = await _http.GetStringAsync(url).ConfigureAwait(false); + icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { diff --git a/LehrerApp.Desktop/Views/SplashWindow.axaml b/LehrerApp.Desktop/Views/SplashWindow.axaml index 4e5e58f..21332c2 100644 --- a/LehrerApp.Desktop/Views/SplashWindow.axaml +++ b/LehrerApp.Desktop/Views/SplashWindow.axaml @@ -7,8 +7,21 @@ ShowInTaskbar="False" WindowDecorations="None" WindowStartupLocation="CenterScreen"> - + + + + + + + + + + + diff --git a/LehrerApp.Desktop/Views/SplashWindow.axaml.cs b/LehrerApp.Desktop/Views/SplashWindow.axaml.cs index 3f46413..2fcd8fc 100644 --- a/LehrerApp.Desktop/Views/SplashWindow.axaml.cs +++ b/LehrerApp.Desktop/Views/SplashWindow.axaml.cs @@ -5,4 +5,10 @@ namespace LehrerApp.Desktop.Views; public partial class SplashWindow : Window { public SplashWindow() => InitializeComponent(); + + public void SetProgress(int value, string status) + { + StartupProgress.Value = value; + StartupStatus.Text = status; + } } diff --git a/TODO.md b/TODO.md index 3bd4845..b543c50 100644 --- a/TODO.md +++ b/TODO.md @@ -4801,3 +4801,12 @@ Die Abschnitte sind thematisch, nicht chronologisch nummeriert. Sinnvolle Bearbe **→ nächster sinnvoller Schritt:** offene größere Kapitel wie **3.1** (Mitarbeit-Aspekte pro Gruppe verwalten), **9** (Dashboard-Kacheln), **10** (Sync) oder **11** (Export) — oder gezielt eines der oben zurückgestellten 4.5.x-Punkte, falls der Bedarf danach entsteht. + + +### Startoptimierung (September 2026) + +- [x] Backup, Backup-Pruefung und Datenbankinitialisierung laufen ausserhalb des UI-Threads. +- [x] Splashscreen mit echtem, phasenbasiertem Ladebalken und aktuellem Arbeitsschritt. +- [x] Optionale iCal-Erstabgleiche starten nach dem Hauptfenster; lokale Daten sind sofort nutzbar. + Laufende Erstabgleiche werden vor der Freigabe der Datenbank beendet; HTTP-Abrufe beim Beenden abgebrochen. + Die Zeit bis zum Hauptfenster wird im App-Log protokolliert.