编辑
2025-11-29
C#
00

目录

💡 核心概念深度解析
🌳 UI Automation Tree:桌面应用的"DOM树"
🎯 AutomationElement:界面元素的抽象表示
🤔 互动思考
✨ 核心要点总结

你是否在开发桌面应用自动化测试时,面对复杂的界面元素定位而头疼?是否在处理不同控件交互时,总是找不到合适的方法?作为一名资深的C#开发者,我发现许多同行都在UI Automation的学习路上踩过这些坑:不理解UI自动化树结构、找不准控件元素、搞不清楚控件模式的使用场景

今天这篇文章,我将带你深入理解UI Automation的四大核心概念,通过实战代码和真实场景,让你彻底掌握这些技术要点,从此告别"盲人摸象"式的开发模式!

💡 核心概念深度解析

🌳 UI Automation Tree:桌面应用的"DOM树"

什么是UI自动化树?

UI Automation Tree就像网页的DOM树一样,是Windows桌面应用程序界面元素的层次化表示。每个窗口、按钮、文本框都是这棵树上的一个节点,通过父子关系组织起来。

实战场景:定位计算器中的按钮

C#
using UIAutomationClient; namespace AppAutomationTreeExample { internal class Program { static void Main(string[] args) { Console.OutputEncoding = System.Text.Encoding.UTF8; CUIAutomation8 automation = new CUIAutomation8(); var desktop = automation.GetRootElement(); var windowCondition = automation.CreateAndConditionFromArray(new[] { automation.CreatePropertyCondition( UIA_PropertyIds.UIA_NamePropertyId, "Calculator"), automation.CreatePropertyCondition( UIA_PropertyIds.UIA_ControlTypePropertyId, UIA_ControlTypeIds.UIA_WindowControlTypeId) }); var calcWindow = desktop.FindFirst(TreeScope.TreeScope_Children, windowCondition); if (calcWindow != null) { Console.WriteLine($"找到计算器窗口:{calcWindow.CurrentName}"); // 🎯 分析整个UI树结构 AnalyzeUITree(calcWindow, automation, 0, 5); // 最多5层深度 } } // 🔥 递归分析UI树结构 static void AnalyzeUITree(IUIAutomationElement element, CUIAutomation8 automation, int level, int maxLevel) { if (level > maxLevel) return; string indent = new string(' ', level * 2); string controlType = GetControlTypeName(element.CurrentControlType); Console.WriteLine($"{indent}├─ {element.CurrentName} ({controlType})"); Console.WriteLine($"{indent} AutomationId: {element.CurrentAutomationId}"); Console.WriteLine($"{indent} ClassName: {element.CurrentClassName}"); // 查找所有子元素 var children = element.FindAll(TreeScope.TreeScope_Children, automation.CreateTrueCondition()); for (int i = 0; i < children.Length; i++) { AnalyzeUITree(children.GetElement(i), automation, level + 1, maxLevel); } } // 获取控件类型名称 static string GetControlTypeName(int controlTypeId) { var controlTypes = new Dictionary<int, string> { { UIA_ControlTypeIds.UIA_ButtonControlTypeId, "Button" }, { UIA_ControlTypeIds.UIA_WindowControlTypeId, "Window" }, { UIA_ControlTypeIds.UIA_PaneControlTypeId, "Pane" }, { UIA_ControlTypeIds.UIA_GroupControlTypeId, "Group" }, { UIA_ControlTypeIds.UIA_TextControlTypeId, "Text" }, { UIA_ControlTypeIds.UIA_EditControlTypeId, "Edit" } }; return controlTypes.ContainsKey(controlTypeId) ? controlTypes[controlTypeId] : $"Unknown({controlTypeId})"; } } }

image.png

⚠️ 常见坑点提醒:

  • TreeScope_Children只查找直接子元素,TreeScope_Descendants查找所有后代
  • 不同应用程序的树结构差异很大,需要实际调试确认

🎯 AutomationElement:界面元素的抽象表示

核心理解:万物皆Element

AutomationElement是UI Automation的基础单元,每个界面控件都对应一个AutomationElement对象。它封装了控件的属性、支持的操作模式等信息。

实战代码:多种方式定位文本框

C#
using UIAutomationClient; namespace AppAutomationTreeExample { public class UIAutomationHelper { private readonly CUIAutomation8 _automation; public UIAutomationHelper() { _automation = new CUIAutomation8(); } /// <summary> /// 根据应用程序名称查找窗口 /// </summary> /// <param name="windowName">窗口名称</param> /// <returns>找到的窗口元素</returns> public IUIAutomationElement? FindWindowByName(string windowName) { var desktop = _automation.GetRootElement(); var windowCondition = _automation.CreateAndConditionFromArray(new[] { _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_NamePropertyId, windowName), _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_ControlTypePropertyId, UIA_ControlTypeIds.UIA_WindowControlTypeId) }); return desktop.FindFirst(TreeScope.TreeScope_Children, windowCondition); } /// <summary> /// 根据进程名称查找窗口 /// </summary> /// <param name="processName">进程名称(不包含.exe)</param> /// <returns>找到的窗口元素</returns> public IUIAutomationElement? FindWindowByProcessName(string processName) { var desktop = _automation.GetRootElement(); var allWindows = desktop.FindAll(TreeScope.TreeScope_Children, _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_ControlTypePropertyId, UIA_ControlTypeIds.UIA_WindowControlTypeId)); for (int i = 0; i < allWindows.Length; i++) { var window = allWindows.GetElement(i); if (window.CurrentProcessId > 0) { try { var process = System.Diagnostics.Process.GetProcessById(window.CurrentProcessId); if (process.ProcessName.Equals(processName, StringComparison.OrdinalIgnoreCase)) { return window; } } catch { // 进程可能已经结束,忽略异常 } } } return null; } /// <summary> /// 在指定窗口中查找所有 TextBox 控件 /// </summary> /// <param name="window">窗口元素</param> /// <returns>TextBox 控件列表</returns> public List<TextBoxInfo> FindAllTextBoxes(IUIAutomationElement window) { var textBoxes = new List<TextBoxInfo>(); var editCondition = _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_ControlTypePropertyId, UIA_ControlTypeIds.UIA_EditControlTypeId); var foundElements = window.FindAll(TreeScope.TreeScope_Descendants, editCondition); for (int i = 0; i < foundElements.Length; i++) { var element = foundElements.GetElement(i); textBoxes.Add(new TextBoxInfo { Element = element, Name = element.CurrentName ?? "", AutomationId = element.CurrentAutomationId ?? "", ClassName = element.CurrentClassName ?? "", Value = GetElementValue(element), IsEnabled = element.CurrentIsEnabled>0, IsVisible = element.CurrentIsOffscreen>0 }); } return textBoxes; } /// <summary> /// 根据 AutomationId 查找 TextBox /// </summary> /// <param name="window">窗口元素</param> /// <param name="automationId">AutomationId</param> /// <returns>找到的 TextBox 信息</returns> public TextBoxInfo? FindTextBoxByAutomationId(IUIAutomationElement window, string automationId) { var condition = _automation.CreateAndConditionFromArray(new[] { _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_ControlTypePropertyId, UIA_ControlTypeIds.UIA_EditControlTypeId), _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_AutomationIdPropertyId, automationId) }); var element = window.FindFirst(TreeScope.TreeScope_Descendants, condition); if (element != null) { return new TextBoxInfo { Element = element, Name = element.CurrentName ?? "", AutomationId = element.CurrentAutomationId ?? "", ClassName = element.CurrentClassName ?? "", Value = GetElementValue(element), IsEnabled = element.CurrentIsEnabled>0, IsVisible = element.CurrentIsOffscreen>0 }; } return null; } /// <summary> /// 根据名称查找 TextBox /// </summary> /// <param name="window">窗口元素</param> /// <param name="name">TextBox 名称</param> /// <returns>找到的 TextBox 信息</returns> public TextBoxInfo? FindTextBoxByName(IUIAutomationElement window, string name) { var condition = _automation.CreateAndConditionFromArray(new[] { _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_ControlTypePropertyId, UIA_ControlTypeIds.UIA_EditControlTypeId), _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_NamePropertyId, name) }); var element = window.FindFirst(TreeScope.TreeScope_Descendants, condition); if (element != null) { return new TextBoxInfo { Element = element, Name = element.CurrentName ?? "", AutomationId = element.CurrentAutomationId ?? "", ClassName = element.CurrentClassName ?? "", Value = GetElementValue(element), IsEnabled = element.CurrentIsEnabled>0, IsVisible = element.CurrentIsOffscreen>0 }; } return null; } /// <summary> /// 根据类名查找 TextBox /// </summary> /// <param name="window">窗口元素</param> /// <param name="className">类名</param> /// <returns>找到的 TextBox 信息列表</returns> public List<TextBoxInfo> FindTextBoxesByClassName(IUIAutomationElement window, string className) { var textBoxes = new List<TextBoxInfo>(); var condition = _automation.CreateAndConditionFromArray(new[] { _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_ControlTypePropertyId, UIA_ControlTypeIds.UIA_EditControlTypeId), _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_ClassNamePropertyId, className) }); var foundElements = window.FindAll(TreeScope.TreeScope_Descendants, condition); for (int i = 0; i < foundElements.Length; i++) { var element = foundElements.GetElement(i); textBoxes.Add(new TextBoxInfo { Element = element, Name = element.CurrentName ?? "", AutomationId = element.CurrentAutomationId ?? "", ClassName = element.CurrentClassName ?? "", Value = GetElementValue(element), IsEnabled = element.CurrentIsEnabled>0, IsVisible = element.CurrentIsOffscreen>0 }); } return textBoxes; } /// <summary> /// 设置 TextBox 的值 /// </summary> /// <param name="textBoxInfo">TextBox 信息</param> /// <param name="value">要设置的值</param> /// <returns>是否设置成功</returns> public bool SetTextBoxValue(TextBoxInfo textBoxInfo, string value) { try { var valuePattern = textBoxInfo.Element.GetCurrentPattern(UIA_PatternIds.UIA_ValuePatternId) as IUIAutomationValuePattern; if (valuePattern != null) { valuePattern.SetValue(value); return true; } return false; } catch { return false; } } /// <summary> /// 获取元素的值 /// </summary> /// <param name="element">UI 元素</param> /// <returns>元素值</returns> private string GetElementValue(IUIAutomationElement element) { try { var valuePattern = element.GetCurrentPattern(UIA_PatternIds.UIA_ValuePatternId) as IUIAutomationValuePattern; return valuePattern?.CurrentValue ?? ""; } catch { return ""; } } } /// <summary> /// TextBox 信息类 /// </summary> public class TextBoxInfo { public IUIAutomationElement Element { get; set; } = null!; public string Name { get; set; } = ""; public string AutomationId { get; set; } = ""; public string ClassName { get; set; } = ""; public string Value { get; set; } = ""; public bool IsEnabled { get; set; } public bool IsVisible { get; set; } public override string ToString() { return $"Name: {Name}, AutomationId: {AutomationId}, Value: '{Value}', Enabled: {IsEnabled}, Visible: {IsVisible}"; } } }
C#
private static void Test1() { Console.OutputEncoding = System.Text.Encoding.UTF8; CUIAutomation8 automation = new CUIAutomation8(); var desktop = automation.GetRootElement(); var windowCondition = automation.CreateAndConditionFromArray(new[] { automation.CreatePropertyCondition( UIA_PropertyIds.UIA_NamePropertyId, "Calculator"), automation.CreatePropertyCondition( UIA_PropertyIds.UIA_ControlTypePropertyId, UIA_ControlTypeIds.UIA_WindowControlTypeId) }); var calcWindow = desktop.FindFirst(TreeScope.TreeScope_Children, windowCondition); if (calcWindow != null) { Console.WriteLine($"找到计算器窗口:{calcWindow.CurrentName}"); // 🎯 分析整个UI树结构 AnalyzeUITree(calcWindow, automation, 0, 5); // 最多5层深度 } }

image.png

💡 最佳实践技巧:

C#
using UIAutomationClient; namespace AppAutomationTreeExample { public class UIAutomationHelper { private readonly CUIAutomation8 _automation; public UIAutomationHelper() { _automation = new CUIAutomation8(); } /// <summary> /// 根据应用程序名称查找窗口 /// </summary> /// <param name="windowName">窗口名称</param> /// <returns>找到的窗口元素</returns> public IUIAutomationElement? FindWindowByName(string windowName) { var desktop = _automation.GetRootElement(); var windowCondition = _automation.CreateAndConditionFromArray(new[] { _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_NamePropertyId, windowName), _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_ControlTypePropertyId, UIA_ControlTypeIds.UIA_WindowControlTypeId) }); return desktop.FindFirst(TreeScope.TreeScope_Children, windowCondition); } /// <summary> /// 根据进程名称查找窗口 /// </summary> /// <param name="processName">进程名称(不包含.exe)</param> /// <returns>找到的窗口元素</returns> public IUIAutomationElement? FindWindowByProcessName(string processName) { var desktop = _automation.GetRootElement(); var allWindows = desktop.FindAll(TreeScope.TreeScope_Children, _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_ControlTypePropertyId, UIA_ControlTypeIds.UIA_WindowControlTypeId)); for (int i = 0; i < allWindows.Length; i++) { var window = allWindows.GetElement(i); if (window.CurrentProcessId > 0) { try { var process = System.Diagnostics.Process.GetProcessById(window.CurrentProcessId); if (process.ProcessName.Equals(processName, StringComparison.OrdinalIgnoreCase)) { return window; } } catch { // 进程可能已经结束,忽略异常 } } } return null; } /// <summary> /// 在指定窗口中查找所有 TextBox 控件 /// </summary> /// <param name="window">窗口元素</param> /// <returns>TextBox 控件列表</returns> public List<TextBoxInfo> FindAllTextBoxes(IUIAutomationElement window) { var textBoxes = new List<TextBoxInfo>(); var editCondition = _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_ControlTypePropertyId, UIA_ControlTypeIds.UIA_EditControlTypeId); var foundElements = window.FindAll(TreeScope.TreeScope_Descendants, editCondition); for (int i = 0; i < foundElements.Length; i++) { var element = foundElements.GetElement(i); textBoxes.Add(new TextBoxInfo { Element = element, Name = element.CurrentName ?? "", AutomationId = element.CurrentAutomationId ?? "", ClassName = element.CurrentClassName ?? "", Value = GetElementValue(element), IsEnabled = element.CurrentIsEnabled>0, IsVisible = element.CurrentIsOffscreen>0 }); } return textBoxes; } /// <summary> /// 根据 AutomationId 查找 TextBox /// </summary> /// <param name="window">窗口元素</param> /// <param name="automationId">AutomationId</param> /// <returns>找到的 TextBox 信息</returns> public TextBoxInfo? FindTextBoxByAutomationId(IUIAutomationElement window, string automationId) { var condition = _automation.CreateAndConditionFromArray(new[] { _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_ControlTypePropertyId, UIA_ControlTypeIds.UIA_EditControlTypeId), _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_AutomationIdPropertyId, automationId) }); var element = window.FindFirst(TreeScope.TreeScope_Descendants, condition); if (element != null) { return new TextBoxInfo { Element = element, Name = element.CurrentName ?? "", AutomationId = element.CurrentAutomationId ?? "", ClassName = element.CurrentClassName ?? "", Value = GetElementValue(element), IsEnabled = element.CurrentIsEnabled>0, IsVisible = element.CurrentIsOffscreen>0 }; } return null; } /// <summary> /// 根据名称查找 TextBox /// </summary> /// <param name="window">窗口元素</param> /// <param name="name">TextBox 名称</param> /// <returns>找到的 TextBox 信息</returns> public TextBoxInfo? FindTextBoxByName(IUIAutomationElement window, string name) { var condition = _automation.CreateAndConditionFromArray(new[] { _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_ControlTypePropertyId, UIA_ControlTypeIds.UIA_EditControlTypeId), _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_NamePropertyId, name) }); var element = window.FindFirst(TreeScope.TreeScope_Descendants, condition); if (element != null) { return new TextBoxInfo { Element = element, Name = element.CurrentName ?? "", AutomationId = element.CurrentAutomationId ?? "", ClassName = element.CurrentClassName ?? "", Value = GetElementValue(element), IsEnabled = element.CurrentIsEnabled>0, IsVisible = element.CurrentIsOffscreen>0 }; } return null; } /// <summary> /// 根据类名查找 TextBox /// </summary> /// <param name="window">窗口元素</param> /// <param name="className">类名</param> /// <returns>找到的 TextBox 信息列表</returns> public List<TextBoxInfo> FindTextBoxesByClassName(IUIAutomationElement window, string className) { var textBoxes = new List<TextBoxInfo>(); var condition = _automation.CreateAndConditionFromArray(new[] { _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_ControlTypePropertyId, UIA_ControlTypeIds.UIA_EditControlTypeId), _automation.CreatePropertyCondition( UIA_PropertyIds.UIA_ClassNamePropertyId, className) }); var foundElements = window.FindAll(TreeScope.TreeScope_Descendants, condition); for (int i = 0; i < foundElements.Length; i++) { var element = foundElements.GetElement(i); textBoxes.Add(new TextBoxInfo { Element = element, Name = element.CurrentName ?? "", AutomationId = element.CurrentAutomationId ?? "", ClassName = element.CurrentClassName ?? "", Value = GetElementValue(element), IsEnabled = element.CurrentIsEnabled>0, IsVisible = element.CurrentIsOffscreen>0 }); } return textBoxes; } /// <summary> /// 设置 TextBox 的值 /// </summary> /// <param name="textBoxInfo">TextBox 信息</param> /// <param name="value">要设置的值</param> /// <returns>是否设置成功</returns> public bool SetTextBoxValue(TextBoxInfo textBoxInfo, string value) { try { var valuePattern = textBoxInfo.Element.GetCurrentPattern(UIA_PatternIds.UIA_ValuePatternId) as IUIAutomationValuePattern; if (valuePattern != null) { valuePattern.SetValue(value); return true; } return false; } catch { return false; } } /// <summary> /// 获取元素的值 /// </summary> /// <param name="element">UI 元素</param> /// <returns>元素值</returns> private string GetElementValue(IUIAutomationElement element) { try { var valuePattern = element.GetCurrentPattern(UIA_PatternIds.UIA_ValuePatternId) as IUIAutomationValuePattern; return valuePattern?.CurrentValue ?? ""; } catch { return ""; } } } /// <summary> /// TextBox 信息类 /// </summary> public class TextBoxInfo { public IUIAutomationElement Element { get; set; } = null!; public string Name { get; set; } = ""; public string AutomationId { get; set; } = ""; public string ClassName { get; set; } = ""; public string Value { get; set; } = ""; public bool IsEnabled { get; set; } public bool IsVisible { get; set; } public override string ToString() { return $"Name: {Name}, AutomationId: {AutomationId}, Value: '{Value}', Enabled: {IsEnabled}, Visible: {IsVisible}"; } } }
C#
private static void Test3() { Console.OutputEncoding = System.Text.Encoding.UTF8; // 获取桌面 var desktop = ElementFinder.GetDesktop(); // 查找计算器窗口 var calcWindow = ElementFinder.FindByNameAndControlType( desktop, "Calculator", UIA_ControlTypeIds.UIA_WindowControlTypeId, 3000); if (calcWindow != null) { Console.WriteLine($"找到计算器窗口:{calcWindow.CurrentName}"); Console.WriteLine(); // 示例1:查找所有按钮 var buttons = ElementFinder.FindByControlType(calcWindow, UIA_ControlTypeIds.UIA_ButtonControlTypeId); Console.WriteLine($"找到 {buttons?.Length ?? 0} 个按钮"); // 示例2:查找特定按钮 var button1 = ElementFinder.FindButton(calcWindow, "One"); if (button1 != null) { Console.WriteLine($"找到按钮1: {ElementFinder.GetElementInfo(button1)}"); } // 示例3:查找所有文本框 var textBoxes = ElementFinder.FindAllTextBoxes(calcWindow); if (textBoxes != null) { Console.WriteLine($"找到 {textBoxes.Length} 个文本框:"); for (int i = 0; i < textBoxes.Length; i++) { var textBox = textBoxes.GetElement(i); Console.WriteLine($" - {ElementFinder.GetElementInfo(textBox)}"); } } // 示例4:等待元素出现 Console.WriteLine("\n等待结果显示..."); var resultCondition = ElementFinder.CreateCondition( UIA_PropertyIds.UIA_AutomationIdPropertyId, "CalculatorResults"); bool appeared = ElementFinder.WaitForElement(calcWindow, resultCondition, 2000); Console.WriteLine($"结果元素出现: {appeared}"); // 示例5:查找可见元素 var visibleButton = ElementFinder.FindVisibleElement(calcWindow, ElementFinder.CreateCondition(UIA_PropertyIds.UIA_ControlTypePropertyId, UIA_ControlTypeIds.UIA_ButtonControlTypeId)); if (visibleButton != null) { Console.WriteLine($"找到可见按钮: {ElementFinder.GetElementInfo(visibleButton)}"); } // 示例6:调试查找 Console.WriteLine("\n=== 调试信息 ==="); var debugCondition = ElementFinder.CreateCondition( UIA_PropertyIds.UIA_ControlTypePropertyId, UIA_ControlTypeIds.UIA_ButtonControlTypeId); var debugInfo = ElementFinder.DebugFindAll(calcWindow, debugCondition, 50); Console.WriteLine(debugInfo); // 示例7:组合条件查找 var conditions = new IUIAutomationCondition[] { ElementFinder.CreateCondition(UIA_PropertyIds.UIA_ControlTypePropertyId, UIA_ControlTypeIds.UIA_ButtonControlTypeId), ElementFinder.CreateCondition(UIA_PropertyIds.UIA_NamePropertyId, "Equals") }; var equalButton = ElementFinder.FindByAndConditions(calcWindow, conditions); if (equalButton != null) { Console.WriteLine($"找到等号按钮: {ElementFinder.GetElementInfo(equalButton)}"); } // 示例8:按索引查找 var buttonCondition = ElementFinder.CreateCondition( UIA_PropertyIds.UIA_ControlTypePropertyId, UIA_ControlTypeIds.UIA_ButtonControlTypeId); var thirdButton = ElementFinder.FindElementByIndex(calcWindow, buttonCondition, 2); if (thirdButton != null) { Console.WriteLine($"第3个按钮: {ElementFinder.GetElementInfo(thirdButton)}"); } } else { Console.WriteLine("未找到计算器窗口,请确保计算器应用程序正在运行。"); } Console.WriteLine("\n按任意键退出..."); Console.ReadKey(); }

image.png

🤔 互动思考

看完这篇深度解析,你有没有想过:

  1. 在你的项目中,哪种元素定位方式最稳定可靠?
  2. 如何设计一个更通用的控件模式处理策略?

欢迎在评论区分享你的UI自动化实战经验,或者遇到的技术难题,我会第一时间回复大家!

✨ 核心要点总结

通过今天的深度讲解,我们掌握了UI Automation的四大核心概念:

🌳 UI自动化树:理解了界面元素的层次化组织方式,学会了高效的元素遍历和定位策略

🎯 自动化元素:掌握了多种可靠的元素查找方法,特别是组合条件定位和超时处理的最佳实践

🎮 控件模式:深入理解了不同控件类型的交互方式,学会了智能化的模式检测和处理

这四个概念就像是UI自动化开发的"四大基石",只有牢固掌握它们,才能在复杂的桌面应用自动化项目中游刃有余。记住:理论结合实践,多调试多总结,你的UI Automation技能必将更上一层楼!

觉得这篇文章对你有帮助的话,请转发给更多需要的同行,让我们一起提升C#开发技能! 🚀

本文作者:技术老小子

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!