Dritter und letzter Teil der Serie zur WPF-Lokalisierung, in welchem die zuvor erstellten Klassen (Daten und Datenbindung) miteinander zu interagieren beginnen. Im Zentrum dieses Posts steht der eigentliche Localizer, welcher über deklarativen XAML-Code die Sprachdaten an die Oberfläche bindet. Abschließend wird die vorgestellte Lösung kritisch betrachtet und Raum für Verbesserungen aufgezeigt.

Im zweiten Teil dieser Serie haben wir uns mit wichtigen terminologischen und konzeptuellen Grundlagen zur Lokalisierung einer WPF-Anwendung vertraut gemacht. Außerdem wurde eine Schnittstelle zur Datenhaltung vorgestellt und in einer einfachen Variante implementiert sowie die Basisklasse für die Datenbindung an die UI erzeugt. In diesem Teil geht es nun um die zentrale Verantwortlichkeit der Datenbindung und des Umschaltens der aktiven Zielkultur.

Aufgabe 3: Die Lokalisierung und der Sprachumschalter

Als zentrale Anforderung an unseren Sprachumschalter (“Localizer“) haben wir gefordert, dass die Datenbindung möglichst einfach und direkt zu realisieren sei. “Einfach und direkt” bedeutet in WPF, dass wir uns die Möglichkeiten der deklarativen XAML-Oberflächenbeschreibung zu Nutze machen. Als Beispiel sei ein Fenster gegeben, das die folgenden Komponenten enthält:

  • Sprachliste und -umschalter: eine Liste aller verfügbaren Zielkulturen, aus welcher eine dieser durch Anklicken aktiviert werden kann,
  • Beispiel-Steuerelemente, die über den Localizer angebunden werden und deren Datenbindung beim Umschalten der aktiven Kultur automatisch aktualisiert wird.

Betrachten wir folgenden Code, der obige Komponenten beschreibt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<Window 
    x:Class="FSBlog.WPFLocalization.MainWindow"
    x:Name="MainWindowRoot"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Title="MainWindow" Height="350" Width="525"
    >
    
    <Grid DataContext="{Binding ElementName=MainWindowRoot}">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="5" />
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>

        <!-- this box lists the available target cultures and allows the user to switch the active target culture -->
        <ListBox Grid.Column="0" ItemsSource="{Binding Localizer.AvailableTargetCultures}" SelectedItem="{Binding Localizer.ActiveTargetCulture}" />
        
        <!-- these sample controls show how the UI is "translated" instantly upon switching the currently active target culture -->
        <Grid Grid.Column="2">
            <Label Content="{Binding Localizer[Hello World]}" />
            <Button Height="50" Width="200" Content="{Binding Localizer[Bye]}" />
        </Grid>
        
    </Grid>
    
</Window>

Das Fenster stellt den Localizer (dessen Implementierung wir in Kürze besprechen werden) als zentrale Eigenschaft zur Verfügung. Das Grid-Wurzelelement (Zeile 9) bindet den DataContext per ElementName an das Fenster (das wir in Zeile 3 benannt haben), so dass die Steuerelemente auf die Liste der verfügbaren Zielkulturen (Zeile 17) sowie die Sprachdaten (Zeilen 21 und 22) im Localizer zugreifen können. Letztere greifen über einen sogenannten Indexer auf die Liste der verfügbaren Übersetzungen zu. Hierbei handelt es sich um den zentralen Bindungspunkt, der für die Echtzeit-Aktualisierung der UI beim Wechseln der aktiven Zielkultur sorgt.

Bevor wir uns den Mechanismus genauer ansehen, folgt hier noch die Fenster-Implementierung:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public partial class MainWindow : Window
{
    // PRIVATE MEMBERS

    private readonly Localizer _localizer;

    // CTORS

    public MainWindow()
    {
        // we have to initialize and load the localizer...

        _localizer = LoadNewLocalizer();

        // ... before we actually load the window contents

        InitializeComponent();
    }

    // PROPERTIES

    public Localizer Localizer { get { return _localizer; } }

    // PRIVATE METHODS

    private static Localizer LoadNewLocalizer()
    {
        return new Localizer(new TestTranslations());
    }
}

