Расширения привязки и xaml-разметки на примере локализации

    Одним из ключевых моментов в разработке xaml-ориентированных приложений является использование привязок (Bindings). Привязка — это медиатор (посредник), с помощью которого синхронизируются значения свойств между связанными объектами.

    Стоит отметить не очевидный, но важный нюанс: хотя привязка так или иначе ссылается на взаимодействующие объекты, она не удерживает их от сборки мусора!

    Наследование от класса Binding разрешено, но в целях безопасности кода переопределение метода ProvideValue, который связан с основной логикой работы, не допускается. Это так или иначе провоцирует разработчиков на применение паттерна Converter, который тесно переплетается с темой привязок.

    Привязки очень мощный инструмент, но в некоторых случаях их декларирование получается многословным и неудобным при регулярном использовании, например, для локализации. В этой статье мы разберём простой и элегантный способ, делающий код намного более чистым и красивым.


    Объявлять привязки в xaml допустимо двумя образами:

    <TextBlock>
    	<TextBlock.Text>
    		<Binding ...>
    	</TextBlock.Text>
    </TextBlock>
    

    <TextBlock Text="{Binding ...}"/>
    

    Очевидно, что первый способ выглядит не очень лаконично, второй же, основанный на применении расширений разметки, применяется наиболее часто. На платформе WPF существует возможность создавать пользовательские расширения разметки. Например, их удобно использовать для локализации.

    <TextBlock Text="{Localizing AppTitle}"/>
    

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

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

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

    Более того, на xaml-платформах Windows Phone, Windows Store и Xamarin.Forms нет возможности создавать пользовательские расширения разметки, что наталкивает на идею использования привязок в качестве расширений разметки

    Не будем ходить вокруг да около, вот то, что нам нужно:

        public abstract class BindingExtension : Binding, IValueConverter
        {
            protected BindingExtension()
            {
                Source = Converter = this;
            }
    
            protected BindingExtension(object source) // set Source to null for using DataContext
            {
                Source = source;
                Converter = this;
            }
    
            protected BindingExtension(RelativeSource relativeSource)
            {
                RelativeSource = relativeSource;
                Converter = this;
            }
    
            public abstract object Convert(object value, Type targetType, object parameter, CultureInfo culture);
    
            public virtual object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
            {
                throw new NotImplementedException();
            }
        }
    

    Примечательно, что привязка является конвертером для самой себя. В результате мы получаем поведение очень похожее, как при наследовании от класса MarkupExtension, но, кроме того, остаётся возможность использовать стандартные механизмы контроля сборки мусора!

    Теперь логика для локализации выглядит проще некуда:

        public partial class Localizing : Base.BindingExtension
        {
            public static readonly Manager ActiveManager = new Manager();
    
            public Localizing()
            {
                Source = ActiveManager;
                Path = new PropertyPath("Source");
            }
    
            public Localizing(string key)
            {
                Key = key;
                Source = ActiveManager;
                Path = new PropertyPath("Source");
            }
    
            public string Key { get; set; }
    
            public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
            {
                var key = Key;
                var resourceManager = value as ResourceManager;
                var localizedValue = resourceManager == null || string.IsNullOrEmpty(key)
                    ? ":" + key + ":"
                    : (resourceManager.GetString(key) ?? ":" + key + ":");
    
                return localizedValue;
            }
        }
    

        public partial class Localizing
        {
            public class Manager : INotifyPropertyChanged
            {
                private ResourceManager _source;
    
                public ResourceManager Source
                {
                    get { return _source; }
                    set
                    {
                        _source = value;
                        PropertyChanged(this, new PropertyChangedEventArgs("Source"));
                    }
                }
    
                public string Get(string key, string stringFormat = null)
                {
                    if (_source == null || string.IsNullOrWhiteSpace(key)) return key;
                    var localizedValue = _source.GetString(key) ?? ":" + key + ":";
                    return string.IsNullOrEmpty(stringFormat)
                        ? localizedValue
                        : string.Format(stringFormat, localizedValue);
                }
    
                public event PropertyChangedEventHandler PropertyChanged = (sender, args) => { };
            }
        }
    

    Легко добавить возможность для смены регистра букв:

        public partial class Localizing : Base.BindingExtension
        {
            public enum Cases
            {
                Default,
                Lower,
                Upper
            }
    
            public static readonly Manager ActiveManager = new Manager();
    
            public Localizing()
            {
                Source = ActiveManager;
                Path = new PropertyPath("Source");
            }
    
            public Localizing(string key)
            {
                Key = key;
                Source = ActiveManager;
                Path = new PropertyPath("Source");
            }
    
            public string Key { get; set; }
            public Cases Case { get; set; }
    
            public override string ToString()
            {
                return Convert(ActiveManager.Source, null, Key, Thread.CurrentThread.CurrentCulture) as string ??
                       string.Empty;
            }
    
            public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
            {
                var key = Key;
                var resourceManager = value as ResourceManager;
                var localizedValue = resourceManager == null || string.IsNullOrEmpty(key)
                    ? ":" + key + ":"
                    : (resourceManager.GetString(key) ?? ":" + key + ":");
    
                switch (Case)
                {
                    case Cases.Lower:
                        return localizedValue.ToLower();
                    case Cases.Upper:
                        return localizedValue.ToUpper();
                    default:
                        return localizedValue;
                }
            }
        }
    

    В xaml запись выглядит удобно и красиво, но есть некоторые ограничения парсеров разметки на различных платформах:

    <!--WPF-->
    <TextBlock Text="{Localizing AppTitle, Case=Upper}"/>
    <TextBlock Text="{Localizing Key=AppDescription}"/>
    
    <!--WPF, Windows Phone-->
    <TextBlock Text="{m:Localizing Key=AppTitle, Case=Upper}"/>
    <TextBlock Text="{m:Localizing Key=AppDescription}"/>
    
    <!--WPF, Windows Phone, Windows Store-->
    <TextBlock>
    	<TextBlock.Text>
    		<m:Localizing Key=AppDescription>
    	</TextBlock.Text>
    </TextBlock>
    

    Чтобы избавиться на WPF от обязательного префикса m: нужно поместить расширение разметки в отдельную сборку и в Properties/AssemblyInfo.cs указать следующие директивы:

    [assembly: XmlnsDefinition("http://schemas.microsoft.com/winfx/2006/xaml/presentation", "Aero.Markup")]
    [assembly: XmlnsPrefix("http://schemas.microsoft.com/winfx/2006/xaml/presentation", "m")]
    

    Для регулирования имени префикса на Windows Phone или Store:

    [assembly: XmlnsDefinition("clr-namespace:Aero.Markup;assembly=Aero.Phone", "Aero.Markup")]
    [assembly: XmlnsPrefix("clr-namespace:Aero.Markup;assembly=Aero.Phone", "m")]
    

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

    Продемонстрированный подход интенсивно используется в библиотеке Aero Framework, о которой было рассказано несколько ранее. К ней также прилагается пример проекта, где вы можете увидеть все эти механизмы в действии. Благодарю за внимание!
    Метки:
    Поделиться публикацией
    Похожие публикации
    Комментарии 35
    • 0
      Как по мне так не очень большая разница с более простым методом без всяких наследований и работает одинаково на WP и WS.
      <TextBlock Text="{Binding Path=AppResources.LoginTitle, Source={StaticResource LocalizedStrings}}"/>

      • 0
        А на мой взгляд разница огромна, особенно при наличии не тривиальной «Hello World» разметки.
        • 0
          Если у вас в приложении такая запись встречается несколько раз, то, вероятно, разницы вы не ощутите, но когда нужно локализовывать сотни элементов, то банальный выигрыш во времени набора текста прочувсвуете хорошо.

          Кроме того, применение расширений привязки не ограничивается одной лишь локализацией, а позволяет делать гораздо более интересные вещи… Чтобы много не говорить, рекомендую просто ознакомиться с примером.
        • 0
          На самом деле статья уже была причем не так давно.
          Главным недостатком этой реализации является, отсутствие поддержки со стороны IntelliSense и невозможность использовать рефакторинг.
          • 0
            Да, сравнительно недавно была обзорная и довольно насыщенная статья об Aero Framework, где этот вопрос поднимался. Но из-за насыщенности не всем хватило терпения её изучить, поэтому было решено разобрать некоторые аспекты детальнее и в более доступном виде.
            • +1
              Я говорил об этой статье habrahabr.ru/post/210778/
              • 0
                =) Эта совсем старая, в ней ещё только зарождались некоторые идеи. Окончательную форму они приобрели уже в Aero Framework…
          • 0
            Любопытно.
            К сожалению, одними текстами локализация не ограничивается. Для разных языков, например, потребуется разная ширина полей, другое форматирование (например, для иерографического письма сделать шрифт крупнее) и пр. Это можно было сделать в WinForms. Может, кто знает нормальный инструмент для WPF?
            • 0
              Для таких вещей прекрасно подходят стили (Styles). С их помощью запросто можно создавать различные темы приложения, в том числе и для определённых языковых локализаций.
              • 0
                С помощью них нельзя «точечно» изменить определённый контрол в приложении, не корректируя сам контрол.
                • 0
                  Чем плох такой способ для точечных изменений?

                  <Button Style={StaticResource CustomButtonStyle}/>
                  
                  • 0
                    Нужно корректировать сам XAML-файл, а не отдельные сущности для локализации. В WinForms был отдельный файл ресурсов к каждой форме, в которых можно было делать «override» практически любого значения параметра контролов в ассоциированной форме. И один файл — один язык.
                    • 0
                      <Button Style={StaticResource CustomButtonStyle}/>
                      

                      Эту запись достаточно сделать один раз. Для каждого же языка можно создать отдельный файл ресурсов и редактировать тему в нём, не затрагивая само представление. В простейшем случае потребуется перезагрузка представления для смены темы, но с помощью тех же Binding Extensions очень просто реализовать «горячую» смену во время работы программы.

                      <Button Style={Theme Key=CustomButtonStyle}/>
                      

                      WPF отличается от WinPhone и тяжелее для изучения, но он на порядок мощнее и гораздо более гибкий, чем кажется сразу.
                      • 0
                        Вы предлагаете для каждого контрола генерировать свой уникальный стиль, помноженный на количество языков? Не всем Button-ам при локализации нужно изменить размер. Некоторым понадобится изменить ширину на 10, некоторым на 20.
                        Это ещё очень много лишнего текста добавить в итак до предела перегруженный xaml потребуется.
                        Да и в конце концов, стили не для локализации нужны. Они сами по себе довольно сложный и комплексный инструмент, чтобы на них ещё локализацию подвешивать.
                        • 0
                          Не нужно делать отдельных стилей на каждый контрол да и ещё для каждого языка. Вы забываете о наследовании свойств в стилях и их переопределении. В вашем случае достаточно всего лишь трёх: первый самый общий — для большинства кнопок, второй — для кнопок с шириной 10, третий — с шириной 20. В зависимости от языка вы применяете нужную тему, например, через MergedDictionaries. Это всё делается достаточно просто.

                          Стили во-многом нужны для создания разных тем интерфейса. В вашем примере так и получается, что для некоторых языков темы разные (например, отличаются отступы или шрифты), поэтому их применение вполне логично и оправдано.
                          • 0
                            Хм. Видимо, я чего-то недопонимаю. Вот приходит переводчик и говорит, что текстовое поле Text1 на форме Form1 нужно слегка расширить на 10 у. е., кнопку Button1 на форме Form2 подвинуть немного вправо, а колонку Column1 на форме Form3 в таблице Table1 немного уменьшить в размерах (на сколько он сам пока не знает — ему нужно самому попробовать подвигать её). И не забудьте, что текст на контролах нужно менять тоже адресно — на каждом контроле свой. Как Вы это решите с помощью стилей?
                            Локализация — это совсем не то, что темки навешивать. Это, по сути, индивидуальный подход к каждому контролу.
                            • 0
                              Мне не доводилось заниматься локализацией на WinForms, поэтому благоразумнее об этом не спорить. Но с разработкой на этой платформе раньше сталкивался более-менее плотно, и, когда начал изучать WPF, вторая вызвала во мне ощутимое сопротивление обилием нетривиальных идей и подходов к разработке. Просто не понимал, зачем всё так сложно, ведь раньше хватало WinForms. Со временем же, когда проникся всеми этими мыслями и пришло их осознание, понял, что по мощности своей WPF очень далеко впереди и если что-то кажется трудноосуществимым, то это, скорее всего, от незнания или плохого понимания какого-либо механизма.

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

                              По-моему, если не ошибаюсь, существует и другой способ: задать каждому визуальному элементу уникальный ключ и по нему точечно менять его параметры. По крайней мере, с помощью attached property такое достаточно просто самому реализовать. Но к такому методу сам не прибегал, поскольку стилей всегда хватало.
                              • +1
                                > Вот приходит переводчик и говорит, что текстовое поле Text1 на форме Form1 нужно слегка расширить на 10 у. е.

                                А зачем так?
                                Почему поля двигает переводчик, а не дизайнер?
                                Зачем поле надо расширить для какого-то языка, почему не хватает Alignment, Margin, Padding, MaxWidth?
                                У меня ощущение, что вы что-то не так делаете.
                                • 0
                                  Перевод UI тесно связан с дизайном. Если Вам будет проще, то пусть приходит дизайнер после получения перевода от переводчика.
                                  Проблема в том, что языки очень разные. Одна фраза на русском может быть размером в один символ на китайском и в одно длиннющее слово (которое нельзя будет разнести на пару строк) на немецком. Не говоря уже о том, что при переводе на арабский нужно будет всё переделывать на концепцию «справа налево» — в том числе и всякие выплывающие менюшки. Никогда нельзя в процессе локализации ограничиваться только текстом.
                                  У меня ощущение, что Вы просто не адаптировали сложные формы на несколько весьма разных языков.
                                  • 0
                                    > Одна фраза на русском может быть размером в один символ на китайском

                                    Так и почему не хватает Alignment, Margin, Padding, MaxWidth?

                                    > Не говоря уже о том, что при переводе на арабский нужно будет всё переделывать на концепцию «справа налево»

                                    Ну да, ещё выставить FlowDirection
              • 0
                В качестве дополнения, хочу указать вот на этот репозиторий, в котором есть объемный, хотя и немного устаревший (но имеет ли смысл это замечание к WPF), документ о способах локализации в WPF.
                • +1
                  Так и не смог победить это:

                  [assembly: XmlnsDefinition(«clr-namespace:Aero.Markup;assembly=Aero.Phone», «Aero.Markup»)]
                  [assembly: XmlnsPrefix(«clr-namespace:Aero.Markup;assembly=Aero.Phone», «m»)]

                  Для универсальных проектов, а использовать расширение с явным указанием неймспейса, не дает компилятор :(
                  • 0
                    Спасибо, немного позже посмотрю. По крайней мере, для WP8.1 расширения привязки у меня работали с префиксом local, то есть когда находились в основной сборке. Для WP7 и WP8 всё точно работает, это хорошо проверял.
                    • 0
                      Ещё на Windows Store и Windows Phone 8.1, к сожалению, допустим только такой вариант декларации из-за ограничений парсера разметки

                      <TextBlock>
                          <TextBlock.Text>
                              <local:Localizing Key=AppDescription>
                          </TextBlock.Text>
                      </TextBlock>
                      
                      • 0
                        Да это работает, но теряет всякий смысл. Поправьте статью, что бы не вводила в заблуждение.
                        • 0
                          Про ограничения парсеров разметки на некоторых платформах упоминалось в статье. Соглашусь с тем, что в ряде случаев такой способ локализации теряет лаконичность и практическую ценность. Но тем не менее сам механизм расширений привязки сохраняет смысл, поскольку оставляет возможность реализации принципа прямых инжекций, который рассмотрен в следующей статье, и ряда других удобных усовершенствований.

                          Конечно, стоит признать многословность, но обычно интерфейс и разметка в мобильных Windows Store и Windows Phone 8.1 приложениях проще, чем на десктоп-платформах, поэтому на этот недостаток ещё можно закрыть глаза в виду тех преимуществ, которые можно реализовать.
                          • 0
                            Главный недостаток такого решения отсутствие поддержки интеллисенса со стороны VS, привязав модель к датаконтексту через расширения я лишился подсказок по содержанию модели, а для меня это критично, модели сложные.

                            Для локализации это было терпимо, но для свойств модели нет, так как при рефакторинге нет автоматического контроля ссылок :(

                            Хотя согласен лаконично :)
                            • 0
                              Попробуйте починить примерно так <Grid d:DataContext="{d:DesignInstance viewModels:AppViewModel}">, возможно, подсказки появятся (сам не пробовал, поскольку обхожусь без них).

                              А решарперовский Find Usages прекрасно работает и по умолчанию.
                              • 0
                                Решарперовский FU на попытке найти использование AppViewModel не находит его инджекцию.
                                Я тоже обходился до определенного момента, когда размер проекта превысил объем который я могу удержать в памяти.
                                • 0
                                  У меня, похоже, находит инжекции, или вы что-то другое имеете в виду?

                                  image

                                  d:DesignInstance тоже работает и подсказки появляются.

                                      xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
                                      d:DataContext="{d:DesignInstance viewModels:AppViewModel, IsDesignTimeCreatable=True}"
                                  
                                  • 0
                                    Как интересно, почему я в пролете.
                                    • 0
                                      Посмотрел подробнее, ключ тип, поэтому работает поиск типа, отлично!
                                      • 0
                                        В общем сделать расширение решарперу, позволяющее собирать информацию о контекстах и будет отлично.
                                    • 0
                                      Кстати как раз с R# связана была идея написать расширение которое бы предоставляло информацию интеллисенсу, при использовании {l:Localize Path=SMTH}.
                                      Но начав переход на WRT где этот подход локализации не работает, идея останется не реализованной :)
                              • +1
                                Я пробовал изменить это создав свою реализацию IXamlMetadataProvider, но увы компилятор не использует определения пространств имен из пользовательской реализации провейдера.

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