¿Cómo actualizo una ObservableCollection a través de un hilo de trabajo?

83

Tengo un ObservableCollection<A> a_collection;La colección contiene 'n' elementos. Cada elemento A se ve así:

public class A : INotifyPropertyChanged
{

    public ObservableCollection<B> b_subcollection;
    Thread m_worker;
}

Básicamente, todo está conectado a una vista de lista de WPF + un control de vista de detalles que muestra el b_subcollectionelemento seleccionado en una vista de lista separada (enlaces bidireccionales, actualizaciones de propiedad modificada, etc.).

El problema apareció para mí cuando comencé a implementar subprocesos. La idea era que todo el mundo a_collectionusara su hilo de trabajo para "hacer el trabajo" y luego actualizar sus respectivos b_subcollectionsy hacer que la interfaz gráfica de usuario mostrara los resultados en tiempo real.

Cuando lo probé, obtuve una excepción que decía que solo el hilo Dispatcher puede modificar un ObservableCollection y el trabajo se detuvo.

¿Alguien puede explicar el problema y cómo solucionarlo?

Maciek
fuente
Pruebe el siguiente enlace que proporciona una solución segura para subprocesos que funciona desde cualquier subproceso y se puede vincular a través de varios subprocesos de la interfaz de usuario: codeproject.com/Articles/64936/…
Anthony

Respuestas:

74

Técnicamente, el problema no es que esté actualizando ObservableCollection desde un hilo en segundo plano. El problema es que cuando lo haces, la colección genera su evento CollectionChanged en el mismo hilo que causó el cambio, lo que significa que los controles se actualizan desde un hilo en segundo plano.

Para completar una colección desde un hilo en segundo plano mientras los controles están vinculados a ella, probablemente tenga que crear su propio tipo de colección desde cero para solucionar este problema. Sin embargo, hay una opción más simple que puede funcionar para usted.

Publique las llamadas Add en el hilo de la interfaz de usuario.

public static void AddOnUI<T>(this ICollection<T> collection, T item) {
    Action<T> addMethod = collection.Add;
    Application.Current.Dispatcher.BeginInvoke( addMethod, item );
}

...

b_subcollection.AddOnUI(new B());

Este método regresará inmediatamente (antes de que el elemento se agregue realmente a la colección), luego en el hilo de la interfaz de usuario, el elemento se agregará a la colección y todos deberían estar felices.

La realidad, sin embargo, es que esta solución probablemente se atascará bajo una carga pesada debido a toda la actividad de los hilos cruzados. Una solución más eficiente agruparía un montón de elementos y los publicaría en el hilo de la interfaz de usuario periódicamente para que no esté llamando a los hilos para cada elemento.

La clase BackgroundWorker implementa un patrón que le permite informar el progreso a través de su método ReportProgress durante una operación en segundo plano. El progreso se informa en el hilo de la interfaz de usuario a través del evento ProgressChanged. Esta puede ser otra opción para ti.

Josh
fuente
¿qué pasa con el runWorkerAsyncCompleted de BackgroundWorker? ¿Eso también está vinculado al hilo de la interfaz de usuario?
Maciek
1
Sí, la forma en que está diseñado BackgroundWorker es usar SynchronizationContext.Current para aumentar sus eventos de finalización y progreso. El evento DoWork se ejecutará en el hilo de fondo. Aquí hay un buen artículo sobre subprocesos en WPF que también analiza BackgroundWorker msdn.microsoft.com/en-us/magazine/cc163328.aspx#S4
Josh
5
Esta respuesta es hermosa en su simplicidad. ¡Gracias por compartirlo!
Vaso
@Michael En la mayoría de los casos, el hilo en segundo plano no debería estar bloqueado y esperando a que la interfaz de usuario se actualice. El uso de Dispatcher.Invoke corre el riesgo de bloquearse si los dos subprocesos terminan esperando el uno al otro y, en el mejor de los casos, atrofiarán significativamente el rendimiento de su código. En su caso particular, es posible que deba hacerlo de esta manera, pero para la gran mayoría de situaciones, su última oración simplemente no es correcta.
Josh
@Josh He eliminado mi respuesta, porque mi caso parece ser especial. Buscaré más en mi diseño y pensaré de nuevo, qué se podría hacer mejor.
Michael
125

Nueva opción para .NET 4.5

A partir de .NET 4.5 hay un mecanismo integrado para sincronizar automáticamente el acceso a la colección y enviar CollectionChangedeventos al subproceso de la interfaz de usuario. Para habilitar esta función, debe llamar desde su hilo de interfaz de usuario .BindingOperations.EnableCollectionSynchronization

EnableCollectionSynchronization hace dos cosas:

  1. Recuerda el subproceso desde el que se llama y hace que la canalización de enlace de datos marque CollectionChangedeventos en ese subproceso.
  2. Adquiere un bloqueo en la colección hasta que se maneja el evento calculado, de modo que los controladores de eventos que ejecutan el subproceso de IU no intentarán leer la colección mientras se modifica desde un subproceso en segundo plano.

