告别adb shell!用uiautomator2实现Android自动化测试的5个高效技巧

如果你已经熟悉Python,并且对移动端自动化测试产生了兴趣,那么你很可能已经听说过或者尝试过adb shell。诚然,adb命令是连接电脑与Android设备的桥梁,是进行底层调试和简单操作的利器。然而,当我们的测试需求从“点一下”升级到“模拟真实用户操作流”时,单纯依赖adb shell就会显得力不从心——你需要手动拼接坐标、处理复杂的shell输出、编写冗长的脚本去应对动态变化的UI界面。这就像用螺丝刀去组装一台精密仪器,不是不行,但效率低下且容易出错。

这时,uiautomator2 进入了我们的视野。它不是一个全新的概念,而是对Google原生UI Automator框架的Python化封装与增强。它的核心价值在于,将复杂的设备交互抽象为简洁的Python对象和方法,让你能用写Python业务逻辑的思维去编写自动化测试脚本。对于已经具备Python基础的开发者来说,这几乎是无缝衔接的体验。本文将聚焦于五个能显著提升你自动化脚本编写效率和健壮性的实战技巧,这些技巧直接来源于处理滑动解锁、多应用切换、异常弹窗等真实场景的提炼,旨在帮你快速跨越从“会用”到“高效用”的鸿沟。

1. 环境搭建与连接:超越USB的稳定连接方案

万事开头难,一个稳定可靠的测试环境是高效自动化的基石。uiautomator2的安装看似简单,但其中的一些细节和替代方案,能让你在后续的脚本开发中少走很多弯路。

1.1 核心库与工具链安装

首先,通过pip安装核心库。建议使用国内镜像源以加速下载过程。

pip install -U uiautomator2 -i https://pypi.tuna.tsinghua.edu.cn/simple

安装完成后,强烈建议同时安装配套的图形化元素定位工具 WEditor。它比Android SDK自带的uiautomatorviewer更强大,尤其擅长处理动态加载的列表、WebView等复杂元素。

pip install -U weditor -i https://pypi.tuna.tsinghua.edu.cn/simple

安装后,在命令行执行 python -m weditor 即可启动一个本地Web服务,通常在浏览器中打开 http://localhost:17310 即可访问。它的界面直观,能实时刷新设备UI层级,是编写脚本时不可或缺的“眼睛”。

1.2 设备初始化:Wi-Fi连接的巨大优势

大多数教程会教你用USB连接,这确实是最直接的方式:

import uiautomator2 as u2
# 通过设备序列号连接
d = u2.connect_usb("你的设备序列号")

但这里我想重点推荐 Wi-Fi连接。一旦初始设置完成,你的测试机可以摆脱数据线的束缚,这在多设备并行测试、设备置于支架或封闭测试箱内时优势巨大。

Wi-Fi连接的前提是设备与电脑在同一局域网,且设备已开启USB调试。 首次连接仍需借助USB线执行一次初始化:

# 通过USB连接设备后,执行初始化,会自动安装所需的守护进程和APK
python -m uiautomator2 init

初始化成功后,你可以在设备的开发者选项或WEditor中看到设备的IP地址。此后,即可通过Wi-Fi连接:

import uiautomator2 as u2
# 通过设备IP地址连接
d = u2.connect_wifi("192.168.1.100")

注意:Wi-Fi连接的稳定性依赖于网络状况。在实际项目中,我通常会编写一个连接重试机制,并准备USB连接作为备用方案,确保测试任务不会因连接问题而中断。

1.3 连接状态检查与异常处理

一个健壮的脚本应该在开始时就确认连接状态。device.info 是一个快速检查连接和设备基本状态的好方法。

import uiautomator2 as u2
import time

def safe_connect(device_serial_or_ip, max_retries=3):
    """安全的设备连接函数,包含重试机制"""
    for i in range(max_retries):
        try:
            if ":" in device_serial_or_ip: # 简单判断是否为IP
                d = u2.connect_wifi(device_serial_or_ip)
            else:
                d = u2.connect_usb(device_serial_or_ip)
            # 尝试获取设备信息,验证连接是否真正可用
            info = d.info
            print(f"设备连接成功: {info['productName']} (SDK {info['sdkInt']})")
            return d
        except Exception as e:
            print(f"第{i+1}次连接尝试失败: {e}")
            if i < max_retries - 1:
                time.sleep(2)
    raise ConnectionError(f"无法连接到设备: {device_serial_or_ip}")

# 使用示例
try:
    device = safe_connect("192.168.1.100")
except ConnectionError as e:
    print(e)
    # 可以在这里切换到USB连接或终止脚本

