To bardzo ważna kwestia, o której tutaj wspominasz i z którą masz problem. Jest niby wiele artykułów na ten temat, pięknie zatytułowanych, a w ostateczności żaden z nich nie podaje rozwiązania dla WPF (bo różni się ono trochę od np. Windows Forms).
Postaram się w podstawowym zakresie wyjaśnić jak obsługiwane są kontrolki z innego wątku w ogóle, a później pokazać ostateczny przykład rozwiązujący problem w WPF.
No więc wszystkie kontrolki, które tworzysz w swojej aplikacji, są tworzone w wątku głównym. Podstawowa zasada działania wątków jest taka, że dany wątek ma dostęp wyłącznie do swoich własnych zmiennych / obiektów. To znaczy, że z zewnętrznego wątku nie możesz zmienić zmiennej należącej do innego wątku. Takie działanie ma oczywiście sens, ponieważ (w teorii) 2 wątki działają jednocześnie. A co gdyby jednocześnie mogły uzyskać dostęp do jakiejś zmiennej? Jeden czyta, drugi zapisuje, no i robi się bałagan.
Czy to oznacza, że z jednego wątku nie możemy w ogóle ingerować w obiekty / kontrolki innego wątku? Oczywiście, że możemy. Tylko nie bezpośrednio. Rozwiązaniem tego problemu w WPF jest obiekt Dispatcher. Potrafi on niejako zlecić jakieś zadanie wątkowi głównemu. Czy nam się to w ogóle przyda? Oczywiście, że tak! Jak wspominałem, kontrolki w WPF są tworzone w wątku głównym programu. A co gdyby tak z innego wątku zlecić wątkowi głównemu zmienić coś w kontrolce? To jest dokładnie to, o co nam chodziło. Obiekt Dispatcher powinien być elementem każdej standardowej kontrolki WPF. Posiada on kilka ciekawych funkcji. Ja omówię tylko te najbardziej Ci potrzebne.
Dispatcher.CheckAccess()
Zwraca prawdę, jeśli obecnie znajdujesz się w wątku głównym. W przeciwnym przypadku — fałsz. Zastosowanie pokażę na końcu "wykładu" :-D
Dispatcher.Invoke()
Zleca wykonanie danej funkcji wątkowi głównemu i czeka na jej zakończenie. Dopiero potem wątek pracuje dalej.
Dispatcher.BeginInvoke()
Zleca wykonanie danej funkcji wątkowi głównemu i nie czeka na jej zakończenie. Wątek po zleceniu zadania, pracuje dalej.
Teraz jeszcze jakie parametry przyjmują funkcje Invoke i BeginInvoke. Funkcja jest przeładowana wielokrotnie. Może przyjmować obiekt typu Action, Func, delegate i wiele innych. Te trzy, które wymieniłem są najważniejsze. Powiedzmy, że stworzyłeś TextBox o nazwie "textBox". Teraz zmienimy jego zawartość na różne sposoby funkcją Invoke, aby pokazać wywołania na przykładach:
Invoke( new Action( () => {textBox.Text = "Napis"; } ) )
Tworzymy nowy obiekt typu Action. Polecam Ci poczytać o obiektach Action oraz Func. Bardzo ułatwisz sobie sprawę, a i mi oszczędzisz dalszych wyjaśnień :-)
Invoke( () => { textBox.Text = "Napis"; } )
Tutaj korzystamy z wyrażenia lambda. Delegat to taki jakby wskaźnik na funkcję, a lambda, to właśnie funkcja, więc owe wyrażenie zostanie przypisane do delegata.
Invoke( delegate { textBox.Text = "Napis"; } )
Tym razem wyraźnie zaznaczamy, że tworzymy delegata, który ma zostać wywołany przez wątek główny.
Invoke(System.Windows.Threading.DispatcherPriority.Normal, new Action(() => { textBox.Text = "Napis"; }))
Możemy też ustalić z jakim priorytetem zlecamy wątkowi głównemu wykonanie naszej funkcji. Pamiętajmy, że wątek główny ma też swoje własne sprawy na głowie ;-)
Jest wiele wiele więcej wywołań tej funkcji, rzecz jasna nie jestem w stanie pokazać wszystkich.
Wszędzie powyżej tworzyłem ciało funkcji w klamrach, w momencie wywołania Invoke. Oczywiście nie trzeba tak robić, można posłać normalną, wcześniej utworzoną i nazwaną funkcję do wykonania, ale po co. My tylko zmieniamy zawartość pola Text na inną. Taki sposób nie ma sensu:
private void nowyText()
{
textBox.Text = "Napis";
}
// ...
Invoke( nowyText );
Ta wiedza wystarczy aby używać kontrolek z innych wątków. Teraz przyda Ci się jakiś praktyczny przykład, który zademonstruje działanie dwóch wątków w akcji.
Spróbuj stworzyć projekt aplikacji według tego co napiszę. Dodaj do okienka Button oraz ProgressBar. Nazwij button i progressBar. Cel do wykonania: wypełnianie się naszego paska bez zablokowania interakcji z programem (wypełnianie paska ma być z osobnego wątku). Na początek dodaj:
using System.Threading;
Teraz zróbmy funkcję wypełniającą cały pasek:
void uzupelnij()
{
for( int i = 1; i < =100; i++ )
{
progressBar.Value = i;
Thread.Sleep(100);
}
}
Następnie kliknij dwukrotnie na Button, który dodałeś i w funkcji Click przycisku dopisz wywołanie powyższej funkcji:
private void button_Click(object sender, RoutedEventArgs e)
{
uzupelnij();
}
Uruchom program i wciśnij przycisk. Efekt? Zawieszenie interfejsu i po chwili nagle pojawia się w pełni uzupełniony pasek. To było do przewidzenia. Teraz obsłużmy tę funkcję w innym wątku.
Do Click przycisku wpisz takie coś:
private void button_Click(object sender, RoutedEventArgs e)
{
Thread thread = new Thread(uzupelnij);
thread.start();
}
Uruchom program i wciśnij przycisk. Znany błąd, prawda? Próba uzyskania dostępu do kontrolki z innego wątku.
I teraz sedno całego mojego wywodu. Rozwiązanie Twojego problemu. Po tym wszystkim co napisałem wiesz już jak to działa i co trzeba zrobić. Musimy zlecić zmianę wartości progressBar wątkowi głównemu, bo tylko on ma prawo dostępu do tej kontrolki. Teraz tylko jak to zrobić:
W funkcji void uzupelnij() zamień linijkę:
progressBar.Value = i;
na takie coś:
progressBar.Dispatcher.Invoke( () => {progressBar.Value = i;} );
Zlecamy zmianę wartości do wątku głównego, a dokładniej zlecamy wątkowi głównemu wywołanie funkcji, którą przekazujemy (w tym przypadku wyrażenie lambda).
Teraz uruchom program i wciśnij przycisk.
Tadaaaam, ładuje się, a interfejs dalej działa. (Zamknięcie programu w czasie ładowania może rzucić wyjątek).
Mam nadzieję, ze po moich opisach rozumiesz różnicę pomiędzy Invoke oraz BeginInvoke. Kilka słów jeszcze o CheckAccess.
Czasami jakaś funkcja może być wywoływana zarówno przez wątek główny oraz inny. Co się stanie jeśli wywołamy funkcję uzupelnij() będąc w wątku głównym? Wątek główny zleci samemu sobie wykonanie przesłanej funkcji? Otóż... trochę jakby tak. Efekt tego będzie taki, że działanie głównego wątku się spowolni. Dlatego właśnie stworzono możliwość sprawdzenia wątku, w którym się aktualnie znajdujemy. Jeżeli jesteśmy w wątku głównym to nie należy używać Dispatcher.
Pokażę to na ostatnim już przykładzie. TextBox o nazwie log oraz funkcja wypisz().
Raz wywołujemy tę funkcje z wątku głównego (dostęp do kontrolek normalny), a raz z innego wątku (dostęp do kontrolek poprzez Dispatcher).
public MainWindow()
{
InitializeComponent();
wypisz(); // główny wątek
Thread thread = new Thread( wypisz );
thread.Start(); // inny wątek
}
private void wypisz()
{
if( log.Dispatcher.CheckAccess())
{
log.Text += "Wątek główny";
}
else
{
log.Dispatcher.Invoke( delegate
{
log.Text += "Inny wątek";
})
}
}
Przy okazji pokazałem ciekawy sposób wywołania i zapisania Invoke z delegatem :-)
Mam nadzieję, że nie tylko wiesz jak obsłużyć kontrolkę z innego wątku, ale jednocześnie rozumiesz jak ta "obsługa" działa. Taki był cel całego mojego wywodu. A nuż, widelec trafią tu inni i zechcą się czegoś nowego dowiedzieć :-)
Pozdrawiam.