简介:面向 Winform 桌面开发初学者与需要实现界面多语言切换的 C# 开发者,该项目围绕中英文切换场景,演示以 XML 文件集中存放语言文本、程序运行时动态读取并刷新控件显示的基本做法,开发环境标注为 Visual Studio 2012。压缩包共 35 个文件,约 64KB,包含 10 个 cs 源码、3 个 xml 语言或配置文件,以及 config、resx、resources、exe 运行文件与 sln、csproj 等工程组织内容,便于直接打开对照源码结构。已有 1474 人学习,适合用来理解国际化与本地化在 Winform 中的落地方式。读者可从中获取可运行的示例工程、XML 多语言资源组织思路、窗体事件触发语言切换的代码参考,以及资源文件与工程配置的对应关系,便于迁移到自己的桌面应用。
1. 中英文切换:Winform 老项目里最容易被低估的一块改造
一个做设备管理客户端的开发者找过来,说他那套跑了好几年的 Winform 程序要交付给海外客户,界面上两百多个按钮、标签、表头全写死在 Designer 里,每改一版文案就得重新编译发一次包。他最初想逐个控件改成.resx资源,试了两小时发现设计器一保存就把手写的赋值覆盖回去,翻车翻得很彻底。后来落地的方案是这套「XML 语言包 + 控件树递归回填」:所有显示的文本外置成 XML,切换语言时遍历窗体控件树按键名把Text重新赋一遍,加一个语种只是多一个 XML 文件。这套中英文切换思路特别适合 VS2012 时代留下的 .NET Framework Winform 项目——不想重构界面,只想在不搬砖的前提下把多语言补上。
2. 工程结构与技术选型:VS2012 解决方案里到底放了什么
2.1 目录结构与每个文件的职责
拿到这份 Winform 中英文切换工程,先在资源管理器里把目录捋一遍,比直接双击.sln有用得多。它是一个标准的单工程解决方案,语言包和代码是分开的两个区域,改造的时候你只会长时间待在这两块地方。
WinFormLangDemo/ ├── WinFormLangDemo.sln # VS2012 解决方案文件 └── WinFormLangDemo/ ├── Program.cs # 入口:先定语言,再起主窗体 ├── Forms/ │ ├── MainForm.cs # 业务逻辑 + 语言切换事件 │ ├── MainForm.Designer.cs # 设计器生成,禁止手改 │ └── MainForm.resx # 只放图片等资源,不放文案 ├── Lang/ │ ├── LanguageManager.cs # 加载、查询、递归回填 │ ├── zh-CN.xml # 中文语言包 │ └── en-US.xml # 英文语言包 ├── Config/ │ └── IniHelper.cs # 记住用户上次选的语言 └── WinFormLangDemo.csprojLang目录下两个 XML 是最关键的资产,翻译人员拿到的就是这两个文件,不碰 C#。LanguageManager.cs是唯一的运行时入口,窗体里任何地方要拿文案都走它。
注意Lang/*.xml在工程里的「生成操作」必须设成「内容」,并且「复制到输出目录」选「如果较新则复制”。默认新建 XML 文件是「无」,编译完bin\Debug\Lang下空空如也,程序一启动就是FileNotFoundException,这是新手遇到的第一个坎。
2.2 为什么把文案外置成 XML,而不是继续用 .resx
VS2012 自带的 Winform 本地化是通过Form.Localizable = true加Language属性生成MainForm.zh-CN.resx这类附属资源实现的,功能完整,但对这个场景有几个明显不适配的地方。
| 对比项 | .resx 内嵌资源 | XML 语言包 |
|---|---|---|
| 编译期依赖 | 必须重新编译才能改文案 | 改完保存即可生效 |
| 交付后修改 | 需要开发人员 + 源码 | 现场用记事本就能改 |
| 翻译人员友好度 | 要装 VS 或用 ResX 编辑器 | 任意文本编辑器 |
| 键名可读性 | $this.btnSave.Text设计器命名 | MainForm.btnSave.Text自解释 |
| 设计器支持 | 完整 | 无,需要自己写回填逻辑 |
| 强类型校验 | 有 | 无,缺键要靠兜底 |
真正让人倒向 XML 的是交付环节。设备类软件的文案改动极其频繁,客户今天要把「保存」改成「保存参数」,明天要加一句合规提示,走一遍发版流程代价太大。XML 外置之后,运维发一个文件过去覆盖就能解决,代价是失去设计器的强类型支持,所以缺键兜底逻辑必须写扎实。
另一个常被忽略的点是键名。.resx的键是设计器按控件层级自动生成的,控件一改名键就漂移,中英文两份资源很容易对不齐;XML 里的MainForm.btnSave.Text是人手写的,改控件名的时候顺手全局替换一下就行,可控得多。
2.3 运行环境与打开方式
工程目标框架是 .NET Framework 4.0,VS2012 原生支持,不需要额外装 SDK 或 NuGet 包。用 VS2012 打开.sln之后先别急着 F5,按这个顺序确认三件事。
第一,右键工程 → 属性 → 应用程序 → 目标框架,确认是.NET Framework 4或4.5,别顺手改成4.5.1以上,客户现场的老工控机不一定装了对应运行时。第二,生成 → 平台目标保持AnyCPU,这个工程没有 P/Invoke,不需要锁 x86。第三,把Lang目录下两个 XML 的「复制到输出目录」再核一遍,Visual Studio 里这个设置在文件属性的第二栏,很容易漏。
调试阶段建议把「生成 → 输出路径」固定成bin\Debug\,不要自定义。语言包路径在代码里是Application.StartupPath拼出来的,输出路径一换,StartupPath跟着变,如果 XML 没跟着复制过去,表现就是「本机好好的,换个配置就崩」。
2.4 启动顺序:语言必须先于窗体
Program.cs只有几十行,但它决定了整个程序的启动顺序,写错的话会出现「窗体已经画出来但标题还是中文」的诡异现象。
[STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); // 1. 取上次选择的语言,没存过就跟随系统区域 string saved = IniHelper.Get("Language", string.Empty); string code = string.IsNullOrEmpty(saved) ? LanguageManager.DetectSystemLanguage() // 返回 zh-CN 或 en-US : saved; // 2. 语言包必须在任何窗体 new 出来之前加载完 string langDir = Path.Combine(Application.StartupPath, "Lang"); LanguageManager.Instance.Load(code, langDir); // 3. 此时 MainForm 构造里的翻译调用才拿得到数据 Application.Run(new MainForm()); }逻辑说明:saved是用户上次在界面上选的语言代码,存在 ini 里;code是最终生效的语言代码,为空时走系统区域探测,英文系统落到en-US,其余一律zh-CN。参数langDir用的是Application.StartupPath,也就是 exe 所在目录,不能用Environment.CurrentDirectory——后者在快捷方式启动、计划任务启动时会被改掉,是典型的「我这能跑,客户那不行」。
第 2 步和第 3 步的顺序不能颠倒。MainForm的构造函数里就会调用一次翻译,如果语言包还没加载,Get全部走兜底返回默认中文,窗体显示出来就是半中半英。这个顺序问题在调试时几乎看不出来,因为改完代码重新运行又好了,属于典型的玄学现场。
3. 核心实现拆解:XML 语言包如何落到每一个控件上
3.1 语言包的键名约定
XML 结构越简单,后面写递归越省事。这份工程用的是扁平的两层结构,根节点是Language带两个属性,下面是平铺的Item。
<?xml version="1.0" encoding="utf-8"?> <Language Code="zh-CN" Display="简体中文"> <Item Key="MainForm.Text">设备管理控制台</Item> <Item Key="MainForm.lblTitle.Text">设备管理</Item> <Item Key="MainForm.btnSave.Text">保存</Item> <Item Key="MainForm.btnCancel.Text">取消</Item> <Item Key="MainForm.grpFilter.Text">筛选条件</Item> <Item Key="MainForm.dgvList.Columns.colName.HeaderText">名称</Item> <Item Key="MainForm.mnuFile.Text">文件</Item> <Item Key="Msg.SaveSuccess">保存成功</Item> </Language>键名规则只有三条,记住就不会乱:控件文本是窗体名.控件名.Text;窗体自身标题是窗体名.Text;跨窗体复用的提示语单独起个前缀,比如Msg.xxx,不挂窗体名。Code属性必须和文件名一致,Display是下拉框里给人看的名字。
容器控件的键名容易写错。GroupBox、Panel、TabPage本身都是控件,它们的标题同样遵循窗体名.控件名.Text,不要因为它是容器就省掉。DataGridView的列头是个例外,列不是Control,只能用控件名.Columns.列名.HeaderText这种四段式,代码里要单独开分支。
编码一定要存成 UTF-8 带 BOM 或者纯 UTF-8 无 BOM,别存成 ANSI。XML 声明里写了encoding="utf-8",文件实际是 GB2312 的话,XmlDocument.Load会按声明去解,中文直接变问号。
3.2 LanguageManager:加载、缓存与缺键兜底
这个类是整套方案的心脏,负责把 XML 读进字典、对外提供查询、并且在键缺失的时候兜住不让界面变空白。
public sealed class LanguageManager { private static readonly LanguageManager _inst = new LanguageManager(); public static LanguageManager Instance { get { return _inst; } } // 当前语言,忽略大小写,避免 zh-cn / zh-CN 打架 private Dictionary<string, string> _map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); // 中文兜底包,英文缺键时回落 private Dictionary<string, string> _fallback = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); public string CurrentCode { get; private set; } public void Load(string code, string langDir) { string file = Path.Combine(langDir, code + ".xml"); if (!File.Exists(file)) throw new FileNotFoundException("语言包缺失: " + file); _map = ReadFile(file); // 非中文语言时额外加载一份中文包做兜底 string zh = Path.Combine(langDir, "zh-CN.xml"); if (code != "zh-CN" && File.Exists(zh)) _fallback = ReadFile(zh); CurrentCode = code; } private static Dictionary<string, string> ReadFile(string path) { var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var doc = new XmlDocument(); doc.Load(path); // Load 会读 XML 声明里的编码,别用 LoadXml 手拼字符串 XmlNodeList nodes = doc.SelectNodes("/Language/Item"); if (nodes == null) return dict; foreach (XmlNode n in nodes) { if (n.Attributes == null || n.Attributes["Key"] == null) continue; dict[n.Attributes["Key"].Value] = n.InnerText; } return dict; } // defaultText 传控件当前的 Text,缺键时原样保留,界面不会出现空白 public string Get(string key, string defaultText) { string v; if (_map.TryGetValue(key, out v)) return v; if (_fallback.TryGetValue(key, out v)) return v; return defaultText; } }逻辑说明:ReadFile用XmlDocument.Load(path)而不是先File.ReadAllText再LoadXml,是因为前者会按文件头声明的编码解析,后者默认按 UTF-16 处理,中文场景下必踩乱码。_fallback的作用是应对翻译滞后——英文包通常比中文包晚几天到位,缺的那几条键回落显示中文,总比显示MainForm.btnSave.Text这种键名强。
参数说明:code是 ISO 风格的语言代码,必须和Lang目录下文件名严格对应;langDir传 exe 所在目录下的Lang文件夹;Get的第二个参数在调用侧统一传控件当前的Text,这样缺键时的表现是「保持设计器里的原始文案」,而不是变成空白。
Load里抛出FileNotFoundException是有意为之。语言包缺失属于部署错误,早崩比晚崩好,崩在启动阶段日志里能直接看到路径,崩在切换按钮里就只剩一句「未将对象引用设置到对象的实例」。
3.3 控件树递归:一次遍历翻译整个窗体
有了查询能力,剩下的就是把窗体上所有控件扫一遍。Winform 的Controls集合是树形结构,递归写法最省心,二十行就能覆盖全部嵌套容器。
// formName 由外部传一次,递归过程中不变 public void ApplyTo(Control root, string formName) { // 1. 先处理自己:窗体标题或容器标题 root.Text = Get(formName + "." + root.Name + ".Text", root.Text); // 2. 再处理子控件 foreach (Control c in root.Controls) { if (c is DataGridView) { ApplyGrid((DataGridView)c, formName); continue; // 表格自身没有 Text,跳过 } if (c is ToolStrip) { ApplyToolStrip((ToolStrip)c, formName); continue; // 工具栏项不是 Control,递归进不去 } ApplyTo(c, formName); // 普通控件和容器都走这里 } }逻辑说明:函数第一行就把root.Text赋掉,所以窗体本身的标题、GroupBox的边框文字、TabPage的页签都在同一套逻辑里处理,不需要额外写。formName之所以从外面传进来而不是用root.Name,是因为递归下去之后root会变成panel1、groupBox2之类的子容器,键名就拼错了。
参数说明:root第一次调用传this(窗体实例),后续递归传子控件;formName固定传this.Name,也就是MainForm。DataGridView和ToolStrip必须在continue之前处理完,因为这两类控件的子项不是标准Control,交给递归会静默跳过,表现为「界面上别的都翻译了,就表格列头和菜单还是中文」——这是血泪经验,排查起来很费时间。
continue之后不再递归还有一个隐藏收益:DataGridView的Controls集合里有滚动条、编辑框等内部控件,遍历进去纯属浪费,还容易误伤。
3.4 特殊控件分支:菜单栏、工具栏与表格列头
菜单和表格是 Winform 里唯二不能靠Controls递归覆盖的东西,必须单独写。
private void ApplyToolStrip(ToolStrip strip, string formName) { foreach (ToolStripItem item in strip.Items) { // 顶级菜单项,如「文件」「编辑」 item.Text = Get(formName + "." + item.Name + ".Text", item.Text); // 下拉菜单是嵌套的,用 DropDownItems 继续往下 var dropDown = item as ToolStripDropDownItem; if (dropDown == null) continue; foreach (ToolStripItem sub in dropDown.DropDownItems) sub.Text = Get(formName + "." + sub.Name + ".Text", sub.Text); } } private void ApplyGrid(DataGridView grid, string formName) { foreach (DataGridViewColumn col in grid.Columns) { string key = formName + "." + grid.Name + ".Columns." + col.Name + ".HeaderText"; col.HeaderText = Get(key, col.HeaderText); } }逻辑说明:ApplyToolStrip里判断ToolStripDropDownItem是为了处理二级菜单,ToolStripMenuItem继承自它,所以一级菜单和下拉项走同一段代码。工程里没有做三级菜单,如果业务上有,把内层循环改成递归即可。
参数说明:item.Name是设计器里控件的(Name)属性,不是显示文字,如果某個菜单项在设计器里没命名(默认toolStripMenuItem1),键名就是MainForm.toolStripMenuItem1.Text,能跑但可读性极差,建议先把菜单项统一改名再补语言包。
ApplyGrid里的列名col.Name对应设计器里「列集合」编辑器中每列的Name属性,不是HeaderText。这一条坑过不少人:直接填中文列名当键,翻译到英文时键名里带中文,虽然能跑,但后续维护的人会一脸问号。
3.5 语言切换的触发与用户选择持久化
界面上放一个ComboBox,DropDownStyle设成DropDownList,DisplayMember绑Display,ValueMember绑Code,切换时立刻重刷当前窗体。
private void cboLanguage_SelectedIndexChanged(object sender, EventArgs e) { string code = cboLanguage.SelectedValue as string; if (string.IsNullOrEmpty(code)) return; if (code == LanguageManager.Instance.CurrentCode) return; // 防重复触发 string langDir = Path.Combine(Application.StartupPath, "Lang"); LanguageManager.Instance.Load(code, langDir); // 当前窗体立即重刷 LanguageManager.Instance.ApplyTo(this, this.Name); // 其他已打开的窗体也要刷,否则主窗体切了、子窗体还是旧语言 foreach (Form f in Application.OpenForms) { if (f == this) continue; LanguageManager.Instance.ApplyTo(f, f.Name); } // 记住选择,下次启动直接生效 IniHelper.Set("Language", code); }逻辑说明:if (code == CurrentCode) return;这行不能省。SelectedIndexChanged在窗体初始化绑定数据源时也会触发一次,没有这行守卫,程序一启动就会触发一次多余的Load,日志里多出一堆无意义的加载记录。
参数说明:SelectedValue拿到的是绑定到ValueMember的语言代码;Application.OpenForms是当前进程所有打开窗体的集合,遍历时要把this排除掉,否则当前窗体被翻译两遍——虽然结果一样,但会触发两次布局重算,界面会闪一下。
IniHelper用的是一个几十行的 ini 读写类,路径固定在Application.StartupPath\config.ini。用Properties.Settings也可以,但它写到用户的AppData目录下,多用户共用一台工控机时行为不符合预期,谁最后登录谁的语言生效,现场运维会懵。
4. 跑起来:编译、验证到新增窗体的完整流程
4.1 第一次编译要确认的三件事
按 F5 之前,这三件事没确认,后面大概率要浪费半小时。第一,输出目录里有没有Lang文件夹和两个 XML,没有就回去改文件属性。第二,config.ini是不是老版本遗留的,里面Language=en-US但你手上只有中文包,程序直接抛异常,删掉 ini 让它重新生成。第三,工程属性里的「目标框架」和你机器上装的运行时是否匹配,.NET Framework 4的工程在只有 4.5 的机器上一般能跑,反过来不行。
如果编译报XmlDocument 找不到类型,是缺少using System.Xml;;报Application.OpenForms不存在,是漏了using System.Windows.Forms;。这个工程没引任何第三方 DLL,编译期的错误基本都是缺 using。
4.2 切换验证:检查翻译覆盖率
眼睛扫一遍界面不靠谱,两百多个控件总有几个漏的。工程里带了一个自检方法,思路很直接:把界面上所有控件的键名收集起来,跟语言包里的键做差集。
// 返回当前窗体上所有找不到对应键的控件键名 public List<string> Audit(Control root, string formName) { var missing = new List<string>(); string key = formName + "." + root.Name + ".Text"; if (root.Text != Get(key, null)) missing.Add(key); // Get 返回 null 说明缺键 foreach (Control c in root.Controls) { if (c is DataGridView) { foreach (DataGridViewColumn col in ((DataGridView)c).Columns) missing.Add(formName + "." + c.Name + ".Columns." + col.Name + ".HeaderText"); continue; } missing.AddRange(Audit(c, formName)); } return missing; }用法是在窗体Shown事件里临时调一次,把结果Debug.WriteLine出来。跑一遍中英文各看一次,缺的键名一目了然,比对着界面找快得多。
参数说明:Get(key, null)传null作为默认值是有意的,这样才能区分「键存在但值是空字符串」和「键不存在」两种情况。这个方法只做诊断用,不要留在生产代码的启动路径里,遍历一遍控件树在控件多的窗体上有肉眼可见的卡顿。
4.3 新增一个窗体的标准动作
后续要加新窗体,按六步走,一步都不能省。第一步,窗体命名成有意义的名字,比如FrmDeviceEdit,别用默认的Form2。第二步,窗体上所有控件的(Name)属性全部改成有语义的英文名,button1、label3这类名字会让语言包变成天书。第三步,在zh-CN.xml里补上这个窗体的所有键,键名前缀是FrmDeviceEdit。第四步,复制一份到en-US.xml,值换成英文。第五步,窗体构造函数InitializeComponent()之后调一次LanguageManager.Instance.ApplyTo(this, this.Name)。第六步,运行起来用 4.2 的自检方法扫一遍。
第三步最容易偷懒,直觉上觉得「先跑起来,缺的后面补」。但语言包是一份完整资产,今天缺三条明天缺五条,一两周之后就没人说得清哪条缺、哪条是故意留空的。我一般的做法是:新窗体开发完成但还没接业务逻辑时,就先把键补齐,此时界面改动最少,补键最省事。
5. 踩坑排查:漏译、乱码、设计器覆盖的 5 个真实问题
5.1 现象:切换语言后大部分控件变了,某几个死活不变
原因通常有三种。一是这几个控件在容器里,容器本身没设Name,键名拼出来是MainForm.panel2.Text,但 XML 里压根没这条。二是控件是ToolStrip或DataGridView的子项,没走特殊分支。三是控件在运行时动态创建,创建代码在ApplyTo之后才执行,翻译时它还不存在。
解决办法:先用 4.2 的Audit扫一遍确认缺哪些键;动态创建的控件在创建完成后单独调一次ApplyTo(newControl, this.Name),不要指望一次性翻译能覆盖后创建的东西。
5.2 现象:英文切换正常,切回中文后一部分文字变成问号
原因基本锁定在 XML 文件编码上。VS2012 里「文件 → 高级保存选项」默认可能是 ANSI(简体中文 GB2312),文件头写的却是encoding="utf-8",XmlDocument.Load按 UTF-8 去解 GB2312 的字节,中文就烂了。
解决办法:把两个 XML 全部另存为 UTF-8 带签名。改完在记事本里看一眼文件开头有没有 ``,有了就对了。顺带提醒,别用系统自带的记事本改语言包,它偶尔会给你加上一个多余的 BOM 到文件中间,症状更隐蔽。
5.3 现象:在 VS2012 设计器里打开一次窗体,界面文字全变回中文
原因是设计器加载MainForm.cs时会执行InitializeComponent(),而翻译代码如果写在构造函数最前面,设计器执行到一半就拿到语言包数据,把Text覆盖成了运行时的值;保存时设计器又把这些值写回Designer.cs,中文就固化进去了。
解决办法:翻译调用必须放在InitializeComponent()之后,而且加一层if (!DesignInProgress())判断。常见做法是判断LicenseManager.UsageMode == LicenseUsageMode.DesigTime,设计态直接跳过翻译。已经固化的中文回滚只能靠版本管理,所以Designer.cs一定要纳入版本控制,别图省事加进.gitignore。
5.4 现象:本机调试一切正常,拷到客户机器上启动就报找不到文件
原因是Lang目录没跟着 exe 一起过去。开发机上bin\Debug\Lang是 Visual Studio 按文件属性复制过去的,打包的人只拿了 exe 和 dll。另一个变种是客户把程序装在Program Files下,config.ini写不进去,语言选择每次重启都丢失。
解决办法:语言包目录跟着安装包一起发,安装脚本里显式创建Lang子目录;config.ini不要写在 exe 同目录,改写到%ProgramData%或者用户AppData下,用Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData)拼路径。
5.5 现象:切成英文后按钮文字被截断,布局挤成一团
原因是中文两个字的位置放不下英文单词,AutoSize = false且宽度写死的按钮首当其冲;Label开了AutoSize = true的则会撑开把旁边的控件顶走。这属于多语言项目的通病,不是这套方案的缺陷。
解决办法:所有按钮的AutoSize设成true,Padding留 6 到 10 像素的余量;用TableLayoutPanel做布局而不是绝对定位;实在改不动的老窗体,在英文语言包里把长词换成缩写,比如Configuration换成Config,这属于务实的妥协。
6. 二次开发:把双语示例改成可交付的多语言方案
6.1 加语种只需要加一个文件
这套结构最大的好处是语种扩展零成本。要加日文,复制一份en-US.xml改名ja-JP.xml,把Code属性改成ja-JP,Display改成「日本語」,值逐条翻译,然后把文件属性设成「内容 / 如果较新则复制」。代码侧唯一要动的是下拉框数据源的枚举来源——建议改成扫描Lang目录下的所有 XML,读根节点的Code和Display动态填充,以后加语种连代码都不用碰。
// 扫描 Lang 目录,动态构建语言下拉列表 foreach (string file in Directory.GetFiles(langDir, "*.xml")) { var doc = new XmlDocument(); doc.Load(file); var root = doc.DocumentElement; cboLanguage.Items.Add(new LanguageItem { Code = root.GetAttribute("Code"), Display = root.GetAttribute("Display") }); }参数说明:LanguageItem是个两字段的小类,DisplayMember绑Display、ValueMember绑Code。这段代码要放在窗体加载时执行,别放在构造函数里,设计器会跟着跑。
6.2 缺键兜底与热更新
交付之后客户自己改 XML 是常态,兜底逻辑必须扛得住三种情况:文件被改坏、键被删掉、值被改成空。前两种LanguageManager已经有了,第三种需要在Get里加一句if (string.IsNullOrEmpty(v)) return defaultText;。另外建议在Load外面包一层 try-catch,解析失败时回落到上一次成功的字典,界面上弹一句「语言包格式有误,已回退到上次配置」,别让程序直接退出。
热更新更简单,语言切换事件里本来就重新Load了一次。如果想让用户改完 XML 不重启就生效,可以在设置页加一个「重新加载语言包」按钮,内部就是Load(CurrentCode, langDir)再ApplyTo刷一遍,十行代码。
6.3 交付前自检表
| 检查项 | 通过标准 |
|---|---|
| 语言包完整性 | 中英文 XML 的Key集合完全一致 |
| 编码 | 两个 XML 均为 UTF-8 带签名 |
| 文件复制属性 | Lang下所有 XML 为「内容 / 如果较新则复制」 |
| 设计器污染 | Designer.cs里没有硬编码的中文文案 |
| 路径 | 语言包用StartupPath,配置写ProgramData |
| 布局 | 英文下所有按钮文字不截断、不重叠 |
这张表我一般贴在提测单上,六项全过才发版。前四项机器能查,后两项得人肉切一次英文看一遍。
说到教训,早几年做类似改造时我图快,直接把翻译调用塞进了InitializeComponent()前面,本地跑得好好的,提测当天设计器一打开,整个窗体三百多条文案全变成运行时的中文并写回了Designer.cs,回滚花了半天。从那以后我每次动 Winform 的本地化代码,都先把Designer.cs提交一次再动手,并且强制在设计态断点走一遍构造函数。希望帮到你。
本文还有配套的精品资源,点击获取