Локализация 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 или на 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
    • +3
      Я в своё время тоже задумывался о динамической локализации WPF-приложений, но пришёл к выводу, что игра не стоит свеч. По-моему, смена системной локали происходит крайне редко. Даже если пользователь и поменяет системный язык во время работы приложения, его всё-равно попросят перегрузиться. А возможность выбора языка прямо из приложения — это, по-моему, рудимент из нулевых годов. Не думаю, что пользователь, у которого стоит английская локаль, вдруг захочет использовать ваше приложение на русском. Возможно, я не прав. Если не сложно, расскажите про ваш кейс.

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


        Зато вполне может быть наоборот: пользователь, например, с русской локалью вполне может захотеть англоязычный интерфейс. Я сам так делаю, т.к. иногда не понимаю некоторых формулировок.
        • +1
          Спрос рождает предложение, как говорится. Даже на хабрахабре находил посты про локализацию в wpf, где в комментариях люди как раз писали о том, что хотели бы менять локализацию на лету.
          Мой кейс такой же, какой написал scumware. Некоторые трудно-переводимые предложения намного легче читаются на английском языке, нежели на русском. + бывает, что английский интерфейс выглядит лаконичнее. К тому же, я считаю, что заставлять пользователя перезапускать приложение — это как раз так и рудимент. Изменение локализации на лету более userfriendly.
          Я посмотрю Weak Event Patterns, спасибо за совет =)
          • 0
            А есть ли спрос? Если вы пишете приложение для себя, то делаете как удобнее себе, конечно. Если же приложение коммерческое, то становится интересно, скольким пользователям нужна эта фича, и находится ли она в топе хотелок. У меня в этом большие сомнения.
          • +2
            Может я еще не проснулся, но отписыватся от события в деструкторе вообще не имеет смысла…
            Подписка на LocalizationManager.Instance.CultureChanged означает, что в объекте LocalizationManager.Instance появится ссылка на ваш объект BindingLocalizationListener. Соответственно сборщик мусора соберет его только когда ОБА объекта (LocalizationManager.Instance и BindingLocalizationListener) станут недоступны. Т.е. кажется никогда до окончания работы всего приложения.
            Так что не пишите так, это дает иллюзию защищенности… Возьмите какой-нибудь профилировщик памяти и посмотрите на ваше приложение через часок работы… На правах рекламы могу посоветовать dotmemory…
            • 0
              Я уже переписал эту часть под Weak Event Pattern по совету lam0x86, но статью редактировать не стал. Проект обновлен на GitHub.
              • 0
                Возможно это не единственная утечка памяти =)
          • +1
            А возможность выбора языка прямо из приложения — это, по-моему, рудимент из нулевых годов. Не думаю, что пользователь, у которого стоит английская локаль, вдруг захочет использовать ваше приложение на русском.

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

            Вы предлагаете оставить только одну системную настройку и не давать переключать локаль на уровне приложения? Странная затея. Если честно, не помню серьёзных приложений, которые так делают. Даже модные метрошные приложения типа ВК дают переключать язык в приложении. Office, VS — везде настройка в приложениях. Причём в Офисе можно по отдельности настраивать язык справки и язык подсказок, например. В куче игр по отдельности настраиваются язык звука и язык субтитров. Я бы сказал, в современном мире языковые настройки наоборот стали более мощными.
            • +4
              Есть несколько сценариев, когда переключение языка на лету очень удобно. Мы вот разрабатываем приложения для промышленной автоматики, которые работают неделями без перезагрузки, и там зачастую рестарт приложения вызовет, скажем, останов конвейера. Это не смертельно — поломки не будет, но ошибку надо подтвердить, конвейер перезапустить, и т.п. Иногда пользователи обращаются в техподдержку, тогда оператор логигинится в систему и видит экран — а там всё на китайском. И вот тогда он переключает язык на лету, показывает что к чему, после чего язык переключается обратно, и всё это без рестарта приложения. Ну ещё при локализации бывает, что локализованные строки сильно длиннее английских (несмотря на то, что дазаном это предусмотрено), и тогда удобно переключаться туда-сюда и корректировать перевод без рестарта. Ну и когда я работаю на техподдержке, то отправляю пользователю скриншоты с пояснениями на его родном языке — и мне тоже неудобно переключать язык с перезагрузкой (особенно если пользователь висит на телефоне и ждёт).
              В общем я за то, что если есть возможность делать переключение языка на лету — то это хорошо и правильно и повышает удобство. Это особенно приятно, если структура приложения модульная, тогда имеет смысл реализовать локализацию в ядре, так что новые модули будут переключаться на лету автоматически.
              Обычно тут рассуждают о том, что конечный пользователь будет редко переключать язык, и это в общем-то так, но есть ведь ещё тестирование, техподдержка, сервис, пусконаладка, обучение персонала, и т.д., и вот тут переключение языка используется довольно часто.
              • 0
                Хм. Интересно. Эта необходимость в основном является следствием того, что одни техподдержка и сервис работают сразу на несколько стран, или такая же проблема, скажем, есть у Microsoft, где техподдержка наверняка разделена? Никогда не звонил им в саппорт, но в принципе любопытно: я могу попросить их объяснять мне последовательность нажатий кнопок на разных языках? Скажем, говорить мне удобнее по-русски, а винда у меня на английском…
            • +2
              вы зря ставите выбранную культуру одновременно и в CurrentCulture и CurrentUICulture. Для смены языка строк из ресурсов нужна только CurrentUICulture, которая отвечает за взятие даннных из ресурсов. А CurrentCulture переключит форматирование дат и всего прочего, что в готовом софте вызовет крайне нелестные эмоции от пользователей вроде меня, у которых например русская локаль но английский интерфейс.
              • 0
                Спасибо за совет, буду иметь в виду.
                К слову, я обычно не использую стандартный вывод дат, а форматирую с помощью конвертеров, т.к. пользователи просят формат yyyy-MM-dd.
                • +1
                  Количество приложений, которое понимает разницу между CurrentCulture и CurrentUICulture, примерно равно нулю. :( Я пробовал использовать связку «русская локаль + английский интерфейс», но все программы норовят выставить русский интерфейс из-за русской локали. В конце концов плюнул, поставил локалью «English (UK)» и настроил вручную все форматы.
                • +1
                  Про культуры уже сказали, повторяться не буду.

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

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

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

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

                      Кстати о сорцах, вы б лицензию на гитхабе указали.
                      • 0
                        Из статьи убирать не буду, но проект на GitHub-е обновил.
                        • 0
                          Copyright {yyyy} {name of copyright owner}

                          :)
                    • 0
                      Вспомнил, что стили тоже умеют принимать привязки, поэтому нужно лишь добавить еще одну проверку
                      if ((target.TargetObject is DependencyObject && target.TargetProperty is DependencyProperty) || target.TargetObject is Setter)
                      
                    • 0
                      Менять значения «на лету» при смене языка с использованием ресурсов Resx не так уж сложно, я это описал в своей статье на хабре ещё в апреле в Локализация WPF приложений на лету.
                      • 0
                        Да, я читал вашу статью. Мне требовалось использовать биндинг в качестве ключа ресурса, а вашим способом это невозможно сделать.
                        И описанный в этой статье способ позволяет использовать не только ресурсы, но и любые другие источники (например, база данных).
                        • 0
                          Кстати, интересно, какие другие источники данных вы реально используете. Потому что «например, база данных» звучит ну очень странно, не могу придумать причину так делать.
                          • 0
                            Реально используется такой кейс: шаблоны писем на email хранятся в БД, т.к. письма отправляются через SQL Server, а редактируются через приложение. Сделано это для того, чтобы можно было оперативно менять содержание и тему писем. Использование ресурсов потребовало бы частый их деплой на сервер.

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.