这个简单的封装能有效避免因网络波动或设备服务未就绪导致的脚本开局失败。

2. 元素定位的进阶策略:告别脆弱的XPath

定位UI元素是自动化测试的“灵魂”。uiautomator2提供了丰富的定位器,远不止于text和resourceId。掌握组合定位和相对定位,能极大提升定位的准确性和脚本的适应性。

2.1 多重属性组合定位

这是最推荐的方式。通过组合多个属性,可以精确定位到目标元素,避免因页面中存在多个相似元素而误操作。

假设我们要定位一个“登录”按钮,它不仅文本是“登录”,而且是可点击的,并且它的类名是android.widget.Button。

# 不推荐:仅用文本定位,如果页面有多个“登录”文本(如提示语)就会出错
login_btn = device(text="登录")

# 推荐:组合文本、类名、可点击属性进行精确定位
login_btn = device(text="登录", className="android.widget.Button", clickable=True)

下表对比了常用定位属性的适用场景:

属性关键字参数示例适用场景与特点
文本内容text="确定"定位按钮、标签等显示明确文本的元素。最直观,但文本可能变化或国际化。
资源IDresourceId="com.example:id/btn_submit"最稳定、首选的定位方式。由开发人员定义,通常唯一且不随UI调整轻易变化。
描述内容description="提交订单"对应Android的contentDescription,常用于无障碍功能,对于图标按钮非常有用。
类名className="android.widget.EditText"定位某一类控件,如所有输入框。常与其他属性组合使用。
索引index=0当其他属性无法区分时,使用元素在兄弟节点中的位置。慎用,UI变动极易导致失败。

2.2 相对定位与方向定位

当元素本身没有特别独特的属性,但其周围元素很明确时,相对定位是更好的选择。这模仿了用户的视觉查找逻辑。

  • child / sibling: 通过父子或兄弟关系定位。
    # 定位一个卡片容器内,文本为“价格”的元素旁边的文本元素(假设是金额)
    price_card = device(description="商品信息卡片")
    price_value = price_card.child(text="价格").sibling(className="android.widget.TextView")
    
  • left / right / up / down: 基于当前元素,向指定方向寻找相邻元素。
    # 定位“用户名”输入框下方的元素(很可能是密码输入框)
    username_field = device(resourceId="com.app:id/et_username")
    password_field = username_field.down(className="android.widget.EditText")
    

2.3 处理动态列表与滚动查找

对于长列表(如通讯录、商品列表),目标元素可能不在初始可视区域内。uiautomator2提供了优雅的滚动查找机制。

scroll.to() 方法会自动滚动直到找到目标元素。

# 滚动查找列表中的某个特定联系人
device(scrollable=True).scroll.to(text="张三")
device(text="张三").click()

更常见的情况是,我们需要在滚动过程中判断是否到达列表底部。可以结合 exists() 和滚动动作实现:

def find_item_by_scroll(device, target_text, max_scrolls=20):
    """通过滚动查找列表项,找到返回True,到底部未找到返回False"""
    for _ in range(max_scrolls):
        if device(text=target_text).exists(timeout=1):
            return True
        # 判断是否已滚动到底部(例如,底部有一个特定的“没有更多”的提示)
        if device(text="没有更多了").exists(timeout=0.5):
            print("已滚动至列表底部,未找到目标项。")
            return False
        # 执行向下滚动
        device(scrollable=True).scroll.vert.forward()
    return False

# 使用示例
if find_item_by_scroll(device, "目标商品"):
    device(text="目标商品").click()
else:
    print("未在列表中找到该商品。")

3. 复杂手势与系统交互模拟

真实的用户操作远不止点击。滑动解锁、长按拖拽、多指缩放等手势,以及处理系统弹窗、通知栏,都是自动化测试必须跨越的关卡。

3.1 精准滑动与解锁模式

滑动操作的核心在于坐标计算。绝对坐标是脚本脆弱的根源,应始终使用相对坐标或基于元素位置的坐标。

技巧1:基于屏幕比例的滑动

# 从屏幕底部向上滑动(常用于解锁或进入应用列表)
screen_width, screen_height = device.window_size()
start_x = screen_width * 0.5  # 屏幕横向中心
start_y = screen_height * 0.8 # 屏幕底部80%位置
end_y = screen_height * 0.2   # 屏幕顶部20%位置
device.swipe(start_x, start_y, start_x, end_y, duration=0.2)

技巧2:实现九宫格图案解锁 图案解锁的本质是将屏幕划分为九宫格,计算每个点的中心坐标。swipe_points 方法完美契合此场景。

