Хочу рассказать, как максимально удобно работать с XAML-ресурсами из Code-Behind. В этой статье мы разберемся, как работают пространства имен XAML, узнаем о XmlnsDefinitionAttribute, используем Т4-шаблоны и сгенерируем статичный класс для доступа к XAML-ресурсам.
Введение
При работе с XAML широко используется ResourceDictionary для организации ресурсов: стилей, кистей, конвертеров. Рассмотрим ресурс, объявленный в App.xaml:
<Application.Resources>
<SolidColorBrush x:Key="HeaderBrush" Color="Black" />
<Application.Resources>
При верстке View этот ресурс будет использоваться таким образом:
<TextBlock x:Name="header" Foreground="{StaticResource HeaderBrush}" />
Когда необходимо использовать тот же самый ресурс из Code-Behind, обычно применяется конструкция:
header.Foreground = (SolidColorBrush)Application.Current.Resources["HeaderBrush"];
В ней есть ряд недостатков: строковой идентификатор (ключ) ресурса увеличивает вероятность ошибки, а при большом количестве ресурсов, скорее всего, придется лезть в xaml и вспоминать этот самый ключ. Еще одна неприятная мелочь — приведение к SolidColorBrush т.к. все ресурсы хранятся в виде object.
Эти недостатки могут быть устранены с помощью кодогенерации, в конечном счете получится такая конструкция:
header.Foreground = AppResources.HeaderBrush;
Сразу оговорюсь, что поскольку цель статьи — показать сам подход, для упрощения я заостряю внимание на одном файле App.xaml, но при желании несложные модификации позволят обработать все XAML-ресурсы в проекте и даже разложить их в отдельные файлы.
Создаем T4-шаблон:
Если вы не очень знакомы с T4, можете почитать эту статью.
Используем стандартный для T4-заголовок:
<#@ template debug="false" hostSpecific="true" language="C#" #>
<#@ output extension=".cs" #>
Установка hostSpecific=true необходима для того, чтобы иметь доступ к свойству Host класса TextTransformation, от которого наследуется класс шаблона T4. С помощью Host будет осуществляться доступ к файловой структуре проекта и к некоторым другим необходимым данным.
Все ресурсы будут собраны в один статичный класс со статичными readonly Property. Основной скелет шаблона выглядит так:
using System.Windows;
namespace <#=ProjectDefaultNamespace#>
{
public static class AppResources
{
<#
foreach (var resource in ResourcesFromFile("/App.xaml"))
{
OutputPropery(resource);
}
#>
}
}
Все вспомогательные функции и свойства, задействованные в скрипте, объявляются в секции <#+ #> после основного тела скрипта.
Первое свойство VsProject выбирает проект из Solution, в котором лежит сам скрипт:
private VSProject _vsProject;
public VSProject VSProject
{
get
{
if (_vsProject == null)
{
var serviceProvider = (IServiceProvider) Host;
var dte = (DTE)serviceProvider.GetService(typeof (DTE));
_vsProject = (VSProject)dte.Solution.FindProjectItem(Host.TemplateFile).ContainingProject.Object;
}
return _vsProject;
}
}
ProjectDefaultNamespace — пространство имен проекта:
private string _projectDefaultNamespace;
public string ProjectDefaultNamespace
{
get
{
if (_projectDefaultNamespace == null)
_projectDefaultNamespace = VSProject.Project.Properties.Item("DefaultNamespace").Value.ToString();
return _projectDefaultNamespace;
}
}
Всю основную работу по сбору ресурсов из XAML выполняет ResourcesFromFile(string filename). Чтобы понять принцип его работы, разберем подробней, как в XAML устроены пространства имен, префиксы, а также как они используются.
Пространства имен и префиксы в XAML
Чтобы однозначно указать на определенный тип в C#, необходимо полностью указать имя типа вместе с пространством имен, в котором он объявлен:
var control = new CustomNamespace.CustomControl();
При использовании using приведенную выше конструкцию можно записать короче:
using CustomNamespace;
var control = new CustomControl();
Похожим образом работают и пространства имен в XAML. XAML — это подмножество XML и использует правила объявления пространств имен из XML.
Тип CustomControl в XAML будет объявлен так:
<local:CustomControl />
В этом случае XAML-анализатор при разборе документа смотрит на префикс local, который описывает, где искать данный тип.
xmlns:local="clr-namespace:CustomNamespace"
Зарезервированное имя атрибута — xmlns — указывает на то, что это объявление пространства имен XML. Имя префикса (в данном случае “local”) может быть любым в рамках правил XML-разметки. А также оно вообще может отсутствовать, тогда объявление пространства имен принимает вид:
xmlns="clr-namespace:CustomNamespace"
Такая запись устанавливает пространство имен по умолчанию для элементов, объявленных без префиксов. Если, например, пространство имен CustomNamespace будет объявлено по умолчанию, то CustomControl можно будет использовать без префикса:
<CustomControl />
В приведенном выше примере, значение атрибута xmlns содержит метку clr-namespace, сразу за которой следует указание на пространство имен .net. Благодаря этому XAML-анализатор понимает, что ему нужно искать CustomControl в пространстве имен CustomNamespace.
Типы, входящие в состав SDK, например, SolidColorBrush объявляются без префикса.
<SolidColorBrush Color="Red" />
Это возможно благодаря тому, что в корневом элементе XAML-документа объявлено пространство имен по умолчанию:
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
Это второй способ объявления пространства имен в XAML. Значение атрибута xmlns — некоторая уникальная строка-alias, она не содержит clr-namespace. Когда XAML-анализатор встречает такую запись, он проверяет .net сборки проекта на атрибут XmlnsDefinitionAttribute.
Атрибут XmlnsDefinitionAttribute переменяется к сборке множество раз описывая пространства имен соответствующие alias-строке:
[assembly: XmlnsDefinition("http://schemas.microsoft.com/winfx/2006/xaml/presentation", "System.Windows")]
[assembly: XmlnsDefinition("http://schemas.microsoft.com/winfx/2006/xaml/presentation", "System.Windows.Media")]
[assembly: XmlnsDefinition("http://schemas.microsoft.com/winfx/2006/xaml/presentation", "System.Windows.Shapes")]
Сборка System.WIndows помечена множеством таких атрибутов, таким образом alias schemas.microsoft.com/winfx/2006/xaml/presentation включает в себя множество пространств имен из стандартной SDK таких как: System.Windows, System.Windows.Media и т.д. Это позволяет сопоставить пространство имен XML множеству пространств имен из .net.
Стоит заметить, что, если в двух пространствах имен, объединенных под одним alias, есть типы с одинаковым именем, то возникнет коллизия, и XAML-анализатор не сможет разобрать, откуда ему взять искомый тип.
Итак, теперь мы знаем, что пространства имен XAML сопоставляются с пространствами имен в .net двумя разными способами: один к одному при использовании clr-namespace и один ко многим при использовании alias.
Конструкция xmlns, как правило, встречается в корневом элементе XAML-документа, но на самом деле достаточно, чтобы xmlns был объявлен хотя бы на том же уровне, на котором используется. В случае с CustomControl возможна такая запись:
<local:CustomControl xmlns:local="clr-namespace:CustomNamespace" />
Все вышеизложенное понадобится для создания скрипта, который может правильно понять XAML-разметку ReosurceDictionary в котором могут лежать разнородные объекты, входящие в SDK, а также компоненты сторонних библиотек, использующих разные способы объявления пространств имен.
Приступим к основной части
Задача по определению полного имени типа по XAML-тегу возложена на интерфейс ITypeResolver:
public interface ITypeResolver
{
string ResolveTypeFullName(string localTagName);
}
Поскольку есть два вида объявления пространства имен, получилось две реализации данного интерфейса:
public class ExplicitNamespaceResolver : ITypeResolver
{
private string _singleNamespace;
public ExplicitNamespaceResolver(string singleNamespace)
{
_singleNamespace = singleNamespace;
}
public string ResolveTypeFullName(string localTagName)
{
return _singleNamespace + "." + localTagName;
}
}
Данная реализация обрабатывает случай, когда .net пространство имен указано явно с использованием clr-namespace.
Другой за случай отвечает XmlnsAliasResolver:
public class XmlnsAliasResolver : ITypeResolver
{
private readonly List<Tuple<string, Assembly>> _registeredNamespaces = new List<Tuple<string, Assembly>>();
public XmlnsAliasResolver(VSProject project, string alias)
{
foreach (var reference in project.References.OfType<Reference>()
.Where(r => r.Path.EndsWith(".dll", StringComparison.InvariantCultureIgnoreCase)))
{
try
{
var assembly = Assembly.ReflectionOnlyLoadFrom(reference.Path);
_registeredNamespaces.AddRange(assembly.GetCustomAttributesData()
.Where(attr => attr.AttributeType.Name == "XmlnsDefinitionAttribute" &&
attr.ConstructorArguments[0].Value.Equals(alias))
.Select(attr => Tuple.Create(attr.ConstructorArguments[1].Value.ToString(), assembly)));
}
catch {}
}
}
public string ResolveTypeFullName(string localTagName)
{
return _registeredNamespaces.Select(i => i.Item2.GetType(i.Item1 + "." + localTagName)).First(i => i != null).FullName;
}
}
XmlnsAliasResolver регистрирует внутри себя пространства имен, помеченные атрибутом XmlnsDefinitionAttribute с определенным alias, и сборки, в которых они объявлены. Поиск осуществляется в каждом зарегистрированном пространстве имен, пока не будет найден результат.
В реализацию ResolveTypeFullName по желанию можно добавить кэширование найденных типов.
Вспомогательный метод TypeResolvers разбирает XAML-документ, находит все пространства имен и сопоставляет их XML-префиксу, на выходе получается “словарь” Dictionary<string, ITypeResolver>:
public Dictionary<string, ITypeResolver> TypeResolvers(XmlDocument xmlDocument)
{
var resolvers = new Dictionary<string, ITypeResolver>();
var namespaces = xmlDocument.SelectNodes("//namespace::*").OfType<XmlNode>().Distinct().ToArray();
foreach (var nmsp in namespaces)
{
var match = Regex.Match(string.Format("{0}="{1}"", nmsp.Name, nmsp.Value),
@"xmlns:(?<prefix>w*)=""((clr-namespace:(?<namespace>[w.]*))|([^""]))*""");
var namespaceGroup = match.Groups["namespace"];
var prefix = match.Groups["prefix"].Value;
if (string.IsNullOrEmpty(prefix))
prefix = "";
if (resolvers.ContainsKey(prefix))
continue;
if (namespaceGroup != null && namespaceGroup.Success)
{
//Явное указание namespace
resolvers.Add(prefix, new ExplicitNamespaceResolver(namespaceGroup.Value));
}
else
{
//Alias который указан в XmlnsDefinitionAttribute
resolvers.Add(prefix, new XmlnsAliasResolver(VSProject, nmsp.Value));
}
}
return resolvers;
}
С помощью xpath — "//namespace::*" выбираются все пространства имен, объявленные на любых уровнях документа. Далее каждое пространство имен разбирается регулярным выражением на префикс и на пространство имен .net, указаное после clr-namespace, если оно есть. В соответствие с результатами создается либо ExplicitNamespaceResolver, либо XmlnsAliasResolver и сопоставляется с префиксом или префиксом по умолчанию.
Метод ResourcesFromFile собирает все воедино:
public Resource[] ResourcesFromFile(string filename)
{
var xmlDocument = new XmlDocument();
xmlDocument.Load(Path.GetDirectoryName(VSProject.Project.FileName) + filename);
var typeResolvers = TypeResolvers(xmlDocument);
var nsmgr = new XmlNamespaceManager(xmlDocument.NameTable);
nsmgr.AddNamespace("x", "http://schemas.microsoft.com/winfx/2006/xaml");
var resourceNodes = xmlDocument.SelectNodes("//*[@x:Key]", nsmgr).OfType<XmlNode>().ToArray();
var result = new List<Resource>();
foreach (var resourceNode in resourceNodes)
{
var prefix = GetPrefix(resourceNode.Name);
var localName = GetLocalName(resourceNode.Name);
var resourceName = resourceNode.SelectSingleNode("./@x:Key", nsmgr).Value;
result.Add(new Resource(resourceName, typeResolvers[prefix].ResolveTypeFullName(localName)));
}
return result.ToArray();
}
После загрузки XAML-докуменета и инициализации typeResolvers для правильной работы xpath в XmlNamespaceManager добавляется пространство имен schemas.microsoft.com/winfx/2006/xaml, на которое указывают все атрибуты-ключи в ResourceDictionary.
При использовании xpath — "//*[@x:Key]" со всех уровней XAML документа выбираются объекты имеющие атрибут-ключ. Далее скрипт пробегает по всем найденным объектам и с помощью “словаря” typeResolvers ставит в соответствие каждому полное имя .net типа.
На выходе получается массив структур Resource, содержащих в себе все необходимые данные для кодогенерации:
public struct Resource
{
public string Key { get; private set; }
public string Type { get; private set; }
public Resource(string key, string type) : this()
{
Key = key;
Type = type;
}
}
Ну и напоследок метод, который выводит полученный Resource в виде текста:
public void OutputPropery(Resource resource)
{
#>
private static bool _<#=resource.Key #>IsLoaded;
private static <#=resource.Type #> _<#=resource.Key #>;
public static <#=resource.Type #> <#=resource.Key #>
{
get
{
if (!_<#=resource.Key #>IsLoaded)
{
_<#=resource.Key #> = (<#=resource.Type #>)Application.Current.Resources["<#=resource.Key #>"];
_<#=resource.Key #>IsLoaded = true;
}
return _<#=resource.Key #>;
}
}
<#+
}
Стоит заметить, что свойство Key возвращает значение атрибута-ключа из XAML как есть, и случаи использования ключей с символами, не валидными для объявления свойств в C#, приведут к ошибке. Дабы не усложнять и без того большие куски кода, я намеренно оставляю реализацию получения безопасных для Property имен на ваше усмотрение.
Заключение
Данный скрипт работает в WPF-, Silverlight-, WindowsPhone-проектах. Что касается семейства WindowsRT, UniversalApps, в следующих статьях мы окунемся в XamlTypeInfo.g.cs, поговорим о IXamlMetadataProvider, который пришел на смену XmlnsDefinitionAttribute и заставим скрипт работать с UniversalApps.
Под спойлером вы можете найти полный код скрипта, копируйте в свой проект, используйте с удовольствием.
<#@ template debug="false" hostSpecific="true" language="C#" #>
<#@ assembly name="System.Windows" #>
<#@ assembly name="System.Core" #>
<#@ assembly name="System.Linq" #>
<#@ assembly name="System.Core" #>
<#@ assembly name="System.Xml" #>
<#@ assembly name="System.Xml.Linq" #>
<#@ assembly name="EnvDTE" #>
<#@ assembly name="VSLangProj" #>
<#@ import namespace="EnvDTE" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Reflection" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ import namespace="System.Xml" #>
<#@ import namespace="System.Text.RegularExpressions" #>
<#@ import namespace="VSLangProj" #>
<#@ output extension=".cs" #>
using System.Windows;
namespace <#=ProjectDefaultNamespace#>
{
public static class AppResourcess
{
<#
foreach (var resource in ResourcesFromFile("/App.xaml"))
{
OutputPropery(resource);
}
#>
}
}
<#+
public void OutputPropery(Resource resource)
{
#>
private static bool _<#=resource.Key #>IsLoaded;
private static <#=resource.Type #> _<#=resource.Key #>;
public static <#=resource.Type #> <#=resource.Key #>
{
get
{
if (!_<#=resource.Key #>IsLoaded)
{
_<#=resource.Key #> = (<#=resource.Type #>)Application.Current.Resources["<#=resource.Key #>"];
_<#=resource.Key #>IsLoaded = true;
}
return _<#=resource.Key #>;
}
}
<#+
}
private VSProject _vsProject;
public VSProject VSProject
{
get
{
if (_vsProject == null)
{
var serviceProvider = (IServiceProvider) Host;
var dte = (DTE)serviceProvider.GetService(typeof (DTE));
_vsProject = (VSProject)dte.Solution.FindProjectItem(Host.TemplateFile).ContainingProject.Object;
}
return _vsProject;
}
}
private string _projectDefaultNamespace;
public string ProjectDefaultNamespace
{
get
{
if (_projectDefaultNamespace == null)
_projectDefaultNamespace = VSProject.Project.Properties.Item("DefaultNamespace").Value.ToString();
return _projectDefaultNamespace;
}
}
public struct Resource
{
public string Key { get; private set; }
public string Type { get; private set; }
public Resource(string key, string type) : this()
{
Key = key;
Type = type;
}
}
public Resource[] ResourcesFromFile(string filename)
{
var xmlDocument = new XmlDocument();
xmlDocument.Load(Path.GetDirectoryName(VSProject.Project.FileName) + filename);
var typeResolvers = TypeResolvers(xmlDocument);
var nsmgr = new XmlNamespaceManager(xmlDocument.NameTable);
nsmgr.AddNamespace("x", "http://schemas.microsoft.com/winfx/2006/xaml");
var resourceNodes = xmlDocument.SelectNodes("//*[@x:Key]", nsmgr).OfType<XmlNode>().ToArray();
var result = new List<Resource>();
foreach (var resourceNode in resourceNodes)
{
var prefix = GetPrefix(resourceNode.Name);
var localName = GetLocalName(resourceNode.Name);
var resourceName = resourceNode.SelectSingleNode("./@x:Key", nsmgr).Value;
result.Add(new Resource(resourceName, typeResolvers[prefix].ResolveTypeFullName(localName)));
}
return result.ToArray();
}
public Dictionary<string, ITypeResolver> TypeResolvers(XmlDocument xmlDocument)
{
var resolvers = new Dictionary<string, ITypeResolver>();
var namespaces = xmlDocument.SelectNodes("//namespace::*").OfType<XmlNode>().Distinct().ToArray();
foreach (var nmsp in namespaces)
{
var match = Regex.Match(string.Format("{0}="{1}"", nmsp.Name, nmsp.Value),
@"xmlns:(?<prefix>w*)=""((clr-namespace:(?<namespace>[w.]*))|([^""]))*""");
var namespaceGroup = match.Groups["namespace"];
var prefix = match.Groups["prefix"].Value;
if (string.IsNullOrEmpty(prefix))
prefix = "";
if (resolvers.ContainsKey(prefix))
continue;
if (namespaceGroup != null && namespaceGroup.Success)
{
//Явное указание namespace
resolvers.Add(prefix, new ExplicitNamespaceResolver(namespaceGroup.Value));
}
else
{
//Alias который указан в XmlnsDefinitionAttribute
resolvers.Add(prefix, new XmlnsAliasResolver(VSProject, nmsp.Value));
}
}
return resolvers;
}
public interface ITypeResolver
{
string ResolveTypeFullName(string localTagName);
}
public class ExplicitNamespaceResolver : ITypeResolver
{
private string _singleNamespace;
public ExplicitNamespaceResolver(string singleNamespace)
{
_singleNamespace = singleNamespace;
}
public string ResolveTypeFullName(string localTagName)
{
return _singleNamespace + "." + localTagName;
}
}
public class XmlnsAliasResolver : ITypeResolver
{
private readonly List<Tuple<string, Assembly>> _registeredNamespaces = new List<Tuple<string, Assembly>>();
public XmlnsAliasResolver(VSProject project, string alias)
{
foreach (var reference in project.References.OfType<Reference>()
.Where(r => r.Path.EndsWith(".dll", StringComparison.InvariantCultureIgnoreCase)))
{
try
{
var assembly = Assembly.ReflectionOnlyLoadFrom(reference.Path);
_registeredNamespaces.AddRange(assembly.GetCustomAttributesData()
.Where(attr => attr.AttributeType.Name == "XmlnsDefinitionAttribute" &&
attr.ConstructorArguments[0].Value.Equals(alias))
.Select(attr => Tuple.Create(attr.ConstructorArguments[1].Value.ToString(), assembly)));
}
catch {}
}
}
public string ResolveTypeFullName(string localTagName)
{
return _registeredNamespaces.Select(i => i.Item2.GetType(i.Item1 + "." + localTagName)).First(i => i != null).FullName;
}
}
string GetPrefix(string xamlTag)
{
if (string.IsNullOrEmpty(xamlTag))
throw new ArgumentException("xamlTag is null or empty", "xamlTag");
var strings = xamlTag.Split(new[] {":"}, StringSplitOptions.RemoveEmptyEntries);
if(strings.Length <2)
return "";
return strings[0];
}
string GetLocalName(string xamlTag)
{
if (string.IsNullOrEmpty(xamlTag))
throw new ArgumentException("xamlTag is null or empty", "xamlTag");
var strings = xamlTag.Split(new[] {":"}, StringSplitOptions.RemoveEmptyEntries);
if(strings.Length <2)
return xamlTag;
return strings[1];
}
#>
Автор: EBCEu4