commit 81102ec396c704fec89b706a90f47347eb7e0b64 Author: guangfei.zhao Date: Fri Oct 31 17:53:12 2025 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38da47f --- /dev/null +++ b/.gitignore @@ -0,0 +1,265 @@ +# Python Git Ignore + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be added to the global gitignore or merged into this project gitignore. For a PyCharm +# project, it is recommended to use the following settings: +# https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# Uncomment the following lines if you want to ignore PyCharm files: +.idea/ +*.iws +*.iml +*.ipr +out/ + +# Visual Studio Code +.vscode/ +*.code-workspace + +# Sublime Text +*.sublime-project +*.sublime-workspace + +# Vim +*.swp +*.swo +*~ + +# Emacs +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# macOS +.DS_Store +.AppleDouble +.LSOverride +Icon +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# Windows +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db +*.tmp +*.temp +Desktop.ini +$RECYCLE.BIN/ +*.cab +*.msi +*.msix +*.msm +*.msp +*.lnk + +# Linux +*~ +.fuse_hidden* +.directory +.Trash-* +.nfs* + +# Project-specific ignores +# Temporary files +temp/ +tmp/ +*.tmp + +# Log files +logs/ +*.log + +# Configuration files with sensitive data +config_local.py +secrets.py +.secrets + +# Test artifacts +test_results/ +test_reports/ + +# Screenshots and media (if not needed in version control) +# Uncomment if you don't want to track these files: +# screenshots/ +# reports/ +# visual_comparisons/ + +# Appium specific +appium.log +node_modules/ + +# Device specific files +device_info.json + diff --git a/README.md b/README.md new file mode 100644 index 0000000..de57d29 --- /dev/null +++ b/README.md @@ -0,0 +1,396 @@ +# 🚀 企业级UI测试工具 + +基于Appium的Android应用UI测试工具,采用模块化架构设计,支持实时截图、XML布局分析、视觉比对、性能监控、智能报告生成等企业级功能。 + +## ✨ 核心特性 + +### 🎯 主要功能 +- **🔄 零配置启动**: 自动创建目录结构,开箱即用 +- **📸 实时截图**: 快速捕获当前屏幕状态,支持批量操作 +- **🔍 智能分析**: XML布局深度解析,自动识别UI问题 +- **👁️ 视觉验证**: 纯图像像素级对比,检查UI外观效果 +- **📐 布局比对**: XML-SVG结构比对,验证布局逻辑一致性 +- **⚡ 性能优化**: 实时监控操作响应时间和设备状态 +- **📊 详细报告**: 生成JSON/HTML格式的专业测试报告 + +### 🏗️ 模块化架构 + +项目采用清晰的模块化设计,便于维护和扩展: + +``` +📁 项目结构 +├── 📄 ui_test.py # 主程序入口和交互界面 +├── ⚙️ config.py # 统一配置管理 +├── 🔍 xml_analyzer.py # XML布局分析模块 +├── 👁️ visual_comparator.py # 视觉比对分析模块 +├── 📊 test_reporter.py # 测试报告生成模块 +├── 📋 requirements.txt # 项目依赖 +├── 📖 README.md # 项目文档 +├── 📁 screenshots/ # 截图保存目录 +├── 📁 xml_layouts/ # XML布局保存目录 +├── 📁 design_references/ # 设计参考图目录 (SVG格式) +├── 📁 visual_comparisons/ # 视觉对比结果目录 +└── 📁 reports/ # 测试报告目录 +``` + +### 🔧 模块功能详解 + +#### 📋 Config (config.py) +- 统一配置管理 +- Appium连接设置 +- 目录路径配置 +- 热键映射定义 +- 分析参数调优 + +#### 🔍 XMLAnalyzer (xml_analyzer.py) +- XML结构解析 +- SVG文件解析和元素提取 +- XML-SVG布局结构比对 +- 无障碍性问题检测 +- 重复ID识别 +- 空文本元素检查 +- 元素统计分析 +- 数据导出 (JSON/CSV) + +#### 👁️ VisualComparator (visual_comparator.py) +- 多格式图像支持 (PNG/JPG/SVG) +- 智能图像尺寸处理(保持宽高比) +- SSIM相似度计算 +- 差异区域检测 +- 自动图像调整 +- 批量比对处理 +- 结果可视化 + +#### 📊 TestReporter (test_reporter.py) +- 专业HTML报告 +- JSON数据导出 +- 多维度分析 +- 性能数据统计 +- 问题汇总建议 +- 自定义模板支持 + +## 🛠️ 安装配置 + +### 环境要求 +- Python 3.8+ +- Android SDK +- Appium Server + +### 依赖安装 +```bash +pip install -r requirements.txt +``` + +### 主要依赖 +``` +Appium-Python-Client>=3.1.0 # Appium客户端 +keyboard>=0.13.5 # 热键支持 +opencv-python>=4.8.0 # 图像处理 +scikit-image>=0.21.0 # 图像分析 +lxml>=4.9.0 # XML解析 +jinja2>=3.1.0 # 报告模板 +cairosvg>=2.7.0 # SVG处理 +``` + +## 🚀 快速开始 + +### 1. 启动Appium服务器 +```bash +appium +``` + +### 2. 连接设备 +- 连接Android设备或启动模拟器 +- 确保ADB可访问设备 + +### 3. 运行测试工具 +```bash +python ui_test.py +``` + +### 4. 配置应用信息 +在 `config.py` 中修改目标应用配置: +```python +DEVICE_CAPABILITIES = { + 'platformName': 'Android', + 'automationName': 'uiautomator2', + 'deviceName': 'your-device-name', + 'appPackage': 'com.your.app', + 'appActivity': '.MainActivity' +} +``` + +## 🎮 操作指南 + +### 热键操作 +| 热键 | 功能 | 说明 | +|------|------|------| +| **F1** | 📸 截图 | 保存当前屏幕截图 | +| **F2** | 📄 XML布局 | 保存页面XML结构 | +| **F3** | 📱 应用信息 | 获取当前应用和Activity | +| **F4** | 🔍 元素信息 | 显示页面元素详情 | +| **F5** | 👁️ 视觉比对 | 纯图像视觉对比(仅外观检查) | +| **F6** | 🔍 XML分析 | XML结构分析 + SVG布局比对 | +| **F7** | ⚡ 性能监控 | 监控操作性能 | +| **F8** | 🖱️ 元素点击 | 智能元素交互 | +| **F9** | 📊 生成报告 | 创建测试报告 | +| **Ctrl+Q** | 🚪 退出 | 安全退出程序 | + +### 命令行操作 +```bash +# 基础操作 +help # 显示帮助信息 +status # 显示连接状态 +reconnect # 重新连接设备 + +# 功能操作 +screenshot # 截图 (或输入 1) +xml # 保存XML (或输入 2) +activity # 应用信息 (或输入 3) +elements # 元素信息 (或输入 4) +compare # 视觉比对 - 纯图像对比 (或输入 5) +layout # XML-SVG布局比对 - 专门的结构对比 +analyze # XML分析和布局比对 (或输入 6) +performance # 性能监控 (或输入 7) +click # 元素点击 (或输入 8) +report # 生成报告 (或输入 9) + +# 退出程序 +quit # 退出 (或输入 q) +``` + +## 📊 功能详解 + +### 🎯 功能分工说明 + +#### 👁️ F5 - 视觉比对 (`compare`) +- **专注功能**: 纯视觉图像比对 +- **适用场景**: 检查UI外观、颜色、布局视觉效果 +- **操作流程**: + 1. 自动截图 + 2. 与设计图进行像素级比对 + 3. 显示相似度和差异区域 + 4. 生成可视化比对结果 +- **输出结果**: 相似度评分、差异区域标注图 + +#### 🔍 F6 - XML分析和布局比对 (`analyze`) +- **专注功能**: XML结构分析 + SVG布局比对 +- **适用场景**: 检查页面结构、元素层级、布局逻辑 +- **操作流程**: + 1. 保存并分析XML布局 + 2. 自动截图(用于报告) + 3. 如果有SVG设计图,进行结构比对 + 4. 生成综合测试报告 +- **输出结果**: XML分析报告、布局匹配度、测试报告 + +#### 📐 layout命令 - 专门布局比对 +- **专注功能**: 纯XML-SVG结构比对 +- **适用场景**: 专门检查布局结构一致性 +- **操作流程**: 直接进行XML和SVG的结构对比 +- **输出结果**: 元素匹配情况、结构相似度 + +### 🔍 XML分析功能 +- **结构解析**: 完整的UI层级结构分析 +- **问题检测**: + - 无障碍性问题 (缺少contentDescription) + - 重复资源ID + - 空文本元素 + - 尺寸异常元素 +- **统计信息**: 元素类型分布、层级深度等 +- **数据导出**: 支持JSON和CSV格式 + +### 👁️ 视觉比对功能 +- **多格式支持**: 专门优化SVG格式处理 +- **智能对比**: + - SSIM结构相似性分析 + - 差异区域自动标注 + - 相似度评分 +- **批量处理**: 支持多个设计图同时比对 +- **结果可视化**: 生成直观的对比图像 + +### 📊 报告生成功能 +- **HTML报告**: + - 专业样式设计 + - 交互式图表 + - 详细问题分析 + - 改进建议 +- **JSON数据**: 结构化数据导出 +- **多维度分析**: + - XML结构分析 + - 视觉比对结果 + - 性能监控数据 + - 问题汇总统计 + +### ⚡ 性能监控功能 +- **响应时间**: 操作执行时间统计 +- **设备状态**: CPU、内存使用情况 +- **操作历史**: 详细的操作记录 +- **性能趋势**: 长期性能变化分析 + +## 🎯 使用场景 + +### 📱 UI测试验证 +- **视觉回归测试**: 使用F5进行纯图像对比,检查UI外观变化 +- **布局结构验证**: 使用F6进行XML-SVG结构比对,确保布局逻辑正确 +- **专项布局测试**: 使用`layout`命令专门检查XML和SVG的结构一致性 +- **性能监控**: 实时监控UI操作响应时间 + +### 🔍 问题诊断 +- **XML结构分析**: 深度解析页面层级,发现结构问题 +- **无障碍性检查**: 自动检测缺失的accessibility属性 +- **重复元素识别**: 发现重复ID和空文本元素 +- **视觉差异定位**: 精确标注UI变化区域 + +### 📊 测试报告 +- **综合分析报告**: F6生成包含XML分析和布局比对的完整报告 +- **视觉比对报告**: F5生成专门的图像对比结果 +- **性能数据统计**: 操作响应时间和设备状态监控 +- **多格式导出**: 支持JSON、CSV、HTML格式 + +### 🔄 自动化测试 +```python +from ui_test import InteractiveUITester +from config import Config + +# 初始化测试器 +tester = InteractiveUITester() +tester.connect_device() + +# 执行测试流程 +tester.take_screenshot() +tester.save_xml_layout() +tester.compare_with_design() +tester.generate_report() +``` + +### 📊 批量分析 +```python +# 批量视觉比对 +comparator = VisualComparator() +results = comparator.batch_compare_screenshots() + +# 生成综合报告 +reporter = TestReporter() +reporter.add_visual_comparison_data(results) +reporter.generate_html_report() +``` + +### 🔍 深度分析 +```python +# XML深度分析 +analyzer = XMLAnalyzer() +analysis_result = analyzer.analyze_xml_file('layout.xml') +issues = analyzer.check_accessibility_issues() +stats = analyzer.get_element_statistics() +``` + +## 🔧 高级配置 + +### 📁 目录结构 +``` +appium_ui_test/ +├── screenshots/ # 截图文件 +├── xml_layouts/ # XML布局文件 +├── designs/ # 设计图文件 (PNG/JPG/SVG) +├── reports/ # 测试报告 +├── logs/ # 日志文件 +└── temp/ # 临时文件 +``` + +### 🎨 设计文件支持 +- **图像格式**: PNG、JPG(用于视觉比对) +- **矢量格式**: SVG(支持视觉比对 + 布局结构比对) +- **命名规则**: 建议使用描述性文件名 +- **SVG优势**: + - 支持双重比对(视觉 + 结构) + - 可缩放无损质量 + - 包含布局元素信息 + +### 自定义分析规则 +在 `config.py` 中调整分析参数: +```python +XML_ANALYSIS = { + 'check_accessibility': True, + 'check_duplicate_ids': True, + 'check_empty_text': True, + 'min_element_size': 10, + 'max_nesting_level': 15 +} +``` + +### 视觉比对优化 +```python +VISUAL_COMPARISON = { + 'similarity_threshold': 0.95, + 'diff_threshold': 50, + 'min_diff_area': 100, + 'supported_formats': ['.svg'] +} +``` + +### 报告自定义 +```python +REPORT = { + 'include_screenshots': True, + 'include_xml_analysis': True, + 'include_visual_comparison': True, + 'include_performance_data': True, + 'template_style': 'professional' +} +``` + +## 🚨 故障排除 + +### 常见问题 +1. **连接失败** + - 检查Appium服务器状态 + - 验证设备连接和ADB访问 + - 确认设备名称配置正确 + +2. **模块导入错误** + - 安装所有必需依赖: `pip install -r requirements.txt` + - 检查Python版本兼容性 + - 验证虚拟环境激活状态 + +3. **视觉比对失败** + - 确保设计图为SVG格式 + - 检查图像文件路径 + - 验证cairosvg依赖安装 + +4. **报告生成失败** + - 检查输出目录权限 + - 验证Jinja2模板完整性 + - 确认数据格式正确性 + +### 功能选择指导 +- **只需要检查UI外观**: 使用F5(视觉比对) +- **需要检查布局结构**: 使用F6(XML分析+布局比对) +- **专门比对XML和SVG结构**: 使用`layout`命令 +- **SVG文件**: 自动支持双重比对(视觉+结构) +- **PNG/JPG文件**: 仅支持视觉比对 + +### 调试模式 +启用详细日志输出: +```python +import logging +logging.basicConfig(level=logging.DEBUG) +``` + +## 🤝 贡献指南 + +欢迎提交Issue和Pull Request来改进项目! + +### 开发环境设置 +1. Fork项目仓库 +2. 创建功能分支 +3. 安装开发依赖 +4. 运行测试套件 +5. 提交代码变更 + +## 📄 许可证 + +本项目采用MIT许可证,详见LICENSE文件。 + +--- + +**🎉 享受高效的UI测试体验!** \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..d7ec20e --- /dev/null +++ b/config.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +UI测试工具配置文件 +统一管理所有配置参数 +""" + +from pathlib import Path + +class Config: + """配置管理类""" + + # Appium连接配置 + APPIUM_SERVER_URL = 'http://localhost:4723' + + # 设备配置 + DEVICE_CAPABILITIES = { + 'platformName': 'Android', + 'automationName': 'uiautomator2', + 'deviceName': 'emulator-5554', + 'appPackage': 'com.example.myapplication', + 'appActivity': '.MainActivity', + 'noReset': True, + 'newCommandTimeout': 300 + } + + # 目录配置 + BASE_DIR = Path(".") + SCREENSHOTS_DIR = BASE_DIR / "screenshots" + XML_LAYOUTS_DIR = BASE_DIR / "xml_layouts" + DESIGN_REFERENCES_DIR = BASE_DIR / "design_references" + VISUAL_COMPARISONS_DIR = BASE_DIR / "visual_comparisons" + REPORTS_DIR = BASE_DIR / "reports" + + # 热键配置 + HOTKEYS = { + 'screenshot': 'F1', + 'save_xml': 'F2', + 'get_activity': 'F3', + 'element_info': 'F4', + 'visual_compare': 'F5', + 'analyze_page': 'F6', + 'monitor_performance': 'F7', + 'click_element': 'F8', + 'generate_report': 'F9', + 'quit': 'ctrl+q' + } + + # 视觉比对配置 + VISUAL_COMPARISON = { + 'similarity_threshold': 0.95, # 相似度阈值 + 'excellent_threshold': 0.95, # 优秀相似度阈值 + 'good_threshold': 0.85, # 良好相似度阈值 + 'diff_threshold': 50, # 差异检测阈值 + 'min_diff_area': 100, # 最小差异区域面积 + 'supported_formats': ['.svg'] + } + + # XML分析配置 + XML_ANALYSIS = { + 'min_clickable_size': 48, # 最小可点击尺寸 + 'max_text_length': 50, # 最大文本长度 + 'check_accessibility': True, # 检查可访问性 + 'check_duplicates': True # 检查重复ID + } + + # 性能监控配置 + PERFORMANCE = { + 'enable_monitoring': True, + 'memory_check_interval': 5, # 内存检查间隔(秒) + 'operation_timeout': 30 # 操作超时时间(秒) + } + + # 报告配置 + REPORT = { + 'default_format': 'html', # 默认报告格式 + 'generate_html': True, # 生成HTML报告 + 'include_screenshots': True, # 包含截图 + 'include_xml': True, # 包含XML分析 + 'include_performance': True # 包含性能数据 + } + + # 日志配置 + LOGGING = { + 'level': 'INFO', + 'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s', + 'file': 'ui_test.log' + } + + @classmethod + def setup_directories(cls): + """创建必要的目录""" + directories = [ + cls.SCREENSHOTS_DIR, + cls.XML_LAYOUTS_DIR, + cls.DESIGN_REFERENCES_DIR, + cls.VISUAL_COMPARISONS_DIR, + cls.REPORTS_DIR + ] + + for directory in directories: + directory.mkdir(exist_ok=True) + + return directories + + @classmethod + def get_supported_design_formats(cls): + """获取支持的设计文件格式""" + return cls.VISUAL_COMPARISON['supported_formats'] + + @classmethod + def is_svg_preferred(cls): + """SVG是否为首选格式""" + return True # SVG矢量格式,文件小,可缩放 \ No newline at end of file diff --git a/design_references/sample_design.svg b/design_references/sample_design.svg new file mode 100644 index 0000000..df82085 --- /dev/null +++ b/design_references/sample_design.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/reports/test_report_20251031_174221.html b/reports/test_report_20251031_174221.html new file mode 100644 index 0000000..cd291b0 --- /dev/null +++ b/reports/test_report_20251031_174221.html @@ -0,0 +1,325 @@ + + + + + + UI测试报告 + + + +
+
+

