Perspective : un processus léger de traduction d'application WPF

Perspective : un processus léger de traduction d'application WPF

Principe et mise en oeuvre

Le processus standard de traduction des applications WPF s'avère relativement lourd et peu adapté à la traduction à la volée d'une petite application en cours de développement.

En effet, il repose sur l'externalisation des ressources BAML (ressources binaire correspondant les fichiers XAML) dans des assemblys satellites. Un utilitaire basique, LocBaml (fourni comme application exemple avec le SDK) permet d'extraire les données textuelles des ressources BAML pour les réintégrer dans un nouvel assembly satellite une fois traduites. Au préalable, tous les éléments à traduire doivent avoir été marqués par un attribut x:Uid, idéalement de façon automatique au moyen d'une option de MsBuild. Résultat : le code XAML se retrouve truffé d'attribut x:Uid avec des noms générés automatiquement, ce qui réduit sa lisibilité. Par ailleurs, les ressources BAML étant dupliquées dans les assemblys satellites, toute modification du code XAML requiert une régénération de tous les assemblys satellites traduits. Autrement dit, la traduction n'est réellement possible qu'à la fin du cycle de développement.

J'ai donc développé dans la bibliothèque Perspective une alternative consistant à utiliser des ressources string .NET classiques pour toutes les propriétés texte à traduire. Grace au mécanisme standard des assemblys satellites, l'application peut facilement être traduite au fil des développements.

Mise en oeuvre :

  • Créer dans le projet un fichier de ressources .resx pour chaque fichier XAML, dans la langue de base de l'application, et y créer une ressource pour chaque texte à traduire. Par exemple, pour un fichier XAML donné, DpiScaling.xaml, créer un fichier DpiScaling.resx dans un sous-dossier Strings du dossier ou se trouve le fichier XAML (Pages/pWpf dans le cas de l'application démo de Perspective). Dans la zone Modificateur d'accès (access modifier), choisissez Pas de génération de code (No code generation).

Visual Studio : Ressources par défaut (langue anglaise)

  • Créer par copie un fichier de ressources pour chaque culture ciblée, par exemple Strings\DpiScaling.fr.resx, et traduire les ressources.

Visual Studio : Ressources de la langue française

  • Dans le fichier XAML, intégrer un élément Perspective ResourceStringDecorator comme conteneur de tous les éléments à traduire.
  • Indiquer dans sa propriété AssemblyName le nom de l'assembly, sans l'extension, et dans sa propriété BaseName le nom du fichier .resx, sans l'extension, précédé de son chemin en notation pointée, par exemple :
<p:ResourceStringDecorator 
    AssemblyName="Perspective.Config"
    BaseName="View.Strings.DpiScaling">
...
</p:ResourceStringDecorator>
  • Pour chaque propriété à traduire, utiliser l'extension markup ResourceString (de Perspective) pour faire référence à la ressource correspondante dans le .resx.
<TextBlock Text="{p:ResourceString HowToSetScale}"/>
  • Pour que le système soit opérationnel, il est nécessaire d'enregistrer l'assembly dans le dictionnaire ResourceAssemblies du singleton CultureManager fourni dans Perspective. L'assembly Perspective.Config de l'application Perspective encapsule cet enregistrement dans la méthode Register() de la classe ResourceAssembly :
