Существуют разные способы локализации 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 или на Setter
            if ((target.TargetObject is DependencyObject && target.TargetProperty is DependencyProperty) ||
                target.TargetObject is Setter)
            {
                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. UPDATE: также такую привязку можно использовать в стилях, поэтому объект назначения может являться Setter-ом.
В случае, если текущий экземпляр 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.

Комментарии (24)


  1. lam0x86
    03.01.2016 23:34
    +3

    Я в своё время тоже задумывался о динамической локализации WPF-приложений, но пришёл к выводу, что игра не стоит свеч. По-моему, смена системной локали происходит крайне редко. Даже если пользователь и поменяет системный язык во время работы приложения, его всё-равно попросят перегрузиться. А возможность выбора языка прямо из приложения — это, по-моему, рудимент из нулевых годов. Не думаю, что пользователь, у которого стоит английская локаль, вдруг захочет использовать ваше приложение на русском. Возможно, я не прав. Если не сложно, расскажите про ваш кейс.

    Код не особо смотрел, но заметил использование деструктора для отписки от событий. Использование деструкторов для действий, не связанных с освобождением неуправляемых ресурсов, — плохая практика. Возможно, стоит посмотреть в сторону Weak Event Patterns.


    1. scumware
      04.01.2016 07:19
      +2

      Не думаю, что пользователь, у которого стоит английская локаль, вдруг захочет использовать ваше приложение на русском.


      Зато вполне может быть наоборот: пользователь, например, с русской локалью вполне может захотеть англоязычный интерфейс. Я сам так делаю, т.к. иногда не понимаю некоторых формулировок.


    1. adeptuss
      04.01.2016 10:18
      +1

      Спрос рождает предложение, как говорится. Даже на хабрахабре находил посты про локализацию в wpf, где в комментариях люди как раз писали о том, что хотели бы менять локализацию на лету.
      Мой кейс такой же, какой написал scumware. Некоторые трудно-переводимые предложения намного легче читаются на английском языке, нежели на русском. + бывает, что английский интерфейс выглядит лаконичнее. К тому же, я считаю, что заставлять пользователя перезапускать приложение — это как раз так и рудимент. Изменение локализации на лету более userfriendly.
      Я посмотрю Weak Event Patterns, спасибо за совет =)


      1. Athari
        04.01.2016 10:34

        А есть ли спрос? Если вы пишете приложение для себя, то делаете как удобнее себе, конечно. Если же приложение коммерческое, то становится интересно, скольким пользователям нужна эта фича, и находится ли она в топе хотелок. У меня в этом большие сомнения.


        1. adeptuss
          04.01.2016 10:45
          +1

          Не думаю, что проводился опрос. Скорее это хотелка руководителя, чем пользователей.
          А в комментарии я говорил про этот пост Локализация WPF приложений


      1. DragonFire
        04.01.2016 12:13
        +2

        Может я еще не проснулся, но отписыватся от события в деструкторе вообще не имеет смысла…
        Подписка на LocalizationManager.Instance.CultureChanged означает, что в объекте LocalizationManager.Instance появится ссылка на ваш объект BindingLocalizationListener. Соответственно сборщик мусора соберет его только когда ОБА объекта (LocalizationManager.Instance и BindingLocalizationListener) станут недоступны. Т.е. кажется никогда до окончания работы всего приложения.
        Так что не пишите так, это дает иллюзию защищенности… Возьмите какой-нибудь профилировщик памяти и посмотрите на ваше приложение через часок работы… На правах рекламы могу посоветовать dotmemory…


        1. adeptuss
          04.01.2016 12:16

          Я уже переписал эту часть под Weak Event Pattern по совету lam0x86, но статью редактировать не стал. Проект обновлен на GitHub.


          1. DragonFire
            04.01.2016 14:05

            Возможно это не единственная утечка памяти =)


    1. Athari
      04.01.2016 10:47
      +1

      А возможность выбора языка прямо из приложения — это, по-моему, рудимент из нулевых годов. Не думаю, что пользователь, у которого стоит английская локаль, вдруг захочет использовать ваше приложение на русском.

      Я переходил на английскую локаль постепенно. В школьные годы, когда я только начинал пользоваться компьютером, у меня всё было по-русски, а где-то по окончанию универа всё уже было по-английски, но переход не был моментальным: сначала английскими стали профессиональные инструменты, потом постепенно всё остальное и только в самом конце сама ось.

      Вы предлагаете оставить только одну системную настройку и не давать переключать локаль на уровне приложения? Странная затея. Если честно, не помню серьёзных приложений, которые так делают. Даже модные метрошные приложения типа ВК дают переключать язык в приложении. Office, VS — везде настройка в приложениях. Причём в Офисе можно по отдельности настраивать язык справки и язык подсказок, например. В куче игр по отдельности настраиваются язык звука и язык субтитров. Я бы сказал, в современном мире языковые настройки наоборот стали более мощными.


    1. AndreyDmitriev
      04.01.2016 14:57
      +4

      Есть несколько сценариев, когда переключение языка на лету очень удобно. Мы вот разрабатываем приложения для промышленной автоматики, которые работают неделями без перезагрузки, и там зачастую рестарт приложения вызовет, скажем, останов конвейера. Это не смертельно — поломки не будет, но ошибку надо подтвердить, конвейер перезапустить, и т.п. Иногда пользователи обращаются в техподдержку, тогда оператор логигинится в систему и видит экран — а там всё на китайском. И вот тогда он переключает язык на лету, показывает что к чему, после чего язык переключается обратно, и всё это без рестарта приложения. Ну ещё при локализации бывает, что локализованные строки сильно длиннее английских (несмотря на то, что дазаном это предусмотрено), и тогда удобно переключаться туда-сюда и корректировать перевод без рестарта. Ну и когда я работаю на техподдержке, то отправляю пользователю скриншоты с пояснениями на его родном языке — и мне тоже неудобно переключать язык с перезагрузкой (особенно если пользователь висит на телефоне и ждёт).
      В общем я за то, что если есть возможность делать переключение языка на лету — то это хорошо и правильно и повышает удобство. Это особенно приятно, если структура приложения модульная, тогда имеет смысл реализовать локализацию в ядре, так что новые модули будут переключаться на лету автоматически.
      Обычно тут рассуждают о том, что конечный пользователь будет редко переключать язык, и это в общем-то так, но есть ведь ещё тестирование, техподдержка, сервис, пусконаладка, обучение персонала, и т.д., и вот тут переключение языка используется довольно часто.


      1. Athari
        05.01.2016 14:43

        Хм. Интересно. Эта необходимость в основном является следствием того, что одни техподдержка и сервис работают сразу на несколько стран, или такая же проблема, скажем, есть у Microsoft, где техподдержка наверняка разделена? Никогда не звонил им в саппорт, но в принципе любопытно: я могу попросить их объяснять мне последовательность нажатий кнопок на разных языках? Скажем, говорить мне удобнее по-русски, а винда у меня на английском…


  1. Einherjar
    04.01.2016 02:08
    +2

    вы зря ставите выбранную культуру одновременно и в CurrentCulture и CurrentUICulture. Для смены языка строк из ресурсов нужна только CurrentUICulture, которая отвечает за взятие даннных из ресурсов. А CurrentCulture переключит форматирование дат и всего прочего, что в готовом софте вызовет крайне нелестные эмоции от пользователей вроде меня, у которых например русская локаль но английский интерфейс.


    1. adeptuss
      04.01.2016 10:26

      Спасибо за совет, буду иметь в виду.
      К слову, я обычно не использую стандартный вывод дат, а форматирую с помощью конвертеров, т.к. пользователи просят формат yyyy-MM-dd.


    1. Athari
      04.01.2016 10:31
      +1

      Количество приложений, которое понимает разницу между CurrentCulture и CurrentUICulture, примерно равно нулю. :( Я пробовал использовать связку «русская локаль + английский интерфейс», но все программы норовят выставить русский интерфейс из-за русской локали. В конце концов плюнул, поставил локалью «English (UK)» и настроил вручную все форматы.


  1. Athari
    04.01.2016 10:26
    +1

    Про культуры уже сказали, повторяться не буду.

    if (target.TargetObject is DependencyObject && target.TargetProperty is DependencyProperty)

    А в стиле работать будет?

    target.TargetProperty.ToString() == «System.Object Source»

    Это задокументированное поведение ToString, которое гарантированно не сломается в следующей версии?


    1. adeptuss
      04.01.2016 10:37

      В стилях, к сожалению, не работает смена культуры, т.к. Setter не является DependencyObject, а Value не является DependencyProperty. Если получится решить эту проблему, я напишу в комментариях или в статье.
      Думаю, что проверку «System.Object Source» можно вообще убрать, т.к. нет других свойств, в которые можно было бы навесить локализацию в привязке. Т.к. System.Reflection.RuntimePropertyInfo является internal классом, то я не нашел другого выхода. Выглядит костыльно, согласен =)


      1. Athari
        04.01.2016 10:50
        +1

        Лучше убрать, потому что может случайно сломаться при минорном апдейте.

        Кстати о сорцах, вы б лицензию на гитхабе указали.


        1. adeptuss
          04.01.2016 11:30

          Из статьи убирать не буду, но проект на GitHub-е обновил.


          1. Athari
            05.01.2016 14:48

            Copyright {yyyy} {name of copyright owner}

            :)


    1. adeptuss
      04.01.2016 10:50

      Вспомнил, что стили тоже умеют принимать привязки, поэтому нужно лишь добавить еще одну проверку

      if ((target.TargetObject is DependencyObject && target.TargetProperty is DependencyProperty) || target.TargetObject is Setter)
      


  1. Epsil0neR
    04.01.2016 17:53

    Менять значения «на лету» при смене языка с использованием ресурсов Resx не так уж сложно, я это описал в своей статье на хабре ещё в апреле в Локализация WPF приложений на лету.


    1. adeptuss
      04.01.2016 18:03

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


      1. Athari
        05.01.2016 14:46

        Кстати, интересно, какие другие источники данных вы реально используете. Потому что «например, база данных» звучит ну очень странно, не могу придумать причину так делать.


        1. adeptuss
          05.01.2016 15:48

          Реально используется такой кейс: шаблоны писем на email хранятся в БД, т.к. письма отправляются через SQL Server, а редактируются через приложение. Сделано это для того, чтобы можно было оперативно менять содержание и тему писем. Использование ресурсов потребовало бы частый их деплой на сервер.