🤖 UI测试报告

+

生成时间: 2025-10-31 17:42:21

+
+ +
+

📊 测试概览

+
+
+

18

+

总元素数

+
+
+

1

+

发现问题

+
+
+

88.6%

+

视觉相似度

+
+
+

100%

+

性能评分

+
+
+ + +
+

视觉相似度进度:

+
+
+
+

89.0% 相似度

+
+ +
+ +
+

📱 XML布局分析

+
+
+

1

+

可点击元素

+
+
+

0

+

文本元素

+
+
+

9

+

最大层级深度

+
+
+ + +

🔍 发现的问题:

+ +
+ android.widget.Button: 可点击元素缺少content-desc + + (位置: 333,1518) + +
+ + + +
+ +
+

👁️ 视觉比对分析

+ +
+
+

88.6%

+

相似度得分

+
+
+

1

+

差异区域数量

+
+
+

244545.0

+

差异面积(像素)

+
+
+ + +
+ ⚠️ 视觉差异较大 (11.4%),建议检查UI实现 + 未通过阈值检查 +
+ + +
+ +
+

⚡ 性能数据

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
操作类型执行时间(秒)时间戳状态
xml_analysis0.06920251031_174305 + + 快速 + +
screenshot0.33420251031_174305 + + 快速 + +
screenshot0.27820251031_174305 + + 快速 + +
xml_analysis0.05520251031_174306 + + 快速 + +
+ +
+ +
+

📋 建议和总结

