Moderne Revit Add-in-Entwicklung (Teil 3)

Moderne Revit Add-in-Entwicklung (Teil 3)

Um bei der Erstellung von Revit Add-ins Kosten und Zeit zu sparen, werden oft 3rd-Party-Bibliotheken eingesetzt. Referenzieren dabei mehrere Add-ins verschiedene Versionen der gleichen Bibliothek, kommt es zu Versionskonflikten (DLL-Hölle).

Moderne Revit Add-in-Entwicklung (Teil 1)
Moderne Revit Add-in-Entwicklung (Teil 2)

In meinem dritten Teil zum Thema "Moderne Revit Add-in-Entwicklung" möchte ich Ihnen zeigen, wie Sie Versionskonflikte beim Einsatz von 3rd-Party-Bibliotheken durch Add-in-Isolation vermeiden.

Ab der Version 2025 unterstützt Revit .NET 8 und ich hatte gehofft, das Revit nach der Umstellung auf .NET 8 die Möglichkeit nutzt, Add-ins jeweils in einem eigenen Kontext zu laden. Leider erhielt ich gleich bei meinem ersten Test-Add-in mit .NET8 die folgende Fehlermeldung:

Wie kommt es zu Versionskonflikten

Versionskonflikte entstehen immer dann, wenn zwei oder mehr Add-ins die gleichen Assemblies referenzieren, jedoch unterschiedliche Versionen dieser Assemblies benötigen. Wurde beispielsweise eine ältere Version eines Assemblies geladen, das Add-in benötigt jedoch Typen aus der neueren Version, wird eine TypeLoadException geworfen. Zu ähnlichen Fehlern kommt es auch, wenn z.B. Methoden nicht gefunden wurden oder deren Parameter sich geändert haben.

Das vorhergehende Bild zeigt eine TypeLoadException welche beim Zugriff auf das Assembly Microsoft.Extensions.Options geworfen wurde. Revit hatte das Assembly in der Version 7.0 geladen, mein Test-Addin benötigte jedoch die Version 8.0 des gleichen Assemblies.

AssemblyLoadContext

In .NET4.x wurden Assemblies innerhalb eines Prozesses in einer AppDomain geladen. Um Versionskonflikte zu vermeiden, haben einige Add-in Frameworks für jedes Add-in eine weitere AppDomain erstellt und die Add-in-Assemblies innerhalb dieser AppDomain geladen. Jedoch ist ein Datenaustausch zwischen AppDomains nicht ohne weiteres möglich, die Transferobjekte müssen entweder serialisierbar sein oder von MarshallByRefObject ableiten. In beiden Fällen nimmt der Austausch größer Datenmengen erheblich Zeit in Anspruch. Dies war sicherlich mit ein Grund, warum dies in den vorhergehenden Revit-Versionen nicht angeboten wurde.

Seit .NET6 gibt nur noch genau eine Instanz einer AppDomain innerhalb eines Prozesses und AppDomains zur Isolierung von Assemblies werden nicht mehr unterstützt. Stattdessen werden nun AssemblyLoadContext-Instanzen verwendet. Jede AssemblyLoadContext-Instanz stellt einen eindeutigen Bereich für Assembly-Instanzen und den enthaltenen Typdefinitionen dar. Im Gegensatz zu den AppDomains gibt es keine binäre Isolierung zwischen diesen Abhängigkeiten, Objekte können somit beliebig zwischen den Kontexten ausgetauscht werden.

Achtung: Wenn zwei AssemblyLoadContext-Instanzen Typdefinitionen mit demselben Namen enthalten, sind sie nicht derselbe Typ. Sie sind der nur dann der derselbe Typ, wenn sie von der gleichen Assembly-Instanz stammen.

Resolving Assemblies

Gewöhnlich werden Assemblies aus dem Anwendungsverzeichnis heraus in den Default-AssemblyLoadContext geladen. In Add-in Frameworks (wie z.B. Revit) liegen die Assemblies in der Regel jedoch in separaten Verzeichnissen, in denen sie von der Anwendung nicht gefunden werden. Damit Revit ein Add-in in den Default-Kontext laden kann, benötigt es einen Hinweis, in welchem Verzeichnis die zugehörigen Assemblies liegen. Dazu geben wir den Typ der Revit-App sowie den Pfad zum Assembly im *.addin Manifest an:

