
Заполнение TreeView и ListBox в фоновом потоке на VB.NET
Дерево из десятков тысяч файлов строится минутами, и всё это время форма не отвечает. Разбираем, как вынести обход диска в фоновый поток и при этом не нарушить главное правило WinForms.
Задача: плейлист на 20 ГБ музыки
Типичный сценарий: редактор плейлистов для мультимедийной программы. Пользователь выбирает песни в TreeView, который повторяет структуру папок на диске, а рядом в ListBox собирается плоский список файлов. Когда на диске двадцать гигабайт музыки, обход всех папок и добавление узлов занимает несколько минут.
Если выполнять обход прямо в обработчике кнопки, весь этот срок форма мертва: не перерисовывается, не реагирует на клики, а Windows через пару секунд пишет в заголовке «Не отвечает». Решение очевидно: работать с диском в отдельном потоке. Но здесь начинается самое интересное.
Главное правило WinForms
Элементы управления Windows Forms привязаны к потоку, который создал их окно. Обращаться к свойствам и методам TreeView, ListBox или Label можно только из этого потока. Начиная с .NET 2.0 в режиме отладки нарушение сразу даёт InvalidOperationException с текстом про недопустимую межпоточную операцию, а в релизе программа может работать неделями и потом зависнуть или испортить дерево.
Чтобы фоновый поток что-то поменял на форме, он должен попросить об этом поток интерфейса. Для этого у каждого контрола есть метод Invoke (синхронный, ждёт выполнения) и BeginInvoke (асинхронный, ставит вызов в очередь). Свойство InvokeRequired показывает, нужен ли маршалинг из текущего потока.
' Безопасное добавление строки в ListBox из любого потока
Private Sub AddToList(item As String)
If ListBox1.InvokeRequired Then
' мы в фоновом потоке: просим поток UI выполнить этот же метод
ListBox1.BeginInvoke(New Action(Of String)(AddressOf AddToList), item)
Return
End If
ListBox1.Items.Add(item)
lblStatus.Text = "Добавлен: " & item
End SubПочему Invoke на каждый узел медленный
Прямолинейный перенос кода в поток выглядит так: фоновый поток обходит папки и для каждого файла вызывает TreeView1.Invoke с делегатом, который добавляет узел. Форма оживает, но общая скорость падает в разы. Каждый Invoke это переключение потоков и ожидание, пока поток интерфейса обработает сообщение. На десятках тысяч файлов накладные расходы становятся главными.
Вторая проблема: TreeView после каждой вставки пересчитывает прокрутку и перерисовывается. Если вставлять узлы по одному без BeginUpdate, даже в главном потоке заполнение будет медленным.
- собирайте данные пачками в фоне и передавайте в интерфейс сразу по 200-500 элементов;
- на стороне интерфейса оборачивайте вставку в BeginUpdate и EndUpdate;
- для ListBox используйте Items.AddRange, для TreeView метод Nodes.AddRange;
- не трогайте контролы из фонового потока даже для чтения, например SelectedNode.
BackgroundWorker: поток без ручного Invoke
Компонент BackgroundWorker из .NET 2.0 избавляет от явного маршалинга. Работа выполняется в DoWork в пуле потоков, а события ProgressChanged и RunWorkerCompleted приходят уже в поток интерфейса. Через ReportProgress удобно отправлять пачки найденных файлов, а флаг CancellationPending позволяет прервать долгий обход кнопкой «Стоп».
В DoWork мы не создаём узлы и не трогаем контролы, а только собираем пути в список. Узлы создаёт ProgressChanged, где обращаться к форме можно безопасно.
' bgw: BackgroundWorker с WorkerReportsProgress и WorkerSupportsCancellation
Private Sub bgw_DoWork(sender As Object, e As DoWorkEventArgs) Handles bgw.DoWork
Dim batch As New List(Of String)
For Each f In IO.Directory.EnumerateFiles(CStr(e.Argument), "*.mp3",
IO.SearchOption.AllDirectories)
If bgw.CancellationPending Then e.Cancel = True : Exit For
batch.Add(f)
If batch.Count = 300 Then
bgw.ReportProgress(0, batch) ' пачка уходит в поток UI
batch = New List(Of String)
End If
Next
If batch.Count > 0 Then bgw.ReportProgress(0, batch)
End Sub
Private Sub bgw_ProgressChanged(sender As Object, e As ProgressChangedEventArgs) _
Handles bgw.ProgressChanged
Dim files = DirectCast(e.UserState, List(Of String))
ListBox1.BeginUpdate()
ListBox1.Items.AddRange(files.ToArray())
ListBox1.EndUpdate()
lblStatus.Text = "Найдено файлов: " & ListBox1.Items.Count
End SubЛенивая загрузка дерева
Для TreeView самый эффективный приём вообще не многопоточность, а отказ от полной загрузки. Пользователь никогда не раскрывает все тысячи папок, поэтому нет смысла читать их заранее. Достаточно загрузить первый уровень, а каждому узлу-папке добавить фиктивного потомка, чтобы появился значок раскрытия. Реальное содержимое читается в событии BeforeExpand.
Чтение одной папки занимает миллисекунды, и интерфейс остаётся отзывчивым даже без фонового потока. Для медленных сетевых дисков чтение в BeforeExpand тоже можно вынести в фон, показав в узле временную подпись «Загрузка…».
Private Const Dummy As String = "..."
Private Function MakeFolderNode(path As String) As TreeNode
Dim n As New TreeNode(IO.Path.GetFileName(path)) With {.Tag = path}
n.Nodes.Add(Dummy) ' заглушка, чтобы появился плюсик
Return n
End Function
Private Sub TreeView1_BeforeExpand(sender As Object, e As TreeViewCancelEventArgs) _
Handles TreeView1.BeforeExpand
Dim node = e.Node
If node.Nodes.Count <> 1 OrElse node.Nodes(0).Text <> Dummy Then Return
Dim path = CStr(node.Tag)
TreeView1.BeginUpdate()
node.Nodes.Clear()
Try
For Each d In IO.Directory.GetDirectories(path)
node.Nodes.Add(MakeFolderNode(d))
Next
For Each f In IO.Directory.GetFiles(path, "*.mp3")
node.Nodes.Add(New TreeNode(IO.Path.GetFileName(f)) With {.Tag = f})
Next
Catch ex As UnauthorizedAccessException
node.Nodes.Add("(нет доступа)")
Finally
TreeView1.EndUpdate()
End Try
End SubЗакрытие формы во время работы потока
Частая ошибка в многопоточном коде: пользователь закрывает форму, а фоновый поток продолжает вызывать Invoke на уже уничтоженном контроле и получает ObjectDisposedException. В обработчике FormClosing проверьте, занят ли BackgroundWorker, запросите отмену через CancelAsync и закройте форму после события RunWorkerCompleted.
Если используется обычный Thread, сделайте его фоновым (IsBackground = True): тогда процесс не будет висеть в диспетчере задач после закрытия главного окна.
Как это делают сегодня
Вопросы и ответы
Чем отличаются Invoke и BeginInvoke?
Invoke ждёт, пока поток интерфейса выполнит делегат, и возвращает результат. BeginInvoke только ставит вызов в очередь и сразу возвращает управление, поэтому фоновый поток не тормозит.
Можно ли создавать TreeNode в фоновом потоке?
Объекты узлов, ещё не добавленные в дерево, создавать можно, у них нет окна. Добавлять их в TreeView и менять уже видимые узлы нужно только в потоке интерфейса.
Почему в отладке ошибка межпоточной операции есть, а в релизе нет?
Проверку включает свойство Control.CheckForIllegalCrossThreadCalls, по умолчанию оно активно под отладчиком. Отсутствие исключения в релизе не значит, что код корректен: ошибка проявится зависанием или порчей данных.