+ +
+ 🔧 发现 1 个布局问题,建议优化可访问性和用户体验 +
+ + + +
+ 🎨 视觉差异超过10%,建议检查UI实现是否符合设计要求 +
+ + + + + + + +
+ ✅ 测试完成,详细数据已记录。建议定期进行UI测试以确保应用质量。 +
+
+
+ + + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ec91f1f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,27 @@ +# 核心依赖 +Appium-Python-Client>=3.1.0 +keyboard>=0.13.5 +pathlib2>=2.3.7 +Pillow>=10.0.0 +opencv-python>=4.8.0 +numpy>=1.24.0 +lxml>=4.9.0 +jinja2>=3.1.0 +scikit-image>=0.21.0 +matplotlib>=3.7.0 +seaborn>=0.12.0 + +# SVG处理库 (选择安装其中一组) +# 选项1: Selenium (推荐,浏览器渲染SVG,无需额外系统库) +selenium>=4.15.0 +webdriver-manager>=4.0.0 + +# 选项2: ReportLab + svglib (在Windows上需要Cairo库,可能安装困难) +reportlab>=4.0.0 +svglib>=1.5.0 + +# 选项3: CairoSVG (需要Cairo系统库,Windows下安装困难) +# cairosvg>=2.7.0 + +# 选项4: Wand (需要ImageMagick,安装复杂) +# Wand>=0.6.0 \ No newline at end of file diff --git a/screenshots/screenshot_20251031_174424.png b/screenshots/screenshot_20251031_174424.png new file mode 100644 index 0000000..e91573f Binary files /dev/null and b/screenshots/screenshot_20251031_174424.png differ diff --git a/test_reporter.py b/test_reporter.py new file mode 100644 index 0000000..b7529cd --- /dev/null +++ b/test_reporter.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +""" +测试报告生成器模块 +用于生成JSON和HTML格式的测试报告 +""" + +import json +import datetime +from pathlib import Path +from jinja2 import Template +from config import Config + + +class TestReporter: + """测试报告生成器""" + + def __init__(self): + self.reports_dir = Config.REPORTS_DIR + self.reports_dir.mkdir(exist_ok=True) + self.config = Config.REPORT + self.test_data = { + 'timestamp': datetime.datetime.now(), + 'xml_analysis': {}, + 'visual_comparison': {}, + 'performance_data': {}, + 'issues': [], + 'test_summary': {} + } + + def add_xml_analysis(self, analyzer): + """添加XML分析结果""" + self.test_data['xml_analysis'] = { + 'statistics': analyzer.get_statistics(), + 'issues': analyzer.issues, + 'elements_count': len(analyzer.elements_data), + 'duplicate_ids': analyzer.find_duplicate_ids(), + 'accessibility_issues': analyzer.get_accessibility_issues() + } + + def add_visual_comparison(self, comparator): + """添加视觉比对结果""" + if comparator.comparison_results: + result = comparator.comparison_results[-1] + summary = comparator.get_comparison_summary() + + self.test_data['visual_comparison'] = { + 'similarity_score': result['similarity_score'], + 'diff_regions_count': len(result['diff_regions']), + 'diff_percentage': result['diff_percentage'], + 'total_diff_area': result['total_diff_area'], + 'meets_threshold': result['meets_threshold'], + 'summary': summary + } + + def add_performance_data(self, data): + """添加性能数据""" + self.test_data['performance_data'] = data + + def add_test_summary(self, summary): + """添加测试摘要""" + self.test_data['test_summary'] = summary + + def generate_report(self, format=None): + """生成测试报告""" + if format is None: + format = self.config['default_format'] + + timestamp = self.test_data['timestamp'].strftime("%Y%m%d_%H%M%S") + + if format == 'json': + report_path = self.reports_dir / f"test_report_{timestamp}.json" + self._generate_json_report(report_path) + + elif format == 'html': + report_path = self.reports_dir / f"test_report_{timestamp}.html" + self._generate_html_report(report_path) + + else: + raise ValueError(f"不支持的报告格式: {format}") + + print(f"📊 测试报告已生成: {report_path}") + return report_path + + def _generate_json_report(self, output_path): + """生成JSON格式报告""" + with open(output_path, 'w', encoding='utf-8') as f: + json.dump(self.test_data, f, ensure_ascii=False, indent=2, default=str) + + def _generate_html_report(self, output_path): + """生成HTML格式报告""" + html_template = """ + + + + + UI测试报告 + + + +
+
+

🤖 UI测试报告

+

生成时间: {{ timestamp }}

+
+ +
+

📊 测试概览

+
+
+

{{ xml_stats.total_elements or 0 }}

+

总元素数

+
+
+

{{ xml_stats.issues_count or 0 }}

+

发现问题

+
+
+

{{ visual_similarity }}%

+

视觉相似度

+
+
+

{{ performance_score }}%

+

性能评分

+
+
+ + {% if visual_data and visual_data.similarity_score %} +
+

视觉相似度进度:

+
+
+
+

{{ (visual_data.similarity_score * 100)|round }}% 相似度

+
+ {% endif %} +
+ +
+

📱 XML布局分析

+
+
+

{{ xml_stats.clickable_elements or 0 }}

+

可点击元素

+
+
+

{{ xml_stats.text_elements or 0 }}

+

文本元素

+
+
+

{{ xml_stats.max_depth or 0 }}

+

最大层级深度

+
+
+ + {% if xml_issues %} +

🔍 发现的问题:

+ {% for issue in xml_issues[:10] %} +
+ {{ issue.element }}: {{ issue.issue }} + {% if issue.bounds %} + (位置: {{ issue.bounds.x1 }},{{ issue.bounds.y1 }}) + {% endif %} +
+ {% endfor %} + {% if xml_issues|length > 10 %} +

... 还有 {{ xml_issues|length - 10 }} 个问题未显示

+ {% endif %} + {% else %} +
+ ✅ 未发现XML布局问题 +
+ {% endif %} +
+ +
+

👁️ 视觉比对分析

+ {% if visual_data %} +
+
+

{{ "%.1f"|format(visual_data.similarity_score * 100) }}%

+

相似度得分

+
+
+

{{ visual_data.diff_regions_count }}

+

差异区域数量

+
+
+

{{ visual_data.total_diff_area }}

+

差异面积(像素)

+
+
+ + {% if visual_data.meets_threshold %} +
+ ✅ 视觉效果良好,与设计图高度一致 + 通过阈值检查 +
+ {% else %} +
+ ⚠️ 视觉差异较大 ({{ "%.1f"|format(visual_data.diff_percentage) }}%),建议检查UI实现 + 未通过阈值检查 +
+ {% endif %} + {% else %} +
+ ❌ 未进行视觉比对分析 +
+ {% endif %} +
+ +
+

⚡ 性能数据

+ {% if performance_data and performance_data.operations %} + + + + + + + + + + + {% for op in performance_data.operations[-10:] %} + + + + + + + {% endfor %} + +
操作类型执行时间(秒)时间戳状态
{{ op.type }}{{ "%.3f"|format(op.time) }}{{ op.timestamp }} + {% if op.time < 1.0 %} + 快速 + {% elif op.time < 3.0 %} + 正常 + {% else %} + + {% endif %} +
+ {% else %} +
+ ❌ 无性能数据记录 +
+ {% endif %} +
+ +
+

📋 建议和总结

