Локализация WPF-приложения и мгновенная смена культуры

в 19:37, , рубрики: .net, C#, wpf, локализация интерфейса, Локализация продуктов, разработка под windows

Существуют разные способы локализации WPF-приложения. Самый простой и распространенный вариант — использование файла ресурсов Resx и автоматически сгенерированный к ним Designer-класс. Но этот способ не позволяет менять значения «на лету» при смене языка. Для этого необходимо открыть окно повторно, либо перезапустить приложение.
В этой статье я покажу вариант локализации WPF-приложения с мгновенной сменой культуры.

Постановка задачи

Обозначим задачи, которые должны быть решены:

  1. Возможность использования различных поставщиков локализованных строк (ресурсы, база данных и т.п.);
  2. Возможность указания ключа для локализации не только через строку, но и через привязку;
  3. Возможность указания аргументов (в том числе привязки аргументов), в случае если локализованное значение является форматируемой строкой;
  4. Мгновенное обновление всех локализованных объектов при смене культуры.

Реализация

Для осуществления возможности использования различных поставщиков локализации создадим интерфейс ILocalizationProvider:

public interface ILocalizationProvider
{
    object Localize(string key);

    IEnumerable<CultureInfo> Cultures { get; }
}

Интерфейс имеет метод, осуществляющий непосредственно локализацию по ключу и список доступных культур для данной реализации.
Реализация ResxLocalizationProvider этого интерфейса для ресурсов будет иметь следующий вид:

public class ResxLocalizationProvider : ILocalizationProvider
{
    private IEnumerable<CultureInfo> _cultures;

    public object Localize(string key)
    {
        return Strings.ResourceManager.GetObject(key);
    }

    public IEnumerable<CultureInfo> Cultures => _cultures ?? (_cultures = new List<CultureInfo>
    {
        new CultureInfo("ru-RU"),
        new CultureInfo("en-US"),
    });
}

Также создадим вспомогательный класс-одиночку LocalizationManager, через который будут происходить все манипуляции с культурой и текущим экземпляром поставщика локализованных строк:

public class LocalizationManager
{
    private LocalizationManager()
    {
    }

    private static LocalizationManager _localizationManager;

    public static LocalizationManager Instance => _localizationManager ?? (_localizationManager = new LocalizationManager());

    public event EventHandler CultureChanged;

    public CultureInfo CurrentCulture
    {
        get { return Thread.CurrentThread.CurrentCulture; }
        set
        {
            if (Equals(value, Thread.CurrentThread.CurrentUICulture))
                return;
            Thread.CurrentThread.CurrentCulture = value;
            Thread.CurrentThread.CurrentUICulture = value;
            CultureInfo.DefaultThreadCurrentCulture = value;
            CultureInfo.DefaultThreadCurrentUICulture = value;
            OnCultureChanged();
        }
    }

    public IEnumerable<CultureInfo> Cultures => LocalizationProvider?.Cultures ?? Enumerable.Empty<CultureInfo>();

    public ILocalizationProvider LocalizationProvider { get; set; }

    private void OnCultureChanged()
    {
        CultureChanged?.Invoke(this, EventArgs.Empty);
    }

    public object Localize(string key)
    {
        if (string.IsNullOrEmpty(key))
            return "[NULL]";
        var localizedValue = LocalizationProvider?.Localize(key);
        return localizedValue ?? $"[{key}]";
    }
}

Также этот класс будет оповещать об изменении культуры через событие CultureChanged.
Реализацию ILocalizationProvider можно указать в App.xaml.cs в методе OnStartup:

LocalizationManager.Instance.LocalizationProvider = new ResxLocalizationProvider();

Рассмотрим, каким образом происходит обновление локализованных объектов после смены культуры.
Простейшим вариантом является использование привязки (Binding). Ведь если в привязке в свойстве UpdateSourceTrigger указать значение «PropertyChanged» и вызвать событие PropertyChanged интерфейса INotifyPropertyChanged, то и выражение привязки обновится. Источником данных (Source) для привязки послужит слушатель изменения культуры KeyLocalizationListener:

public class KeyLocalizationListener : INotifyPropertyChanged
{
    public KeyLocalizationListener(string key, object[] args)
    {
        Key = key;
        Args = args;
        LocalizationManager.Instance.CultureChanged += OnCultureChanged;
    }

    private string Key { get; }

