Appium相关

12 minute read

Appium 移动自动化测试 完整系统学习大纲

本文面向测试工程师及移动端测试初学者。Appium 是移动端自动化测试的行业事实标准:一套脚本同时跑 Android 和 iOS,不侵入 App 源码,支持原生应用、混合应用(H5)和移动 Web。与站内已有的 UIAutomator2(Android 专用、Python 直连)相比,Appium 的强项是跨平台 + 生态完整,代价是多一层 Server 通信、执行略慢。两者定位互补,建议都了解。 文中基于 Appium 2.x + Appium-Python-Client 3.x(Appium 1.x 已于 2022 年停止维护),Windows 用户请在 PowerShell(非 cmd)中执行。

一、Appium 概述

1.1 什么是 Appium?

  • 定义:Appium 是一个开源、跨平台的移动应用自动化测试框架,用统一的 API 驱动 Android、iOS 上的原生应用、混合应用和移动 Web 应用。
  • 出身:2012 年由 Dan Cuellar 发起(最初叫 iOSAuto),2013 年加入 Sauce Labs 并支持 Android,2022 年发布 Appium 2.0(插件化架构,驱动与主体解耦),目前主流版本为 2.x。
  • 核心思想:用统一的 WebDriver 协议屏蔽底层差异 —— 测试脚本只管发标准指令,具体怎么操控手机交给底层的各家驱动去适配。

1.2 为什么它是行业标准?

优势 说明
跨平台 同一套脚本跑 Android + iOS,测试代码复用率可达 70%+
不侵入源码 不需要改 App 代码、不需要重新打包(黑盒测试)
多语言 Python / Java / JavaScript / Ruby / C# 任选
支持多种应用形态 原生 App、混合 App(WebView)、移动 Web 浏览器
生态成熟 Appium Inspector、云测平台(BrowserStack / SauceLabs / Testin)全兼容
面试硬通货 中高级测试岗移动端必问

1.3 分层结构:Appium 和底层框架的关系

很多人搞不清 Appium、UIAutomator2、XCUITest 的关系,其实它们是上下层,不是并列关系:

        你的测试脚本(Python / Java)
                  │  标准 WebDriver 协议(HTTP + JSON)
                  ▼
        ┌─────────────────────────┐
        │   Appium Server (Node)  │  接收指令、管理 Session、转发
        └───────────┬─────────────┘
                    │  按 platformName 分派给对应 Driver
        ┌───────────┴────────────┐
        ▼                        ▼
┌───────────────┐        ┌───────────────┐
│ uiautomator2  │        │   xcuitest    │   ← Appium 2.x 的可插拔驱动
│    driver     │        │    driver     │
└───────┬───────┘        └───────┬───────┘
        ▼                        ▼
  Android UIAutomator2      iOS XCUITest / WDA
        ▼                        ▼
   Android 设备/模拟器        iOS 真机/模拟器
  • Appium 本身不直接操作设备,它只是”协议翻译 + 指令转发”。
  • Android 侧实际干活的是 Google 官方的 UIAutomator2(uiautomator2 driver);iOS 侧是 Apple 的 XCUITest + WebDriverAgent(xcuitest driver)。
  • 所以 Appium 2.x 装完主体后,必须单独安装驱动(见 2.2),这是 1.x 用户最容易踩的坑。

1.4 与其他移动端框架的对比

框架 跨平台 语言 侵入性 执行速度 适用场景
Appium Android + iOS 多语言 黑盒,不侵入 中(有 Server 转发) 跨平台回归、云测平台、团队统一
UIAutomator2 仅 Android Python 黑盒 快(直连设备) 纯 Android 的项目、快速脚本
Espresso / XCUITest 单平台 Java/Kotlin、Swift 白盒(需源码) 最快 开发自测、单测级 UI 测试
Airtest(网易) Android + iOS + 游戏 Python 黑盒,图像识别 中 游戏、Unity、无法定位元素的场景
Playwright 移动浏览器模拟 多语言 — 快 移动 Web / H5,不能测原生控件

一句话:要跨平台选 Appium,要极致速度且只测 Android 选 UIAutomator2,要测游戏或有图像识别需求看 Airtest。 三者不是替代关系,很多团队是 Appium 做主回归 + UIAutomator2 做快速冒烟。

1.5 核心概念

概念 说明
Client 你写脚本用的客户端库(如 Appium-Python-Client),负责发 HTTP 请求
Server Node.js 写的服务(npm install -g appium),监听 4723 端口,管理 Session
Driver 平台适配器(uiautomator2 / xcuitest / espresso 等),Appium 2.x 独立安装
Plugin 可选插件(如 images 图像定位、relaxed-caps 等)
Session 一次会话 = 一次连接。脚本通过 session 与设备交互,driver.quit() 结束
Capabilities 启动参数(平台、设备、App、行为开关),决定”连哪台设备、开哪个 App”
Desired Capabilities 旧称,Appium 2.x 起规范叫 W3C Capabilities,需用 Options 类传入

