В этой статье я покажу вариант локализации WPF-приложения с мгновенной сменой культуры.
Постановка задачи
Обозначим задачи, которые должны быть решены:
- Возможность использования различных поставщиков локализованных строк (ресурсы, база данных и т.п.);
- Возможность указания ключа для локализации не только через строку, но и через привязку;
- Возможность указания аргументов (в том числе привязки аргументов), в случае если локализованное значение является форматируемой строкой;
- Мгновенное обновление всех локализованных объектов при смене культуры.
Реализация
Для осуществления возможности использования различных поставщиков локализации создадим интерфейс 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)
Einherjar
04.01.2016 02:08+2вы зря ставите выбранную культуру одновременно и в CurrentCulture и CurrentUICulture. Для смены языка строк из ресурсов нужна только CurrentUICulture, которая отвечает за взятие даннных из ресурсов. А CurrentCulture переключит форматирование дат и всего прочего, что в готовом софте вызовет крайне нелестные эмоции от пользователей вроде меня, у которых например русская локаль но английский интерфейс.
adeptuss
04.01.2016 10:26Спасибо за совет, буду иметь в виду.
К слову, я обычно не использую стандартный вывод дат, а форматирую с помощью конвертеров, т.к. пользователи просят формат yyyy-MM-dd.
Athari
04.01.2016 10:31+1Количество приложений, которое понимает разницу между CurrentCulture и CurrentUICulture, примерно равно нулю. :( Я пробовал использовать связку «русская локаль + английский интерфейс», но все программы норовят выставить русский интерфейс из-за русской локали. В конце концов плюнул, поставил локалью «English (UK)» и настроил вручную все форматы.
Athari
04.01.2016 10:26+1Про культуры уже сказали, повторяться не буду.
if (target.TargetObject is DependencyObject && target.TargetProperty is DependencyProperty)
А в стиле работать будет?
target.TargetProperty.ToString() == «System.Object Source»
Это задокументированное поведение ToString, которое гарантированно не сломается в следующей версии?adeptuss
04.01.2016 10:37В стилях, к сожалению, не работает смена культуры, т.к. Setter не является DependencyObject, а Value не является DependencyProperty. Если получится решить эту проблему, я напишу в комментариях или в статье.
Думаю, что проверку «System.Object Source» можно вообще убрать, т.к. нет других свойств, в которые можно было бы навесить локализацию в привязке. Т.к. System.Reflection.RuntimePropertyInfo является internal классом, то я не нашел другого выхода. Выглядит костыльно, согласен =)
adeptuss
04.01.2016 10:50Вспомнил, что стили тоже умеют принимать привязки, поэтому нужно лишь добавить еще одну проверку
if ((target.TargetObject is DependencyObject && target.TargetProperty is DependencyProperty) || target.TargetObject is Setter)
Epsil0neR
04.01.2016 17:53Менять значения «на лету» при смене языка с использованием ресурсов Resx не так уж сложно, я это описал в своей статье на хабре ещё в апреле в Локализация WPF приложений на лету.
adeptuss
04.01.2016 18:03Да, я читал вашу статью. Мне требовалось использовать биндинг в качестве ключа ресурса, а вашим способом это невозможно сделать.
И описанный в этой статье способ позволяет использовать не только ресурсы, но и любые другие источники (например, база данных).Athari
05.01.2016 14:46Кстати, интересно, какие другие источники данных вы реально используете. Потому что «например, база данных» звучит ну очень странно, не могу придумать причину так делать.
adeptuss
05.01.2016 15:48Реально используется такой кейс: шаблоны писем на email хранятся в БД, т.к. письма отправляются через SQL Server, а редактируются через приложение. Сделано это для того, чтобы можно было оперативно менять содержание и тему писем. Использование ресурсов потребовало бы частый их деплой на сервер.
lam0x86
Я в своё время тоже задумывался о динамической локализации WPF-приложений, но пришёл к выводу, что игра не стоит свеч. По-моему, смена системной локали происходит крайне редко. Даже если пользователь и поменяет системный язык во время работы приложения, его всё-равно попросят перегрузиться. А возможность выбора языка прямо из приложения — это, по-моему, рудимент из нулевых годов. Не думаю, что пользователь, у которого стоит английская локаль, вдруг захочет использовать ваше приложение на русском. Возможно, я не прав. Если не сложно, расскажите про ваш кейс.
Код не особо смотрел, но заметил использование деструктора для отписки от событий. Использование деструкторов для действий, не связанных с освобождением неуправляемых ресурсов, — плохая практика. Возможно, стоит посмотреть в сторону Weak Event Patterns.
scumware
Зато вполне может быть наоборот: пользователь, например, с русской локалью вполне может захотеть англоязычный интерфейс. Я сам так делаю, т.к. иногда не понимаю некоторых формулировок.
adeptuss
Спрос рождает предложение, как говорится. Даже на хабрахабре находил посты про локализацию в wpf, где в комментариях люди как раз писали о том, что хотели бы менять локализацию на лету.
Мой кейс такой же, какой написал scumware. Некоторые трудно-переводимые предложения намного легче читаются на английском языке, нежели на русском. + бывает, что английский интерфейс выглядит лаконичнее. К тому же, я считаю, что заставлять пользователя перезапускать приложение — это как раз так и рудимент. Изменение локализации на лету более userfriendly.
Я посмотрю Weak Event Patterns, спасибо за совет =)
Athari
А есть ли спрос? Если вы пишете приложение для себя, то делаете как удобнее себе, конечно. Если же приложение коммерческое, то становится интересно, скольким пользователям нужна эта фича, и находится ли она в топе хотелок. У меня в этом большие сомнения.
adeptuss
Не думаю, что проводился опрос. Скорее это хотелка руководителя, чем пользователей.
А в комментарии я говорил про этот пост Локализация WPF приложений
DragonFire
Может я еще не проснулся, но отписыватся от события в деструкторе вообще не имеет смысла…
Подписка на LocalizationManager.Instance.CultureChanged означает, что в объекте LocalizationManager.Instance появится ссылка на ваш объект BindingLocalizationListener. Соответственно сборщик мусора соберет его только когда ОБА объекта (LocalizationManager.Instance и BindingLocalizationListener) станут недоступны. Т.е. кажется никогда до окончания работы всего приложения.
Так что не пишите так, это дает иллюзию защищенности… Возьмите какой-нибудь профилировщик памяти и посмотрите на ваше приложение через часок работы… На правах рекламы могу посоветовать dotmemory…
adeptuss
Я уже переписал эту часть под Weak Event Pattern по совету lam0x86, но статью редактировать не стал. Проект обновлен на GitHub.
DragonFire
Возможно это не единственная утечка памяти =)
Athari
Я переходил на английскую локаль постепенно. В школьные годы, когда я только начинал пользоваться компьютером, у меня всё было по-русски, а где-то по окончанию универа всё уже было по-английски, но переход не был моментальным: сначала английскими стали профессиональные инструменты, потом постепенно всё остальное и только в самом конце сама ось.
Вы предлагаете оставить только одну системную настройку и не давать переключать локаль на уровне приложения? Странная затея. Если честно, не помню серьёзных приложений, которые так делают. Даже модные метрошные приложения типа ВК дают переключать язык в приложении. Office, VS — везде настройка в приложениях. Причём в Офисе можно по отдельности настраивать язык справки и язык подсказок, например. В куче игр по отдельности настраиваются язык звука и язык субтитров. Я бы сказал, в современном мире языковые настройки наоборот стали более мощными.
AndreyDmitriev
Есть несколько сценариев, когда переключение языка на лету очень удобно. Мы вот разрабатываем приложения для промышленной автоматики, которые работают неделями без перезагрузки, и там зачастую рестарт приложения вызовет, скажем, останов конвейера. Это не смертельно — поломки не будет, но ошибку надо подтвердить, конвейер перезапустить, и т.п. Иногда пользователи обращаются в техподдержку, тогда оператор логигинится в систему и видит экран — а там всё на китайском. И вот тогда он переключает язык на лету, показывает что к чему, после чего язык переключается обратно, и всё это без рестарта приложения. Ну ещё при локализации бывает, что локализованные строки сильно длиннее английских (несмотря на то, что дазаном это предусмотрено), и тогда удобно переключаться туда-сюда и корректировать перевод без рестарта. Ну и когда я работаю на техподдержке, то отправляю пользователю скриншоты с пояснениями на его родном языке — и мне тоже неудобно переключать язык с перезагрузкой (особенно если пользователь висит на телефоне и ждёт).
В общем я за то, что если есть возможность делать переключение языка на лету — то это хорошо и правильно и повышает удобство. Это особенно приятно, если структура приложения модульная, тогда имеет смысл реализовать локализацию в ядре, так что новые модули будут переключаться на лету автоматически.
Обычно тут рассуждают о том, что конечный пользователь будет редко переключать язык, и это в общем-то так, но есть ведь ещё тестирование, техподдержка, сервис, пусконаладка, обучение персонала, и т.д., и вот тут переключение языка используется довольно часто.
Athari
Хм. Интересно. Эта необходимость в основном является следствием того, что одни техподдержка и сервис работают сразу на несколько стран, или такая же проблема, скажем, есть у Microsoft, где техподдержка наверняка разделена? Никогда не звонил им в саппорт, но в принципе любопытно: я могу попросить их объяснять мне последовательность нажатий кнопок на разных языках? Скажем, говорить мне удобнее по-русски, а винда у меня на английском…