def unlock_pattern(device, pattern_points):
    """
    解锁图案锁
    :param device: uiautomator2设备对象
    :param pattern_points: 图案点的序列,如 [1, 2, 5, 8, 9]
    """
    # 假设锁屏界面已出现,并定位到九宫格区域
    # 这里需要先用WEditor获取九宫格容器的坐标
    lock_area = device(resourceId="com.android.systemui:id/lockPatternView")
    bounds = lock_area.bounds() # 获取区域坐标 (left, top, right, bottom)
    l, t, r, b = bounds

    width = r - l
    height = b - t
    cell_width = width / 6  # 通常九宫格有边距,横向6等分(3格+2边距+1间隔?需根据实际情况调整)
    cell_height = height / 6

    # 计算9个点的中心坐标(这是一个简化模型,实际需根据具体UI调整)
    points_map = {}
    for i in range(3):
        for j in range(3):
            index = i * 3 + j + 1
            x = l + cell_width * (1.5 + j*2) # 示例计算
            y = t + cell_height * (1.5 + i*2)
            points_map[index] = (x, y)

    # 根据传入的图案序列,生成滑动坐标点列表
    swipe_path = [points_map[p] for p in pattern_points]
    # 执行滑动解锁
    device.swipe_points(swipe_path, duration=0.05) # 每个点之间间隔很短

# 使用示例:绘制一个“L”形图案 (1->4->7->8->9)
unlock_pattern(device, [1, 4, 7, 8, 9])

3.2 优雅处理系统弹窗与权限请求

应用在安装、运行时经常会弹出系统级的对话框(如权限申请、安装确认、USB调试授权)。这些弹窗不属于应用本身,需要特殊处理。

策略:使用Watcher(监视器) Watcher可以在后台监控UI变化,并在特定条件满足时自动执行操作,非常适合处理不可预知但模式固定的弹窗。

# 注册一个名为“permission_watcher”的监视器
watcher = device.watcher("permission_watcher")
# 当同时出现“允许”文本和包含“权限”的文本时,点击“允许”
watcher.when(text="允许").when(textContains="权限").click(text="允许")
# 当出现“确定”按钮时点击
watcher.when(text="确定").click()
# 当出现“安装”按钮时点击(针对APK安装弹窗)
watcher.when(text="安装").click()

# 启动监视器,每2秒检查一次
watcher.start(interval=2.0)

# 注意:在测试用例结束时,记得停止监视器,避免干扰后续用例
# def tearDown(self):
#     device.watcher.remove("permission_watcher")

提示:Watcher虽然强大,但会增加开销。通常只在需要处理权限的用例开始前注册,并在结束后移除。对于明确的、可预知的弹窗,更推荐在脚本中主动使用 device(text="允许").click_exists(timeout=5) 来处理。

3.3 多应用切换与状态管理

自动化测试流程常常涉及多个应用,例如从浏览器复制链接,再切换到社交应用粘贴分享。

def switch_and_operate(device, target_package, operation_callback):
    """
    切换到指定应用并执行操作
    :param target_package: 目标应用包名,如 `com.tencent.mm`
    :param operation_callback: 一个函数,包含在目标应用中要执行的操作
    """
    current_app = device.app_current()
    if current_app['package'] != target_package:
        print(f"正在从 {current_app['package']} 切换到 {target_package}")
        device.app_start(target_package)
        # 等待目标应用主活动完全启动
        device.app_wait(target_package, front=True, timeout=10)
    # 执行传入的操作函数
    operation_callback(device)

# 定义在微信中要执行的操作
def wechat_share(device):
    if device(text="发现").exists(timeout=5):
        device(text="发现").click()
        device(scrollable=True).scroll.to(text="朋友圈")
        device(text="朋友圈").click()
        # 找到输入框并点击
        device(className="android.widget.EditText").click()
        # 这里可以结合后面提到的粘贴板操作
        device.send_keys("分享内容来自自动化测试")
        device(text="发表").click()

# 使用:假设当前在浏览器,现在要切换到微信发朋友圈
switch_and_operate(device, "com.tencent.mm", wechat_share)

4. 脚本健壮性提升:等待、重试与异常捕获

自动化脚本在真实环境中运行,总会遇到网络延迟、页面加载慢、元素未及时出现等问题。编写容错性强的脚本是关键。

4.1 智能等待策略

