Заполнение TreeView и ListBox в фоновом потоке на VB.NET

РазделVB.NET и WinFormsУровеньОпытный Чтение9 минОпубликовано ТегиVB.NETWinFormsМногопоточностьTreeView

Дерево из десятков тысяч файлов строится минутами, и всё это время форма не отвечает. Разбираем, как вынести обход диска в фоновый поток и при этом не нарушить главное правило WinForms.

Главное. Контролы WinForms трогают только из потока интерфейса. Тяжёлый обход диска выносите в фон, передавайте результаты пачками через BackgroundWorker или BeginInvoke и вставляйте их с BeginUpdate. Для TreeView ещё лучше ленивая загрузка по BeforeExpand.

Задача: плейлист на 20 ГБ музыки

Типичный сценарий: редактор плейлистов для мультимедийной программы. Пользователь выбирает песни в TreeView, который повторяет структуру папок на диске, а рядом в ListBox собирается плоский список файлов. Когда на диске двадцать гигабайт музыки, обход всех папок и добавление узлов занимает несколько минут.

Если выполнять обход прямо в обработчике кнопки, весь этот срок форма мертва: не перерисовывается, не реагирует на клики, а Windows через пару секунд пишет в заголовке «Не отвечает». Решение очевидно: работать с диском в отдельном потоке. Но здесь начинается самое интересное.

Главное правило WinForms

Элементы управления Windows Forms привязаны к потоку, который создал их окно. Обращаться к свойствам и методам TreeView, ListBox или Label можно только из этого потока. Начиная с .NET 2.0 в режиме отладки нарушение сразу даёт InvalidOperationException с текстом про недопустимую межпоточную операцию, а в релизе программа может работать неделями и потом зависнуть или испортить дерево.

Чтобы фоновый поток что-то поменял на форме, он должен попросить об этом поток интерфейса. Для этого у каждого контрола есть метод Invoke (синхронный, ждёт выполнения) и BeginInvoke (асинхронный, ставит вызов в очередь). Свойство InvokeRequired показывает, нужен ли маршалинг из текущего потока.

SafeUi.vbVB.NET
' Безопасное добавление строки в 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
Вызов Application.DoEvents из фонового потока ничего не даёт и только маскирует ошибки. DoEvents обрабатывает очередь сообщений текущего потока, а у фонового потока её нет.

Почему 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, где обращаться к форме можно безопасно.

Form1.Worker.vbVB.NET
' 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
Не используйте один и тот же список после ReportProgress: создавайте новый, иначе поток интерфейса будет читать коллекцию, которую фоновый поток продолжает менять.

Ленивая загрузка дерева

Для TreeView самый эффективный приём вообще не многопоточность, а отказ от полной загрузки. Пользователь никогда не раскрывает все тысячи папок, поэтому нет смысла читать их заранее. Достаточно загрузить первый уровень, а каждому узлу-папке добавить фиктивного потомка, чтобы появился значок раскрытия. Реальное содержимое читается в событии BeforeExpand.

Чтение одной папки занимает миллисекунды, и интерфейс остаётся отзывчивым даже без фонового потока. Для медленных сетевых дисков чтение в BeforeExpand тоже можно вынести в фон, показав в узле временную подпись «Загрузка…».

Form1.Lazy.vbVB.NET
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): тогда процесс не будет висеть в диспетчере задач после закрытия главного окна.

Как это делают сегодня

В современных WinForms на .NET 8 фоновую работу пишут через Async и Await: обход папок запускают в Await Task.Run, а прогресс отправляют через Progress(Of T), который сам возвращает вызовы в поток интерфейса. Отмену передают через CancellationToken, а для очень больших списков используют ListView в виртуальном режиме (VirtualMode), который хранит только видимые строки.

Вопросы и ответы

Чем отличаются Invoke и BeginInvoke?

Invoke ждёт, пока поток интерфейса выполнит делегат, и возвращает результат. BeginInvoke только ставит вызов в очередь и сразу возвращает управление, поэтому фоновый поток не тормозит.

Можно ли создавать TreeNode в фоновом потоке?

Объекты узлов, ещё не добавленные в дерево, создавать можно, у них нет окна. Добавлять их в TreeView и менять уже видимые узлы нужно только в потоке интерфейса.

Почему в отладке ошибка межпоточной операции есть, а в релизе нет?

Проверку включает свойство Control.CheckForIllegalCrossThreadCalls, по умолчанию оно активно под отладчиком. Отсутствие исключения в релизе не значит, что код корректен: ошибка проявится зависанием или порчей данных.

Читайте также