head.WriteLine()

Montag, Januar 07, 2008

Windows Forms Support für System.AddIn, Teil 1

.NET 3.5 führt mit System.AddIn ist ein neues System zur Erstellung von erweiterbaren Anwendungen ein. Dariusz hat die grundlegenden Details bereits hier und hier beschrieben. Das System hat im Moment jedoch einen Haken: Wenn ein Add-In eine grafischen Oberfläche anbietet möchte, muss dieses – ebenso wie die hostende Anwendung – auf WPF basieren. Eine Unterstützung für Windows Forms existiert zurzeit nicht, was verwunderlich ist, da Windows Forms momentan eine deutlich größere Verbreitung hat.

Da ich das System aber gerne jetzt und nicht erst in der nächsten WPF-Anwendung nutzen wollte, habe ich mir ein Herz gefasst und die nötige Windows Forms-Funktionalität selbst implementiert. Doch bevor ich zu den technischen Details komme, zunächst einige Grundlagen zum besseren Verständnis.

System.AddIn-Architektur

Bei System.AddIn kommunizieren Host und Add-Ins durch eine sogenannte Pipeline, die aus Contracts, Views und Adaptern besteht, wie die folgende Abbildung zeigt.

SystemAddInPipeline

In der Mitte der Pipeline stehen die Contracts, über die Host und Add-Ins miteinander kommunizieren. Darüber hinaus verfügen beide Seiten jeweils über Views und Adapter. Diese dienen der Isolation und ermöglichen eine sichere und Versions-unabhängige Kommunikation.

Die Add-Ins werden in einer separaten AppDomain oder einem separaten Prozess ausgeführt. Die Kommunikation findet hierbei über den IPC-Channel von .NET-Remoting statt.

Das Hauptproblem bei der Übertragung von UI-Elementen besteht nun darin, dass die Klassen im System.Windows.Forms-Namespace nicht serialisierbar sind und somit nicht direkt übertragen werden können. Die leiten zwar von MarshalByRefObject ab, können aber auch nicht per Referenz übertragen werden. Doch wie sonst soll die Kommunikation erfolgen?

Der Windows Forms Support

Der Trick besteht darin, die Windows Forms-Elemente nicht in serialisierter Form über die Leitung zu schicken, sondern lediglich das Handle der Container-Instanz (z.B. eines User Controls). Dieses kann daraufhin vom Host „umgehängt“ werden. Der Contract für die Kommunikation könnte somit wie folgt aussehen:

public interface IWindowProxy
{
    IntPtr Handle { get; set; }
}

Analog zur Pipeline-Architektur wird zusätzlich eine abstrakte Basisklasse für die View-Ebene, sowie eine entsprechende Ableitung zur Verwendung in Add-In und Host benötigt. Hierzu dienen die Klassen WindowProxyBase und WindowProxy, welche dieselbe Signatur wie IWindowProxy aufweisen.

Zusätzlich werden Adapter-Klassen für die Add-In- und die Host-Seite benötigt. Hierfür werden die Klassen WindowProxyViewToContractAddInAdapter und WindowProxyViewToContractHostAdapter bereit gestellt.

Da das alles auf den ersten Blick etwas verwirrend wirken kann, soll das folgende Schaubild für die nötige Transparenz sorgen. Es zeigt den Aufbau der WindowFormsAddInProxy.dll, die alle nötigen Klassen enthält und von den jeweiligen Pipeline-Assemblies referenziert wird:

WindowsFormsAddInProxy

Das Demo-Projekt

Hat man den Aufbau erst einmal verstanden, ist die Verwendung in einem Add-In-Projekt relativ einfach. Zur Veranschaulichung habe ich ein kleines Demoprojekt erstellt, in dem ein sehr einfacher Contract verwendet wird:

[AddInContract]
public interface IAddInContract : IContract
{
      IWindowProxy GetSurface();
}

Die GetSurface()-Methode wird vom Host aufgerufen, um die Oberfläche des jeweiligen Add-In zu ermitteln. Sie liefert eine IWindowProxy-Instanz zurück, sprich das Handle eines User Controls.

Analog dazu werden zwei View-Klassen für die Add-In- und die Host-Seite definiert. Für die Add-In-Seite ist dies die Klasse AddInView:

[AddInBase]
public abstract class AddInView
{
    public abstract WindowProxyBase GetSurface();
}

Diese definiert ebenfalls die GetSurface()-Methode, gibt jedoch eine WindowProxyBase-Instanz zurück. Auf Host-Seite wird die Methode durch die AddInHostView-Klasse bereit gestellt.

public abstract class AddInHostView
{
    public abstract WindowProxyBase GetSurface();
}

Fehlen noch die entsprechenden Adapter-Klassen. Zunächst die AddInViewToContractAdapter-Klasse der Add-In-Seite:

[AddInAdapter]
public class AddInViewToContractAdapter
    : ContractBase, IAddInContract
{
    private AddInView m_view;

    public AddInViewToContractAdapter(AddInView view)
    {
        m_view = view;
    }

    public IWindowProxy GetSurface()
    {
        WindowProxyBase proxy =
          this.m_view.GetSurface();
        return new
          WindowProxyViewToContractAddInAdapter(proxy);
    }
}

Hier kommt die WindowProxyViewToContractAddInAdapter-Klasse aus der WindowsFormsAddInProxy.dll zum Einsatz, um eine IWindowProxy-Implementierung einer bestimmten Version bereit zu stellen.

Analog dazu stellt AddInContractToHostViewAdapter den entsprechenden Adapter für die Host-Seite bereit.

[HostAdapter]
public class AddInContractToHostViewAdapter
    : AddInHostView
{
    private IAddInContract m_contract;
    private ContractHandle m_handle;

    public AddInContractToHostViewAdapter
        (IAddInContract contract)
    {
        m_contract = contract;
        m_handle = new ContractHandle(contract);
    }

    public override WindowProxyBase GetSurface()
    {
        IWindowProxy proxy = m_contract.GetSurface();
        return new 
           WindowProxyViewToContractHostAdapter(proxy);
    }
}

Hier kommt die WindowProxyViewToContractHostAdapter-Klasse zum Einsatz, die für die Bereitstellung einer entsprechenden WindowProxyBase-Instanz sorgt.

Add-In erstellen

Nachdem die Infrastruktur steht, kann es an die Implementierung der Add-Ins gehen. Hierfür muss zunächst einmal eine entsprechende Oberfläche bereit gestellt werden. Die WindowsFormsAddInProxy.dll stellt hierfür die Basisklasse AddInSurface bereit. Sie leitet von UserControl ab und enthält die nötige Logik zum Transfer der Oberfläche über die AppDomain-Grenze. Durch die Ableitung von UserControl kann der Entwurf der Oberfläche bequem über den Control Designer erfolgen, ohne das irgendwelche Add-In-spezifischen Details berücksichtigt werden müssen. Im Demoprojekt habe ich einfach eine Handvoll Controls aus der Toolbox gezogen auf der Oberfläche platziert:

Surface

Nachdem die Entwurfsphase abgeschlossen ist, kann es an die Implementierung gehen. Da diese im Wesentlichen nur aus der GetSurface()-Methode besteht, ist das Ganze recht überschaubar:

[System.AddIn.AddIn("Add-In 1",
    Version="1.0.0.0",
    Description="Add-In Nr. 1",
    Publisher="Jörg Neumann")]
public class FirstAddIn : AddInView
{
    private Surface m_surface;

    public override WindowProxyBase GetSurface()
    {
        m_surface = new Surface();
        return new WindowProxy(m_surface);
    }
}

In GetSurface() wird eine Instanz von Surface erzeugt. Surface leitet wiederum von AddInSurface ab und enthält die gerade entworfene Oberfläche. Hierbei ist zu beachten, dass die entsprechende Variable auf Klassenebene deklariert wird, da sonst der Garbage Collector zuschlägt und die Oberfläche nach kurzer Zeit entsorgt. Für die Rückgabe muss nun noch eine WindowProxyBase-Instanz erstellt werden. Dies ist jedoch mit einem Einzeiler erledigt, da WindowProxy ein AddInSurface-Objekt im Konstruktor entgegen nimmt.

Add-In im Host laden