uiautomator2内置了隐式等待,但有时需要更精细的控制。

  • 全局隐式等待:device.implicitly_wait(10) 设置后,所有元素查找操作最多等待10秒。
  • 显式等待:使用元素的 wait() 或 exists() 方法。
    # 等待“登录成功”提示出现,最多等10秒
    if device(text="登录成功").wait(timeout=10):
        print("登录成功确认!")
    else:
        print("登录成功提示未出现,可能登录失败。")
        # 这里可以补充截图等诊断操作
        device.screenshot().save("login_failed.jpg")
    
  • 自定义轮询等待:对于某些复杂条件(如某个元素消失后才出现另一个),需要自定义逻辑。
    import time
    def wait_until(condition_func, timeout=30, interval=0.5):
        """自定义等待函数,直到condition_func返回True"""
        start_time = time.time()
        while time.time() - start_time < timeout:
            if condition_func():
                return True
            time.sleep(interval)
        return False
    
    # 等待加载动画消失
    def loading_finished():
        return not device(resourceId="com.app:id/loading_indicator").exists(timeout=0.5)
    
    if wait_until(loading_finished, timeout=15):
        print("页面加载完成")
    

4.2 操作重试机制

对于非确定性的失败(如点击因动画未完全停止而无效),加入重试能显著提高成功率。

def retry_click(device, selector, max_attempts=3, **kwargs):
    """带重试的点击操作"""
    for attempt in range(max_attempts):
        try:
            elem = device(**selector)
            if elem.exists(timeout=2):
                elem.click(**kwargs)
                # 点击后,通过判断下一个预期元素是否出现来验证点击成功
                # 例如,点击登录按钮后,应该出现主页或错误提示
                if device(resourceId="com.app:id/main_page").exists(timeout=3) or \
                   device(textContains="错误").exists(timeout=3):
                    return True
            print(f"第{attempt+1}次点击尝试,元素未找到或点击后状态未变")
        except Exception as e:
            print(f"第{attempt+1}次点击尝试发生异常: {e}")
        device.sleep(1) # 等待一秒后重试
    print(f"点击操作失败: {selector}")
    return False

# 使用示例
retry_click(device, {"text": "登录", "clickable": True})

4.3 结构化异常处理与日志记录

将测试步骤封装在独立的函数或方法中,并统一进行异常捕获和日志记录,便于问题定位。

import logging
from functools import wraps

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

def step_logger(step_name):
    """装饰器:用于记录步骤开始、结束,并捕获异常、截图"""
    def decorator(func):
        @wraps(func)
        def wrapper(device, *args, **kwargs):
            logging.info(f"[开始] 步骤: {step_name}")
            try:
                result = func(device, *args, **kwargs)
                logging.info(f"[成功] 步骤: {step_name}")
                return result
            except Exception as e:
                logging.error(f"[失败] 步骤: {step_name} - 错误: {e}")
                # 失败时截图,以时间戳命名
                import datetime
                timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
                screenshot_path = f"screenshot_failure_{step_name}_{timestamp}.png"
                device.screenshot().save(screenshot_path)
                logging.info(f"已保存失败截图至: {screenshot_path}")
                # 可以选择将异常继续抛出,或者返回一个错误标识
                raise
        return wrapper
    return decorator

@step_logger("执行用户登录")
def user_login(device, username, password):
    device(resourceId="com.app:id/et_username").set_text(username)
    device(resourceId="com.app:id/et_password").set_text(password)
    device(text="登录").click()
    # 验证登录成功
    assert device(text="我的主页").wait(timeout=10), "登录后未跳转到我的主页"

# 在测试用例中调用
try:
    user_login(device, "testuser", "password123")
except AssertionError as e:
    print(f"登录断言失败: {e}")
    # 执行清理或后续操作

5. 集成与实战:构建可维护的测试框架

单个脚本解决单个问题,但我们需要的是一个易于维护、可复用的测试框架。这里分享如何将上述技巧组织起来。

5.1 使用Page Object模式

这是UI自动化测试的经典设计模式,将每个页面抽象为一个类,页面的元素定位和操作作为类的方法。这极大提高了代码的可读性和可维护性。

# base_page.py
class BasePage:
    def __init__(self, device):
        self.device = device
        self.implicit_wait = 10
        self.device.implicitly_wait(self.implicit_wait)

    def find_element(self, **kwargs):
        """查找元素,可在此处添加公共的等待或日志"""
        return self.device(**kwargs)

    def take_screenshot(self, name):
        """页面截图"""
        self.device.screenshot().save(f"{name}.png")

# login_page.py
from base_page import BasePage