1.6 一次点击背后发生了什么?

1. 脚本:driver.find_element(AppiumBy.ID, "login_btn").click()
2. Client → 转成 HTTP POST /session/xxx/element(JSON Wire / W3C 协议)
3. Server → 查 session,转给对应 driver(如 uiautomator2)
4. Driver → 调 Android UIAutomator2 的 API 在设备上找控件
5. 设备 → 返回元素信息,driver 再执行 click
6. 结果沿原路返回给脚本

这也是 Appium 比 UIAutomator2 慢的原因:每次操作都是一次 HTTP 往返。优化方向是减少无效查询(见第九章)。

1.7 适用场景与不适用

适合:

  1. 跨 Android / iOS 的回归测试(核心场景)
  2. 需要云测平台批量跑兼容性(几十台机型)
  3. 混合应用(原生 + WebView)的端到端流程
  4. 团队技术栈统一、要沉淀 PO 框架

不适合:

  1. 追求毫秒级响应的性能/启动耗时测试(用原生工具更准)
  2. 游戏、Unity、Flutter 自绘引擎界面(控件树不可见,图像识别更合适)
  3. 纯单元测试级别验证(那是 Espresso/XCUITest 的活)

二、环境搭建(Appium 2.x)

2.1 安装 Node.js 与 Appium Server

Appium Server 是 Node.js 程序,先装 Node(建议 LTS 18+):

# 验证 Node 环境
node -v
npm -v

# 全局安装 Appium(2.x)
npm install -g appium

# 验证
appium --version

2.2 安装驱动(Appium 2.x 必做!)

Appium 2.x 把驱动从主体剥离开了,装完 appium 不能直接跑,必须装 driver:

# 查看可安装的驱动
appium driver list

# Android 驱动
appium driver install uiautomator2

# iOS 驱动(需要在 macOS 上才有意义)
appium driver install xcuitest

# 查看已安装
appium driver list --installed

# 更新驱动
appium driver update uiautomator2

⚠️ Appium 1.x 用户最容易懵的地方:1.x 装完 npm install -g appium 就能用,2.x 直接跑会报 Could not find a driver for automationName 'UiAutomator2'。记住 2.x = 主体 + 驱动。

2.3 安装 Appium Inspector

Appium 2.x 起,Appium Desktop(含 Inspector)已废弃,Inspector 独立成单独应用:

  • 下载地址:https://github.com/appium/appium-inspector/releases
  • 作用:连上设备后实时查看控件树、录制操作、复制定位表达式
  • 相当于 UIAutomator2 生态里的 weditor

2.4 Android 环境(JDK + Android SDK)

  1. 安装 JDK 11+,配置 JAVA_HOME:
    $env:JAVA_HOME = "C:\Program Files\Java\jdk-17"
    [Environment]::SetEnvironmentVariable("JAVA_HOME", $env:JAVA_HOME, "Machine")
    
  2. 安装 Android Studio 或单独装 command-line tools,配置 ANDROID_HOME:
    $env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk"
    $env:PATH += ";$env:ANDROID_HOME\platform-tools;$env:ANDROID_HOME\emulator"
    
  3. 验证设备连接:
    adb devices
    # List of devices attached
    # emulator-5554   device      ← 状态必须是 device,不是 unauthorized/offline
    

真机要点:手机打开开发者选项 → USB 调试,首次连接要在手机上点”允许 USB 调试”。显示 unauthorized 就重插并确认授权弹窗。

2.5 iOS 环境(仅 macOS)

依赖 说明
macOS + Xcode 必须,iOS 自动化只能在 Mac 上跑(XCUITest 依赖 Xcode 编译)
Xcode Command Line Tools xcode-select --install
carthage WDA 依赖管理:brew install carthage
ios-deploy / tidevice 真机部署;tidevice 可在 Linux/Windows 上做部分设备管理
WebDriverAgent 签名 真机必须用开发者账号给 WDA 签名(免费账号有效期 7 天)
brew install carthage
npm install -g ios-deploy
pip3 install tidevice      # 跨平台查看设备、安装 App 很方便
tidevice list

2.6 安装 Python 客户端

pip install Appium-Python-Client
pip install pytest          # 测试框架
pip install pytest-rerunfailures   # 失败重试(稳定性必备)
python -c "import appium; print(appium.__version__)"

2.7 环境与启动参数

# 启动服务(2.x 默认端口 4723,默认 base path 为 / ,不再是 /wd/hub)
appium