Zum Einbinden der Add-In-Oberfläche in die Host-Anwendung stellt die WindowsFormsAddInProxy.dll die Komponente WindowProxyPanel bereit. Sie leitet von UserControl ab und kann somit direkt auf die Form der Anwendung gezogen werden. WindowProxyPanel hostet die über AddInSurface bereitgestellte Oberfläche und kümmert sich hinter den Kulissen um die Synchronisation der entsprechenden Windows Messages (näheres dazu im nächsten Teil).

Wie die folgende Abbildung zeigt, ist die Oberfläche der Demoanwendung relativ überschaubar.

HostApp

Sie besteht lediglich aus einer WindowProxyPanel-Instanz, sowie einem Button zur Aktivierung. Der Code zum Laden des Add-Ins liegt der Einfachheit halber direkt im Click-Event Handler:

private void bindAddInButton_Click(
    object sender, EventArgs e)
{
    Collection<AddInToken> availableAddIns =
        AddInStore.FindAddIns(
            typeof(AddInHostView),
            Environment.CurrentDirectory);
    if (availableAddIns.Count > 0)
    {
        instance =
            availableAddIns[0].Activate<AddInHostView>
            (AddInSecurityLevel.Host);
        if (instance != null)
        {
            WindowProxyBase window =
                instance.GetSurface();
            if (window != null)
            {
                this.windowsProxyPanel1.
                    SetWindow(window);
                this.windowsProxyPanel1.Select();
            }
        }
    }
}

Hier werden zunächst über die FindAddIns()-Methode die verfügbaren Add-Ins ermittelt. Daraufhin wird das erste gefundene Add-In über die Activate()-Methode aktiviert, wobei über den Wert AddInSecurityLevel.Host festgelegt wird, dass das Add-In im Sicherheitskontext der Anwendung laufen soll.

Nun kommen wir zum spannenden Teil: Auf der erzeugten Instanz wird über die GetSurface()-Methode die Oberfläche des Add-In ermittelt. Das zurückgegebene WindowProxyBase-Objekt wird daraufhin an das WindowProxyPanel gebunden. Hierzu nimmt dessen SetWindow()-Methode die entsprechende WindowProxyBase-Instanz entgegen.

Im Vergleich zum getriebenen Aufwand sieht das Ergebnis dann doch relativ unspektakulär aus:

HostInRuntime

Bei dieser Art der Verwendung gibt es jedoch noch einen kleinen Schönheitsfehler. Da die Oberfläche über die Windows API „umgehängt“ wurde, hat Windows Forms keine Informationen über die enthaltenen Controls in der Add-In-Oberfläche. Dies führt dazu, dass bei einem Tab-Wechsel der Fokus lediglich zwischen Button und WindowProxy wechselt und dabei die eingebetteten Controls ignoriert. Zwar wurde eine entsprechende Fokussteuerung in AddInSurface implementiert, diese wird jedoch vom Hostfenster übersteuert. Als Workaround kann die ProcessTabKey()-Methode der Form wie folgt überschrieben werden.

protected override bool ProcessTabKey(bool forward)
{
    if (this.ActiveControl == this.windowsProxyPanel1)
    {
        this.windowsProxyPanel1.TabInto();
        return false;
    }
    return base.ProcessTabKey(forward);
}

Hier wird geprüft, ob die WindowProxyPanel-Instanz gerade aktiviert ist. Wenn ja, wird dieser die Tab-Steuerung durch einen Aufruf der TabInto()-Methode übergeben.

In der Praxis erweist sich dieser kleine Schönheitsfehler jedoch als eher unbedeutend, da das Hauptfenster einer komplexen Anwendung meist aus Menüs, Toolboxen und List Bars besteht und diese nicht über die klassische Tab-Steuerung erreichbar sind.

Im nächsten Teil gehe ich näher auf die Implementierung der WindowsFormsAddInProxy.dll ein und zeige wie die Win32-Kommunikation zwischen Add-In und Host funktioniert.

Den Source Code von WindowsFormsAddInProxy können Sie inkl. Demoprojekt hier herunter laden. Für Feedback bin ich stets dankbar.

PS: Wenn Sie mehr über System.AddIn erfahren möchten, schauen Sie auf der diesjährigen BASTA! Spring Edition vorbei. Dort führen Dominick Baier und ich in der Session „.NET-Anwendungen sicher und robust durch Add-ins erweitern“ in das Thema ein.

6 Comments:

Kommentar veröffentlichen

<< Home