In den letzten Monaten habe ich vermehrt Artikel über das Cross-Platform Framework Avalonia geschrieben. Für mich persönlich hat sich das Framework mittlerweile zum Standard für Desktop-Applikationen entwickelt. Es kann fast alles, was man braucht und läuft auf allen gängigen Desktop Plattformen (Windows, macOS, Linux) stabil. In diesem Artikel möchte ich mich mit einem Thema beschäftigen, auf das die meisten Softwareentwickler bei UIs irgendwann stoßen: Applikationen in andere Sprachen übersetzen. Auch wenn man nicht den akuten Bedarf hat, so sollte man UIs möglichst von vorne herein darauf vorbereiten, dass sie übersetzbar sind. Übersetzung im Nachhinein einzubauen kann häufig sehr schwierig sein. Insbesondere, wenn Daten von außen bereits behaftet mit einer bestimmten Sprache in der eigenen Applikation ankommen.
Inhaltsverzeichnis
Grundsätzliches
Bevor es ans Übersetzen der UI geht, möchte ich auf ein grundlegendes Thema eingehen. Ich sehe es häufig, dass Daten bereits auf Applikations-Ebene behaftet mit irgendeiner Sprache oder Region vorliegen. Ein gutes Beispiel sind Datumswerte. Wenn ein angebundener Service anstelle eines DateTimeOffset lieber einen formatierten String zurückgibt, wird es schwierig mit der Übersetzung in der UI. Ähnliches gilt für DateTime, falls diese nicht UTC sind und eine Information zur Zeitzone fehlt. Darum gilt: Zunächst ist immer darauf zu achten, dass Daten unterhalb der UI-Ebene nicht bereits zu stark mit einer Sprache oder Region behaftet sind.
Ein anderes, mehr grundlegendes Thema betrifft die UI selbst. Die UI sollte so gebaut sein, dass übersetzbare Texte unterschiedliche Längen aufweisen können. Der Hintergrund ist sehr einfach: Das gleiche Wort oder der gleiche Satz in verschiedenen Sprachen sind häufig unterschiedlich lang. Man sollte also insbesondere an den Stellen aufpassen, an denen fixe Breiten und höhen angegeben werden.
Die aktuelle Sprache
Die aktuelle Sprache wird in .NET Applikationen in zwei verschiedenen Eigenschaften am aktuellen Thread abgelegt. Thread.CurrentCulture und Thread.CurrentUICulture. Der Artikel unter [1] ist zwar schon etwas älter (von 2013), erklärt die Unterschiebe aber kurz und knapp. In der aktuellen .NET Core Welt verhält es sich meines Wissens nach wie vor genau so. Thread.CurrentCulture wird als Standard-Sprache etwa bei Formatierungen (string.Format(…), float.ToString(…), etc.) verwendet. Thread.CurrentUICulture dagegen wird bei der Abfrage von sprachabhängigen Ressourcen aus Resx Dateien benutzt. Letzteres schauen wir uns im nächsten Kapitel genauer an. An dieser Stelle sind lediglich folgende Dinge wichtig:
- Die aktuelle Sprache wird am Thread gespeichert
- Am Thread gibt es zwei für die Übersetzung relevante Eigenschaften: Thread.CurrentCulture und Thread.CurrentUICulture
- Wir können diese Eigenschaften auch selbst beim Start der Applikation überschreiben (wenn wir das wollen). Standardmäßig werden sie anhand der Einstellungen des Betriebssystems gesetzt
- Die Werte in Thread.CurrentCulture und Thread.CurrentUICulture werden auf neu erzeugte Threads weitergegeben
Übersetzbare Texte in Resx Dateien ablegen
Zur Übersetzung von Texten (Strings) in der UI werden in .NET Resx Dateien verwendet. Das ist schon seit Windows.Forms in den früheren .NET Framework Versionen so gewesen und hat sich nicht wesentlich verändert. Der Artikel unter [2] etwa beschreibt den Einsatz von Resx Dateien unter WPF. In Avalonia lässt sich das genau so umsetzen. Es gilt also, folgende Schritte durchzugehen:
- Erstellen einer Resx-Datei mit der “Standardsprache”. Der Access Modifier muss hierbei auf Public gestellt werden
- Erstellen weiterer Resx-Dateien für zu unterstützende Sprachen. Die Dateiendung wird hierbei von .resx auf <Sprache>.resx erweitert, also zum Beispiel .de.resx für Deutsch. Der Access Modifier sollte bei diesen Dateien auf “No code generation” gestellt werden
- Pflegen der übersetzbaren Strings in den Resx-Dateien nach dem Key-Value Prinzip. Die Keys sind hier die Namen
- Für die Resx-Datei mit der Standardsprache wird eine Klasse generiert, bei der es eine statische Eigenschaft für jeden gepflegten Namen gibt
- Aus dem Xaml heraus kann per x:static auf diese Eigenschaften verwiesen werden. .NET kümmert sich automatisch darum, dass der Wert aus der für die aktuelle Sprache passenden Resx-Datei gezogen wird
- Aus dem C#-Code kann ebenfalls direkt auf die Eigenschaften dieser generierten Klasse zugegriffen werden. Auch hier kümmert sich .NET darum, dass der Wert anhand der aktuellen Sprache ermittelt wird
Technisch passiert dabei folgendes: Die Resx-Datei mit der “Standardsprache” wird direkt in die Assembly des Programms eingebunden. Das heißt, die hier gepflegten Werte sind immer vorhanden und werden auch immer verwendet, wenn nichts anderes gefunden wird. Die Resx-Dateien mit anderen Sprachen landen in sogenannten Satelliten-Assemblies (mehr Infos unter [3]). Diese werden als eigene Assemblies ausgeliefert und nur bei Bedarf geladen.
Beispiel einer übersetzten Avalonia Applikation
Auf GitHub unter [4] habe ich ein Beispiel hochgeladen. Schauen wir uns darin das Hauptfenster genauer an. Zur Übersetzung des Hauptfensters habe ich die Dateien MainWindowResources.resx und MainWindowResources.de.resx angelegt. Hierbei gilt: Man kann Resx-Dateien für das ganze Projekt oder nur für einzelne Controls erstellen. .NET gibt hier nichts vor. Ich persönlich setze hier mehr auf Dezentralisierung und erstelle meist eigene Resx-Dateien pro Control. Das hat den Vorteil, dass keine oder zumindest weniger aufgeblähte Resx Dateien entstehen. Zudem hilft eine solche Aufteilung bei der Suche nach bestimmten Werten.
MainWindowResources.resx kümmert sich um die Ressourcen in der Standardsprache (hier Englisch). Wichtig ist hier nochmal der Access Modifier auf “Public”, ansonsten kann Avalonia von Xaml aus nicht auf die Werte zugreifen. Ich habe hier auch zwei übersetzbare String eingefügt, die noch Parameter enthalten (CurrentCultureName und CurrentUiCultureName). Diese werden im ViewModel des Hauptfensters verwendet, die Werte für die Parameter werden per string.Format eingesetzt.
MainWindowRessources.de.resx kümmert sich um die Übersetzung ins Deutsche. Hier finden wir jedes Key-Value Paar aus der Resx Datei der Standardsprache wieder, nur eben mit auf Deutsch übersetzten Werten. Der Access Modifier ist hier auf “No code gneration” gestellt.
Im nachfolgenden Codeausschnitt stehen wir das Xaml des Hauptfensters. In Zeile 5 werden der aktuelle Namespace als “local” eingebunden. Darüber können wir per x:Static auf die Eigenschaften in der generierten Ressourcen-Klasse zugreifen. Dafür gibt es im Code genug Beispiele, so etwa bei dem Menü ab Zeile 17. Hier sieht man auch gut, dass die Namen der Strings sprechend gesetzt werden sollten. Ansonsten hätte man aus dem Xaml Code heraus wenig Chancen, den Sinn der verschiedenen Elemente zu erkennen.
<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:local="clr-namespace:HappyCoding.AvaloniaWithLocalization" xmlns:firstPage="clr-namespace:HappyCoding.AvaloniaWithLocalization.FirstPage" xmlns:secondPage="clr-namespace:HappyCoding.AvaloniaWithLocalization.SecondPage" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="HappyCoding.AvaloniaWithLocalization.MainWindow" Title="HappyCoding.AvaloniaWithLocalization"> <Window.DataContext> <local:MainWindowViewModel /> </Window.DataContext> <DockPanel LastChildFill="True"> <Menu DockPanel.Dock="Top"> <MenuItem Header="{x:Static local:MainWindowResources.Menu_File}"> <MenuItem Header="{x:Static local:MainWindowResources.Menu_File_New}" /> <MenuItem Header="{x:Static local:MainWindowResources.Menu_File_Open}" /> <Separator /> <MenuItem Header="{x:Static local:MainWindowResources.Menu_File_Close}" /> <Separator /> <MenuItem Header="{x:Static local:MainWindowResources.Menu_File_Save}" /> <MenuItem Header="{x:Static local:MainWindowResources.Menu_File_SaveAs}" /> <Separator /> <MenuItem Header="{x:Static local:MainWindowResources.Menu_File_Exit}" /> </MenuItem> <MenuItem Header="{x:Static local:MainWindowResources.Menu_Help}"> <MenuItem Header="{x:Static local:MainWindowResources.Menu_Help_About}" /> </MenuItem> </Menu> <StackPanel DockPanel.Dock="Bottom" Margin="4" Orientation="Horizontal"> <TextBlock Text="{Binding Path=CurrentCultureStatusText}" /> <TextBlock Text="|" Margin="4,0,4,0" /> <TextBlock Text="{Binding Path=CurrentUiCultureStatusText}" /> </StackPanel> <TabControl> <TabItem Header="{x:Static local:MainWindowResources.Tab_FirstPage}"> <firstPage:FirstPage /> </TabItem> <TabItem Header="{x:Static local:MainWindowResources.Tab_SecondPage}"> <secondPage:SecondPage /> </TabItem> </TabControl> </DockPanel> </Window>
Nachfolgend noch das C# Coding des ViewModels. Auch hier greifen wir auf die übersetzbaren Werte aus den Resx Dateien zu. Hier noch mit dem Unterschied, dass wir per string.Format Parameter-Werte setzen. Das wars, mehr ist zum Übersetzen von Texten in Avalonia Oberflächen nicht zu beachten.
using System.Threading; namespace HappyCoding.AvaloniaWithLocalization; public class MainWindowViewModel { public string CurrentCultureStatusText => string.Format( MainWindowResources.CurrentCultureName, Thread.CurrentThread.CurrentCulture.DisplayName); public string CurrentUiCultureStatusText => string.Format( MainWindowResources.CurrentUiCultureName, Thread.CurrentThread.CurrentUICulture.DisplayName); }
Weitere Hinweise zum Übersetzen von Avalonia Applikationen
Natürlich gibt es über die behandelten Themen dieses Artikels hinaus noch viele weitere Punkte im Detail, die man beachten sollte. Fremde Sprachen zu unterstützen ist mehr als nur Texte zu übersetzen und Werte richtig zu formatieren. Je nach Sprache kann etwa die Lese-Richtung anders sein oder Farben können andere Bedeutungen haben. Auch Texte auf Bildern (und damit die Bilder) sollten übersetzt werden. Diese Beispiele sind mir jetzt nur spontan eingefallen und dienen repräsentativ für viele weitere Details, die es zu beachten gilt.
Fazit
Ich beschäftige mich nach längerer Pause auch erst seit einigen Monaten wieder mit der Übersetzung von .NET Applikationen. Dabei hat mich tatsächlich überrascht, dass sich bei den Ressourcen-Dateien seit .NET Framework 2.0 nichts wesentliches verändert hat. Unabhängig davon funktioniert das Vorgehen aber sehr gut und intuitiv. Die Tooling-Unterstützung in Visual Studio ist dabei ähnlich geblieben, wie vor vielen Jahren. Andere IDEs wie etwa Rider bieten etwas mehr, etwa über die Anzeige der Werte aus verschiedenen Sprachen in einer Tabelle nebeneinander.
Downloads
- Quellcode der Beispiele aus diesem Artikel
https://www.rolandk.de/files/2022/HappyCoding.AvaloniaWithLocalization.zip
Verweise
- Unterschied zwischen Thread.CurrentCulture und Thread.CurrentUICulture
https://wpf.2000things.com/2013/02/25/763-the-difference-between-currentculture-and-currentuiculture/ - Übersetzen von WPF-Applikationen mit Resx Dateien
https://www.tutorialspoint.com/wpf/wpf_localization.htm - Intos zu Satelliten-Assemblies
https://learn.microsoft.com/en-us/dotnet/core/extensions/create-satellite-assemblies - Beispiel-Quellcode für diesen Artikel auf GitHub
https://github.com/RolandKoenig/HappyCoding/tree/main/2022/HappyCoding.AvaloniaWithLocalization
Ebenfalls interessant
- Cross-Plattform GUI mit C# und Avalonia
https://www.rolandk.de/wp-posts/2020/07/cross-platform-gui-mit-c-und-avalonia/ - Custom Window Chrome mit Avalonia
https://www.rolandk.de/wp-posts/2021/05/custom-window-chrome-mit-avalonia/ - Das DataGrid von Avalonia
https://www.rolandk.de/wp-posts/2022/10/das-datagrid-von-avalonia/