# 常用参数
appium --address 127.0.0.1 --port 4723 --log-level info
appium --relaxed-security          # 放开 mobile: shell 等受限指令
appium --base-path /wd/hub         # 老客户端兼容时需要

Appium 2.x 的 URL 变化:1.x 是 http://127.0.0.1:4723/wd/hub,2.x 默认去掉了 /wd/hub,直接写 http://127.0.0.1:4723。脚本连不上时先确认这一点。

三、第一个自动化脚本

3.1 常用 Capabilities 速查

参数 说明 示例
platformName 平台 Android / iOS
automationName 驱动名 UiAutomator2 / XCUITest
deviceName 设备名(任意,仅标识) emulator-5554
udid 设备唯一标识(多设备必填) emulator-5554 / iOS 的 40 位 UUID
app App 安装包路径或 URL D:/app/demo.apk
appPackage / appActivity Android 启动入口 com.demo / .MainActivity
bundleId iOS 包名 com.demo.app
noReset 不清除 App 数据(保留登录态) true
fullReset 卸载重装 false
unicodeKeyboard / resetKeyboard 中文输入支持 true
autoAcceptAlerts 自动接受系统弹窗(iOS) true
autoGrantPermissions 自动授予权限(Android) true
newCommandTimeout 无指令超时自动结束 session 300
systemPort uiautomator2 driver 端口(多机并发必配) 8200
chromedriverExecutableDir 指定 chromedriver 目录(WebView 用) D:/drivers
settings[waitForIdleTimeout] 等待界面空闲超时(毫秒) 100

3.2 Android 示例

from appium import webdriver
from appium.options.android import UiAutomator2Options
from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

options = UiAutomator2Options()
options.platform_name = "Android"
options.automation_name = "UiAutomator2"
options.device_name = "emulator-5554"
options.app_package = "com.example.demo"
options.app_activity = ".MainActivity"
options.no_reset = True
options.auto_grant_permissions = True
options.new_command_timeout = 300

driver = webdriver.Remote("http://127.0.0.1:4723", options=options)

try:
    # 等待并点击登录按钮
    login_btn = WebDriverWait(driver, 15).until(
        EC.element_to_be_clickable((AppiumBy.ID, "com.example.demo:id/btn_login"))
    )
    login_btn.click()

    # 输入账号密码
    driver.find_element(by=AppiumBy.ID, value="com.example.demo:id/et_account").send_keys("test01")
    driver.find_element(by=AppiumBy.ID, value="com.example.demo:id/et_pwd").send_keys("123456")
    driver.find_element(by=AppiumBy.ID, value="com.example.demo:id/btn_submit").click()

    # 断言:出现首页元素
    WebDriverWait(driver, 10).until(
        EC.presence_of_element_located((AppiumBy.ID, "com.example.demo:id/rv_home"))
    )
    print("登录流程通过")
finally:
    driver.quit()     # 必须退出,否则 session 残留占用设备

3.3 iOS 示例

from appium import webdriver
from appium.options.ios import XCUITestOptions

options = XCUITestOptions()
options.platform_name = "iOS"
options.automation_name = "XCUITest"
options.platform_version = "17.0"
options.device_name = "iPhone 15"
options.udid = "00008120-XXXXXXXXXXXXXXXX"
options.bundle_id = "com.example.demo"
options.no_reset = True
options.auto_accept_alerts = True

driver = webdriver.Remote("http://127.0.0.1:4723", options=options)
driver.quit()

3.4 Session 生命周期与常见启动失败

创建 session(能力校验 → 装驱动到设备 → 启动 App → 返回 sessionId)
        ↓
执行操作(find / click / swipe ...)
        ↓
driver.quit()  ← 关键:释放设备、清理端口
报错 原因 处理
Could not find a driver for automationName 没装 driver appium driver install uiautomator2
Could not find a connected Android device adb 看不到设备 adb devices 检查授权与连接
Failed to launch App / Activity not found 包名或 Activity 写错 用 adb shell dumpsys window \| findstr mCurrentFocus 查当前 Activity
Session not created after 60s 设备卡死 / WDA 未启动 重启 adb(adb kill-server && adb start-server)、重启设备
Original error: Unable to find an active device 模拟器未启动 先 emulator -avd <name> 或启动 Android Studio 设备

四、元素定位(核心重点)

4.1 定位策略全表

