first commit

This commit is contained in:
2025-10-31 17:53:12 +08:00
commit 81102ec396
13 changed files with 3440 additions and 0 deletions

265
.gitignore vendored Normal file
View File

@@ -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

396
README.md Normal file
View File

@@ -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视觉比对
- **需要检查布局结构**: 使用F6XML分析+布局比对)
- **专门比对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测试体验**

113
config.py Normal file
View File

@@ -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矢量格式文件小可缩放

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -0,0 +1,325 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>UI测试报告</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 { margin: 0; font-size: 2.5em; }
.header p { margin: 10px 0 0 0; opacity: 0.9; }
.section {
margin: 0;
padding: 25px;
border-bottom: 1px solid #eee;
}
.section:last-child { border-bottom: none; }
.section h2 {
color: #333;
margin-top: 0;
padding-bottom: 10px;
border-bottom: 2px solid #667eea;
}
.issue {
background: #fff3cd;
border: 1px solid #ffeaa7;
color: #856404;
padding: 12px;
margin: 8px 0;
border-radius: 5px;
border-left: 4px solid #f39c12;
}
.success {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
padding: 12px;
margin: 8px 0;
border-radius: 5px;
border-left: 4px solid #28a745;
}
.error {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
padding: 12px;
margin: 8px 0;
border-radius: 5px;
border-left: 4px solid #dc3545;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin: 20px 0;
}
.stat-item {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
text-align: center;
border: 1px solid #e9ecef;
}
.stat-item h3 {
font-size: 2em;
margin: 0;
color: #667eea;
}
.stat-item p {
margin: 5px 0 0 0;
color: #666;
font-weight: 500;
}
.progress-bar {
background: #e9ecef;
border-radius: 10px;
height: 20px;
margin: 10px 0;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #28a745, #20c997);
transition: width 0.3s ease;
}
table {
width: 100%;
border-collapse: collapse;
margin: 15px 0;
background: white;
}
th, td {
border: 1px solid #dee2e6;
padding: 12px;
text-align: left;
}
th {
background: #f8f9fa;
font-weight: 600;
color: #495057;
}
tr:nth-child(even) { background: #f8f9fa; }
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8em;
font-weight: 500;
}
.badge-success { background: #d4edda; color: #155724; }
.badge-warning { background: #fff3cd; color: #856404; }
.badge-danger { background: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🤖 UI测试报告</h1>
<p>生成时间: 2025-10-31 17:42:21</p>
</div>
<div class="section">
<h2>📊 测试概览</h2>
<div class="stats">
<div class="stat-item">
<h3>18</h3>
<p>总元素数</p>
</div>
<div class="stat-item">
<h3>1</h3>
<p>发现问题</p>
</div>
<div class="stat-item">
<h3>88.6%</h3>
<p>视觉相似度</p>
</div>
<div class="stat-item">
<h3>100%</h3>
<p>性能评分</p>
</div>
</div>
<div>
<p><strong>视觉相似度进度:</strong></p>
<div class="progress-bar">
<div class="progress-fill" style="width: 89.0%"></div>
</div>
<p>89.0% 相似度</p>
</div>
</div>
<div class="section">
<h2>📱 XML布局分析</h2>
<div class="stats">
<div class="stat-item">
<h3>1</h3>
<p>可点击元素</p>
</div>
<div class="stat-item">
<h3>0</h3>
<p>文本元素</p>
</div>
<div class="stat-item">
<h3>9</h3>
<p>最大层级深度</p>
</div>
</div>
<h3>🔍 发现的问题:</h3>
<div class="issue">
<strong>android.widget.Button</strong>: 可点击元素缺少content-desc
<small style="color: #666;"> (位置: 333,1518)</small>
</div>
</div>
<div class="section">
<h2>👁️ 视觉比对分析</h2>
<div class="stats">
<div class="stat-item">
<h3>88.6%</h3>
<p>相似度得分</p>
</div>
<div class="stat-item">
<h3>1</h3>
<p>差异区域数量</p>
</div>
<div class="stat-item">
<h3>244545.0</h3>
<p>差异面积(像素)</p>
</div>
</div>
<div class="issue">
⚠️ 视觉差异较大 (11.4%)建议检查UI实现
<span class="badge badge-warning">未通过阈值检查</span>
</div>
</div>
<div class="section">
<h2>⚡ 性能数据</h2>
<table>
<thead>
<tr>
<th>操作类型</th>
<th>执行时间(秒)</th>
<th>时间戳</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr>
<td>xml_analysis</td>
<td>0.069</td>
<td>20251031_174305</td>
<td>
<span class="badge badge-success">快速</span>
</td>
</tr>
<tr>
<td>screenshot</td>
<td>0.334</td>
<td>20251031_174305</td>
<td>
<span class="badge badge-success">快速</span>
</td>
</tr>
<tr>
<td>screenshot</td>
<td>0.278</td>
<td>20251031_174305</td>
<td>
<span class="badge badge-success">快速</span>
</td>
</tr>
<tr>
<td>xml_analysis</td>
<td>0.055</td>
<td>20251031_174306</td>
<td>
<span class="badge badge-success">快速</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="section">
<h2>📋 建议和总结</h2>
<div class="issue">
🔧 发现 1 个布局问题,建议优化可访问性和用户体验
</div>
<div class="error">
🎨 视觉差异超过10%建议检查UI实现是否符合设计要求
</div>
<div class="success">
✅ 测试完成详细数据已记录。建议定期进行UI测试以确保应用质量。
</div>
</div>
</div>
</body>
</html>

27
requirements.txt Normal file
View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

451
test_reporter.py Normal file
View File

@@ -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 = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>UI测试报告</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 { margin: 0; font-size: 2.5em; }
.header p { margin: 10px 0 0 0; opacity: 0.9; }
.section {
margin: 0;
padding: 25px;
border-bottom: 1px solid #eee;
}
.section:last-child { border-bottom: none; }
.section h2 {
color: #333;
margin-top: 0;
padding-bottom: 10px;
border-bottom: 2px solid #667eea;
}
.issue {
background: #fff3cd;
border: 1px solid #ffeaa7;
color: #856404;
padding: 12px;
margin: 8px 0;
border-radius: 5px;
border-left: 4px solid #f39c12;
}
.success {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
padding: 12px;
margin: 8px 0;
border-radius: 5px;
border-left: 4px solid #28a745;
}
.error {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
padding: 12px;
margin: 8px 0;
border-radius: 5px;
border-left: 4px solid #dc3545;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin: 20px 0;
}
.stat-item {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
text-align: center;
border: 1px solid #e9ecef;
}
.stat-item h3 {
font-size: 2em;
margin: 0;
color: #667eea;
}
.stat-item p {
margin: 5px 0 0 0;
color: #666;
font-weight: 500;
}
.progress-bar {
background: #e9ecef;
border-radius: 10px;
height: 20px;
margin: 10px 0;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #28a745, #20c997);
transition: width 0.3s ease;
}
table {
width: 100%;
border-collapse: collapse;
margin: 15px 0;
background: white;
}
th, td {
border: 1px solid #dee2e6;
padding: 12px;
text-align: left;
}
th {
background: #f8f9fa;
font-weight: 600;
color: #495057;
}
tr:nth-child(even) { background: #f8f9fa; }
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8em;
font-weight: 500;
}
.badge-success { background: #d4edda; color: #155724; }
.badge-warning { background: #fff3cd; color: #856404; }
.badge-danger { background: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🤖 UI测试报告</h1>
<p>生成时间: {{ timestamp }}</p>
</div>
<div class="section">
<h2>📊 测试概览</h2>
<div class="stats">
<div class="stat-item">
<h3>{{ xml_stats.total_elements or 0 }}</h3>
<p>总元素数</p>
</div>
<div class="stat-item">
<h3>{{ xml_stats.issues_count or 0 }}</h3>
<p>发现问题</p>
</div>
<div class="stat-item">
<h3>{{ visual_similarity }}%</h3>
<p>视觉相似度</p>
</div>
<div class="stat-item">
<h3>{{ performance_score }}%</h3>
<p>性能评分</p>
</div>
</div>
{% if visual_data and visual_data.similarity_score %}
<div>
<p><strong>视觉相似度进度:</strong></p>
<div class="progress-bar">
<div class="progress-fill" style="width: {{ (visual_data.similarity_score * 100)|round }}%"></div>
</div>
<p>{{ (visual_data.similarity_score * 100)|round }}% 相似度</p>
</div>
{% endif %}
</div>
<div class="section">
<h2>📱 XML布局分析</h2>
<div class="stats">
<div class="stat-item">
<h3>{{ xml_stats.clickable_elements or 0 }}</h3>
<p>可点击元素</p>
</div>
<div class="stat-item">
<h3>{{ xml_stats.text_elements or 0 }}</h3>
<p>文本元素</p>
</div>
<div class="stat-item">
<h3>{{ xml_stats.max_depth or 0 }}</h3>
<p>最大层级深度</p>
</div>
</div>
{% if xml_issues %}
<h3>🔍 发现的问题:</h3>
{% for issue in xml_issues[:10] %}
<div class="issue">
<strong>{{ issue.element }}</strong>: {{ issue.issue }}
{% if issue.bounds %}
<small style="color: #666;"> (位置: {{ issue.bounds.x1 }},{{ issue.bounds.y1 }})</small>
{% endif %}
</div>
{% endfor %}
{% if xml_issues|length > 10 %}
<p><em>... 还有 {{ xml_issues|length - 10 }} 个问题未显示</em></p>
{% endif %}
{% else %}
<div class="success">
✅ 未发现XML布局问题
</div>
{% endif %}
</div>
<div class="section">
<h2>👁️ 视觉比对分析</h2>
{% if visual_data %}
<div class="stats">
<div class="stat-item">
<h3>{{ "%.1f"|format(visual_data.similarity_score * 100) }}%</h3>
<p>相似度得分</p>
</div>
<div class="stat-item">
<h3>{{ visual_data.diff_regions_count }}</h3>
<p>差异区域数量</p>
</div>
<div class="stat-item">
<h3>{{ visual_data.total_diff_area }}</h3>
<p>差异面积(像素)</p>
</div>
</div>
{% if visual_data.meets_threshold %}
<div class="success">
✅ 视觉效果良好,与设计图高度一致
<span class="badge badge-success">通过阈值检查</span>
</div>
{% else %}
<div class="issue">
⚠️ 视觉差异较大 ({{ "%.1f"|format(visual_data.diff_percentage) }}%)建议检查UI实现
<span class="badge badge-warning">未通过阈值检查</span>
</div>
{% endif %}
{% else %}
<div class="issue">
❌ 未进行视觉比对分析
</div>
{% endif %}
</div>
<div class="section">
<h2>⚡ 性能数据</h2>
{% if performance_data and performance_data.operations %}
<table>
<thead>
<tr>
<th>操作类型</th>
<th>执行时间(秒)</th>
<th>时间戳</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{% for op in performance_data.operations[-10:] %}
<tr>
<td>{{ op.type }}</td>
<td>{{ "%.3f"|format(op.time) }}</td>
<td>{{ op.timestamp }}</td>
<td>
{% if op.time < 1.0 %}
<span class="badge badge-success">快速</span>
{% elif op.time < 3.0 %}
<span class="badge badge-warning">正常</span>
{% else %}
<span class="badge badge-danger">慢</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="issue">
❌ 无性能数据记录
</div>
{% endif %}
</div>
<div class="section">
<h2>📋 建议和总结</h2>
{% if xml_stats.issues_count and xml_stats.issues_count > 0 %}
<div class="issue">
🔧 发现 {{ xml_stats.issues_count }} 个布局问题,建议优化可访问性和用户体验
</div>
{% endif %}
{% if visual_data and visual_data.diff_percentage > 10 %}
<div class="error">
🎨 视觉差异超过10%建议检查UI实现是否符合设计要求
</div>
{% 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 %}
<div class="issue">
⚡ 平均操作时间较长 ({{ "%.2f"|format(avg_time) }}秒),建议优化性能
</div>
{% endif %}
{% endif %}
<div class="success">
✅ 测试完成详细数据已记录。建议定期进行UI测试以确保应用质量。
</div>
</div>
</div>
</body>
</html>
"""
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

666
ui_test.py Normal file
View File

@@ -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()

459
visual_comparator.py Normal file
View File

@@ -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"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ margin: 0; padding: 20px; background: white; }}
svg {{ max-width: 100%; height: auto; display: block; }}
</style>
</head>
<body>
{svg_content}
</body>
</html>"""
# 创建临时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

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

658
xml_analyzer.py Normal file
View File

@@ -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('<?xml'):
# 找到XML声明的结束位置
declaration_end = xml_content.find('?>') + 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

View File

@@ -0,0 +1,29 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<hierarchy index="0" class="hierarchy" rotation="0" width="1080" height="1920">
<android.widget.FrameLayout index="0" package="com.example.myapplication" class="android.widget.FrameLayout" text="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[0,0][1080,1920]" displayed="true" a11y-important="true" screen-reader-focusable="false" drawing-order="0" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false">
<android.widget.LinearLayout index="0" package="com.example.myapplication" class="android.widget.LinearLayout" text="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[0,0][1080,1920]" displayed="true" a11y-important="false" screen-reader-focusable="false" drawing-order="1" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false">
<android.widget.FrameLayout index="0" package="com.example.myapplication" class="android.widget.FrameLayout" text="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[0,0][1080,1920]" displayed="true" a11y-important="false" screen-reader-focusable="false" drawing-order="2" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false">
<android.widget.LinearLayout index="0" package="com.example.myapplication" class="android.widget.LinearLayout" text="" resource-id="com.example.myapplication:id/action_bar_root" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[0,0][1080,1920]" displayed="true" a11y-important="false" screen-reader-focusable="false" drawing-order="1" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false">
<android.widget.FrameLayout index="0" package="com.example.myapplication" class="android.widget.FrameLayout" text="" resource-id="android:id/content" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[0,0][1080,1920]" displayed="true" a11y-important="false" screen-reader-focusable="false" drawing-order="2" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false">
<android.view.ViewGroup index="0" package="com.example.myapplication" class="android.view.ViewGroup" text="" resource-id="com.example.myapplication:id/main" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[0,0][1080,1920]" displayed="true" a11y-important="false" screen-reader-focusable="false" drawing-order="1" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false">
<android.view.ViewGroup index="0" package="com.example.myapplication" class="android.view.ViewGroup" text="" resource-id="com.example.myapplication:id/toolbar" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[0,72][1080,264]" displayed="true" a11y-important="false" screen-reader-focusable="false" drawing-order="1" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false">
<android.widget.ImageButton index="0" package="com.example.myapplication" class="android.widget.ImageButton" text="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[0,84][168,252]" displayed="true" a11y-important="true" screen-reader-focusable="false" drawing-order="2" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false" />
<android.widget.TextView index="1" package="com.example.myapplication" class="android.widget.TextView" text="Title" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[476,124][604,212]" displayed="true" a11y-important="true" screen-reader-focusable="false" drawing-order="1" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false" />
<android.widget.ImageView index="2" package="com.example.myapplication" class="android.widget.ImageView" text="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[960,108][1080,228]" displayed="true" a11y-important="false" screen-reader-focusable="false" drawing-order="3" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false" />
</android.view.ViewGroup>
<android.widget.ScrollView index="1" package="com.example.myapplication" class="android.widget.ScrollView" text="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" long-clickable="false" password="false" scrollable="true" selected="false" bounds="[0,264][1080,1920]" displayed="true" a11y-important="true" screen-reader-focusable="false" drawing-order="2" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false">
<android.view.ViewGroup index="0" package="com.example.myapplication" class="android.view.ViewGroup" text="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" long-clickable="false" password="false" scrollable="false" selected="false" bounds="[0,264][1080,1920]" displayed="true" a11y-important="false" screen-reader-focusable="false" drawing-order="1" showing-hint="false" text-entry-key="false" dismissable="false" a11y-focused="false" heading="false" live-region="0" context-clickable="false" content-invalid="false">