From 81102ec396c704fec89b706a90f47347eb7e0b64 Mon Sep 17 00:00:00 2001 From: "guangfei.zhao" Date: Fri, 31 Oct 2025 17:53:12 +0800 Subject: [PATCH] first commit --- .gitignore | 265 +++++++ README.md | 396 +++++++++++ config.py | 113 +++ design_references/sample_design.svg | 51 ++ reports/test_report_20251031_174221.html | 325 +++++++++ requirements.txt | 27 + screenshots/screenshot_20251031_174424.png | Bin 0 -> 45276 bytes test_reporter.py | 451 ++++++++++++ ui_test.py | 666 ++++++++++++++++++ visual_comparator.py | 459 ++++++++++++ .../comparison_20251031_174425.png | Bin 0 -> 110373 bytes xml_analyzer.py | 658 +++++++++++++++++ xml_layouts/layout_20251031_174424.xml | 29 + 13 files changed, 3440 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.py create mode 100644 design_references/sample_design.svg create mode 100644 reports/test_report_20251031_174221.html create mode 100644 requirements.txt create mode 100644 screenshots/screenshot_20251031_174424.png create mode 100644 test_reporter.py create mode 100644 ui_test.py create mode 100644 visual_comparator.py create mode 100644 visual_comparisons/comparison_20251031_174425.png create mode 100644 xml_analyzer.py create mode 100644 xml_layouts/layout_20251031_174424.xml 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 0000000000000000000000000000000000000000..e91573f2809367cdaed5da65d8de73eb487f31d9 GIT binary patch literal 45276 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8dbfaceb8f0932498ca4bb5f6dbfb231656d715e GIT binary patch literal 110373 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 B8ma&Q literal 0 HcmV?d00001 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