ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Appium Android Toast定位实战:从原理到自动化断言

Appium Android Toast定位实战:从原理到自动化断言 1. Toast消息为什么是UI自动化里的老大难做Appium自动化测试的朋友一定经历过这种场景测一个登录功能账号密码填对了点击登录后页面底部嗖地弹出一条小灰条上面写着登录成功然后1到2秒就消失了。你拿定位工具去抓这个元素发现死活抓不到你怀疑是Toast还没出现就点快了于是手动操作一遍Toast明明出现了可自动化脚本就是拿不到。这个小灰条就是Android系统的Toast消息它在UI自动化中的特殊地位值得专门花一整篇来讲。先回答最基础的问题Toast到底是什么它是Android系统提供的轻量级消息提示机制由系统WindowManager直接管理不需要用户做任何交互操作到了一定时间自动消失。和Activity、Dialog完全不一样——它是一个独立的系统级窗口不在当前页面Activity的View层级里。正是这个系统级窗口的特性让它在Appium眼中变得非常特殊。我们平时定位元素原理是Appium通过UiAutomator去向系统要当前的页面层级结构accessibility hierarchy然后在这一层结构里去根据resource-id、class、text这些属性找元素。但Toast是系统进程直接弹出来的窗口它不挂在当前页面的ViewGroup下面在绝大多数时候根本不会出现在页面UI层级结构里。在你的代码里写driver.find_element(By.ID, xxx)或者By.XPATH去定位返回的要么是找不到元素要么直接超时。所以说Toast难做的根源不在Appium本身而在Android系统对Toast的机制设计上——它压根没打算让你通过常规UI遍历去拿到它。当然也不是完全没辙Toast虽然在常规层级里隐身但它仍然会出现在Accessibility事件流里也会短暂出现在getPageSource()返回的XML结构里。这就是后面要详细展开的技术突破口。2. 跑通Toast识别之前环境上必须做对的三件事2.1 automationName必须切换成UiAutomator2这是第一道坎也是最基础的一道。很多人用的是Appium默认的automationName也就是旧版的UiAutomatorUiAutomator1这种情况下哪怕precise的配置全都对也是拿不到Toast的。原因很简单UiAutomator1对系统Toast这类非标准控件的事件支持非常有限它在底层根本没有暴露Toast的Accessibility节点信息给你。而UiAutomator2官方名UiAutomator2 Driver是基于Google最新的UiAutomator测试框架重写的它不仅能拿到页面常规元素也能捕捉到Toast节点。所以在启动Appium会话时desired capabilities里必须明确写desired_caps { platformName: Android, appium:automationName: UiAutomator2, appium:deviceName: emulator-5554, appium:appPackage: com.example.demo, appium:appActivity: .MainActivity }这里有个我踩过的坑有些教程在写capabilities时用的是automationName不带appium:前缀。在Appium 1.x版本里两种写法都能兼容但Appium 2.x对无前缀的写法会告警并且一旦同时配置了其他带appium:前缀的capability可能导致automationName被忽略默认走UiAutomator1。所以建议统一带appium:前缀别在这种细节上给自己留隐患。2.2 Appium Settings必须开启相关辅助选项这里说的Appium Settings是Appium在设备上自动安装的一个辅助工具App包名通常是io.appium.settings。UiAutomator2驱动能拿到Toast节点依赖的就是这个App在后台做Accessibility事件监听。但有个问题这个能力默认未必是开启的。如果你在跑脚本的时候发现Toast节点怎么都拿不到但自动化Name又确实切到了UiAutomator2那就要考虑去检查Appium Settings里的开关。一个比较直观的检查方式手动打开手机上的Appium Settings应用查看是否有Show Taps、Show Passwords之类的辅助功能菜单同时去系统设置里确认Appium Settings的无障碍服务是否处于开启状态。有些手机尤其国产ROM会在安装第三方应用后默认关闭无障碍权限导致Appium Settings虽然装了但压根没在工作。如果你的脚本是跑在真机上建议每次连接新设备后先手动确认一遍设置-辅助功能-无障碍找有没有Appium Settings这一项有的话确保是打开状态。如果被系统回收或权限被重置先卸载重装Appium Settings然后重新开启无障碍权限。2.3 确认Appium版本和服务启动方式最后一点Appium的版本不是越新越好。UiAutomator2驱动对Appium服务器版本有对应关系如果你用的是Appium 1.15之前的旧版UiAutomator2驱动的兼容性会很不好甚至Toast节点解析直接失败。我个人的建议Appium桌面端用1.22版本以上Appium 2.x则推荐2.0以上稳定版。还要注意服务启动方式。如果你用的是Appium Desktop自带的Start Server按钮没问题但如果你是通过命令行appium启动服务务必确认全局环境里的Appium和驱动版本。appium --version appium driver list如果appium driver list显示没有安装UiAutomator2先安装appium driver install uiautomator2搞定了这些前置条件环境层面才算真正准备好。下面进入正题怎么把Toast内容拿回来。3. 两种主流抓取方式getPageSource与XPath直取3.1 方式一getPageSource全文检索Toast出现的时间窗口非常短通常在1到3秒之间。最稳妥的做法是在触发Toast的操作之后立刻去抓一次当前的页面XML结构getPageSource()然后把XML当作普通文本去搜索关键词。import time from appium import webdriver def get_toast_text(driver, keyword, timeout10, interval0.5): 轮询获取页面源码在源码中搜索包含keyword的Toast文本 end_time time.time() timeout found_text None while time.time() end_time: page_source driver.page_source # Toast在页面源码中通常以 text 或 resource-id 形式存在 if keyword in page_source: found_text keyword break time.sleep(interval) return found_text这段代码的逻辑很直白不直接去找元素而是把页面源码当字符串来处理循环轮询直到出现目标关键词或超时。因为Toast出现时间短轮询间隔建议设短一些实测0.3到0.5秒是比较平衡的值——太短会增加CPU开销太长容易错过Toast窗口期。这样拿到的只是页面源码里包含这个词如果你想把完整的Toast文本提取出来可以进一步用正则去匹配import re def extract_toast_text(page_source): 从页面源码中提取toast文本 Toast在XML中通常长这样 android.widget.TextView text登录成功 resource-idandroid:id/notification_text ... / match re.search(rtext([^]*)[^]*resource-idandroid:id/notification_text, page_source) if match: return match.group(1) return None但这里有个细节需要注意不同Android版本的Toast在XML里的表现形式不太一样。比较高版本的Android比如10以上Toast的resource-id很多时候是android:id/message有些时候是android:id/notification_text还有一些系统弹窗非Toast可能也出现在source里干扰判断。所以正则策略要留好容错只依赖text属性去匹配反而更通用一些match re.search(rtext([^]*)[^]*classandroid.widget.TextView, page_source)这种方式的好处是兼容性强不依赖具体的id。坏处是source里TextView一大堆很容易误抓页面上的普通文本。一个更靠谱的做法是结合Toast出现的临时特征——通常Toast相关的TextView在XML里没有bounds信息、或者bounds值很小很集中——但这需要针对具体项目去调。3.2 方式二使用XPath直接定位元素在部分情况下Toast是会作为Accessibility节点暴露出来的这时候可以用XPath直接定位。虽然这种暴露在不同设备上不稳定但一旦能吃透它效率上比全文检索高一个档次。from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def find_toast_by_xpath(driver, text, timeout5): 直接通过XPath定位Toast元素 toast_xpath //*[contains(text, text )] try: toast WebDriverWait(driver, timeout).until( EC.presence_of_element_located((By.XPATH, toast_xpath)) ) return toast.text except Exception: return None这段代码里contains(text, ...)是关键因为Toast的文本是完整的但你写断言的时候可能只想匹配关键词。比如实际Toast是登录成功欢迎回来你断言时只想查登录成功用contains就能做到灵活匹配。这里有个使用限制要提前说明如果当前页面还有其他元素的text包含同样的关键词这个XPath会优先匹配到页面上的常驻元素而不是Toast。所以建议XPath定位方式只用在Toast文案比较独特且页面本身不会出现同样文字的场景下。3.3 两种方式怎么选对比项getPageSource全文检索XPath直取元素兼容性高各版本都适用依赖设备对Accessibility节点的暴露不稳定速度较慢涉及页面源码序列化和网络传输较快直接命中节点误报率低可控性强中需注意页面其他元素干扰获取完整文本需配合正则提取直接返回元素文本适合场景跨设备测试、页面元素复杂文案独特且需要强断言的场景我在实际项目里通常是两套都写封装在一个工具类里。默认走getPageSource全文检索因为最稳XPath作为快速通道在明确Toast内容时直接用。4. 把Toast识别封装成项目通用方法前面给出的都是零散的函数实际落到项目里建议做一层封装把Toast的出现等待文本提取这些逻辑收敛成统一工具测试用例里只关心我要断言哪段文案这一件事。先定义一个Toast工具类import time import re from appium import webdriver class ToastUtil: def __init__(self, driver, default_timeout10, poll_interval0.5): :param driver: Appium driver实例 :param default_timeout: 最长等待Toast出现的时间秒 :param poll_interval: 轮询页面源码的时间间隔秒 self.driver driver self.default_timeout default_timeout self.poll_interval poll_interval def get_toast_text(self, timeoutNone): 主动抓取当前页面中Toast的完整文本 注意需要在触发Toast操作后调用 wait_timeout timeout if timeout else self.default_timeout end_time time.time() wait_timeout toast_text None while time.time() end_time: source self.driver.page_source toast_text self._parse_toast_text(source) if toast_text: break time.sleep(self.poll_interval) return toast_text def assert_toast_appear(self, expected_keyword, timeoutNone): 断言Toast出现且包含预期关键词 actual_text self.get_toast_text(timeout) if actual_text is None: raise AssertionError(f未捕获到任何Toast消息) if expected_keyword not in actual_text: raise AssertionError( fToast内容不包含预期关键词预期{expected_keyword}实际{actual_text} ) return actual_text def _parse_toast_text(self, source): 解析页面源码提取Toast的文本内容 优先匹配resource-id候选值若没有则尝试匹配包含Toast特征的TextView if not source or Toast not in source: return None # 方案一按已知的Toast resource-id匹配 patterns [ rtext([^]*)[^]*resource-idandroid:id/notification_text, rtext([^]*)[^]*resource-idandroid:id/message ] for pattern in patterns: match re.search(pattern, source) if match: return match.group(1) # 方案二兜底匹配文本较短且为纯文本的TextView # 这种方式误报率高一些作为最后的保底方案 match re.search(rtext([^]*)[^]*classandroid.widget.TextView, source) if match: # 过滤掉页面常驻元素常见的较长的文本 if 0 len(match.group(1)) 50: return match.group(1) return None这段封装里有几个细节是实际项目中沉淀下来的经验第一assert_toast_appear的等待逻辑是等到Toast出现而不是等固定几秒。很多项目为了图省事在触发Toast后直接time.sleep(2)然后断言但实际情况是低端手机Toast可能出现得慢高端手机可能闪得快固定sleep要么等不够要么浪费时间。轮询才是正确姿势。第二_parse_toast_text里写了两套resource-id匹配。这是因为不同Android版本对Toast节点id的命名不同写死一个id会有兼容性问题。第三assert_toast_appear返回的是actual_text。这样做的目的是当断言失败时报错信息里能直接展示实际抓到的Toast文本方便排查到底是Toast没出现还是文案不匹配。5. 真机实测中的常见坑和排查链路5.1 配置全部正确但getPageSource里就是没有Toast这是遇到最多的一个问题。很多人环境配置完全正确capabilities也切到了UiAutomator2代码写得也没问题但跑起来就是拿不到Toast。这时候按下面的链路去排查第一步先确保触发Toast的操作本身已经生效。可以用driver.page_source手动打印肉眼确认当前页面源码里到底有没有android.widget.TextView带Toast文案。如果源码里压根没有说明不是代码问题是环境或设备问题。第二步检查Appium Settings的无障碍服务是否开启。前面讲过UiAutomator2捕捉Toast依赖Appium Settings的Accessibility能力。如果设备是国产ROM小米、华为、OPPO之类系统可能会在后台自动关闭新装应用的辅助功能权限尤其重启设备后经常发生。解决方式就是进系统设置重新打开。第三步确认设备通知权限没有拦截Toast。Android 13开始系统对通知权限管控更严一些App弹出的Toast也受通知权限影响。如果被测App的通知权限被关闭Toast可能不会正常展示自然也就抓不到。这种问题在投影设备上尤其常见检查被测App的通知权限是否开启。5.2 Toast太快一闪而过常规定位直接超时Toast默认展示时长约2秒LENGTH_SHORT长一点的LENGTH_LONG也就3.5秒。但它的隐藏是一个渐隐过程文本可读时间比总展示时间短得多。如果你用WebDriverWait去等Toast超时时间设的宽比如10秒Toast早消失了你就会得到一个超时异常。解决方案就是我前面讲的轮询策略把轮询间隔缩短到0.3-0.5秒在触发Toast的瞬间立刻去抓source多发几次请求抢在Toast消失前捕获它。还有一个小技巧在自动化脚本里触发Toast操作后不要做任何额外的等待或页面刷新动作立即执行driver.page_source。比如点击登录按钮后不要先等页面跳转完成再去拿source而是立刻抓一次因为页面跳转和Toast弹出几乎是同时进行的慢半拍Toast就没了。5.3 弹出的根本不是Toast是Snackbar这个问题很隐蔽。Android里还有一个UI组件叫Snackbar它和Toast外观极其相似——都是屏幕底部一条灰色消息条——但Snackbar是页面Activity的一部分它的元素在页面层级结构里是可见的。如果你用Toast的方式去定位Snackbar你会发现XPath能匹配到但元素定位很怪而且Snackbar会一直存在直到用户操作或超时默认4秒左右。如果你拿它当Toast断言轮询等待可能能等到它的文本但时序和Toast完全不同。简单区分方法Toast是系统级的不在页面树里Snackbar是页面级的通过driver.page_source能在常规元素树里看到它。如果遇到Toast一直不消失的情况先怀疑下是不是Snackbar。5.4 多台设备跑测试时某台设备上老失败这种情况在真机集群里经常出现。根本原因是不同品牌、不同Android版本对Toast节点的暴露度不同。三星和Pixel设备的Accessibility节点通常完整度高Toast容易被抓到一些国产ROM会压缩或过滤节点信息导致Toast节点确实出现在getPageSource()里但缺少text属性。应对方案在用例代码里增加一个设备兼容层。跑用例前先做一次冒烟测试——在设备上弹一个固定文案的Toast验证当前设备能否抓到抓不到就走全文检索的兜底策略把Toast的text按包含关键字的逻辑断言下来。千万别在用例里写死某一种定位策略。6. 断言策略设计让你的Toast测试真正有说服力6.1 断言Toast出现而不是没报错很多用例里判断操作成不成功的逻辑是没有抛异常。但Toast特有的性质是——消息会消失、操作本身的成功可能早于Toast消失所以没报错根本不能证明Toast出现了。一个正确的断言设计应该包含两层# 第一层Toast确实出现了不是超时空手而归 toast_util.assert_toast_appear(登录成功) # 第二层Toast文案完全符合预期不是出现相似关键词就算过 assert toast_text 登录成功欢迎回来第一层保证Toast机制生效了第二层保证文案符合预期。有些团队只写第二层结果就是Toast压根没弹出来但用例因为等待超时后去assert一个空对象直接报个元素找不到的错误——明明想的是断言文案实际却成了断言元素存在语义就混乱了。6.2 正则表达式 vs 精确匹配针对Toast文案这种短文本内容我强烈建议不要用正则做过度匹配。原因很简单Toast文案属于被开发代码直接控制的字符串如果它变了你的断言本来就该失败说明版本和上一个用例有出入。但正则里如果写了很宽泛的匹配规则比如.*成功.*Toast从登录成功变成帐号或密码错误你的用例还是能通过这就失去了断言的意义。所以我的建议是对操作成功类的Toast精确断言完整文案。对业务引导类的Toast比如请输入正确的手机号精确断言或包含断言核心关键词。对系统底层的Toast比如无法连接网络用包含断言保留兼容空间因为不同版本系统对这一类文案有细微差异。6.3 失败信息的可读性最后补一个很多人不在意但排障价值很高的点断言失败的信息一定要把实际抓到的内容打出来。try: toast_util.assert_toast_appear(登录成功) except AssertionError as e: self.logger.error(fToast断言失败{e}) self.logger.info(f当前页面截图{self.driver.get_screenshot_as_file(toast_failed.png)}) raise加一张截图比任何文字描述都管用。Toast一闪而过日志里只有文本很难判断当时页面上发生了什么一张截图能直接让你看到Toast是否出现过、文案是否被截断、操作后页面跳到了哪里。我在实际项目里靠这张截图救回过无数个排查很久找不出原因的用例。7. 关于Toast测试的环境依赖与未来演进在Appium生态里Toast测试其实一直是个半官方半民间的话题。UiAutomator2对Toast的支持本身就有点附带功能的感觉官方文档对这块的说明少得可怜社区里的各种方案都带着各自的设备特性。所以做这一块要有的心理准备是不要指望有一个一次性配好永久可用的方案而是要把整个Toast识别流程设计得足够有弹性。我现在的做法是把Toast能力封装成了一个独立的库每次新接入一批测试设备先跑一个设备兼容性冒烟用例自动把每台设备的Toast暴露行为记录到配置中心。这样即使某台设备后来系统升级导致Toast节点结构变化也能在冒烟阶段快速识别出来而不是等到跑完整轮回归才发现问题一堆。另外如果你的被测App正好是自家开发的产品有一个更彻底的解法要求开发在代码里给Toast增加一个独立的resource-id默认Toast是没有的。这一个改动就能让Toast从难抓的野元素变成稳定可定位的常规元素。虽然这需要跨团队沟通但长期回报非常高。写到这里回看整篇内容其实核心就是四个字理解本质。Toast难做是因为它不是普通页面元素理解了这一点环境配置、定位方式、断言设计都是顺理成章的事。如果你正在被Toast定位折磨希望这篇能帮你把从环境到断言的链路一次理清楚。
返回列表