方式 写法 说明
ID(推荐) AppiumBy.ID, "com.demo:id/btn_login" Android 用 resource-id,iOS 用 name
Accessibility ID(推荐) AppiumBy.ACCESSIBILITY_ID, "登录" Android content-desc / iOS label,跨平台统一
ClassName AppiumBy.CLASS_NAME, "android.widget.Button" 同类元素多,通常配合索引
XPath AppiumBy.XPATH, "//*[@text='登录']" 万能但慢且脆弱,慎用
Android UIAutomator AppiumBy.ANDROID_UIAUTOMATOR, 'new UiSelector().text("登录")' Android 最强,支持组合、滚动查找
iOS Predicate AppiumBy.IOS_PREDICATE, "name == '登录' AND visible == 1" iOS 属性表达式,强大
iOS Class Chain AppiumBy.IOS_CLASS_CHAIN, "**/XCUIElementTypeButton[\name == ‘登录’`]”` iOS 层级定位,性能优于 XPath
Image(图像) AppiumBy.IMAGE, base64图片 需 appium plugin install images,适合游戏

4.2 定位优先级建议

1. resource-id / accessibility id   ← 最稳定、最快
2. Android UIAutomator / iOS Predicate  ← 平台原生强力语法
3. 组合定位(父子、兄弟、instance)    ← 无 id 时的次选
4. XPath                              ← 最后手段(层级一变就废)
5. 坐标 / 图像                        ← 实在没招才用,极不稳定

4.3 Appium Inspector 用法

  1. 启动 Appium Server,手机连好电脑。
  2. 打开 Appium Inspector,填 Remote Host 127.0.0.1、Port 4723、Remote Path /(2.x 默认)。
  3. Desired Capabilities 里填平台、设备、包名等,点 Start Session。
  4. 左侧是屏幕截图,中间是控件树,右侧是选中元素的属性。
  5. 选中元素后可一键复制 ID / XPath 等表达式,直接粘到脚本里。

技巧:Inspector 的控件树是当前屏幕快照,页面跳转后要点刷新按钮重新抓取。

4.4 组合与层级定位

# Android:多条件组合(UiSelector 链式调用)
driver.find_element(
    by=AppiumBy.ANDROID_UIAUTOMATOR,
    value='new UiSelector().resourceId("com.demo:id/item").text("设置")'
)

# Android:父子关系
driver.find_element(
    by=AppiumBy.ANDROID_UIAUTOMATOR,
    value='new UiSelector().className("android.widget.LinearLayout")'
          '.childSelector(new UiSelector().text("确定"))'
)

# Android:按索引取第 N 个
items = driver.find_elements(by=AppiumBy.ID, value="com.demo:id/item_title")
items[2].click()

# XPath:相对路径 + 属性组合
driver.find_element(
    by=AppiumBy.XPATH,
    value='//android.widget.ListView//android.widget.TextView[@text="我的订单"]'
)

# iOS:Predicate
driver.find_element(
    by=AppiumBy.IOS_PREDICATE,
    value="type == 'XCUIElementTypeButton' AND name CONTAINS '登录'"
)

4.5 为什么不建议滥用 XPath?

  • 慢:XPath 要遍历整棵控件树,Appium 上尤其明显(每次可能几百毫秒)。
  • 脆:UI 层级一调整就失效,维护成本高。
  • 同步问题:长列表里 XPath 依赖绝对位置,滑动后就找错了。

经验法则:能用 resource-id 就别用 XPath。确实要用就写成相对路径 //*[@text='xxx'],避免 /hierarchy/android.widget.FrameLayout/.../... 这种绝对路径。

五、元素操作与手势

5.1 基础操作

el = driver.find_element(by=AppiumBy.ID, value="com.demo:id/et_search")

el.click()                     # 点击
el.send_keys("Appium")         # 输入
el.clear()                     # 清空
text = el.text                 # 取文本
enabled = el.get_attribute("enabled")     # 取属性(enabled/checked/selected/clickable...)
bounds = el.rect               # 取位置和尺寸 {"x":0,"y":100,"width":200,"height":80}
displayed = el.is_displayed()  # 是否可见

5.2 滑动与滚动

Appium 2.x 推荐用 W3C 手势指令(mobile: 脚本方式),老版 TouchAction 已废弃:

def swipe_up(driver, percent=0.6):
    """整体上滑"""
    size = driver.get_window_size()
    driver.execute_script("mobile: swipeGesture", {
        "left": size["width"] * 0.1,
        "top": size["height"] * 0.2,
        "width": size["width"] * 0.8,
        "height": size["height"] * 0.6,
        "direction": "up",
        "percent": percent,
        "speed": 2000
    })

def scroll_to_text(driver, text):
    """Android:滚动直到某文本出现(UiScrollable,最稳)"""
    return driver.find_element(
        by=AppiumBy.ANDROID_UIAUTOMATOR,
        value=f'new UiScrollable(new UiSelector().scrollable(true).instance(0))'
              f'.scrollIntoView(new UiSelector().text("{text}"));'
    )