Ganz wichtig: Der Localizer wird instanziiert (Zeile 10) noch bevor das Fenster selbst initialisiert (Zeile 13) wird. Dies ist erforderlich, da sonst die Datenbindung (die mit dem Fenster initialisiert wird) die Bindungsquelle nicht finden würde.

Wie genau arbeitet nun der Localizer?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/// <summary>

/// A binding source that provides a real-time localization for the UI.

/// </summary>

public class Localizer : ObservableObject
{
    // PRIVATE MEMBERS

    private static readonly CultureInfo DEBUG_CULTURE = CultureInfo.InvariantCulture;
    private readonly ILocalizerTranslations _translations;
    private CultureInfo _activeTargetCulture;
    
    // CTORS

    public Localizer(ILocalizerTranslations translations)
    {
        if (translations == null)
        {
            throw new ArgumentNullException("translations");
        }

        _translations = translations;

        // default active target culture to first culture available

        _activeTargetCulture = translations.TargetCultures.First();
    }

    // PROPERTIES

    /// <summary>

    /// Gets or sets the currently active target culture.

    /// </summary>

    public CultureInfo ActiveTargetCulture
    {
        get { return _activeTargetCulture; }
        set
        {
            // whenever the active target culture changes, cause a property change 

            // notification on all members to force any bindings to update

            if (SetPropertyValue(() => ActiveTargetCulture, ref _activeTargetCulture, value))
            {
                RaisePropertyChangedAll();
            }
        }
    }
    /// <summary>

    /// Lists all target cultures available for localization.

    /// </summary>

    public IEnumerable<CultureInfo> AvailableTargetCultures { get { return _translations.TargetCultures; } }

    /// <summary>

    /// Retrieves the target entry for the <see cref="ActiveTargetCulture" /> and the index specified.

    /// </summary>

    /// <param name="index">The entry to retrieve.</param>

    /// <returns>The translation object or else the index itself (if not found).</returns>

    public object this[string index] { get { return GetTargetEntry(index); } }

    // PRIVATE METHODS

    /// <summary>

    /// Retrieves the target entry for the <see cref="ActiveTargetCulture" /> and the index specified.

    /// </summary>

    /// <param name="index">The entry to retrieve.</param>

    /// <returns>The translation object or else the index itself (if not found).</returns>

    private object GetTargetEntry(string index)
    {
        // for debug culture return index itself

        var currentlyActiveTargetCulture = ActiveTargetCulture;
        if (Equals(currentlyActiveTargetCulture, DEBUG_CULTURE))
        {
            return index;
        }

        // entry for index in target culture exists?

        if (!TargetCultureTranslationsExist(currentlyActiveTargetCulture) ||  !TargetCultureTranslationExists(currentlyActiveTargetCulture, index))
        {
            return index;
        }

        // retrieve translation

        return _translations.GetTextEntries(currentlyActiveTargetCulture)[index];
    }
    private bool TargetCultureTranslationsExist(CultureInfo targetCulture)
    {
        return _translations.TargetCultures.Contains(targetCulture);
    }
    private bool TargetCultureTranslationExists(CultureInfo targetCulture, string index)
    {
        var dictionary = _translations.GetTextEntries(targetCulture);
        return dictionary != null && dictionary.ContainsKey(index);
    }
}