class LoginPage(BasePage):
    # 元素定位器 (Locators)
    USERNAME_INPUT = {"resourceId": "com.app:id/et_username"}
    PASSWORD_INPUT = {"resourceId": "com.app:id/et_password"}
    LOGIN_BUTTON = {"text": "登录", "clickable": True}
    ERROR_TOAST = {"textContains": "错误"}

    def input_username(self, username):
        elem = self.find_element(**self.USERNAME_INPUT)
        elem.set_text(username)
        return self

    def input_password(self, password):
        elem = self.find_element(**self.PASSWORD_INPUT)
        elem.set_text(password)
        return self

    def click_login(self):
        elem = self.find_element(**self.LOGIN_BUTTON)
        elem.click()
        return self

    def is_error_displayed(self, timeout=5):
        return self.device(**self.ERROR_TOAST).wait(timeout=timeout)

# 在测试用例中使用
def test_login_success(device):
    login_page = LoginPage(device)
    (login_page.input_username("correct_user")
               .input_password("correct_pass")
               .click_login())
    # 断言:登录后应跳转到主页,而不是出现错误提示
    assert not login_page.is_error_displayed(), "登录失败,出现错误提示"
    # 可以进一步验证主页元素
    assert device(text="欢迎回来").exists(timeout=10)

5.2 测试数据与配置分离

将设备信息、应用包名、账号密码等配置信息从脚本中剥离,使用配置文件(如config.yaml或.env)管理。

# config.yaml
devices:
  test_device_1:
    connection: "wifi"
    address: "192.168.1.100"
  test_device_2:
    connection: "usb"
    serial: "ABCDEFG"

app:
  package_name: "com.example.myapp"
  main_activity: "com.example.myapp.MainActivity"

users:
  admin:
    username: "admin@test.com"
    password: "Admin123"
  normal_user:
    username: "user@test.com"
    password: "User123"

在脚本中读取配置:

import yaml
import os

CONFIG_PATH = os.path.join(os.path.dirname(__file__), 'config.yaml')

with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
    config = yaml.safe_load(f)

device_config = config['devices']['test_device_1']
if device_config['connection'] == 'wifi':
    d = u2.connect_wifi(device_config['address'])
else:
    d = u2.connect_usb(device_config['serial'])

user = config['users']['normal_user']

5.3 简单的测试用例组织

即使不引入pytest或unittest等大型框架,也可以简单地组织用例。

# test_suite.py
import time
from login_page import LoginPage
from config import get_device, get_user

class TestLogin:
    def setup(self):
        self.device = get_device()
        self.login_page = LoginPage(self.device)
        # 确保从首页开始
        self.device.app_start(get_app_package())
        self.device.press("home")

    def teardown(self):
        self.device.app_stop(get_app_package())

    def test_login_with_valid_credentials(self):
        """测试有效账号登录"""
        user = get_user('normal_user')
        (self.login_page.input_username(user['username'])
                       .input_password(user['password'])
                       .click_login())
        assert self.device(text="我的账户").wait(timeout=10), "登录成功后未进入我的账户页"

    def test_login_with_invalid_password(self):
        """测试错误密码登录"""
        user = get_user('normal_user')
        (self.login_page.input_username(user['username'])
                       .input_password("wrong_pass")
                       .click_login())
        assert self.login_page.is_error_displayed(), "输入错误密码后未显示错误提示"

if __name__ == "__main__":
    test_suite = TestLogin()
    test_cases = [test_suite.test_login_with_valid_credentials,
                  test_suite.test_login_with_invalid_password]

    for test_case in test_cases:
        print(f"\n--- 开始执行: {test_case.__name__} ---")
        try:
            test_suite.setup()
            test_case()
            print(f"--- 通过: {test_case.__name__} ---")
        except AssertionError as e:
            print(f"--- 失败: {test_case.__name__} - {e} ---")
            test_suite.login_page.take_screenshot(f"FAIL_{test_case.__name__}")
        except Exception as e:
            print(f"--- 错误: {test_case.__name__} - {e} ---")
            test_suite.login_page.take_screenshot(f"ERROR_{test_case.__name__}")
        finally:
            test_suite.teardown()
        time.sleep(2) # 用例间短暂间隔

通过这五个层面的技巧——从环境搭建、元素定位、复杂交互、健壮性编码到框架设计——你可以系统地构建出高效、稳定且易于维护的Android自动化测试脚本。记住,核心思想是 “用Python思维做自动化” ,让uiautomator2成为你手中得心应手的工具,而不是需要反复查阅文档的负担。在实际项目中,从一个小而具体的场景开始实践,比如自动完成某个表单的填写和提交,逐步叠加复杂度,你会很快感受到效率的提升。

更多推荐