    private object[] Args { get; }

    public object Value
    {
        get
        {
            var value = LocalizationManager.Instance.Localize(Key);
            if (value is string && Args != null)
                value = string.Format((string)value, Args);
            return value;
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;

    private void OnCultureChanged(object sender, EventArgs eventArgs)
    {
        // Уведомляем привязку об изменении строки
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value)));
    }

    ~KeyLocalizationListener()
    {
        LocalizationManager.Instance.CultureChanged -= OnCultureChanged;
    }
}

Так как локализованное значение находится в свойстве Value, то и свойство Path привязки должно иметь значение «Value».

Но что если значение ключа не является постоянной величиной и заранее не известна? Тогда ключ можно получить только через привязку. В этом случае нам поможет мульти-привязка (MultiBinding), которая принимает список привязок, среди которых будет привязка для ключа. Использование такой привязки также удобно для передачи аргументов, в случае если локализованный объект является форматируемой строкой. Для обновления значения нужно вызвать метод UpdateTarget объекта типа MultiBindingExpression мульти-привязки. Этот объект MultiBindingExpression передается в слушателя BindingLocalizationListener:

public class BindingLocalizationListener
{
    private BindingExpressionBase BindingExpression { get; set; }

    public BindingLocalizationListener()
    {
        LocalizationManager.Instance.CultureChanged += OnCultureChanged;
    }

    public void SetBinding(BindingExpressionBase bindingExpression)
    {
        BindingExpression = bindingExpression;
    }

    private void OnCultureChanged(object sender, EventArgs eventArgs)
    {
        try
        {
            // Обновляем результат выражения привязки
            // При этом конвертер вызывается повторно уже для новой культуры
            BindingExpression?.UpdateTarget();
        }
        catch
        {
            // ignored
        }
    }

    ~BindingLocalizationListener()
    {
        LocalizationManager.Instance.CultureChanged -= OnCultureChanged;
    }
}

Мульти-привязка при этом должна иметь конвертер, преобразующий ключ (и аргументы) в локализованное значение. Исходный код такого конвертера BindingLocalizationConverter:

public class BindingLocalizationConverter : IMultiValueConverter
{
    public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
    {
        if (values == null || values.Length < 2)
            return null;
        var key = System.Convert.ToString(values[1] ?? "");
        var value = LocalizationManager.Instance.Localize(key);
        if (value is string)
        {
            var args = (parameter as IEnumerable<object> ?? values.Skip(2)).ToArray();
            if (args.Length == 1 && !(args[0] is string) && args[0] is IEnumerable)
                args = ((IEnumerable) args[0]).Cast<object>().ToArray();
            if (args.Any())
                return string.Format(value.ToString(), args);
        }
        return value;
    }

    public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
    {
        throw new NotSupportedException();
    }
}

Для использования локализации в XAML напишем расширение разметки (MarkupExtension) LocalizationExtension:

[ContentProperty(nameof(ArgumentBindings))]
public class LocalizationExtension : MarkupExtension
{
    private Collection<BindingBase> _arguments;

    public LocalizationExtension()
    {
    }

    public LocalizationExtension(string key)
    {
        Key = key;
    }

    /// <summary>
    /// Ключ локализованной строки
    /// </summary>
    public string Key { get; set; }

    /// <summary>
    /// Привязка для ключа локализованной строки
    /// </summary>
    public Binding KeyBinding { get; set; }

    /// <summary>
    /// Аргументы форматируемой локализованный строки
    /// </summary>
    public IEnumerable<object> Arguments { get; set; }

    /// <summary>
    /// Привязки аргументов форматируемой локализованный строки
    /// </summary>
    public Collection<BindingBase> ArgumentBindings
    {
        get { return _arguments ?? (_arguments = new Collection<BindingBase>()); }
        set { _arguments = value; }
    }

