即时更改应用程序用户界面语言
4.71/5 (42投票s)
无需关闭和重新创建应用程序窗体即可更改其 UI 语言。
 
 
引言
.NET Framework 为编写多语言 Windows Forms 应用程序提供了相当好的支持。一旦将窗体的 Localizable 属性设置为 true,Visual Studio 设计器就会自动在窗体的 InitializeComponent 方法中生成代码,该代码从编译后的资源加载窗体及其控件的可本地化属性。使用 Visual Studio 设计器,您还可以为其他语言创建资源 - 只需设置窗体的 Language 属性并修改窗体和控件的特定于语言的属性。构建应用程序时,Visual Studio 会将这些更改保存到单独的资源文件中,并将其编译成该语言的卫星程序集。
在运行时创建窗体(并调用 InitializeComponent)时,.NET Framework 会根据 System.Threading.Thread.CurrentThread.CurrentUICulture 属性的当前值选择要加载的资源。如果在创建窗体之前更改此属性,则会加载相应文化的资源。这意味着,如果您想在应用程序运行时更改用户界面语言,您必须关闭所有打开的窗体并重新创建它们(或重新启动整个应用程序)以强制为新的 UI Culture 重新加载资源。这有点笨拙,意味着会丢失窗体中存储的任何瞬态应用程序状态。本文提供了一个 CultureManager 组件,该组件允许使用新的 UI Culture 动态重新加载打开的窗体和控件的可本地化资源。
背景
文章 立即更改窗体语言 和 UICultureChanger 组件 也提供了此问题的解决方案。本文采用的方法与这些先前文章的区别如下:
- 上述文章仅处理标准属性的子集,例如 Text、Location、Size等。相比之下,本文提出的解决方案可以加载组件/控件任何可本地化属性的资源(可本地化属性是用LocalizableAttribute标记的)。
- 本文描述的 CultureManager组件通过UICultureChanged事件提供了一个钩子,允许您在窗体资源重新加载后执行代码。这可能很有用,例如,当控件中显示的文本是程序生成的 - 但仍然依赖于文化时。
- CultureManager组件允许您更改应用程序中所有打开的窗体的 UI Culture。或者,您可以更改单个窗体的- UICulture,从而允许您拥有两个(或更多)具有不同 UI Cultures 的打开窗体。
- CultureManager组件可与使用 VB.NET 开发的窗体和控件一起使用。
- 本文的源代码根据 The Code Project Open License (CPOL) 提供。第二篇文章使用了 GNU Lesser General Public License - 这阻止将其源代码直接包含在商业应用程序中。有关更多信息,请参阅 CodeProject 关于 许可证 的以下文章。
工作原理
本节概述了 CultureManager 组件内部实现的工作原理。如果您只想“按原样”使用该组件,您可以跳至 使用该组件。
基本架构
CultureManager 实现为一个组件,您可以将其放置在应用程序中每个您想要动态更改文化的窗体上。CultureManager 组件将一个处理程序附加到 static CultureManager.ApplicationUICultureChanged 事件,以便在更改 CultureManager.ApplicationUICulture static 属性时收到通知。然后,该组件使用 .NET 反射来定位窗体的可本地化资源,并使用新的 UI Culture 重新加载这些资源。这种事件架构允许通过简单地设置 CultureManager.ApplicationUICulture static 属性来更改应用程序中所有打开的窗体的 UI Culture。或者,您可以设置单个窗体的 UICulture。
应用资源
CultureManager.ApplyResources 方法(如下所示)实现了用于重新加载组件可本地化资源的核心逻辑。
/// <summary>
/// Recursively apply localized resources to a component and its constituent components
/// </summary>
/// <PARAM name="componentType">The type we are applying resources for</PARAM>
/// <PARAM name="instance">The component instance to apply resources to</PARAM>
/// <PARAM name="culture">The culture resources to apply</PARAM>
protected virtual void ApplyResources(Type componentType, 
                                      Component instance, 
                                      CultureInfo culture)
{
    // check whether there are localizable resources for the type - if not we are done
    //
    System.IO.Stream resourceStream 
        = componentType.Assembly.GetManifestResourceStream(componentType.FullName 
             + ".resources");
    if (resourceStream == null) return;
    // recursively apply the resources localized in the base type
    //
    Type parentType = componentType.BaseType;
    if (parentType != null)
    {
        ApplyResources(parentType, instance, culture);
    }
    // load the resources for this component type into a sorted list
    //
    ComponentResourceManager resourceManager 
        = new ComponentResourceManager(componentType);
    SortedList<string object,> resources = new SortedList<string object,>();
    LoadResources(resourceManager, culture, resources);
    // build a lookup table of components indexed by resource name
    //
    Dictionary<string Component,> components = new Dictionary<string Component,>();
    // build a lookup table of extender providers indexed by type
    //
    Dictionary<Type IExtenderProvider,> extenderProviders 
        = new Dictionary<Type IExtenderProvider,>();
    bool isVB = IsVBAssembly(componentType.Assembly);
    components["$this"] = instance;
    FieldInfo[] fields = componentType.GetFields(BindingFlags.Instance | 
                                                 BindingFlags.NonPublic | 
                                                 BindingFlags.Public);
    foreach (FieldInfo field in fields)
    {
        string fieldName = field.Name;
        
        // in VB the field names are prepended with an "underscore" so we need to 
        // remove this
        //
        if (isVB)
        {
            fieldName = fieldName.Substring(1, fieldName.Length - 1);
        }
        // check whether this field is a localized component of the parent
        //
        string resourceName = ">>" + fieldName + ".Name";
        if (resources.ContainsKey(resourceName))
        {
            Component childComponent = field.GetValue(instance) as Component;
            if (childComponent != null)
            {
                components[fieldName] = childComponent;
                // apply resources localized in the child component type
                //
                ApplyResources(childComponent.GetType(), childComponent, culture);
                // if this component is an extender provider then keep track of it
                //
                if (childComponent is IExtenderProvider)
                {
                    extenderProviders[childComponent.GetType()] 
                        = childComponent as IExtenderProvider;
                }
            }
        }
    }
    // now process the resources 
    //
    foreach (KeyValuePair<string object,> pair in resources)
    {
        string resourceName = pair.Key;
        object resourceValue = pair.Value;
        string[] resourceNameParts = resourceName.Split('.');
        string componentName = resourceNameParts[0];
        string propertyName = resourceNameParts[1];
        if (componentName.StartsWith(">>")) continue;
        if (IsExcluded(componentName, propertyName)) continue;
        Component component = null;
        if (!components.TryGetValue(componentName, out component)) continue;
        // some special case handling for control sizes/locations
        //
        Control control = component as Control;
        if (control != null)
        {
            switch (propertyName)
            {
               case "AutoScaleDimensions":
                   SetAutoScaleDimensions(control as ContainerControl, (SizeF)resourceValue);
                   continue;
               case "Size":
                   SetControlSize(control, (Size)resourceValue);
                   continue;
               case "Location":
                   SetControlLocation(control, (Point)resourceValue);
                   continue;
               case "Padding":
               case "Margin":
                   resourceValue = AutoScalePadding((Padding)resourceValue);
                   break;
               case "ClientSize":
                   if (control is Form && PreserveFormSize) continue;
                   resourceValue = AutoScaleSize((Size)resourceValue);
                   break;
             }
        }
        // use the property descriptor to set the resource value
        //
        PropertyDescriptor pd 
            = TypeDescriptor.GetProperties(component).Find(propertyName, false);
        if (((pd != null) && !pd.IsReadOnly) && 
           ((resourceValue == null) || pd.PropertyType.IsInstanceOfType(resourceValue)))
        {
            pd.SetValue(component, resourceValue);
        }
        else 
        {
            // there was no property corresponding to the given resource name.  
            // If this is a control the property may be an extender property so 
            // try applying it as an extender resource
            //
            if (control != null)
            {
                ApplyExtenderResource(extenderProviders, 
                                      control, propertyName, resourceValue);
            }
        }
    }
}
ApplyResources 方法首先检查组件类型是否关联有可本地化资源。如果没有,则无需进一步操作。如果有,则该方法会递归调用自身,首先应用与其基类型关联的任何本地化资源。基类型资源必须在派生类定义的任何本地化资源之前应用。
接下来,我们调用 LoadResources 将该组件类型的本地化资源加载到 SortedList 中。这为我们提供了一个组件使用的所有本地化资源列表,并使我们能够按名称快速查找资源。我们使用反射来查找组件中可能具有本地化资源成员变量,并构建一个查找表,使我们能够根据名称快速查找子组件对象。请注意,VB.NET 设计器用于组件成员变量的命名约定需要对 VB.NET 组件进行一些特殊处理。
最后,我们遍历本地化资源列表,并使用反射来设置组件的属性,其中包含一些如下讨论的特殊情况处理。
一些特殊情况
为已锚定或停靠的组件设置 Location 或 Size 属性可能会导致意外行为。SetControlSize 和 SetControlLocation 方法包含检查 Anchor 和 Dock 属性的逻辑,并且仅更新那些不受锚定或停靠控制的位置或大小的组件。代码解决的另一个问题是屏幕分辨率与设计时不同(因为用户正在使用 DPI 缩放)。正常情况下,Windows Forms 会在控件和窗体首次创建后自动缩放它们的大小和位置以考虑这一点。由于资源中的大小和位置是在设计时分辨率下定义的,因此我们需要将它们缩放到匹配当前屏幕分辨率。SetAutoScaleDimensions 方法根据设计时分辨率尺寸和 CurrentAutoScaleDimensions 设置 AutoScaleFactor 。后续的大小、位置和填充资源将按此值缩放。
扩展属性
一些本地化资源与组成组件上的简单属性不对应。扩展组件(例如 ToolTips)为窗体上的其他控件提供扩展设计时属性。扩展组件处理这些属性的代码(和资源)序列化。不幸的是,没有反射机制可以发现扩展组件上我们需要调用的方法 - 或者用于扩展属性的资源命名约定。如果不存在与本地化资源相对应的标准属性,则会调用 ApplyExtenderResource 方法(见下文)来使用一些特殊情况逻辑处理该资源。
/// <summary>
/// Apply a resource for an extender provider to the given control
/// </summary>
/// <param name="extenderProviders">Extender providers for the parent 
/// control indexed by type</param>
/// <param name="control">The control that the extended resource is 
/// associated with</param>
/// <param name="propertyName">The extender provider property name</param>
/// <param name="value">The value to apply</param>
/// <remarks>
/// This can be overridden to add support for other ExtenderProviders.  
/// The default implementation handles <see cref="ToolTip">ToolTips</see>, 
/// <see cref="HelpProvider">HelpProviders</see>, and 
/// <see cref="ErrorProvider">ErrorProviders</see> 
/// </remarks>
protected virtual void ApplyExtenderResource
    (Dictionary<Type, IExtenderProvider> extenderProviders, 
     Control control, string propertyName, object value)
{
    IExtenderProvider extender = null;
    if (propertyName == "ToolTip")
    {
        if (extenderProviders.TryGetValue(typeof(ToolTip), out extender))
        {
            (extender as ToolTip).SetToolTip(control, value as string);
        }
    }
    ...
}
我们将 ApplyExtenderResource 方法传递一个组件查找表(按类型索引),这些组件实现了 IExtenderProvider 接口。这允许该方法定位与给定属性关联的扩展组件,并调用适当的方法来设置本地化属性。ApplyExtenderResource 方法的基实现实现了 ToolTip、ErrorProvider 和 HelpProvider 扩展组件的逻辑。可以重写它以添加对其他自定义扩展组件的支持。
使用组件
下载源代码并生成后,您可以将 CultureManager 组件添加到 Visual Studio 工具箱中。如果您使用的是演示解决方案,则该组件应该已经在“Infralution.Localization Components”选项卡下显示。要在另一个项目中 Ose 该组件,请右键单击工具箱并选择“选择项目..”,然后选择“浏览”按钮并找到您之前构建的 Infralution.Localization.dll 程序集。
将该组件的一个实例放置在应用程序中您想要动态更改文化的每个窗体上。请注意,这通常不需要是应用程序中的所有窗体,而只需要那些非模态打开的窗体。如果一个窗体始终以模态对话框(使用 ShowDialog)的形式打开,那么用户在窗体打开时就无法更改应用程序的 UI Culture。
添加菜单(或其他机制)来设置应用程序的 CultureManager.ApplicationUICulture。这会设置 System.Threading.Thread.CurrentThread.CurrentUICulture 并为具有 CultureManager 组件的每个打开的窗体重新加载可本地化资源。
该组件还提供了一些属性和事件,可用于对 UI Culture 更改过程进行额外控制。
- ExcludeProperties- 这允许您指定一个属性列表,当 UI Culture 更改时,您不希望从资源中重新加载这些属性。例如,如果您将“- Enabled”添加到此列表中,那么任何控件的- Enabled属性都不会从资源中重新加载。请注意,通常不需要使用此机制,因为 Visual Studio 仅在属性具有非默认值时才序列化资源。因此,除非您在 Visual Studio 设计器中将控件的- Enabled属性设置为- false,否则它不会存储在窗体资源中。
- PreserveFormSize- 如果设置为- true,则窗体的大小不会从资源中重新加载。
- PreserveFormLocation- 如果设置为- true,则窗体的位置不会从资源中重新加载。
- UICultureChanged- 当窗体的 UI Culture 更改时(在资源重新加载后)会触发此事件。这允许您执行代码来更新程序生成的文本。
历史
- 2013.02.20 – 添加了处理大小和位置自动缩放的代码
- 2009.10.12 – 添加了 CultureManager.SetThreadUICulture方法以支持多线程
- 2008.09.12 – 修复了本地化 DataGrid视图的问题
- 2008.02.18 – 首次发布