def iOS_scroll(driver, direction="down"):
    """iOS:滚动手势"""
    driver.execute_script("mobile: scroll", {"direction": direction})

5.3 长按、拖拽、双击

# 长按
driver.execute_script("mobile: longClickGesture", {
    "elementId": el.id, "duration": 1500
})

# 拖拽(把 A 拖到 B)
driver.execute_script("mobile: dragGesture", {
    "elementId": el_a.id, "endX": 200, "endY": 600, "speed": 1500
})

# 双击
driver.execute_script("mobile: doubleClickGesture", {"elementId": el.id})

5.4 系统按键、截图、通知栏

driver.press_keycode(4)                 # Android 返回键(KEYCODE_BACK)
driver.press_keycode(3)                 # Home 键
driver.press_keycode(187)               # 最近任务
driver.open_notifications()             # 打开通知栏(Android)
driver.save_screenshot("screen.png")    # 截图
driver.get_screenshot_as_base64()       # 转 base64,用于测试报告

5.5 应用管理

driver.install_app("D:/app/demo.apk")
driver.remove_app("com.demo")
driver.activate_app("com.demo")          # 启动/切前台
driver.terminate_app("com.demo")         # 强杀
driver.background_app(5)                 # 退后台 5 秒再回来
driver.is_app_installed("com.demo")
driver.start_activity("com.demo", ".SettingActivity")   # 直接进指定页面
driver.current_package                   # 当前包名
driver.current_activity                  # 当前 Activity(Android)

5.6 混合应用:WebView 切换

混合 App(原生壳 + H5 页面)必须先切换上下文才能定位网页元素:

# 1. 查看所有上下文
print(driver.contexts)     # ['NATIVE_APP', 'WEBVIEW_com.demo']

# 2. 切到 WebView
driver.switch_to.context("WEBVIEW_com.demo")

# 3. 用 Web 的定位方式操作 H5
driver.find_element(by=AppiumBy.CSS_SELECTOR, value="#username").send_keys("test")
driver.find_element(by=AppiumBy.XPATH, value="//button[@id='submit']").click()

# 4. 切回原生
driver.switch_to.context("NATIVE_APP")

⚠️ 常见坑:

  1. App 的 WebView 必须打开 debug 模式(开发需在 WebView 上设 setWebContentsDebuggingEnabled(true)),否则 contexts 里看不到 WEBVIEW。
  2. chromedriver 版本必须匹配手机里的 Chrome/WebView 版本,不匹配会报 chromedriver ... not supported。用 chromedriverExecutableDir 指定对应版本目录。
  3. 真机上 WebView 包名可能是 WEBVIEW_com.demo,也可能是 WEBVIEW_chrome,要以 driver.contexts 实际输出为准。

六、等待机制

6.1 三种等待对比

类型 写法 优劣
强制等待 time.sleep(3) 简单但死板,拖慢整体耗时
隐式等待 driver.implicitly_wait(10) 全局生效,只管”找到元素”,不管”可点击”
显式等待(推荐) WebDriverWait(driver, 15).until(...) 精准、可设条件,稳定性首选

建议:显式等待为主 + 少量隐式等待,不要混用过多(两者叠加会导致超时时间难以预期)。

6.2 移动端常用等待条件

from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait

wait = WebDriverWait(driver, 15)

wait.until(EC.presence_of_element_located((AppiumBy.ID, "com.demo:id/title")))   # 存在
wait.until(EC.visibility_of_element_located((AppiumBy.ID, "com.demo:id/title"))) # 可见
wait.until(EC.element_to_be_clickable((AppiumBy.ID, "com.demo:id/btn")))         # 可点击
wait.until(EC.invisibility_of_element_located((AppiumBy.ID, "com.demo:id/loading")))  # 消失
wait.until(lambda d: d.find_element(by=AppiumBy.ID, value="com.demo:id/tv").text == "成功")  # 自定义

6.3 自定义等待:等页面稳定

def wait_page_stable(driver, timeout=10, interval=0.5):
    """连续两次截图像素一致,认为页面不再刷新(对抗转圈动画)"""
    import time
    prev = driver.get_screenshot_as_base64()
    end = time.time() + timeout
    while time.time() < end:
        time.sleep(interval)
        curr = driver.get_screenshot_as_base64()
        if curr == prev:
            return True
        prev = curr
    return False

def wait_toast(driver, text, timeout=8):
    """等待 Toast 出现并返回元素"""
    return WebDriverWait(driver, timeout).until(
        EC.presence_of_element_located((AppiumBy.XPATH, f'//android.widget.Toast[contains(@text, "{text}")]'))
    )

6.4 超时相关参数