    public override object ProvideValue(IServiceProvider serviceProvider)
    {
        if (Key != null && KeyBinding != null)
            throw new ArgumentException($"Нельзя одновременно задать {nameof(Key)} и {nameof(KeyBinding)}");
        if (Key == null && KeyBinding == null)
            throw new ArgumentException($"Необходимо задать {nameof(Key)} или {nameof(KeyBinding)}");
        if (Arguments != null && ArgumentBindings.Any())
            throw new ArgumentException($"Нельзя одновременно задать {nameof(Arguments)} и {nameof(ArgumentBindings)}");

        var target = (IProvideValueTarget)serviceProvider.GetService(typeof(IProvideValueTarget));
        if (target.TargetObject.GetType().FullName == "System.Windows.SharedDp")
            return this;

        // Если заданы привязка ключа или список привязок аргументов,
        // то используем BindingLocalizationListener
        if (KeyBinding != null || ArgumentBindings.Any())
        {
            var listener = new BindingLocalizationListener();

            // Создаем привязку для слушателя
            var listenerBinding = new Binding { Source = listener };

            var keyBinding = KeyBinding ?? new Binding { Source = Key };

            var multiBinding = new MultiBinding
            {
                Converter = new BindingLocalizationConverter(),
                ConverterParameter = Arguments,
                Bindings = { listenerBinding, keyBinding }
            };

            // Добавляем все переданные привязки аргументов
            foreach (var binding in ArgumentBindings)
                multiBinding.Bindings.Add(binding);

            var value = multiBinding.ProvideValue(serviceProvider);
            // Сохраняем выражение привязки в слушателе
            listener.SetBinding(value as BindingExpressionBase);
            return value;
        }

        // Если задан ключ, то используем KeyLocalizationListener
        if (!string.IsNullOrEmpty(Key))
        {
            var listener = new KeyLocalizationListener(Key, Arguments?.ToArray());

            // Если локализация навешана на DependencyProperty объекта DependencyObject
            if (target.TargetObject is DependencyObject && target.TargetProperty is DependencyProperty)
            {
                var binding = new Binding(nameof(KeyLocalizationListener.Value))
                {
                    Source = listener,
                    UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged
                };
                return binding.ProvideValue(serviceProvider);
            }

            // Если локализация навешана на Binding, то возвращаем слушателя
            var targetBinding = target.TargetObject as Binding;
            if (targetBinding != null && target.TargetProperty != null &&
                target.TargetProperty.GetType().FullName == "System.Reflection.RuntimePropertyInfo" &&
                target.TargetProperty.ToString() == "System.Object Source")
            {
                targetBinding.Path = new PropertyPath(nameof(KeyLocalizationListener.Value));
                targetBinding.UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged;
                return listener;
            }

            // Иначе возвращаем локализованную строку
            return listener.Value;
        }

        return null;
    }
}

Обратите внимание, что при использовании мульти-привязки мы также создаем привязку для слушателя BindingLocalizationListener и кладем ее в Bindings мульти-привязки. Это сделано для того, чтобы сборщик мусора не удалил слушателя из памяти. Именно поэтому в конвертере BindingLocalizationConverter нулевой элемент values[0] игнорируется.
Также обратите внимание, что, при использовании ключа, привязку мы можем использовать только если объект назначения является свойством DependencyProperty объекта DependencyObject. В случае, если текущий экземпляр LocalizationExtension является источником (Source) привязки (а привязка не является объектом DependencyObject), то создавать новую привязку не нужно. Поэтому просто назначаем привязке Path и UpdateSourceTrigger и возвращаем слушателя KeyLocalizationListener.

Ниже приводятся варианты использования расширения LocalizationExtension в XAML.
Локализация по ключу:

<TextBlock Text="{l:Localization Key=SomeKey}" />

или

<TextBlock Text="{l:Localization SomeKey}" />

Локализация по привязке:

<TextBlock Text="{l:Localization KeyBinding={Binding SomeProperty}}" />

Есть множество сценариев использования локализации по привязке. Например, если необходимо в выпадающем списке вывести локализованные значения некоторого перечисления (Enum).

Локализация с использованием статических аргументов:

<TextBlock>
    <TextBlock.Text>
        <l:Localization Key="SomeKey" Arguments="{StaticResource SomeArray}" />
    </TextBlock.Text>
</TextBlock>

Локализация с использованием привязок аргументов:

<TextBlock>
    <TextBlock.Text>
        <l:Localization Key="SomeKey">
            <Binding Source="{l:Localization SomeKey2}" />
            <Binding Path="SomeProperty" />
        </l:Localization>
    </TextBlock.Text>
</TextBlock>

Такой вариант локализации удобно использовать при выводе сообщений валидации (например, сообщение о минимальной длине поля ввода).

Исходники проекта можно взять на GitHub.

Автор: adeptuss

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js