autool/utils_ios/package_init/ios_automation_base.py
2026-06-17 19:44:18 +08:00

450 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# coding: utf-8
"""
iOS 自动化操作基础类
提供统一的基础设施用于实现各种 iOS 自动化任务,包括:
- WDA 客户端管理
- 统一的元素查找和操作
- 弹窗处理
- 日志记录
- 会话管理
"""
from typing import List, Optional, Tuple, Dict, Any
import time
import sys
# 使用绝对导入替代相对导入
try:
from utils_ios.wda import wda
except ImportError:
# 如果在 DroidBot 包外运行,尝试直接导入
import wda
try:
import logzero
if not (hasattr(sys.stdout, 'isatty') and sys.stdout.isatty()):
log_format = '[%(levelname)1.1s %(asctime)s %(module)s:%(lineno)d] %(message)s'
logzero.setup_default_logger(formatter=logzero.LogFormatter(fmt=log_format))
logger = logzero.logger
except ImportError:
import logging
logger = logging.getLogger(__name__)
class ElementFinder:
"""统一的控件查找器
提供多种方式查找和操作 iOS 界面元素
在每次操作前自动处理 Alert 弹窗
"""
def __init__(self, client: wda.Client, popup_handler: Optional['PopupHandler'] = None, health_monitor=None):
"""
初始化控件查找器
Args:
client: WDA Client 实例
popup_handler: PopupHandler 实例(可选,用于自动处理 Alert
health_monitor: WDAHealthMonitor 实例(可选,用于在 WDA 卡死时快速失败)
"""
self.client = client
self.popup_handler = popup_handler
self.health_monitor = health_monitor
def _check_health_fast_fail(self):
"""检查 WDA 健康状态,异常时等待恢复后继续,恢复失败才抛出异常
原行为:检测到 WDA 下线 → 立刻抛 RuntimeError
新行为:检测到 WDA 下线 → 调用 trigger_recovery_and_wait 阻塞等待恢复
→ 恢复成功则透明继续(调用方重试操作)
→ 恢复失败(超次数/超时)才抛 RuntimeError
"""
if self.health_monitor and not self.health_monitor.check_health(timeout=2.0):
logger.warning("WDA 检测到不健康,等待自动恢复...")
recovered = self.health_monitor.trigger_recovery_and_wait(timeout=120)
if not recovered:
raise RuntimeError("WDA 恢复失败,放弃当前任务")
def _auto_handle_alert(self) -> int:
"""
自动处理 Alert如果配置了 popup_handler
Returns:
处理的 Alert 数量
"""
if self.popup_handler:
return self.popup_handler.handle_alert(max_attempts=5)
return 0
def find_and_click(self, labels: List[str], element_type: str = 'Button',
timeout: float = 1.0, auto_handle_alert: bool = True) -> Tuple[bool, Optional[str]]:
"""
查找并点击控件(操作前自动处理 Alert
Args:
labels: 要查找的标签列表(按优先级)
element_type: 控件类型(如 'Button', 'StaticText' 等)
timeout: 每个标签的查找超时时间
auto_handle_alert: 是否在操作前自动处理 Alert
Returns:
(是否成功, 匹配的标签)
"""
# 操作前先处理 Alert
if auto_handle_alert:
alerts_handled = self._auto_handle_alert()
if alerts_handled > 0:
logger.debug(f"操作前处理了 {alerts_handled} 个 Alert")
self._check_health_fast_fail()
for label in labels:
try:
# 使用线程超时保护 click_exists避免 WDA HTTP 请求阻塞过久
# WDA 底层 HTTP_TIMEOUT 默认 180sclick_exists 的 timeout 仅控制元素等待
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(
lambda l=label: self.client(label=l, type=element_type).click_exists(timeout=timeout)
)
clicked = future.result(timeout=timeout + 10)
if clicked:
logger.debug(f"点击成功: {label} ({element_type})")
return True, label
except concurrent.futures.TimeoutError:
logger.debug(f"查找控件超时 (>{timeout + 10:.1f}s): {label}")
continue
except Exception as e:
logger.debug(f"查找控件异常: {label} - {e}")
continue
logger.debug(f"未找到控件: {labels}")
return False, None
def check_exists(self, texts: List[str],
check_types: List[str] = None,
auto_handle_alert: bool = True) -> Tuple[bool, Optional[str]]:
"""
检查文本是否存在(检查前自动处理 Alert
Args:
texts: 要检查的文本列表
check_types: 检查类型列表,如 ['label', 'name', 'value']
auto_handle_alert: 是否在检查前自动处理 Alert
Returns:
(是否存在, 匹配的文本)
"""
# 检查前先处理 Alert
if auto_handle_alert:
alerts_handled = self._auto_handle_alert()
if alerts_handled > 0:
logger.debug(f"检查前处理了 {alerts_handled} 个 Alert")
self._check_health_fast_fail()
if check_types is None:
check_types = ['label', 'name', 'value']
for text in texts:
for check_type in check_types:
try:
if check_type == 'label' and self.client(labelContains=text).exists:
return True, text
elif check_type == 'name' and self.client(nameContains=text).exists:
return True, text
elif check_type == 'value' and self.client(valueContains=text).exists:
return True, text
except Exception:
pass
return False, None
def wait_for_element(self, labels: List[str], element_type: str = 'Button',
timeout: float = 10.0, auto_handle_alert: bool = True) -> Tuple[bool, Optional[str]]:
"""
等待控件出现(等待期间自动处理 Alert
Args:
labels: 要等待的标签列表
element_type: 控件类型
timeout: 总超时时间
auto_handle_alert: 是否在等待期间自动处理 Alert
Returns:
(是否找到, 匹配的标签)
"""
start = time.time()
while time.time() - start < timeout:
# 等待期间处理 Alert
if auto_handle_alert:
self._auto_handle_alert()
self._check_health_fast_fail()
for label in labels:
if self.client(label=label, type=element_type).exists:
logger.debug(f"找到元素: {label} ({element_type})")
return True, label
time.sleep(0.5)
logger.debug(f"等待元素超时: {labels}")
return False, None
class PopupHandler:
"""统一弹窗处理器
处理系统 Alert 和常规弹窗,提供自动检查和处理机制
"""
# 常见的允许/确认按钮文案
ALLOW_BUTTONS = [
# 中文
"允许", "", "确定", "始终允许", "使用App时允许",
"允许一次", "仅在使用应用期间", "始终", "15分钟后需要",
# 英文
# "Allow", "OK", "Yes", "Always Allow", "Allow While Using App",
# "Allow Once", "While Using the App", "Always",
]
# 常见的拒绝/取消按钮文案
DENY_BUTTONS = [
# 中文
"不允许", "取消", "稍后", "以后", "暂不",
# 英文
# "Don't Allow", "Cancel", "Later", "Not Now", "Deny",
]
def __init__(self, client: wda.Client, finder: ElementFinder, default_alert_strategy: str = "allow"):
"""
初始化弹窗处理器
Args:
client: WDA Client 实例
finder: ElementFinder 实例
default_alert_strategy: 默认的 Alert 处理策略 - "allow""deny"
"""
self.client = client
self.finder = finder
self.default_alert_strategy = default_alert_strategy
def handle_alert(self, strategy: str = None, max_attempts: int = 5) -> int:
"""
处理系统 Alert 弹窗(支持多次尝试)
使用 alert.accept()/alert.dismiss() 直接处理,
通过返回的 status 字段判断是否成功status=0 表示成功)
Args:
strategy: 处理策略 - "allow""deny"None 则使用默认策略
max_attempts: 最大尝试次数
Returns:
处理的 Alert 数量
"""
if strategy is None:
strategy = self.default_alert_strategy
handled_count = 0
for _ in range(max_attempts):
try:
alert = self.client.alert
# 使用线程超时保护 alert.exists 检查,避免 WDA 阻塞过久
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(lambda: alert.exists)
try:
alert_exists = future.result(timeout=30)
except concurrent.futures.TimeoutError:
logger.debug("检查 Alert 超时 (>30s),跳过")
break
if not alert_exists:
break
# 直接使用 accept/dismiss 方法
try:
if strategy == "allow":
result = alert.accept()
else:
result = alert.dismiss()
# 检查返回状态
# 正常返回: {'value': None, 'sessionId': '...', 'status': 0}
# status=0 表示成功
if isinstance(result, dict) and result.get('status') == 0:
handled_count += 1
logger.debug(f"处理 Alert: 使用 {strategy} 方法成功")
time.sleep(0.3)
continue
else:
# status 非 0可能失败了
logger.debug(f"处理 Alert 返回状态: {result}")
break
except Exception as e:
# 如果出现异常,说明可能没有 Alert 或处理失败
logger.debug(f"处理 Alert 异常: {e}")
break
except Exception as e:
logger.debug(f"检查 Alert 异常: {e}")
break
return handled_count
def dismiss_popups(self, max_attempts: int = 3, button_list: List[str] = None, auto_handle_alert = False):
"""
自动处理常规弹窗
Args:
max_attempts: 最多尝试处理的弹窗数量
button_list: 自定义按钮列表,默认使用 ALLOW_BUTTONS
auto_handle_alert: 是否先进行alert检查和处理默认关闭
"""
if button_list is None:
button_list = self.ALLOW_BUTTONS
for _ in range(max_attempts):
clicked, label = self.finder.find_and_click(button_list, timeout=0.5, auto_handle_alert=auto_handle_alert)
if not clicked:
break
logger.debug(f"关闭弹窗: {label}")
time.sleep(0.3)
class IOSAutomationBase:
"""iOS 自动化操作基础类
提供统一的初始化、会话管理和基础操作方法
子类可以继承此类来实现具体的自动化任务
自动处理 Alert 机制:
- 所有控件操作前自动检查并处理 Alert
- 可通过 default_alert_strategy 配置默认处理策略
"""
def __init__(self, server_url: str, default_alert_strategy: str = "allow", health_monitor=None, **kwargs):
"""
初始化基础类
Args:
server_url: WDA Server URL
default_alert_strategy: 默认的 Alert 处理策略 - "allow""deny"
health_monitor: WDAHealthMonitor 实例(可选)
**kwargs: 子类可以传入额外的配置参数
"""
self.server_url = server_url
self.default_alert_strategy = default_alert_strategy
self.health_monitor = health_monitor
self.config = kwargs
self.client: Optional[wda.Client] = None
self.finder: Optional[ElementFinder] = None
self.popup_handler: Optional[PopupHandler] = None
def _init_session(self):
"""初始化 WDA 会话和工具类ElementFinder 自动关联 PopupHandler"""
# 根据传入的连接参数选择连接方式:
# - http: 格式 → 使用 HTTP Client 连接
# - UDID 或空字符串 → 使用 USBClient 通过 USB 连接
if self.server_url and self.server_url.startswith("http:"):
self.client = wda.Client(self.server_url)
else:
udid = self.server_url or ""
self.client = wda.USBClient(udid=udid)
# 先创建 PopupHandler
self.popup_handler = PopupHandler(self.client, None, self.default_alert_strategy)
# 创建 ElementFinder 并关联 PopupHandler用于自动处理 Alert
self.finder = ElementFinder(self.client, self.popup_handler, self.health_monitor)
# 更新 PopupHandler 的 finder 引用
self.popup_handler.finder = self.finder
logger.debug(f"会话初始化完成: {self.server_url}, Alert策略: {self.default_alert_strategy}")
def _check_wda_ready(self, timeout: float = 30.0) -> bool:
"""
检查 WDA 是否就绪
Args:
timeout: 等待超时时间
Returns:
WDA 是否就绪
"""
try:
if not self.client.wait_ready(timeout=timeout):
logger.error("WDA 未就绪")
return False
logger.debug("WDA 已就绪")
return True
except Exception as e:
logger.error(f"检查 WDA 状态失败: {e}")
return False
def _unlock_device(self) -> bool:
"""
解锁设备(如果已锁定)
Returns:
是否成功解锁
"""
try:
if self.client.locked():
self.client.unlock()
logger.info("设备已解锁")
return True
return True
except Exception as e:
logger.warning(f"解锁设备失败: {e}")
return False
def _launch_app(self, bundle_id: str) -> bool:
"""
启动应用
Args:
bundle_id: 应用的 Bundle ID
Returns:
是否成功启动
"""
try:
self.client.session(bundle_id)
logger.info(f"启动应用: {bundle_id}")
time.sleep(2) # 等待应用启动
return True
except Exception as e:
logger.error(f"启动应用失败: {bundle_id} - {e}")
return False
def _open_url(self, url: str, wait_time: float = 5.0) -> bool:
"""
打开 URL如 URL Scheme
Args:
url: 要打开的 URL
wait_time: 打开后等待时间
Returns:
是否成功打开
"""
try:
self.client.open_url(url)
logger.debug(f"打开 URL: {url}")
self.client.wait_ready(wait_time)
return True
except Exception as e:
logger.error(f"打开 URL 失败: {url} - {e}")
return False
def execute(self, *args, **kwargs) -> bool:
"""
执行自动化任务的主方法
子类必须实现此方法来定义具体的任务流程
Returns:
任务是否成功完成
"""
raise NotImplementedError("子类必须实现 execute() 方法")