参数 作用
newCommandTimeout 多少秒没收到指令就自动结束 session(防设备长期占用)
settings[waitForIdleTimeout] 等待界面空闲的超时,调小可提速(默认 10000ms)
settings[waitForSelectorTimeout] 查找元素的超时(默认 10000ms)
WebDriverWait(driver, n) 脚本级显式等待

七、Page Object 模式与工程化

与站内 Details/ 下的 PO 模式专题配套,这里给 Appium 的完整落地结构。

7.1 目录结构

appium_project/
├── config/
│   ├── caps.yaml            # 设备与能力配置
│   └── config.yaml          # 环境、账号数据
├── pages/
│   ├── base_page.py         # 基类:通用操作封装
│   ├── login_page.py        # 登录页
│   └── home_page.py         # 首页
├── tests/
│   ├── conftest.py          # pytest fixture:driver 生命周期
│   └── test_login.py
├── reports/                 # 截图与报告
└── requirements.txt

7.2 基类封装

# pages/base_page.py
import os, time
from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC


class BasePage:
    def __init__(self, driver, timeout=15):
        self.driver = driver
        self.wait = WebDriverWait(driver, timeout)

    def find(self, locator, timeout=None):
        wait = self.wait if timeout is None else WebDriverWait(self.driver, timeout)
        return wait.until(EC.presence_of_element_located(locator))

    def click(self, locator):
        self.wait.until(EC.element_to_be_clickable(locator)).click()

    def input(self, locator, text):
        el = self.find(locator)
        el.clear()
        el.send_keys(text)

    def text_of(self, locator):
        return self.find(locator).text

    def is_exist(self, locator, timeout=3):
        try:
            self.find(locator, timeout)
            return True
        except Exception:
            return False

    def swipe_up(self, percent=0.6):
        size = self.driver.get_window_size()
        self.driver.execute_script("mobile: swipeGesture", {
            "left": size["width"] * 0.1, "top": size["height"] * 0.2,
            "width": size["width"] * 0.8, "height": size["height"] * 0.6,
            "direction": "up", "percent": percent, "speed": 2000
        })

    def scroll_to_text(self, text):
        return self.driver.find_element(
            by=AppiumBy.ANDROID_UIAUTOMATOR,
            value=f'new UiScrollable(new UiSelector().scrollable(true).instance(0))'
                  f'.scrollIntoView(new UiSelector().text("{text}"));'
        )

    def screenshot(self, name="failure"):
        path = os.path.join("reports", f"{name}_{int(time.time())}.png")
        os.makedirs("reports", exist_ok=True)
        self.driver.save_screenshot(path)
        return path

7.3 页面对象

# pages/login_page.py
from appium.webdriver.common.appiumby import AppiumBy
from pages.base_page import BasePage


class LoginPage(BasePage):
    ACCOUNT = (AppiumBy.ID, "com.demo:id/et_account")
    PASSWORD = (AppiumBy.ID, "com.demo:id/et_pwd")
    SUBMIT = (AppiumBy.ID, "com.demo:id/btn_submit")
    TOAST = (AppiumBy.XPATH, "//android.widget.Toast")

    def login(self, username, password):
        self.input(self.ACCOUNT, username)
        self.input(self.PASSWORD, password)
        self.click(self.SUBMIT)
        return self

    def get_toast(self):
        return self.text_of(self.TOAST)

7.4 pytest fixture(含失败截图)

# tests/conftest.py
import pytest
from appium import webdriver
from appium.options.android import UiAutomator2Options


@pytest.fixture(scope="function")
def driver():
    options = UiAutomator2Options()
    options.platform_name = "Android"
    options.automation_name = "UiAutomator2"
    options.device_name = "emulator-5554"
    options.app_package = "com.demo"
    options.app_activity = ".MainActivity"
    options.no_reset = True

    d = webdriver.Remote("http://127.0.0.1:4723", options=options)
    yield d
    d.quit()


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    """用例失败时自动截图"""
    outcome = yield
    report = outcome.get_result()
    if report.when == "call" and report.failed:
        d = item.funcargs.get("driver")
        if d:
            d.save_screenshot(f"reports/{item.name}.png")
# tests/test_login.py
from pages.login_page import LoginPage


def test_login_success(driver):
    page = LoginPage(driver)
    page.login("test01", "123456")
    assert page.is_exist((LoginPage.TOAST[0], "com.demo:id/rv_home"))


def test_login_wrong_password(driver):
    page = LoginPage(driver)
    page.login("test01", "wrong")
    assert "密码错误" in page.get_toast()

7.5 多设备并发

# 每台设备独占:Appium Server 端口 + uiautomator2 systemPort + 设备 udid
DEVICES = [
    {"udid": "emulator-5554", "server": "http://127.0.0.1:4723", "system_port": 8200},
    {"udid": "emulator-5556", "server": "http://127.0.0.1:4725", "system_port": 8201},
]

