Schneller ObservableCollection

Nach dem lesen eines Blog Eintrages des von mir geschätzten Gregor Biswanger, setzte ein langer Denkprozess ein. Gregor beschreibt sinngemäß, das beim Einsatz einer gebundenen Liste in zb WPF es sehr lange dauert, viele Einträge anzuzeigen. Er nimmt knapp 3500 Datensätze und bindet diese per MVVM ans UI.

“Ein Test mit einfachen 3 500 Datensätzen zeigt, dass die Standard-ObservableCollection mit ihrem Verhalten zirka eine halbe Sekunde benötigt, um die Daten bei einem DataGrid zu füllen”

Zentrales Feature der ObservableCollection ist, das es das Interface InotifyPropertyChanged implementiert. Über dieses Interface kann die Klasse Events feuern, über das das gebundene Control weis, das sein Inhalt erneuerungsbedürftig ist.

Wenn man also 50000 Datensätze lädt, dann dauert es bei mir rund 6 Sekunden bis alle Daten gebunden sind. Das könnte man jetzt als langsam bezeichnen. Muss man aber nicht. Ich habe übrigens auch die Variante mit Compiled Bindings (x: Bind ) probiert und nur marginale Unterschiede festgestellt.

Es werden also sehr viele Events gefeuert, die die UI gar nicht abarbeiten kann. In der Praxis wird nämlich das gebundene Control erst erzeugt, wenn das Binding abgeschlossen ist und da dank virtualizedstackpanel nur die sichtbaren. Der Benutzer sieht also quasi 6 Sekunden nichts. Und das ist langsam. Um das ganze zu steigern, baue ich in eine Windows 10 UWP App einen Sleep Ersatz ein (system.threading kann kein Sleep)

   1:   Dim l As New ObservableCollection(Of person)
   2:   grid1.DataContext = l
   3:   For i = 1 To 50
   4:              l.Add(New person With {.id = i})
   5:              Dim h As AutoResetEvent = New AutoResetEvent(False)
   6:              h.WaitOne(100)
   7:   Next
   8:       

Das kann man den Benutzer so nicht zumuten, was also tun?

Die einfachste Lösung ist es den DataContext erst nach der Bulk Insert operation durchzuführen, ab Zeile 8. Allerdings wird der Datacontext meist deklarativ im XAML Code instanziert und zugewiesen. MVVM Puristen würden das ablehnen (mir ist es egal).

Die  weiter entwickelte Idee ist ein Liste vor dem Binding zu erstellen und diese dann der gebundenen Collection zuzuweisen.

   1:          Dim l As New ObservableCollection(Of person)
   2:          Dim l2 As New List(Of person)
   3:          grid1.DataContext = l
   4:          For i = 1 To 500000
   5:              l2.Add(New person With {.id = i})
   6:          Next
   7:          l = New ObservableCollection(Of person)(l2)

 

Das Ergebnis ist in der Tat rasend schnell, aber der Benutzer sieht keine Daten. Das UI ist noch immer an die ursprüngliche leere Liste gebunden, obwohl Debugger korrekt 50.000 Elemente anzeigt! Der Datacontext hat 0. Als nächstes probiert ein Update des Binding über die BindingExpression.

   1:   Dim be = grid1.GetBindingExpression(Grid.DataContextProperty)
   2:          be.UpdateSource()

Das klappt aber nur, wenn der Datacontext deklarativ zugewiesen wurde. Anderenfalls ist be nothing (oder c# null). Das ist zwar der MVVM Weg, verlagert aber die das Problem wieder nach vorne.

Die nächste Idee verwendet einen Timer mit der kleinstmöglichen Einheit 1 Tick, was das System aber nicht schafft.

   1:  dp = New DispatcherTimer()
   2:  dp.Interval = TimeSpan.FromTicks(1)
   3:  AddHandler dp.Tick, AddressOf addrecord
   4:  dp.Start()

 

Die Logik im Timer Event erzeugt dann nur einen neuen Datensatz.

   1:  Private Sub addrecord(sender As Object, e As Object)
   2:          liste.Add(New person With {.id = liste.Count})
   3:          If liste.Count = 50000 Then
   4:              Debug.WriteLine(Date.UtcNow)
   5:              dp.Stop()
   6:          End If
   7:  End Sub

Für den Benutzer fühlt sich die Anwendung so plötzlich superschnell an. Jedenfalls kann er gar nicht so schnell scrollen wie sich die Datensätze anhängen. Die Gesamte Ausführungszeit wird am Ende bei fast einer Stunde liegen. In der Praxis, wenn die Anwendung mit REST Services spricht, bietet sich an die Request Blockweise mit zb 100 Records durchzuführen.

Interessanterweise hat Microsoft im Namensraum Microsoft.VisualStudio.Language.Intellisense.dll durchaus eine Klasse BulkObervableCollection. Mit Addrange kann ein Bulk Insert ohne die InotifypropertyChanged Events ausgeführt werden. Wie der Name schon sagt, vermutlich um Visual Studio Extensions zu schreiben. Die Redmonder Entwickler haben wohl ähnlich gelagerte Probleme.

Also zuletzt eine eigene einfache Implementierung eier ObservableCollection mit einer Addrange Methode.

   1:  Public Class RangeObservableCollection(Of T)
   2:      Inherits ObservableCollection(Of T)
   3:      Public Sub AddRange(coll As IEnumerable(Of T))
   4:          For Each i In coll
   5:              Items.Add(i)
   6:          Next
   7:          Dim args = New NotifyCollectionChangedEventArgs(
   8:              NotifyCollectionChangedAction.Reset)
   9:          OnCollectionChanged(args)
  10:      End Sub

Die Durchlaufzeit liegt nun deutlich unter 1 Sekunde (im Gegensatz zu vorher 6). Der Schönheitsfehler, das klappt nur mit einer leeren Collection. Der Fix ist das Add Event zu feuern, das braucht allerdings den Startindex als Parameter.

   1:  Dim args = New NotifyCollectionChangedEventArgs(
   2:              NotifyCollectionChangedAction.Add, coll.ToList, 0)
   3:  OnCollectionChanged(args)

Wie man sieht, der Teufel steckt im Detail. Wo sollen denn den die neuen Items denn hin und was bedeutet schnell eigentlich wirklich? Warum braucht ein Benutzer 50.000 Datensätze visualisiert?

Performance steckt oft nicht an der Stelle an der man sie auf den ersten Blick vermuten würde.

Kommentare sind geschlossen