public static class ResourceAssembly
{
    private static bool _registered;

public static void Register() { if (!_registered) { Assembly assembly = Assembly.GetExecutingAssembly(); CultureManager.Current.ResourceAssemblies.Add( //"Perspective.Config", Extension.AssemblyNameConst, assembly); _registered = true; } } }

  • Chaque assembly des modules de l'application expose une classe équivalente. Les méthodes Register() de ces classes doivent être appelées dans le constructeur de la classe principale du module :
public Extension()
    : base()
{
    ...
    ResourceAssembly.Register();
    ...
}

A l'exécution, les ressources correspondant à la culture courante (DpiScaling.fr.resx pour la langue française) sont automatiquement chargées par .NET.

Exécution : Ressources de la langue française

Si l'assembly satellite n'est pas trouvé pour la culture courante, les ressources par défaut sont utilisées (DpiScaling.resx).

Exécution : Ressources par défaut (langue anglaise)

Il est possible de provoquer le changement de culture à l'exécution au moyen d'un élément d'interface utilisateur (RadioButton ou autre). Il suffit de modifier la propriété UICulture du CultureManager. Le mécanisme de Perspective répercute alors ce changement en rechargeant les ressources correspondantes.

Changement de culture à l'exécution

Extrait de LocalizationConfig.xaml et LocalizationViewModel.cs (projet Perspective.Config) :

<ListView.ItemTemplate>
    <DataTemplate>
        <RadioButton 
            GroupName="LocaleInfo"
            Content="{Binding Title}"
            Command="{Binding 
                RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type Page}}, 
                Path=DataContext.SetCurrentLocaleInfoCommand}"
            CommandParameter="{Binding}">
            <RadioButton.IsChecked>
                <p:SignalBinding 
                    x:Name="radioButtonIsCheckedBinding"
                    Path="CultureName"
                    Mode="OneWay"
                    Converting="radioButtonIsCheckedBinding_Converting"/>
            </RadioButton.IsChecked>
        </RadioButton>
    </DataTemplate>
</ListView.ItemTemplate>
...
public SignalCommand SetCurrentLocaleInfoCommand { get; private set; }
public LocalizationViewModel()
{
    ...
    SetCurrentLocaleInfoCommand = new SignalCommand();
    SetCurrentLocaleInfoCommand.Executed += (sender, e) =>
    {
        if ((e.Parameter != null) && (e.Parameter is LocaleInfo))
        {
            CurrentLocaleInfo = e.Parameter as LocaleInfo;
            CultureManager.Current.UICulture = CurrentLocaleInfo.CultureName;
        }
    };
}

Contrôles sans contexte d'héritage

Le système présenté ci-dessus exploite le mécanisme des propriétés héritées. Certains contrôles de WPF, tel que ContextMenu, ne disposent pas d'un contexte d'héritage et ne sont donc pas compatibles avec ResourceStringDecorator. Dans ce cas (et uniquement dans ce cas), il est nécessaire d'utiliser les propriétés AssemblyName et BaseName de l'extension markup ResourceString :

<TabItem.ContextMenu>
    <ContextMenu>
        <MenuItem Header="{p:ResourceString Name=English, AssemblyName=PerspectiveDemo.UI, BaseName=UserControls.Strings.MainControl}" />
        <MenuItem Header="{p:ResourceString Name=French, AssemblyName=PerspectiveDemo.UI, BaseName=UserControls.Strings.MainControl}" />
        <MenuItem Header="{p:ResourceString Name=BubbleChart, AssemblyName=PerspectiveDemo.UI, BaseName=UserControls.Strings.MainControl}" />
    </ContextMenu>
</TabItem.ContextMenu>

Architecture

Les classes concernées (ResourceStringDecorator, ResourceStringExtension, CultureManager) se trouvent dans l'espace de noms Perspective.Wpf.ResourceStrings (projet Perspective.Wpf).

La classe ResourceStringDecorator hérite de la classe Decorator, et expose les dependency properties AssemblyName et BaseName. Au changement de valeur de cette dernière, la méthode ApplyResources() est déclenchée. Un objet ResourceManager est instancié, et stocké dans la dependency property ResourceManager. La méthode GetAssembly() renvoit l'assembly enregistré au préalable par la méthode Register() de la classe ResourceAssembly (ce système est nécessaire pour supporter le mode de confiance partielle en XBAP).

private void ApplyResources(string baseName)
{
    string fullBaseName = AssemblyName + "." + baseName;
    Assembly assembly = GetAssembly(AssemblyName);
    ResourceManager rm = new ResourceManager(
        fullBaseName,
        assembly);
    SetValue(ResourceManagerProperty, rm);
    ...
}

ResourceManager est une propriété héritable : chaque élément enfant acquiert automatiquement cette propriété (et sa valeur) comme si elle était sienne.

La classe ResourceStringExtension hérite de la classe MarkupExtension. Elle dispose d'une propriété Name, indiquant le nom de la ressource à utiliser. La méthode ProvideValue() est surchargée pour renvoyer la valeur de la ressource. Pour cela, elle récupère une référence sur l'objet porteur de la propriété, au moyen du service IProvideValueTarget. Une fois qu'elle dispose de cette référence, elle appelle sa méthode InitializeValue() pour récupérer la valeur de la ressource.

public override object ProvideValue(IServiceProvider serviceProvider)
{
    string value = "";
    IProvideValueTarget target = (IProvideValueTarget)serviceProvider.GetService(typeof(IProvideValueTarget));
    if (target != null)
    {
        DependencyObject d = (DependencyObject)target.TargetObject;
        if ( d != null)
        {
            //...
            value = ResourceStringDecorator.InitializeValue(
                d,
                target.TargetProperty,
                _name);
        }
    }
    return value;
}

La méthode InitializeValue() de la classe ResourceStringDecorator renvoie la valeur d'une ressource et enregistre la propriété de telle façon que son contenu puisse être rafraîchi. Elle récupère une référence sur le ResourceManager, "hérité" (acquis) du ResourceStringDecorator, et appelle sa méthode GetString() qui renvoit la valeur de la ressource. La propriété est enregistrée par ajout dans la collection localizedChildren d'un objet DepPropResourceLink ou ClrPropResourceLink en fonction de sa nature (dependency property ou propriété CLR). La méthode ApplyResourceFrom() de cet objet sera appelée lors d'un rafraîchissement par la méthode Refresh() de ResourceStringDecorator.

public static string InitializeValue(
    DependencyObject d,
    object property,
    string resourceName)
{
    string value = "";
    ResourceManager rm = ResourceStringDecorator.GetResourceManager(d);
    if (rm != null)
    {
        value = rm.GetString(resourceName);
        List<ResourceLink> localizedChildren = ResourceStringDecorator.GetLocalizedChildren(d);
        if (localizedChildren != null)
        {
            if (property is DependencyProperty)
            {
                DepPropResourceLink dprl = new DepPropResourceLink(
                    d, resourceName, (DependencyProperty)property);
                localizedChildren.Add(dprl);
            }
            if (property is PropertyInfo)
            {
                ClrPropResourceLink cprl = new ClrPropResourceLink(
                    d, resourceName, (PropertyInfo)property);
                localizedChildren.Add(cprl);
            }
        }
    }
    return value;
}

La méthode Refresh() est appelée automatiquement quand la valeur de la propriété UICulture du singleton CultureManager change (ResourceStringDecorator ayant abonné un gestionnaire à l'événement UICultureChanged).

Contrôles sans contexte d'héritage

Les ressources de traduction des contrôles sans contexte d'héritage sont gérées au moyen des classes ResourceStringCoordinator et ResourceSetManager (au lieu de ResourceStringDecorator).

A propos de cet article

Auteur : Olivier Dewit.

Historique :

  • 30 juin 2010 : mise à jour pour la version 2.0 de Perspective, WPF 4 et Visual Studio 2010
  • 26 mai 2008 : mise à jour pour la version 0.9.2 de Perspective (Support des contrôles sans contexte d'héritage). Article original
  • 19-20 mai 2008 : mise à jour pour la version 0.9 de Perspective.
  • 8 janvier 2008 : 1ère publication (Perspective version 0.5). Article original