def build_driver(dev):
    options = UiAutomator2Options()
    options.platform_name = "Android"
    options.automation_name = "UiAutomator2"
    options.udid = dev["udid"]
    options.system_port = dev["system_port"]
    options.app_package = "com.demo"
    options.app_activity = ".MainActivity"
    return webdriver.Remote(dev["server"], options=options)
# 启动两个 Appium Server
appium -p 4723 &
appium -p 4725 &

# 用 pytest-xdist 并发
pip install pytest-xdist
pytest -n 2 tests/

⚠️ 并发三要素:Server 端口不同、systemPort 不同、udid 不同。任一重复都会导致 session 抢占失败。

八、特殊场景处理

8.1 Toast 提示

# Android:UIAutomator2 驱动直接支持 Toast 定位
toast = WebDriverWait(driver, 8).until(
    EC.presence_of_element_located((AppiumBy.XPATH, '//android.widget.Toast'))
)
print(toast.text)

# 指定内容
driver.find_element(by=AppiumBy.XPATH, value='//android.widget.Toast[contains(@text,"登录成功")]')

Toast 存活时间短(2~3 秒),必须用显式等待而非 sleep 后查找;iOS 无原生 Toast,通常是对话框或自定义 View,用 ACCESSIBILITY_ID 定位。

8.2 系统弹窗与权限弹窗

# 方案1:启动参数自动授权(推荐)
options.auto_grant_permissions = True     # Android
options.auto_accept_alerts = True         # iOS 系统弹窗

# 方案2:脚本里按文本点掉(更可控,可处理"仅在使用中允许")
for text in ["仅在使用中允许", "允许", "始终允许"]:
    if driver.find_elements(by=AppiumBy.XPATH, value=f'//*[@text="{text}"]'):
        driver.find_element(by=AppiumBy.XPATH, value=f'//*[@text="{text}"]').click()
        break

# 方案3:iOS 弹窗显式接受
driver.switch_to.alert.accept()

8.3 中文输入

options.unicode_keyboard = True
options.reset_keyboard = True

说明:这两个参数会安装并使用 Appium 的输入法来完成中文输入。若遇到输入乱码或输入法切不回来,可改用 driver.execute_script("mobile: shell", {"command": "input text ..."}),或让开发在控件上加 content-desc,用 send_keys 前先 clear()。

8.4 其他高频场景

场景 做法
横竖屏切换 driver.orientation = "LANDSCAPE"
深层页面直达 driver.start_activity(pkg, activity) 跳过前置步骤,提速
清理 App 数据 去掉 noReset 或 driver.remove_app + install_app
验证码 让测试环境配置万能码 / 后端接口取码 / 关闭校验,不要靠 OCR
上传图片 通过 push_file 把文件推到设备相册再从相册选
弱网测试 借助 adb shell 改网络策略或配合 Fiddler/Charles 限速
应用升级测试 装旧版本跑一遍 → install_app 升级包 → 再跑一遍

九、稳定性与性能优化

9.1 用例 flaky(时好时坏)的常见原因

原因 表现 对策
用坐标 / 绝对路径 XPath 换机型就失败 改 resource-id、相对定位
等待不足 偶发 NoSuchElement 显式等待 + 页面稳定判断
动画/转圈未结束 点击落空 关动画(开发者选项)、waitForIdleTimeout 调小
前置数据污染 用例互相影响 用例独立、noReset + 接口造数
设备状态残留 上条用例弹窗未关 teardown 里 terminate_app 或重启 App
并发端口冲突 随机 session 失败 端口按设备隔离

9.2 失败重试

pip install pytest-rerunfailures
pytest tests/ --reruns 2 --reruns-delay 3

9.3 执行速度优化

# 关掉等待空闲(提速最明显的一项,默认 10000ms)
driver.update_settings({"waitForIdleTimeout": 100})

# 只抓取必要层级,减少控件树传输量
driver.update_settings({"snapshotMaxDepth": 50})
driver.update_settings({"ignoreUnimportantViews": True})

# 尽量复用 session:用 noReset=True + start_activity 直达目标页

9.4 云测平台

需要覆盖几十上百款机型时,本地跑不现实,可把同一套 Appium 脚本交给云测平台:

  • BrowserStack / SauceLabs:海外,机型全,付费
  • Testin / 阿里云测 / 腾讯 WeTest:国内,真机租赁
  • 接入方式通常只是把 webdriver.Remote 的地址换成平台提供的 URL + 加鉴权 cap,脚本本身不用改

十、常见问题排查(FAQ)

1. Session 创建失败 / 报 driver 找不到

