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

от автора

Существуют разные способы локализации 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.

ссылка на оригинал статьи http://habrahabr.ru/post/274477/