<RevitAddIns>
	<AddIn Type="Application">
		<Name>Test</Name>
		<FullClassName>Scotec.Revit.Test.RevitTestApp</FullClassName>
		<Assembly>.\Scotec.Revit.Test\Scotec.Revit.Test.dll</Assembly>
		<AddInId>F2E1648B-7E1A-4518-95E9-92437EA941A6</AddInId>
		<VendorId>scotec</VendorId>
		<VendorDescription>scotec Software Solutions AB</VendorDescription>
	</AddIn>
</RevitAddIns>

Referenzierte Assemblies, welche im selben Add-in-Verzeichnis liegen, werden jedoch nicht automatisch mit geladen. .NET überprüft zunächst, ob ein Assembly mit gleichem Namen bereits geladen wurde. Falls ja, wird diese Assembly verwendet, auch wenn die vom Add-in referenzierte Version von der geladenen Version abweicht. Wurde die Assembly noch nicht geladen, sucht .NET das Assembly im Anwendungsverzeichnis. Kann diese dort nicht gefunden feuert der Default-Kontext ein Resolving-Event. Add-ins können für dieses Ereignis einen Event-Handler registrieren, welcher dann das Assembly laden kann.

Leider gibt es dabei jedoch ein Problem: Wenn mehr als ein Event-Handler für dieses Ereignis registriert ist, werden die Event-Handler der Reihe nach aufgerufen, bis ein Event-Handler einen Wert zurückgibt, der nicht null ist. Nachfolgende Event-Handler werden danach ignoriert. Welche Version eines Assemblies geladen wird, ist somit abhängig von der Reihenfolge, in der die Add-ins geladen werden.

Isolation - Ein eigener Load-Kontext für jedes Add-in

Um Add-ins voneinander zu isolieren können diese jeweils in einem eigenen Load-Kontext geladen werden. Jeder Load-Kontext kann dabei eigene Assembly-Instanzen - und somit eigene Versionen der Assemblies - laden.

Um einen neuen Kontext zu erstellen, leiten Sie eine neue Klasse von AssemblyLoadContext ab und überschreiben die Load Methode. Soll eine von Ihrem Add-in referenziertes Assembly geladen werden, ist die Load Methode die erste Möglichkeit, zu bestimmen, von wo das Assembly geladen werden soll.

Die Suche nach den Assemblies erfolgt dabei in folgender Reihenfolge:

  • Aufruf der AssemblyLoadContext.Load Methode.
  • Überprüfen des Caches der AssemblyLoadContext.Default-Instanz.
  • Ausführen der Logik für die Standardüberprüfung im Default-Kontext. Wenn ein Assembly neu geladen wird, wird dem Cache der Default-Instanz ein Verweis auf die neue Assembly-Instanz hinzugefügt.
  • Auslösen des AssemblyLoadContext.Resolving-Ereignisses für den aktiven AssemblyLoadContext
  • Auslösen des AppDomain.AssemblyResolve-Ereignisses.

Achtung: Revit erzeugt Instanzen Ihrer Revit-App-Klassen oder Ihrer Revit-Kommandos immer im Default-Kontext. Sie müssen also selbst dafür sorgen, dass Methoden-Aufrufe an diesen Instanzen im entsprechenden Kontext verarbeitet werden.

Eine einfache und effiziente Art, Revit Add-ins im eigenen AssemblyLoadContext zu betreiben, bietet die Open-Source-Komponente Scotec.Revit, welche als Nuget-Paket geladen werden kann.

Nachdem Sie im Packet-Manager das Scotec.Revit Nuget-Package geladen haben, weisen Sie Ihrer App-Klasse das Attribut [RevitApplicationIsolation] zu:

[RevitApplicationIsolation]
public class RevitTestApp : IExternalApplication
{
	public RevitTestApp()
	{
		var context = AssemblyLoadContext.GetLoadContext(Assembly.GetExecutingAssembly());
	}
	...
}