Zunächst werden der Localizer-Instanz die Sprachendaten über den Konstruktor injiziert (Zeilen 12 bis 23), wobei dieser die verfügbaren Zielkulturen (Property AvailableTargetCultures, Zeile 45) und die aktive Zielkultur (Eigenschaft ActiveTargetCulture, Zeilen 29 bis 31) nach außen weiterreicht. Der bereits erwähnte Kniff an dieser Implementierung zeigt sich in Zeile 52. Hier wird eine Indexed Property, welche die verfügbaren Übersetzungen der Datenhaltung an die Benutzeroberfläche durchreicht, direkt in XAML angebunden. Diese einfache aber elegante Lösung bietet folgende Vorteile:

  • die Übersetzungen können über ihren jeweiligen Index sowie über eine einzige Eigenschaft im Localizer deklarativ angebunden werden, so dass kein prozeduraler Code für die Lokalisierung und die Umschalt-Logik geschrieben werden muss;
  • die Übersetzungen werden über ihre zentralen Indizes direkt zur Verfügung gestellt (d.h. wir sparen uns jegliche Indirektion z.B. über GUIDs oder anderweitige nichtssagende Schlüssel);
  • die Sprachumschaltung erfolgt über das bereits implementierte Interface INotifyPropertyChanged, und zwar genau dann, wenn der Wert der ActiveTargetCulture Eigenschaft geändert wird (vgl. Z. 32-40);

Kritische Betrachtung und Schlussbemerkungen

Die hier vorgestellte Lösung erfüllt die eingangs erwähnten Anforderungen (u.a. externe Sprachpakete, direkte und einfache Anbindung der Übersetzungen an die UI, Umschaltung der Lokalisierung in Echtzeit). Insbesondere die Echzeit-Umschaltung der aktiven Zielkultur erfolgt über die Anbindung der UI an eine Indexed Property. Für die Auslagerung der Sprachdaten in externe Dateien sorgt die Daten-Schnittstelle ILocalizerTranslations, welche beliebige Implementierungen und Datenquellen zulässt (zentrale XML-Datei, Datenbank-Server, REST-Service etc.).

Kritisch anzumerken bleibt, dass es sich hierbei um eine rudimentäre Lösung handelt, die noch viel Verbesserungs- und Erweiterungspotential bietet:

  • Zu Debug-Zwecken kann ein Debug-Schalter implementiert werden (z.B. über eine weitere Property); ist dieser gesetzt, kann bereits in der XML-Entwurfsansicht geprüft werden, ob die Datenbindung korrekt funktioniert (indem z.B. der Index an Stelle der eigentlichen Übersetzung an die UI gebunden wird).
  • Der Zugriff auf die Übersetzungen in der Indexed Property erfolgt über einen alphanumerischen Index, welcher in eckigen Klammern notiert den üblichen XAML-Benennungs-Einschränkungen unterliegt. So sind z.B. keine Sonderzeichen erlaubt (wohl aber Leerzeichen). Daher bietet er sich nur für kurze, prägnante Indizes an. Für längere Texte hingegen können beschreibende Schlüssel gewählt werden.
  • Die vorgestellte Lösung erlaubt auch das Binden an Nicht-Text-Übersetzungen wie z.B. Bilder. Hierfür ist das Interface bereits vorbereitet, indem es für Texteinträge die Property GetTextEntries zur Verfügung stellt. Entsprechend könnten weitere Zieltypen erstellt werden (z.B. via Dictionary<string, ImageSource> GetImageEntries(CultureInfo targetCulture)). Der Localizer müsste dann ebenfalls entsprechend modifiziert werden.
  • Natürlich würde eine komplexe Anwendung den Zugriff auf den Localizer über eine separate Schnittstelle zur Verfügung stellen sowie seine Abhängigkeiten per Dependency Injection verwalten;
  • Zuletzt sei auch noch auf das Thema Performance eingegangen: INotifyPropertyChanged ist bekannt dafür, dass es bei vielen Bindungszielen einen heftige Performance-Hit verursacht. Es ist daher angeraten, die Anzahl aktiver Bindungen zu beschränken und die EventHandler so schnell wie möglich wieder vom Event zu deregistrieren.

Source Code on GitHub

Please grab the source bits from GitHub, including the snippets above and some additional helper classes.