按顺序查:

  1. appium driver list --installed 确认装了 uiautomator2(2.x 必装)。
  2. adb devices 状态是 device,不是 unauthorized / offline。
  3. Appium Server 日志里看真实报错(appium --log-level debug)。
  4. 确认 URL 没有多写 /wd/hub(2.x 默认不需要)。
  5. 模拟器完整启动(看到桌面,不是黑屏)。
2. 元素明明在屏幕上却找不到
  • 在 WebView 里:print(driver.contexts),切到 WEBVIEW_xxx 再找。
  • 页面已跳转:Inspector 刷新一下,或重新 driver.page_source 看看当前树。
  • 被弹窗/遮罩挡住:先处理弹窗,或用 is_displayed() 判断可见性。
  • 长列表未加载:先滚动到底触发加载,再查找。
  • 用了绝对路径 XPath:层级变了,改成相对路径或 ID。
3. Appium 1.x 迁移到 2.x 要注意什么?
1.x 2.x
装完即用 需 appium driver install uiautomator2
地址带 /wd/hub 默认不带,写成 http://host:4723
desired_capabilities={...} 字典 用 UiAutomator2Options() 对象
driver.find_element_by_id(...) driver.find_element(by=AppiumBy.ID, value=...)
Appium Desktop 自带 Inspector Inspector 独立安装
appium-doctor 常用 官方弱化,逐项自查为主
4. 中文输入失败或乱码
  1. 加 unicodeKeyboard=True + resetKeyboard=True。
  2. 确认设备上的 Appium 输入法被启用(设置 → 输入法里能看到)。
  3. 仍不行就用 adb shell input text 兜底,或让开发给控件加 content-desc。
  4. 结束后注意把输入法切回,否则手工测试时会觉得”键盘不对”。
5. WebView 切不过去 / chromedriver 报错
  1. driver.contexts 打印看看有没有 WEBVIEW_,没有就是 App 没开 debug 模式,找开发打开 setWebContentsDebuggingEnabled(true)。
  2. 报错 This version of ChromeDriver only supports Chrome version xx → chromedriver 版本不匹配:
    • 手机设置里看 Android System WebView 版本;
    • 下载对应 chromedriver,用 chromedriverExecutableDir 指定目录。
  3. 部分 App 用的是自研内核(如 X5),需要 driver.execute_script 或让开发切换内核。
6. 多设备并发互相干扰
  • 每台设备起一个独立 Appium Server(不同 --port)。
  • 每个 driver 指定独立 systemPort(uiautomator2 默认 8200,会冲突)。
  • 必须传 udid,不要只靠 deviceName。
  • 测试结束后 driver.quit(),否则下次连接会报设备忙。

十一、面试高频题

问题 答题要点
Appium 原理? Client 发 WebDriver 协议请求 → Appium Server(Node)→ 按平台分派 driver(uiautomator2 / xcuitest)→ 底层调原生框架 → 设备上执行
和 UIAutomator2 什么区别? Appium 是跨平台框架,Android 底层就是 UIAutomator2;直接调 UIAutomator2 更快但仅限 Android
为什么跨平台? 统一 WebDriver 协议 + 可插拔 driver,屏蔽底层差异;脚本层用 accessibility id 等通用定位方式
Appium 2.x 有什么变化? 驱动/插件与主体解耦、需单独 install;base path 去掉 /wd/hub;W3C capabilities(Options 类);find_element_by_* 废弃
元素找不到怎么排查? 看 context、刷新控件树、处理弹窗、检查是否长列表未加载、改相对定位
怎么保证用例稳定? 显式等待、稳定定位方式、用例独立、失败重试、关动画、页面稳定判断
混合应用怎么测? driver.contexts 查看,切 WEBVIEW_xxx,chromedriver 版本要匹配,App 需开 WebView debug
多设备并发怎么做? 多 Server 端口 + 独立 systemPort + 指定 udid + pytest-xdist

十二、学习路径建议

第一阶段(跑通)
  概念分层 → 环境搭建(Node/驱动/SDK)→ 第一个脚本 → Inspector 看控件树

第二阶段(写稳)
  定位策略 → 手势与应用管理 → WebView 切换 → 等待机制

第三阶段(工程化)
  PO 模式封装 → pytest fixture → 失败截图 → 多设备并发 → 报告与集成

第四阶段(进阶)
  稳定性治理 → 云测平台 → CI 集成 → 性能与兼容性专项

一句话总结:Appium 的价值不在”能点按钮”,而在”一套脚本跨两个平台、还能上云测平台规模化”。 学的时候抓住三条主线:协议分层(理解原理)、定位与等待(决定稳定性)、PO 封装(决定可维护性),其余都是这三条的延伸。