Das [RevitApplicationIsolation]-Attribut bewirkt, das beim Kompilieren des Codes automatisch eine RevitApplication-Factory generiert wird. Diese ist dafür verantwortlich, einen neuen AssemblyLoadContext für das Add-in zu erstellen und die Revit App in diesem Kontext zu instanziieren. Im zweiten und letzten Schritt müssen Sie im *.addin Manifest diese Factory anstelle der App-Klasse registrieren. Fügen Sie dazu dem Klassennamen den Postfix Factory zu.

<RevitAddIns>
	<AddIn Type="Application">
		<Name>Test</Name>
		<FullClassName>Scotec.Revit.Test.RevitTestAppFactory</FullClassName>
		<Assembly>.\Scotec.Revit.Test\Scotec.Revit.Test.dll</Assembly>
		<AddInId>F2E1648B-7E1A-4518-95E9-92437EA941A6</AddInId>
		<VendorId>scotec</VendorId>
		<VendorDescription>scotec Software Solutions AB</VendorDescription>
	</AddIn>

Im obigen Code-Beispiel ermittle ich im Konstruktor der RevitTestApp-Klasse zu Testzwecken den aktuellen AssemblyLoadContext, in welchem das Assembly ausgeführt wird. Ohne [RevitApplicationIsolation]-Attribut gibt der Aufruf der Methode GetLoadContext den Default-Kontext zurück. Bei Verwendung des Attributs wird ein Add-in-spezifischer Kontext zurückgegeben.

Welche Assemblies tatsächlich geladen wurden, können Sie im Visual Studio der Modul-Ansicht entnehmen. Sie sehen dort sämtliche, innerhalb des Prozesses, geladenen Assemblies sowie deren Pfad zum Add-in-Verzeichnis.

Im nachfolgenden Bild sehen Sie die geladenen Assemblies eines Revit-Prozesses ohne Verwendung von Isolierung. Das Assembly Microsoft.Extensions.Options.dll wurde genau einmal in der Version 7.0 geladen.

Die nächste Auflistung zeigt die geladenen Assemblies unter Anwendung von Isolation mittels AssemblyLoadContext. Das Assembly Microsoft.Extensions.Options.dll wurde nun insgesamt dreimal geladen. Einmal von Revit in der Version 7.0 und von zwei Add-ins, jeweils in der Version 8.0.

Revit Command und CommandAvailability

Revit erzeugt Instanzen weiterer Klassen Ihres Add-ins, wie z.B. Kommandos. Auch für diese erzeugt Revit die Instanzen innerhalb des Default-Kontextes. Wir gehen daher genauso vor, wie schon bei der Revit App. Die Klasse RevitTestCommand erhält das Attribut [RevitCommandIsolation] und die Klasse RevitTestCommandAvailability erhält das Attribut [RevitCommandAvailabilityIsolation].

[RevitCommandIsolation]
[Transaction(TransactionMode.Manual)]
public class RevitTestCommand : IExternalCommand
{
    ...
}

[RevitCommandAvailabilityIsolation]
public class RevitTestCommandAvailability : IExternalCommandAvailability
{
    ...
}

Bei der Registrierung hängen wir dann - wie bereits zuvor bei der Revit App - den Postfix Factory an den Klassennamen an. Im nachfolgenden Code zeige ich Ihnen dies am Beispiel eines Push-Buttons.

private static PushButtonData CreateButtonData(string name, string text, ...)
{
	return new PushButtonData(name
		, text
		, Assembly.GetExecutingAssembly().Location
		, typeof(RevitTestCommandFactory).FullName)
		{
			...
			AvailabilityClassName = typeof(RevitTestCommandAvailabilityFactory).FullName
		};
}



Die Isolierung von Revit Add-ins mittels AssemblyLoadContext bietet einen eleganten und einfachen Ausweg aus der DLL-Hölle. Die Scotec.Revit-Bibliothek unterstützt Sie dabei, in dem sie den erforderlichen Code automatisch beim Kompilieren Ihres Add-ins generiert. Die dabei erstellten Factories erzeugen dann die Instanzen Ihrer Apps oder Kommandos im Add-in-spezifischen Load-Kontext und sorgen dafür, dass die Revit-Aufrufe entsprechend weitergeleitet werden.



Moderne Revit Add-in-Entwicklung (Teil 1)
Moderne Revit Add-in-Entwicklung (Teil 2)

An error has occurred. This application may no longer respond until reloaded. An unhandled exception has occurred. See browser dev tools for details. Reload 🗙