Muy importante, esto no se encarga de todo : para garantizar el acceso seguro para subprocesos a una colección intrínsecamente no segura para subprocesos, debe cooperar con el marco adquiriendo el mismo bloqueo de sus subprocesos en segundo plano cuando la colección está a punto de ser modificada.

Por tanto, los pasos necesarios para un correcto funcionamiento son:

1. Decida qué tipo de bloqueo utilizará

Esto determinará qué sobrecarga se EnableCollectionSynchronizationdebe utilizar. La mayoría de las veces, una lockdeclaración simple será suficiente, por lo que esta sobrecarga es la opción estándar, pero si está utilizando algún mecanismo de sincronización elegante, también hay soporte para bloqueos personalizados .

2. Cree la colección y habilite la sincronización

Según el mecanismo de bloqueo elegido, llame a la sobrecarga adecuada en el subproceso de la interfaz de usuario . Si utiliza una lockdeclaración estándar , debe proporcionar el objeto de bloqueo como argumento. Si utiliza la sincronización personalizada, debe proporcionar un CollectionSynchronizationCallbackdelegado y un objeto de contexto (que puede ser null). Cuando se invoca, este delegado debe adquirir su bloqueo personalizado, invocar el que se le ha Actionpasado y liberar el bloqueo antes de regresar.

3. Coopere bloqueando la colección antes de modificarla.

También debe bloquear la colección utilizando el mismo mecanismo cuando esté a punto de modificarla usted mismo; Haga esto con lock()el mismo objeto de bloqueo que se pasó EnableCollectionSynchronizationen el escenario simple, o con el mismo mecanismo de sincronización personalizado en el escenario personalizado.

Jon
fuente
2
¿Esto hace que las actualizaciones de la colección se bloqueen hasta que el hilo de la interfaz de usuario pueda manejarlas? En escenarios que involucran colecciones unidireccionales de objetos inmutables vinculados a datos (un escenario relativamente común), parecería que sería posible tener una clase de colección que mantendría una "última versión mostrada" de cada objeto, así como una cola de cambios. , y se usa BeginInvokepara ejecutar un método que realice todos los cambios apropiados en el hilo de la interfaz de usuario [como máximo uno BeginInvokeestaría pendiente en un momento dado.
supercat
1
¡Nunca supe que esto existía! ¡Gracias por escribir esto!
Kelly
15
Un pequeño ejemplo haría que esta respuesta sea mucho más útil. Creo que probablemente sea la solución correcta, pero no tengo idea de cómo implementarla.
RubberDuck
2
@Kohanz La invocación al despachador de subprocesos de la interfaz de usuario tiene varias desventajas. El más importante es que su colección no se actualizará hasta que el hilo de la interfaz de usuario realmente procese el envío, y luego se ejecutará en el hilo de la interfaz de usuario, lo que puede causar problemas de respuesta. Con el método de bloqueo, por otro lado, actualiza inmediatamente la colección y puede continuar procesando su hilo de fondo sin depender de que el hilo de la interfaz de usuario haga nada. El hilo de la interfaz de usuario se pondrá al día con los cambios en el próximo ciclo de renderizado según sea necesario.
Mike Marynowski
2
Hay más información de la respuesta a este hilo sobre EnableCollectionSynchronization: stackoverflow.com/a/16511740/2887274
Matthew S
22

Con .NET 4.0 puede utilizar estos conceptos básicos:

.Add

Application.Current.Dispatcher.BeginInvoke(new Action(() => this.MyObservableCollection.Add(myItem)));

.Remove

Application.Current.Dispatcher.BeginInvoke(new Func<bool>(() => this.MyObservableCollection.Remove(myItem)));
WhileTrueSleep
fuente
11

Código de sincronización de colección para la posteridad. Utiliza un mecanismo de bloqueo simple para permitir la sincronización de la colección. Tenga en cuenta que deberá habilitar la sincronización de colecciones en el hilo de la interfaz de usuario.

public class MainVm
{
    private ObservableCollection<MiniVm> _collectionOfObjects;
    private readonly object _collectionOfObjectsSync = new object();

    public MainVm()
    {

        _collectionOfObjects = new ObservableCollection<MiniVm>();
        // Collection Sync should be enabled from the UI thread. Rest of the collection access can be done on any thread
        Application.Current.Dispatcher.BeginInvoke(new Action(() => 
        { BindingOperations.EnableCollectionSynchronization(_collectionOfObjects, _collectionOfObjectsSync); }));
    }

    /// <summary>
    /// A different thread can access the collection through this method
    /// </summary>
    /// <param name="newMiniVm">The new mini vm to add to observable collection</param>
    private void AddMiniVm(MiniVm newMiniVm)
    {
        lock (_collectionOfObjectsSync)
        {
            _collectionOfObjects.Insert(0, newMiniVm);
        }
    }
}
LadderLogic
fuente