+ {% if xml_stats.issues_count and xml_stats.issues_count > 0 %} +
+ 🔧 发现 {{ xml_stats.issues_count }} 个布局问题,建议优化可访问性和用户体验 +
+ {% endif %} + + {% if visual_data and visual_data.diff_percentage > 10 %} +
+ 🎨 视觉差异超过10%,建议检查UI实现是否符合设计要求 +
+ {% endif %} + + {% if performance_data and performance_data.operations %} + {% set avg_time = (performance_data.operations | map(attribute='time') | sum) / (performance_data.operations | length) %} + {% if avg_time > 2.0 %} +
+ ⚡ 平均操作时间较长 ({{ "%.2f"|format(avg_time) }}秒),建议优化性能 +
+ {% endif %} + {% endif %} + +
+ ✅ 测试完成,详细数据已记录。建议定期进行UI测试以确保应用质量。 +
+
+
+ + + """ + + template = Template(html_template) + + xml_stats = self.test_data.get('xml_analysis', {}).get('statistics', {}) + visual_data = self.test_data.get('visual_comparison', {}) + performance_data = self.test_data.get('performance_data', {}) + + # 计算性能评分 + performance_score = 100 + if performance_data.get('operations'): + avg_time = sum(op.get('time', 0) for op in performance_data['operations']) / len(performance_data['operations']) + if avg_time > 3.0: + performance_score = 60 + elif avg_time > 1.0: + performance_score = 80 + + html_content = template.render( + timestamp=self.test_data['timestamp'].strftime("%Y-%m-%d %H:%M:%S"), + xml_stats=xml_stats, + xml_issues=self.test_data.get('xml_analysis', {}).get('issues', []), + visual_data=visual_data, + performance_data=performance_data, + visual_similarity=f"{visual_data.get('similarity_score', 0) * 100:.1f}" if visual_data else "N/A", + performance_score=performance_score + ) + + with open(output_path, 'w', encoding='utf-8') as f: + f.write(html_content) + + def get_report_summary(self): + """获取报告摘要""" + xml_stats = self.test_data.get('xml_analysis', {}).get('statistics', {}) + visual_data = self.test_data.get('visual_comparison', {}) + + summary = { + 'total_elements': xml_stats.get('total_elements', 0), + 'issues_found': xml_stats.get('issues_count', 0), + 'visual_similarity': visual_data.get('similarity_score', 0) * 100 if visual_data else 0, + 'test_passed': xml_stats.get('issues_count', 0) == 0 and visual_data.get('meets_threshold', False) + } + + return summary \ No newline at end of file diff --git a/ui_test.py b/ui_test.py new file mode 100644 index 0000000..7c7e6d9 --- /dev/null +++ b/ui_test.py @@ -0,0 +1,666 @@ +#!/usr/bin/env python3 +""" +Appium 交互式UI测试工具 - 主入口文件 +支持Android应用的UI自动化测试、XML布局分析、视觉比对等功能 +""" + +import os +import time +import threading +import datetime +import base64 +from pathlib import Path +from appium import webdriver +from appium.options.android import UiAutomator2Options +from appium.webdriver.common.appiumby import AppiumBy +import keyboard + +# 导入自定义模块 +from config import Config +from xml_analyzer import XMLAnalyzer +from visual_comparator import VisualComparator +from test_reporter import TestReporter + + +class InteractiveUITester: + """交互式UI测试工具""" + + def __init__(self): + self.driver = None + self.running = False + + # 使用配置文件中的设置 + self.screenshots_dir = Config.SCREENSHOTS_DIR + self.xml_dir = Config.XML_LAYOUTS_DIR + self.designs_dir = Config.DESIGN_REFERENCES_DIR + self.comparisons_dir = Config.VISUAL_COMPARISONS_DIR + + # 设置目录 + Config.setup_directories() + + # 初始化分析器 + self.xml_analyzer = XMLAnalyzer() + self.visual_comparator = VisualComparator() + self.test_reporter = TestReporter() + + # 性能监控数据 + self.performance_data = { + 'start_time': None, + 'operations': [], + 'memory_usage': [] + } + + # Appium配置 + self.capabilities = Config.DEVICE_CAPABILITIES + self.appium_server_url = Config.APPIUM_SERVER_URL + + def connect_device(self): + """连接到Appium服务器""" + try: + print("🔌 正在连接Appium服务器...") + self.driver = webdriver.Remote( + self.appium_server_url, + options=UiAutomator2Options().load_capabilities(self.capabilities) + ) + self.performance_data['start_time'] = time.time() + print("✅ 成功连接到设备!") + return True + except Exception as e: + print(f"❌ 连接失败: {e}") + return False + + def disconnect_device(self): + """断开设备连接""" + if self.driver: + try: + self.driver.quit() + print("🔌 已断开设备连接") + except: + pass + self.driver = None + + def take_screenshot(self): + """截图功能""" + if not self.driver: + print("❌ 设备未连接,无法截图") + return None + + try: + start_time = time.time() + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"screenshot_{timestamp}.png" + filepath = self.screenshots_dir / filename + + # 获取截图 + screenshot_base64 = self.driver.get_screenshot_as_base64() + screenshot_data = base64.b64decode(screenshot_base64) + + # 保存截图 + with open(filepath, 'wb') as f: + f.write(screenshot_data) + + # 记录性能数据 + operation_time = time.time() - start_time + self.performance_data['operations'].append({ + 'type': 'screenshot', + 'time': operation_time, + 'timestamp': timestamp + }) + + print(f"📸 截图已保存: {filepath}") + return filepath + + except Exception as e: + print(f"❌ 截图失败: {e}") + return None + + def save_page_xml(self): + """保存当前页面XML布局""" + if not self.driver: + print("❌ 设备未连接,无法获取XML") + return None + + try: + start_time = time.time() + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"layout_{timestamp}.xml" + filepath = self.xml_dir / filename + + # 获取页面源码 + page_source = self.driver.page_source + + # 保存XML + with open(filepath, 'w', encoding='utf-8') as f: + f.write(page_source) + + # 分析XML + if self.xml_analyzer.parse_xml(page_source): + stats = self.xml_analyzer.get_statistics() + print(f"📄 XML布局已保存: {filepath}") + print(f"📊 分析结果: {stats['total_elements']}个元素, {stats['issues_count']}个问题") + + # 显示主要问题 + if self.xml_analyzer.issues: + print("⚠️ 发现的主要问题:") + for issue in self.xml_analyzer.issues[:3]: # 只显示前3个 + print(f" • {issue['element']}: {issue['issue']}") + if len(self.xml_analyzer.issues) > 3: + print(f" ... 还有 {len(self.xml_analyzer.issues) - 3} 个问题") + + # 记录性能数据 + operation_time = time.time() - start_time + self.performance_data['operations'].append({ + 'type': 'xml_analysis', + 'time': operation_time, + 'timestamp': timestamp + }) + + return filepath + + except Exception as e: + print(f"❌ 保存XML失败: {e}") + return None + + def compare_with_design(self): + """与设计图进行视觉比对(仅视觉比对)""" + if not self.driver: + print("❌ 设备未连接,无法进行比对") + return + + # 检查设计图目录 + supported_formats = Config.get_supported_design_formats() + design_files = [] + for fmt in supported_formats: + design_files.extend(list(self.designs_dir.glob(f"*{fmt}"))) + + if not design_files: + print(f"❌ 未找到设计图,请将设计图放入 {self.designs_dir} 目录") + print(f"💡 支持的格式: {', '.join(supported_formats)}") + return + + # 截图 + screenshot_path = self.take_screenshot() + if not screenshot_path: + return + + try: + # 使用最新的设计图进行比对 + design_path = max(design_files, key=lambda x: x.stat().st_mtime) + print(f"🎨 使用设计图: {design_path.name}") + + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + comparison_path = self.comparisons_dir / f"comparison_{timestamp}.png" + + # 执行视觉比对 + print("👁️ 开始视觉比对...") + visual_result = self.visual_comparator.compare_images( + screenshot_path, design_path, comparison_path + ) + + if visual_result: + similarity = visual_result['similarity_score'] * 100 + diff_count = len(visual_result['diff_regions']) + + print(f"📊 视觉比对结果:") + print(f" 相似度: {similarity:.1f}%") + print(f" 差异区域: {diff_count}个") + print(f" 比对结果: {comparison_path}") + + if similarity >= Config.VISUAL_COMPARISON['excellent_threshold'] * 100: + print("✅ 视觉效果优秀,与设计高度一致") + elif similarity >= Config.VISUAL_COMPARISON['good_threshold'] * 100: + print("⚠️ 视觉效果良好,有轻微差异") + else: + print("❌ 视觉差异较大,建议检查实现") + + except Exception as e: + print(f"❌ 视觉比对失败: {e}") + + def compare_layout_only(self): + """仅进行XML-SVG布局比对""" + if not self.driver: + print("❌ 设备未连接,无法进行比对") + return + + # 检查SVG设计图 + svg_files = list(self.designs_dir.glob("*.svg")) + if not svg_files: + print(f"❌ 未找到SVG设计图,请将SVG文件放入 {self.designs_dir} 目录") + return + + # 保存当前页面XML + xml_path = self.save_page_xml() + if not xml_path: + return + + try: + # 使用最新的SVG设计图 + svg_path = max(svg_files, key=lambda x: x.stat().st_mtime) + print(f"📐 使用SVG设计图: {svg_path.name}") + + # 执行布局比对 + layout_result = self.xml_analyzer.compare_layouts(str(svg_path)) + + if layout_result: + print(f"\n📊 布局比对详细结果:") + print(f" XML元素数量: {layout_result['xml_elements_count']}") + print(f" SVG元素数量: {layout_result['svg_elements_count']}") + print(f" 匹配元素数量: {len(layout_result['matched_elements'])}") + print(f" 布局相似度: {layout_result['layout_similarity']:.1%}") + + # 显示匹配详情 + if layout_result['matched_elements']: + print(f"\n✅ 匹配的元素:") + for i, match in enumerate(layout_result['matched_elements'][:5]): # 只显示前5个 + xml_elem = match['xml_element'] + svg_elem = match['svg_element'] + print(f" {i+1}. {xml_elem['type']} ↔ {svg_elem['type']} (相似度: {match['similarity']:.1%})") + if xml_elem.get('text') or svg_elem.get('text'): + print(f" 文本: '{xml_elem.get('text', '')}' ↔ '{svg_elem.get('text', '')}'") + + # 显示未匹配元素 + if layout_result['unmatched_xml']: + print(f"\n⚠️ 未匹配的XML元素 ({len(layout_result['unmatched_xml'])}个):") + for i, elem in enumerate(layout_result['unmatched_xml'][:3]): # 只显示前3个 + print(f" {i+1}. {elem['type']} - '{elem.get('text', '无文本')}'") + + if layout_result['unmatched_svg']: + print(f"\n⚠️ 未匹配的SVG元素 ({len(layout_result['unmatched_svg'])}个):") + for i, elem in enumerate(layout_result['unmatched_svg'][:3]): # 只显示前3个 + print(f" {i+1}. {elem['type']} - '{elem.get('text', '无文本')}'") + + # 总结 + print(f"\n{self.xml_analyzer.get_layout_comparison_summary()}") + + except Exception as e: + print(f"❌ 布局比对失败: {e}") + + def analyze_current_page(self): + """分析当前页面(XML分析和布局比对)""" + if not self.driver: + print("❌ 设备未连接") + return + + print("🔍 开始综合页面分析...") + + # 1. 保存XML并分析 + xml_path = self.save_page_xml() + + # 2. 截图 + screenshot_path = self.take_screenshot() + + # 3. 检查是否有SVG设计图进行布局比对 + supported_formats = Config.get_supported_design_formats() + design_files = [] + for fmt in supported_formats: + design_files.extend(list(self.designs_dir.glob(f"*{fmt}"))) + + # 查找SVG设计图 + svg_files = [f for f in design_files if f.suffix.lower() == '.svg'] + + if svg_files: + print("\n📐 发现SVG设计图,开始布局比对分析...") + # 使用最新的SVG设计图 + svg_design = max(svg_files, key=lambda x: x.stat().st_mtime) + print(f"🎨 使用SVG设计图: {svg_design.name}") + + # 执行XML-SVG布局比对 + print("🔍 开始XML-SVG布局比对...") + layout_result = self.xml_analyzer.compare_layouts(str(svg_design)) + + if layout_result: + print(f"📐 布局比对结果:") + print(f" XML元素: {layout_result['xml_elements_count']}个") + print(f" SVG元素: {layout_result['svg_elements_count']}个") + print(f" 匹配元素: {len(layout_result['matched_elements'])}个") + print(f" 布局相似度: {layout_result['layout_similarity']:.1%}") + + if layout_result['layout_similarity'] >= 0.8: + print("✅ 布局高度一致") + elif layout_result['layout_similarity'] >= 0.6: + print("⚠️ 布局基本一致,有少量差异") + else: + print("❌ 布局差异较大,建议检查") + + # 显示未匹配的元素 + if layout_result['unmatched_xml']: + print(f" 未匹配XML元素: {len(layout_result['unmatched_xml'])}个") + if layout_result['unmatched_svg']: + print(f" 未匹配SVG元素: {len(layout_result['unmatched_svg'])}个") + else: + print("\n💡 未发现SVG设计图,跳过布局比对分析") + print(f" 提示:将SVG设计图放入 {self.designs_dir} 目录可进行布局比对") + + # 4. 生成报告 + print("\n📋 生成测试报告...") + self.generate_test_report() + + def generate_test_report(self): + """生成测试报告""" + try: + # 添加分析数据到报告 + self.test_reporter.add_xml_analysis(self.xml_analyzer) + self.test_reporter.add_visual_comparison(self.visual_comparator) + self.test_reporter.add_performance_data(self.performance_data) + + # 生成报告 + report_format = Config.REPORT['default_format'] + report_path = self.test_reporter.generate_report(report_format) + + # 如果配置了生成HTML报告,也生成HTML版本 + if Config.REPORT['generate_html'] and report_format != 'html': + html_report = self.test_reporter.generate_report('html') + print(f"📊 HTML报告: {html_report}") + + except Exception as e: + print(f"❌ 生成报告失败: {e}") + + def find_and_click_element(self): + """智能元素定位和点击""" + if not self.driver: + print("❌ 设备未连接") + return + + try: + # 获取所有可点击元素 + clickable_elements = self.driver.find_elements(AppiumBy.XPATH, '//*[@clickable="true"]') + + if not clickable_elements: + print("❌ 未找到可点击元素") + return + + print(f"🔘 找到 {len(clickable_elements)} 个可点击元素:") + + for i, element in enumerate(clickable_elements[:10]): # 显示前10个 + try: + text = element.text or element.get_attribute('content-desc') or f"元素{i+1}" + bounds = element.get_attribute('bounds') + print(f" {i+1}. {text} - {bounds}") + except: + print(f" {i+1}. 未知元素") + + # 让用户选择要点击的元素 + try: + choice = input("\n请输入要点击的元素编号 (1-10, 或按Enter跳过): ").strip() + if choice and choice.isdigit(): + index = int(choice) - 1 + if 0 <= index < len(clickable_elements): + element = clickable_elements[index] + element.click() + print(f"✅ 已点击元素 {choice}") + time.sleep(1) # 等待页面响应 + else: + print("❌ 无效的元素编号") + except: + pass + + except Exception as e: + print(f"❌ 元素定位失败: {e}") + + def monitor_performance(self): + """性能监控""" + if not self.driver: + print("❌ 设备未连接") + return + + try: + # 获取设备信息 + device_info = { + 'platform': self.driver.capabilities.get('platformName'), + 'device_name': self.driver.capabilities.get('deviceName'), + 'app_package': self.driver.current_package, + 'app_activity': self.driver.current_activity + } + + # 计算运行时间 + if self.performance_data['start_time']: + runtime = time.time() - self.performance_data['start_time'] + print(f"⏱️ 性能监控:") + print(f" 运行时间: {runtime:.1f}秒") + print(f" 操作次数: {len(self.performance_data['operations'])}") + + if self.performance_data['operations']: + avg_time = sum(op['time'] for op in self.performance_data['operations']) / len(self.performance_data['operations']) + print(f" 平均操作时间: {avg_time:.2f}秒") + + print(f" 当前应用: {device_info['app_package']}") + print(f" 当前Activity: {device_info['app_activity']}") + + except Exception as e: + print(f"❌ 性能监控失败: {e}") + + def get_current_activity(self): + """获取当前Activity信息""" + if not self.driver: + print("❌ 设备未连接") + return + + try: + current_activity = self.driver.current_activity + current_package = self.driver.current_package + print(f"📱 当前应用: {current_package}") + print(f"📱 当前Activity: {current_activity}") + except Exception as e: + print(f"❌ 获取Activity信息失败: {e}") + + def find_elements_info(self): + """显示当前页面元素信息""" + if not self.driver: + print("❌ 设备未连接") + return + + try: + # 查找所有可点击元素 + clickable_elements = self.driver.find_elements(AppiumBy.XPATH, '//*[@clickable="true"]') + print(f"🔘 找到 {len(clickable_elements)} 个可点击元素") + + # 查找所有文本元素 + text_elements = self.driver.find_elements(AppiumBy.XPATH, '//*[@text!=""]') + print(f"📝 找到 {len(text_elements)} 个文本元素") + + # 显示前5个文本元素 + print("📝 前5个文本元素:") + for i, element in enumerate(text_elements[:5]): + try: + text = element.text + bounds = element.get_attribute('bounds') + print(f" {i+1}. '{text}' - 位置: {bounds}") + except: + pass + + except Exception as e: + print(f"❌ 获取元素信息失败: {e}") + + def setup_hotkeys(self): + """设置热键监听""" + print("⌨️ 设置热键监听...") + + # 从配置文件获取热键设置 + hotkeys = Config.HOTKEYS + + # 基础功能热键 + keyboard.add_hotkey(hotkeys['screenshot'], self.take_screenshot) + print(f" {hotkeys['screenshot'].upper()} - 截图") + + keyboard.add_hotkey(hotkeys['save_xml'], self.save_page_xml) + print(f" {hotkeys['save_xml'].upper()} - 保存XML布局") + + keyboard.add_hotkey(hotkeys['get_activity'], self.get_current_activity) + print(f" {hotkeys['get_activity'].upper()} - 获取当前Activity") + + keyboard.add_hotkey(hotkeys['element_info'], self.find_elements_info) + print(f" {hotkeys['element_info'].upper()} - 显示页面元素信息") + + # 新增功能热键 + keyboard.add_hotkey(hotkeys['visual_compare'], self.compare_with_design) + print(f" {hotkeys['visual_compare'].upper()} - 视觉比对") + + keyboard.add_hotkey(hotkeys['analyze_page'], self.analyze_current_page) + print(f" {hotkeys['analyze_page'].upper()} - 综合页面分析") + + keyboard.add_hotkey(hotkeys['monitor_performance'], self.monitor_performance) + print(f" {hotkeys['monitor_performance'].upper()} - 性能监控") + + keyboard.add_hotkey(hotkeys['click_element'], self.find_and_click_element) + print(f" {hotkeys['click_element'].upper()} - 智能元素点击") + + keyboard.add_hotkey(hotkeys['generate_report'], self.generate_test_report) + print(f" {hotkeys['generate_report'].upper()} - 生成测试报告") + + # 退出热键 + keyboard.add_hotkey(hotkeys['quit'], self.stop) + print(f" {hotkeys['quit'].upper()} - 退出程序") + + def show_menu(self): + """显示操作菜单""" + hotkeys = Config.HOTKEYS + supported_formats = Config.get_supported_design_formats() + + print("\n" + "="*60) + print("🤖 Appium 交互式UI测试工具") + print("="*60) + print("热键操作:") + print(f" {hotkeys['screenshot'].upper()} - 截图") + print(f" {hotkeys['save_xml'].upper()} - 保存XML布局") + print(f" {hotkeys['get_activity'].upper()} - 获取当前Activity") + print(f" {hotkeys['element_info'].upper()} - 显示页面元素信息") + print(f" {hotkeys['visual_compare'].upper()} - 视觉比对(仅图像对比)") + print(f" {hotkeys['analyze_page'].upper()} - XML分析和布局比对") + print(f" {hotkeys['monitor_performance'].upper()} - 性能监控") + print(f" {hotkeys['click_element'].upper()} - 智能元素点击") + print(f" {hotkeys['generate_report'].upper()} - 生成测试报告") + print(f" {hotkeys['quit'].upper()} - 退出程序") + print("\n命令行操作:") + print(" help - 显示帮助") + print(" status - 显示连接状态") + print(" reconnect - 重新连接设备") + print(" compare - 视觉比对(仅图像对比)") + print(" layout - XML-SVG布局比对") + print(" analyze - XML分析和布局比对") + print(" performance - 性能监控") + print(" click - 智能元素点击") + print(" report - 生成测试报告") + print(" quit - 退出程序") + print("\n💡 使用提示:") + print(f" • F5/compare: 纯视觉图像比对,检查UI外观") + print(f" • F6/analyze: XML结构分析 + SVG布局比对") + print(f" • layout: 专门的XML-SVG布局结构比对") + print(f" • 将设计图放入 {self.designs_dir} 目录") + print(f" • 支持的设计图格式: {', '.join(supported_formats)}") + print(" • 测试报告会自动保存为JSON和HTML格式") + print("="*60) + + def handle_command(self, command): + """处理命令行输入""" + command = command.strip().lower() + + if command == 'help': + self.show_menu() + elif command == 'status': + if self.driver: + print("✅ 设备已连接") + self.get_current_activity() + else: + print("❌ 设备未连接") + elif command == 'reconnect': + self.disconnect_device() + self.connect_device() + elif command in ['quit', 'exit', 'q']: + self.stop() + elif command == '1' or command == 'screenshot': + self.take_screenshot() + elif command == '2' or command == 'xml': + self.save_page_xml() + elif command == '3' or command == 'activity': + self.get_current_activity() + elif command == '4' or command == 'elements': + self.find_elements_info() + elif command == '5' or command == 'compare': + self.compare_with_design() + elif command == 'layout': + self.compare_layout_only() + elif command == '6' or command == 'analyze': + self.analyze_current_page() + elif command == '7' or command == 'performance': + self.monitor_performance() + elif command == '8' or command == 'click': + self.find_and_click_element() + elif command == '9' or command == 'report': + self.generate_test_report() + else: + print(f"❓ 未知命令: {command}") + print("输入 'help' 查看可用命令") + + def command_loop(self): + """命令行输入循环""" + while self.running: + try: + command = input("\n> ").strip() + if command: + self.handle_command(command) + except (KeyboardInterrupt, EOFError): + self.stop() + break + except Exception as e: + print(f"❌ 命令执行错误: {e}") + + def stop(self): + """停止程序""" + print("\n🛑 正在退出...") + self.running = False + self.disconnect_device() + + def run(self): + """运行交互式测试工具""" + print("🚀 启动Appium交互式UI测试工具") + + # 连接设备 + if not self.connect_device(): + print("❌ 无法连接到设备,请检查Appium服务器是否启动") + return + + # 设置热键 + self.setup_hotkeys() + + # 显示菜单 + self.show_menu() + + # 标记为运行状态 + self.running = True + + # 启动命令行输入线程 + command_thread = threading.Thread(target=self.command_loop, daemon=True) + command_thread.start() + + print("\n✅ 工具已启动! 使用热键或输入命令进行操作...") + print("💡 提示: 输入 'help' 查看所有可用命令") + + try: + # 保持主线程运行 + while self.running: + time.sleep(0.1) + except KeyboardInterrupt: + self.stop() + + print("👋 程序已退出") + + +def main(): + """主函数""" + print("🎯 交互式UI测试工具") + print("📱 支持Android应用UI测试") + print("🔧 集成XML分析、视觉比对、性能监控等功能") + print("-" * 50) + + tester = InteractiveUITester() + + try: + tester.run() + except Exception as e: + print(f"❌ 程序运行出错: {e}") + finally: + print("🔚 程序已退出") + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/visual_comparator.py b/visual_comparator.py new file mode 100644 index 0000000..1d8efdc --- /dev/null +++ b/visual_comparator.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python3 +""" +视觉比对器模块 +用于比较截图与设计图的视觉差异,支持PNG、JPG和SVG格式 +""" + +import cv2 +import numpy as np +from pathlib import Path +from skimage.metrics import structural_similarity as ssim +from PIL import Image +from config import Config + +# 条件导入cairosvg,如果失败则使用备用方案 +CAIROSVG_AVAILABLE = False +try: + import cairosvg + CAIROSVG_AVAILABLE = True + print("✅ CairoSVG库可用,支持完整SVG处理") +except (ImportError, OSError) as e: + print(f"⚠️ CairoSVG库不可用: {e}") + print("💡 将使用备用的SVG处理方案") + +# 条件导入selenium,用于SVG渲染 +SELENIUM_AVAILABLE = False +try: + from selenium import webdriver + from selenium.webdriver.chrome.options import Options + from selenium.webdriver.chrome.service import Service + from selenium.webdriver.common.by import By + from webdriver_manager.chrome import ChromeDriverManager + import base64 + SELENIUM_AVAILABLE = True + print("✅ Selenium库可用,支持浏览器SVG渲染") +except ImportError as e: + print(f"⚠️ Selenium库不可用: {e}") + print("💡 将跳过浏览器SVG渲染方案") + + +class VisualComparator: + """视觉比对器""" + + def __init__(self): + self.comparison_results = [] + self.config = Config.VISUAL_COMPARISON + + def compare_images(self, screenshot_path, design_path, output_path=None): + """比较截图和设计图""" + try: + # 读取图像 + screenshot = self._load_image(screenshot_path) + design = self._load_image(design_path) + + if screenshot is None or design is None: + print(f"❌ 无法加载图像: {screenshot_path} 或 {design_path}") + return None + + # 智能调整图像尺寸,保持宽高比 + screenshot_processed, design_processed = self._smart_resize_images(screenshot, design) + + # 转换为灰度图 + gray1 = cv2.cvtColor(screenshot_processed, cv2.COLOR_BGR2GRAY) + gray2 = cv2.cvtColor(design_processed, cv2.COLOR_BGR2GRAY) + + # 计算结构相似性 + similarity_score, diff_image = ssim(gray1, gray2, full=True) + diff_image = (diff_image * 255).astype(np.uint8) + + # 查找差异区域 + diff_regions = self._find_diff_regions(diff_image) + + # 生成比对结果图 + if output_path: + self._generate_comparison_image( + screenshot_processed, design_processed, diff_image, + diff_regions, similarity_score, output_path + ) + + result = { + 'similarity_score': similarity_score, + 'diff_regions': diff_regions, + 'total_diff_area': sum(r['area'] for r in diff_regions), + 'diff_percentage': (1 - similarity_score) * 100, + 'meets_threshold': similarity_score >= self.config['similarity_threshold'], + 'original_screenshot_size': screenshot.shape[:2], + 'original_design_size': design.shape[:2], + 'processed_size': screenshot_processed.shape[:2] + } + + self.comparison_results.append(result) + return result + + except Exception as e: + print(f"❌ 图像比对失败: {e}") + return None + + def _load_image(self, image_path): + """加载图像,支持PNG、JPG和SVG格式""" + image_path = Path(image_path) + + if not image_path.exists(): + print(f"❌ 文件不存在: {image_path}") + return None + + # 检查文件格式 + if image_path.suffix.lower() == '.svg': + return self._load_svg_image(image_path) + else: + # 加载常规图像格式 + img = cv2.imread(str(image_path)) + if img is None: + print(f"❌ 无法读取图像文件: {image_path}") + return img + + def _load_svg_image(self, svg_path): + """加载SVG图像并转换为OpenCV格式""" + import io + + if CAIROSVG_AVAILABLE: + # 优先使用cairosvg + try: + # 将SVG转换为PNG字节数据 + png_data = cairosvg.svg2png(url=str(svg_path)) + + # 使用PIL加载PNG数据 + pil_image = Image.open(io.BytesIO(png_data)) + + # 转换为OpenCV格式 + opencv_image = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR) + + return opencv_image + + except Exception as e: + print(f"❌ CairoSVG处理失败: {e}") + + # 备用方案1: 尝试使用wand (ImageMagick) + try: + from wand.image import Image as WandImage + + with WandImage(filename=str(svg_path)) as img: + img.format = 'png' + blob = img.make_blob() + + pil_image = Image.open(io.BytesIO(blob)) + opencv_image = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR) + + print("✅ 使用Wand处理SVG成功") + return opencv_image + + except ImportError: + print("⚠️ Wand库不可用") + except Exception as e: + print(f"❌ Wand处理失败: {e}") + + # 备用方案2: 尝试使用reportlab和svglib + try: + from reportlab.graphics import renderPM + from svglib.svglib import renderSVG + + drawing = renderSVG(str(svg_path)) + pil_image = renderPM.drawToPIL(drawing) + opencv_image = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR) + + print("✅ 使用ReportLab处理SVG成功") + return opencv_image + + except ImportError: + print("⚠️ ReportLab/svglib库不可用") + except Exception as e: + print(f"❌ ReportLab处理失败: {e}") + + # 备用方案3: 尝试使用Selenium浏览器渲染 + if SELENIUM_AVAILABLE: + try: + result = self._render_svg_with_selenium(svg_path) + if result is not None: + print("✅ 使用Selenium浏览器渲染SVG成功") + return result + except Exception as e: + print(f"❌ Selenium渲染失败: {e}") + + # 备用方案4: 简单的文本提示图像 + print(f"⚠️ 无法处理SVG文件: {svg_path}") + print("💡 建议安装以下任一库来支持SVG:") + print(" - pip install cairosvg (推荐,但需要Cairo库)") + print(" - pip install Wand (需要ImageMagick)") + print(" - pip install reportlab svglib") + + # 创建一个提示图像 + placeholder_img = np.ones((400, 600, 3), dtype=np.uint8) * 240 + cv2.putText(placeholder_img, "SVG Not Supported", (150, 180), + cv2.FONT_HERSHEY_SIMPLEX, 1, (100, 100, 100), 2) + cv2.putText(placeholder_img, "Install SVG library", (150, 220), + cv2.FONT_HERSHEY_SIMPLEX, 1, (100, 100, 100), 2) + + return placeholder_img + + def _render_svg_with_selenium(self, svg_path): + """使用Selenium浏览器渲染SVG""" + try: + # 配置Chrome选项 + chrome_options = Options() + chrome_options.add_argument('--headless') # 无头模式 + chrome_options.add_argument('--no-sandbox') + chrome_options.add_argument('--disable-dev-shm-usage') + chrome_options.add_argument('--disable-gpu') + chrome_options.add_argument('--disable-web-security') + chrome_options.add_argument('--allow-running-insecure-content') + chrome_options.add_argument('--disable-extensions') + chrome_options.add_argument('--window-size=1200,800') + + # 创建WebDriver + service = Service(ChromeDriverManager().install()) + driver = webdriver.Chrome(service=service, options=chrome_options) + + try: + # 读取SVG文件内容 + with open(svg_path, 'r', encoding='utf-8') as f: + svg_content = f.read() + + # 创建HTML页面包含SVG + html_content = f""" + + + + + + + {svg_content} + +""" + + # 创建临时HTML文件 + import tempfile + import os + + with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False, encoding='utf-8') as f: + f.write(html_content) + temp_html_path = f.name + + try: + # 使用file:// URL加载本地文件 + file_url = f"file:///{temp_html_path.replace(os.sep, '/')}" + driver.get(file_url) + + # 等待页面加载 + import time + time.sleep(3) + + # 获取SVG元素 + svg_element = driver.find_element(By.TAG_NAME, "svg") + + # 截图SVG元素 + screenshot = svg_element.screenshot_as_png + + # 转换为OpenCV格式 + import io + pil_image = Image.open(io.BytesIO(screenshot)) + opencv_image = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR) + + return opencv_image + + finally: + # 清理临时文件 + try: + os.unlink(temp_html_path) + except: + pass + + finally: + driver.quit() + + except Exception as e: + print(f"Selenium渲染SVG时出错: {e}") + return None + + def _smart_resize_images(self, img1, img2): + """智能调整图像尺寸,保持宽高比""" + h1, w1 = img1.shape[:2] + h2, w2 = img2.shape[:2] + + # 计算宽高比 + ratio1 = w1 / h1 + ratio2 = w2 / h2 + + # 如果宽高比相近(差异小于10%),则按较小的尺寸调整 + if abs(ratio1 - ratio2) / max(ratio1, ratio2) < 0.1: + # 选择较小的尺寸作为目标尺寸 + target_w = min(w1, w2) + target_h = min(h1, h2) + + img1_resized = cv2.resize(img1, (target_w, target_h)) + img2_resized = cv2.resize(img2, (target_w, target_h)) + + return img1_resized, img2_resized + + # 如果宽高比差异较大,则保持原始比例,按最大公共区域对齐 + else: + # 计算最大公共尺寸,保持各自的宽高比 + if ratio1 > ratio2: # img1更宽 + # 以img2的高度为基准 + new_h = min(h1, h2) + new_w1 = int(new_h * ratio1) + new_w2 = int(new_h * ratio2) + else: # img2更宽或相等 + # 以img1的高度为基准 + new_h = min(h1, h2) + new_w1 = int(new_h * ratio1) + new_w2 = int(new_h * ratio2) + + # 调整图像尺寸 + img1_resized = cv2.resize(img1, (new_w1, new_h)) + img2_resized = cv2.resize(img2, (new_w2, new_h)) + + # 如果宽度不同,则创建相同宽度的画布,居中放置 + max_w = max(new_w1, new_w2) + + if new_w1 != max_w: + canvas1 = np.ones((new_h, max_w, 3), dtype=np.uint8) * 255 + offset = (max_w - new_w1) // 2 + canvas1[:, offset:offset+new_w1] = img1_resized + img1_resized = canvas1 + + if new_w2 != max_w: + canvas2 = np.ones((new_h, max_w, 3), dtype=np.uint8) * 255 + offset = (max_w - new_w2) // 2 + canvas2[:, offset:offset+new_w2] = img2_resized + img2_resized = canvas2 + + return img1_resized, img2_resized + + def _resize_to_match(self, img1, img2): + """调整图像尺寸匹配(保留原方法作为备用)""" + h2, w2 = img2.shape[:2] + return cv2.resize(img1, (w2, h2)) + + def _find_diff_regions(self, diff_image): + """查找差异区域""" + threshold = self.config['diff_threshold'] + min_area = self.config['min_diff_area'] + + # 二值化差异图像 + _, binary = cv2.threshold(diff_image, threshold, 255, cv2.THRESH_BINARY) + + # 查找轮廓 + contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + regions = [] + for contour in contours: + area = cv2.contourArea(contour) + if area > min_area: # 过滤小差异 + x, y, w, h = cv2.boundingRect(contour) + regions.append({ + 'x': x, 'y': y, 'width': w, 'height': h, + 'area': area, + 'center_x': x + w//2, 'center_y': y + h//2 + }) + + return regions + + def _generate_comparison_image(self, img1, img2, diff_img, regions, score, output_path): + """生成比对结果图像""" + # 创建拼接图像 + h, w = img1.shape[:2] + result_img = np.zeros((h, w*3, 3), dtype=np.uint8) + + # 放置原图像 + result_img[:, :w] = img1 + result_img[:, w:2*w] = img2 + result_img[:, 2*w:] = cv2.cvtColor(diff_img, cv2.COLOR_GRAY2BGR) + + # 标记差异区域 + for region in regions: + x, y, rw, rh = region['x'], region['y'], region['width'], region['height'] + # 在截图上标记 + cv2.rectangle(result_img, (x, y), (x+rw, y+rh), (0, 0, 255), 2) + # 在设计图上标记 + cv2.rectangle(result_img, (w+x, y), (w+x+rw, y+rh), (0, 0, 255), 2) + + # 添加文本信息 + font = cv2.FONT_HERSHEY_SIMPLEX + + # 相似度信息 + similarity_text = f"Similarity: {score:.2%}" + cv2.putText(result_img, similarity_text, (10, 30), font, 1, (255, 255, 255), 2) + + # 差异统计 + diff_count = len(regions) + diff_text = f"Differences: {diff_count}" + cv2.putText(result_img, diff_text, (10, 70), font, 0.8, (255, 255, 255), 2) + + # 标签 + cv2.putText(result_img, "Screenshot", (10, h-10), font, 0.7, (255, 255, 255), 2) + cv2.putText(result_img, "Design", (w+10, h-10), font, 0.7, (255, 255, 255), 2) + cv2.putText(result_img, "Difference", (2*w+10, h-10), font, 0.7, (255, 255, 255), 2) + + # 保存结果 + cv2.imwrite(str(output_path), result_img) + print(f"📊 比对结果已保存: {output_path}") + + def get_comparison_summary(self): + """获取比对结果摘要""" + if not self.comparison_results: + return None + + latest_result = self.comparison_results[-1] + + summary = { + 'total_comparisons': len(self.comparison_results), + 'latest_similarity': latest_result['similarity_score'], + 'latest_diff_count': len(latest_result['diff_regions']), + 'meets_threshold': latest_result['meets_threshold'], + 'average_similarity': sum(r['similarity_score'] for r in self.comparison_results) / len(self.comparison_results) + } + + return summary + + def find_design_files(self, design_dir): + """查找设计目录中的所有支持格式文件""" + design_dir = Path(design_dir) + supported_formats = self.config['supported_formats'] + + design_files = [] + for format_ext in supported_formats: + design_files.extend(design_dir.glob(f"*{format_ext}")) + + # 优先返回SVG文件 + svg_files = [f for f in design_files if f.suffix.lower() == '.svg'] + other_files = [f for f in design_files if f.suffix.lower() != '.svg'] + + return svg_files + other_files + + def batch_compare(self, screenshot_path, design_dir, output_dir=None): + """批量比对截图与设计目录中的所有文件""" + design_files = self.find_design_files(design_dir) + + if not design_files: + print(f"❌ 在 {design_dir} 中未找到支持的设计文件") + return [] + + results = [] + for design_file in design_files: + print(f"🔍 比对设计文件: {design_file.name}") + + output_path = None + if output_dir: + output_dir = Path(output_dir) + output_dir.mkdir(exist_ok=True) + output_path = output_dir / f"comparison_{design_file.stem}.png" + + result = self.compare_images(screenshot_path, design_file, output_path) + if result: + result['design_file'] = str(design_file) + results.append(result) + + return results \ No newline at end of file diff --git a/visual_comparisons/comparison_20251031_174425.png b/visual_comparisons/comparison_20251031_174425.png new file mode 100644 index 0000000..8dbface Binary files /dev/null and b/visual_comparisons/comparison_20251031_174425.png differ diff --git a/xml_analyzer.py b/xml_analyzer.py new file mode 100644 index 0000000..19b6ca5 --- /dev/null +++ b/xml_analyzer.py @@ -0,0 +1,658 @@ +#!/usr/bin/env python3 +""" +XML布局分析器模块 +用于解析和分析Android UI XML布局文件,以及SVG设计图的布局结构 +""" + +from lxml import etree +import re +from pathlib import Path +from config import Config + + +class XMLAnalyzer: + """XML布局分析器""" + + def __init__(self): + self.elements_data = [] + self.issues = [] + self.svg_elements = [] + self.layout_comparison_result = None + self.config = Config.XML_ANALYSIS + + def parse_xml(self, xml_content): + """解析XML内容""" + try: + # 如果xml_content是字符串且包含编码声明,需要特殊处理 + if isinstance(xml_content, str): + # 移除XML声明,因为etree.fromstring不支持带编码声明的Unicode字符串 + if xml_content.strip().startswith('') + 2 + xml_content = xml_content[declaration_end:].strip() + + root = etree.fromstring(xml_content) + self.elements_data = [] + self.issues = [] + self._parse_element(root, 0) + return True + except Exception as e: + print(f"❌ XML解析失败: {e}") + return False + + def _parse_element(self, element, depth): + """递归解析XML元素""" + # 提取元素信息 + element_info = { + 'tag': element.tag, + 'depth': depth, + 'attributes': dict(element.attrib), + 'text': element.text.strip() if element.text else '', + 'children_count': len(element) + } + + # 解析bounds属性 + bounds = element.get('bounds', '') + if bounds: + try: + # bounds格式: [x1,y1][x2,y2] + coords = bounds.replace('[', '').replace(']', ',').split(',') + if len(coords) >= 4: + x1, y1, x2, y2 = map(int, coords[:4]) + element_info['bounds'] = { + 'x1': x1, 'y1': y1, 'x2': x2, 'y2': y2, + 'width': x2 - x1, 'height': y2 - y1, + 'center_x': (x1 + x2) // 2, 'center_y': (y1 + y2) // 2 + } + except: + pass + + # 检查常见问题 + if self.config['check_accessibility'] or self.config['check_duplicates']: + self._check_element_issues(element_info) + + self.elements_data.append(element_info) + + # 递归处理子元素 + for child in element: + self._parse_element(child, depth + 1) + + def _check_element_issues(self, element_info): + """检查元素问题""" + issues = [] + + # 检查可访问性 + if self.config['check_accessibility']: + if element_info.get('attributes', {}).get('clickable') == 'true': + if not element_info.get('attributes', {}).get('content-desc'): + issues.append("可点击元素缺少content-desc") + + # 检查文本大小 + if 'bounds' in element_info: + bounds = element_info['bounds'] + min_size = self.config['min_clickable_size'] + if bounds['width'] < min_size or bounds['height'] < min_size: + if element_info.get('attributes', {}).get('clickable') == 'true': + issues.append(f"可点击元素尺寸过小: {bounds['width']}x{bounds['height']}") + + # 检查文本内容 + text = element_info.get('text', '') + if text: + max_length = self.config['max_text_length'] + if len(text) > max_length: + issues.append(f"文本过长: {len(text)}字符") + if text.lower() in ['click here', 'button', 'text']: + issues.append(f"文本描述不明确: '{text}'") + + # 检查重叠元素 + if 'bounds' in element_info: + bounds = element_info['bounds'] + if bounds['width'] <= 0 or bounds['height'] <= 0: + issues.append("元素尺寸无效") + + if issues: + self.issues.extend([{ + 'element': element_info['tag'], + 'issue': issue, + 'bounds': element_info.get('bounds', {}), + 'attributes': element_info.get('attributes', {}) + } for issue in issues]) + + def get_statistics(self): + """获取统计信息""" + stats = { + 'total_elements': len(self.elements_data), + 'clickable_elements': 0, + 'text_elements': 0, + 'image_elements': 0, + 'max_depth': 0, + 'issues_count': len(self.issues) + } + + for element in self.elements_data: + if element.get('attributes', {}).get('clickable') == 'true': + stats['clickable_elements'] += 1 + if element.get('text'): + stats['text_elements'] += 1 + if 'Image' in element.get('tag', ''): + stats['image_elements'] += 1 + stats['max_depth'] = max(stats['max_depth'], element.get('depth', 0)) + + return stats + + def find_duplicate_ids(self): + """查找重复的resource-id""" + if not self.config['check_duplicates']: + return [] + + id_counts = {} + duplicates = [] + + for element in self.elements_data: + resource_id = element.get('attributes', {}).get('resource-id') + if resource_id: + id_counts[resource_id] = id_counts.get(resource_id, 0) + 1 + + for resource_id, count in id_counts.items(): + if count > 1: + duplicates.append({ + 'resource_id': resource_id, + 'count': count + }) + + return duplicates + + def get_accessibility_issues(self): + """获取可访问性问题""" + accessibility_issues = [] + + for issue in self.issues: + if 'content-desc' in issue['issue'] or '可访问性' in issue['issue']: + accessibility_issues.append(issue) + + return accessibility_issues + + def export_elements_data(self, format='json'): + """导出元素数据""" + if format == 'json': + import json + return json.dumps(self.elements_data, ensure_ascii=False, indent=2) + elif format == 'csv': + import csv + import io + output = io.StringIO() + if self.elements_data: + fieldnames = ['tag', 'depth', 'text', 'clickable', 'resource-id'] + writer = csv.DictWriter(output, fieldnames=fieldnames) + writer.writeheader() + + for element in self.elements_data: + row = { + 'tag': element.get('tag', ''), + 'depth': element.get('depth', 0), + 'text': element.get('text', ''), + 'clickable': element.get('attributes', {}).get('clickable', 'false'), + 'resource-id': element.get('attributes', {}).get('resource-id', '') + } + writer.writerow(row) + + return output.getvalue() + + return str(self.elements_data) + + def parse_svg(self, svg_path): + """解析SVG文件,提取布局元素信息""" + try: + svg_path = Path(svg_path) + if not svg_path.exists(): + print(f"❌ SVG文件不存在: {svg_path}") + return False + + with open(svg_path, 'r', encoding='utf-8') as f: + svg_content = f.read() + + # 解析SVG XML + root = etree.fromstring(svg_content.encode('utf-8')) + + # 获取SVG的命名空间 + namespaces = {'svg': 'http://www.w3.org/2000/svg'} + if root.nsmap: + namespaces.update(root.nsmap) + + self.svg_elements = [] + self._parse_svg_element(root, 0, namespaces) + + print(f"✅ SVG解析完成,找到 {len(self.svg_elements)} 个元素") + return True + + except Exception as e: + print(f"❌ SVG解析失败: {e}") + return False + + def _parse_svg_element(self, element, depth, namespaces): + """递归解析SVG元素""" + # 获取元素标签名(去除命名空间前缀) + tag = element.tag + if '}' in tag: + tag = tag.split('}')[1] + + # 提取元素信息 + element_info = { + 'tag': tag, + 'depth': depth, + 'attributes': dict(element.attrib), + 'text': (element.text or '').strip(), + 'children_count': len(element) + } + + # 解析位置和尺寸信息 + self._extract_svg_geometry(element_info) + + # 解析样式信息 + self._extract_svg_styles(element_info) + + # 识别UI元素类型 + self._classify_svg_element(element_info) + + self.svg_elements.append(element_info) + + # 递归处理子元素 + for child in element: + self._parse_svg_element(child, depth + 1, namespaces) + + def _extract_svg_geometry(self, element_info): + """提取SVG元素的几何信息""" + attrs = element_info['attributes'] + tag = element_info['tag'] + + # 初始化几何信息 + geometry = { + 'x': 0, 'y': 0, 'width': 0, 'height': 0, + 'center_x': 0, 'center_y': 0 + } + + try: + if tag == 'rect': + geometry['x'] = float(attrs.get('x', 0)) + geometry['y'] = float(attrs.get('y', 0)) + geometry['width'] = float(attrs.get('width', 0)) + geometry['height'] = float(attrs.get('height', 0)) + + elif tag == 'circle': + cx = float(attrs.get('cx', 0)) + cy = float(attrs.get('cy', 0)) + r = float(attrs.get('r', 0)) + geometry['x'] = cx - r + geometry['y'] = cy - r + geometry['width'] = r * 2 + geometry['height'] = r * 2 + + elif tag == 'ellipse': + cx = float(attrs.get('cx', 0)) + cy = float(attrs.get('cy', 0)) + rx = float(attrs.get('rx', 0)) + ry = float(attrs.get('ry', 0)) + geometry['x'] = cx - rx + geometry['y'] = cy - ry + geometry['width'] = rx * 2 + geometry['height'] = ry * 2 + + elif tag == 'line': + x1 = float(attrs.get('x1', 0)) + y1 = float(attrs.get('y1', 0)) + x2 = float(attrs.get('x2', 0)) + y2 = float(attrs.get('y2', 0)) + geometry['x'] = min(x1, x2) + geometry['y'] = min(y1, y2) + geometry['width'] = abs(x2 - x1) + geometry['height'] = abs(y2 - y1) + + elif tag == 'text': + geometry['x'] = float(attrs.get('x', 0)) + geometry['y'] = float(attrs.get('y', 0)) + # 文本的宽高需要根据字体大小估算 + font_size = self._extract_font_size(attrs) + text_length = len(element_info.get('text', '')) + geometry['width'] = text_length * font_size * 0.6 # 估算宽度 + geometry['height'] = font_size + + elif tag == 'g': # 组元素 + # 对于组元素,尝试从transform属性获取位置 + transform = attrs.get('transform', '') + translate_match = re.search(r'translate\(([^)]+)\)', transform) + if translate_match: + coords = translate_match.group(1).split(',') + if len(coords) >= 2: + geometry['x'] = float(coords[0].strip()) + geometry['y'] = float(coords[1].strip()) + + # 计算中心点 + geometry['center_x'] = geometry['x'] + geometry['width'] / 2 + geometry['center_y'] = geometry['y'] + geometry['height'] / 2 + + element_info['geometry'] = geometry + + except (ValueError, TypeError): + # 如果解析失败,使用默认值 + element_info['geometry'] = geometry + + def _extract_font_size(self, attrs): + """提取字体大小""" + # 从style属性中提取 + style = attrs.get('style', '') + font_size_match = re.search(r'font-size:\s*(\d+(?:\.\d+)?)', style) + if font_size_match: + return float(font_size_match.group(1)) + + # 从font-size属性中提取 + font_size = attrs.get('font-size', '12') + try: + return float(re.sub(r'[^\d.]', '', font_size)) + except: + return 12.0 # 默认字体大小 + + def _extract_svg_styles(self, element_info): + """提取SVG元素的样式信息""" + attrs = element_info['attributes'] + styles = {} + + # 解析style属性 + style_attr = attrs.get('style', '') + if style_attr: + for style_rule in style_attr.split(';'): + if ':' in style_rule: + key, value = style_rule.split(':', 1) + styles[key.strip()] = value.strip() + + # 直接的样式属性 + style_attrs = ['fill', 'stroke', 'stroke-width', 'opacity', 'font-family', 'font-size', 'color'] + for attr in style_attrs: + if attr in attrs: + styles[attr] = attrs[attr] + + element_info['styles'] = styles + + def _classify_svg_element(self, element_info): + """分类SVG元素,识别可能的UI组件类型""" + tag = element_info['tag'] + attrs = element_info['attributes'] + styles = element_info.get('styles', {}) + text = element_info.get('text', '') + + ui_type = 'unknown' + + if tag == 'text' or text: + ui_type = 'text' + elif tag == 'rect': + # 判断是否为按钮 + if styles.get('fill') and styles.get('stroke'): + ui_type = 'button' + else: + ui_type = 'container' + elif tag == 'circle' or tag == 'ellipse': + ui_type = 'button' # 圆形通常是按钮 + elif tag == 'image': + ui_type = 'image' + elif tag == 'g': + ui_type = 'group' + elif tag == 'path': + ui_type = 'icon' # path通常用于图标 + + element_info['ui_type'] = ui_type + + def compare_layouts(self, svg_path): + """比较XML布局与SVG设计图的结构""" + if not self.elements_data: + print("❌ 请先解析XML布局") + return None + + if not self.parse_svg(svg_path): + return None + + try: + # 分析XML中的UI元素 + xml_ui_elements = self._extract_xml_ui_elements() + + # 分析SVG中的UI元素 + svg_ui_elements = self._extract_svg_ui_elements() + + # 进行布局比对 + comparison_result = { + 'xml_elements_count': len(xml_ui_elements), + 'svg_elements_count': len(svg_ui_elements), + 'matched_elements': [], + 'unmatched_xml': [], + 'unmatched_svg': [], + 'layout_similarity': 0.0, + 'position_differences': [], + 'size_differences': [] + } + + # 匹配相似的元素 + self._match_ui_elements(xml_ui_elements, svg_ui_elements, comparison_result) + + # 计算布局相似度 + self._calculate_layout_similarity(comparison_result) + + self.layout_comparison_result = comparison_result + + print(f"📊 布局比对完成:") + print(f" XML元素: {comparison_result['xml_elements_count']}个") + print(f" SVG元素: {comparison_result['svg_elements_count']}个") + print(f" 匹配元素: {len(comparison_result['matched_elements'])}个") + print(f" 布局相似度: {comparison_result['layout_similarity']:.1%}") + + return comparison_result + + except Exception as e: + print(f"❌ 布局比对失败: {e}") + return None + + def _extract_xml_ui_elements(self): + """从XML数据中提取UI元素""" + ui_elements = [] + + for element in self.elements_data: + # 过滤掉容器元素,只保留实际的UI组件 + if self._is_ui_component(element): + ui_element = { + 'type': self._classify_xml_element(element), + 'text': element.get('text', ''), + 'bounds': element.get('bounds', {}), + 'attributes': element.get('attributes', {}), + 'source': 'xml' + } + ui_elements.append(ui_element) + + return ui_elements + + def _extract_svg_ui_elements(self): + """从SVG数据中提取UI元素""" + ui_elements = [] + + for element in self.svg_elements: + if element['ui_type'] != 'unknown': + ui_element = { + 'type': element['ui_type'], + 'text': element.get('text', ''), + 'geometry': element.get('geometry', {}), + 'styles': element.get('styles', {}), + 'source': 'svg' + } + ui_elements.append(ui_element) + + return ui_elements + + def _is_ui_component(self, element): + """判断XML元素是否为UI组件""" + tag = element.get('tag', '') + attrs = element.get('attributes', {}) + + # 排除纯容器元素 + container_tags = ['LinearLayout', 'RelativeLayout', 'FrameLayout', 'ConstraintLayout'] + if any(container in tag for container in container_tags): + return False + + # 包含文本或可点击的元素 + if element.get('text') or attrs.get('clickable') == 'true': + return True + + # 特定的UI组件 + ui_tags = ['Button', 'TextView', 'ImageView', 'EditText', 'CheckBox', 'RadioButton'] + return any(ui_tag in tag for ui_tag in ui_tags) + + def _classify_xml_element(self, element): + """分类XML元素""" + tag = element.get('tag', '') + attrs = element.get('attributes', {}) + + if 'Button' in tag: + return 'button' + elif 'TextView' in tag or element.get('text'): + return 'text' + elif 'ImageView' in tag: + return 'image' + elif 'EditText' in tag: + return 'input' + elif attrs.get('clickable') == 'true': + return 'button' + else: + return 'container' + + def _match_ui_elements(self, xml_elements, svg_elements, result): + """匹配XML和SVG中的UI元素""" + matched_xml = set() + matched_svg = set() + + for i, xml_elem in enumerate(xml_elements): + best_match = None + best_score = 0 + + for j, svg_elem in enumerate(svg_elements): + if j in matched_svg: + continue + + score = self._calculate_element_similarity(xml_elem, svg_elem) + if score > best_score and score > 0.5: # 相似度阈值 + best_score = score + best_match = j + + if best_match is not None: + matched_xml.add(i) + matched_svg.add(best_match) + + match_info = { + 'xml_element': xml_elements[i], + 'svg_element': svg_elements[best_match], + 'similarity': best_score, + 'position_diff': self._calculate_position_difference( + xml_elements[i], svg_elements[best_match] + ), + 'size_diff': self._calculate_size_difference( + xml_elements[i], svg_elements[best_match] + ) + } + result['matched_elements'].append(match_info) + + # 记录未匹配的元素 + result['unmatched_xml'] = [xml_elements[i] for i in range(len(xml_elements)) if i not in matched_xml] + result['unmatched_svg'] = [svg_elements[j] for j in range(len(svg_elements)) if j not in matched_svg] + + def _calculate_element_similarity(self, xml_elem, svg_elem): + """计算两个元素的相似度""" + score = 0 + + # 类型匹配 + if xml_elem['type'] == svg_elem['type']: + score += 0.4 + + # 文本匹配 + xml_text = xml_elem.get('text', '').strip().lower() + svg_text = svg_elem.get('text', '').strip().lower() + + if xml_text and svg_text: + if xml_text == svg_text: + score += 0.4 + elif xml_text in svg_text or svg_text in xml_text: + score += 0.2 + elif not xml_text and not svg_text: + score += 0.2 + + # 位置相似度(相对位置) + position_score = self._calculate_relative_position_similarity(xml_elem, svg_elem) + score += position_score * 0.2 + + return min(score, 1.0) + + def _calculate_relative_position_similarity(self, xml_elem, svg_elem): + """计算相对位置相似度""" + # 这里简化处理,实际应该考虑屏幕尺寸的缩放 + return 0.5 # 暂时返回中等相似度 + + def _calculate_position_difference(self, xml_elem, svg_elem): + """计算位置差异""" + xml_bounds = xml_elem.get('bounds', {}) + svg_geometry = svg_elem.get('geometry', {}) + + if not xml_bounds or not svg_geometry: + return {'x': 0, 'y': 0} + + return { + 'x': abs(xml_bounds.get('center_x', 0) - svg_geometry.get('center_x', 0)), + 'y': abs(xml_bounds.get('center_y', 0) - svg_geometry.get('center_y', 0)) + } + + def _calculate_size_difference(self, xml_elem, svg_elem): + """计算尺寸差异""" + xml_bounds = xml_elem.get('bounds', {}) + svg_geometry = svg_elem.get('geometry', {}) + + if not xml_bounds or not svg_geometry: + return {'width': 0, 'height': 0} + + return { + 'width': abs(xml_bounds.get('width', 0) - svg_geometry.get('width', 0)), + 'height': abs(xml_bounds.get('height', 0) - svg_geometry.get('height', 0)) + } + + def _calculate_layout_similarity(self, result): + """计算整体布局相似度""" + total_elements = max(result['xml_elements_count'], result['svg_elements_count']) + if total_elements == 0: + result['layout_similarity'] = 0.0 + return + + matched_count = len(result['matched_elements']) + base_similarity = matched_count / total_elements + + # 考虑匹配质量 + if result['matched_elements']: + avg_match_quality = sum(match['similarity'] for match in result['matched_elements']) / len(result['matched_elements']) + result['layout_similarity'] = base_similarity * avg_match_quality + else: + result['layout_similarity'] = 0.0 + + def get_layout_comparison_summary(self): + """获取布局比对摘要""" + if not self.layout_comparison_result: + return "未进行布局比对" + + result = self.layout_comparison_result + summary = f""" +📊 布局比对摘要: + • XML元素数量: {result['xml_elements_count']} + • SVG元素数量: {result['svg_elements_count']} + • 匹配元素数量: {len(result['matched_elements'])} + • 布局相似度: {result['layout_similarity']:.1%} + • 未匹配XML元素: {len(result['unmatched_xml'])} + • 未匹配SVG元素: {len(result['unmatched_svg'])} +""" + + if result['layout_similarity'] >= 0.8: + summary += "\n✅ 布局高度一致" + elif result['layout_similarity'] >= 0.6: + summary += "\n⚠️ 布局基本一致,有少量差异" + else: + summary += "\n❌ 布局差异较大,建议检查" + + return summary \ No newline at end of file diff --git a/xml_layouts/layout_20251031_174424.xml b/xml_layouts/layout_20251031_174424.xml new file mode 100644 index 0000000..65ddd54 --- /dev/null +++ b/xml_layouts/layout_20251031_174424.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file