尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Appium isVisible()误判揭秘:滚动列表元素可见性判断的坑与解决

Appium isVisible()误判揭秘:滚动列表元素可见性判断的坑与解决 作为一个天天跟自动化测试死磕的老兵isVisible()这个方法我用了快六年。直到上个月它在我负责的一个电商 App 的首页信息流用例里给了我一个大大的惊喜一个明明滚出屏幕之外的商品卡片用isVisible()判断竟然返回True导致断言通过、用例绿着跑完但实际页面里那个元素根本看不见。这种假绿比真红可怕得多因为测试在给出错误的安全感。我知道很多人第一反应是你用的 API 错了吧是不是该用isDisplayed()。但问题是isVisible()确实是我们从老项目里继承下来的存量用例线上也有大量类似断言。出问题之后我花了两天时间把 Appium 在 Android 滚动列表上的可见性判断机制彻底翻了一遍还对比了 iOS、Flutter 和 React Native 的行为差异。这篇文章就当是给自己的排查笔记做个整理也是给所有被isVisible always return True坑过的同学一份避坑手册。1. 问题现场商品卡片滚出屏幕断言却绿得刺眼1.1 最小复现脚本三步打出惊悚结果先把最简单的复现场景摆出来。我用的是 Appium 2.x UiAutomator2被测 App 是一个标准的 Android 原生应用首页是一个RecyclerView加载的商品信息流。测试脚本用的是 Pythonfrom appium import webdriver from appium.webdriver.common.appiumby import AppiumBy caps { platformName: Android, appium:automationName: UiAutomator2, appium:deviceName: emulator-5554, appium:appPackage: com.example.shop, appium:appActivity: .MainActivity, } driver webdriver.Remote(http://localhost:4723/wd/hub, caps) # 第一步定位第一个商品卡片 first_item driver.find_element( AppiumBy.ANDROID_UIAUTOMATOR, new UiSelector().text(商品标题1) ) print(第一次打印结果, first_item.is_visible()) # True符合预期 # 第二步向上滑动让第一个卡片滚出屏幕 driver.swipe(500, 1500, 500, 300, 300) # 第三步再对同一个元素调用 isVisible() print(滚出屏幕后打印结果, first_item.is_visible()) # 依然 True问题出现如果你在真实项目里跑过类似的脚本大概率会和我一样看到第二次输出是True。但当你用driver.get_screenshot_as_png()截图或者直接用眼睛看模拟器那个卡片就是结结实实地滚出屏幕了。页面上既看不到它手指也够不到它。这个现象最坑的地方在于如果你用first_item.is_visible()作为商品卡片是否展示的断言条件测试会一路绿灯地跑下去哪怕 UI 已经发生了严重的渲染错乱比如列表里所有内容都消失了但只要根布局还挂着这些看不见的元素的isVisible()依旧返回True。1.2 这个坑波及的范围比想象中大得多一开始我以为这只是我们测试环境中偶然出现的诡异 Bug后来在排查中发现几乎所有基于isVisible()判断元素是否在屏幕内的用例都可能踩到这个坑。具体来说受影响最大的是下面这几类场景信息流类 App 的列表加载断言检查第 N 个卡片是否加载出来但第 N 个卡片可能早就不在当前视口内了。滑动加载更多的用例滑动之后判断上一条数据是否还在或者下一条数据是否出现。多 Tab 页面切换从一个 Tab 切到另一个 Tab用isVisible()判断上一个 Tab 的内容是否被隐藏。这些场景的共同特点是元素在 UI 层级结构accessibility tree中依然存在但已经离开了当前的屏幕可视区域。isVisible()恰恰不看是否在屏幕内这件事于是就会给出反直觉的结果。2. 源头拆解isVisible() 在 Appium 里到底查的是什么2.1 它从来就不是眼睛而是户口本查号很多刚接触 Appium 的同学会把isVisible()理解成人的眼睛能不能看到这个元素这是一个根本性的误解。实际上Appium 的isVisible()在 Android 平台上通过 UiAutomator2 驱动实现时主要判断的是View 节点在 accessibility 体系中的可见状态。说人话就是它检查的是这个 View 在 UI 层级树accessibility tree里是否标记为可见而不是它现在是否真的渲染在当前窗口上。为了验证这一点我跑了下面这段代码来对比不同属性element driver.find_element( AppiumBy.ANDROID_UIAUTOMATOR, new UiSelector().text(商品标题1) ) print(is_visible():, element.is_visible()) print(get_attribute(visible):, element.get_attribute(visible)) print(get_attribute(displayed):, element.get_attribute(displayed)) print(get_attribute(bounds):, element.get_attribute(bounds)) print(is_displayed():, element.is_displayed())在元素滚出屏幕之后四个输出值会出现明显分岔is_visible()和get_attribute(visible)大概率是True而bounds会变成一个明显超出屏幕范围的坐标比如[0, -300][1080, -80]。这说明 accessibility tree 对该节点的可见标记还是正常的但它实际已不在屏幕范围内。2.2 滚出屏幕后元素为什么还留在 accessibility tree 里这里就涉及 Android 列表组件的工作原理了。以RecyclerView为例它的核心设计理念是 View 复用。当一个 item 滚出屏幕后RecyclerView 不会立刻销毁它而是会把它丢进RecycledViewPool等待后续 item 滚入时复用。在这个过程中AccessibilityNodeInfo无障碍节点信息的生成时机与 View 的绘制生命周期并不完全同步。也就是说就算这个 View 已经滚出屏幕只要它还没有被系统回收、没有触发onDetachedFromWindow它的 accessibility node 信息就依然挂在 accessibility tree 上。而 Appium 通过 UiAutomator2 拿到的页面层级本质上是来自 accessibility tree 的快照。于是你就看到了魔幻的一幕物理世界里的元素已经滚走了UI 树里的元素还稳稳地站在原处。2.3 Appium 版本更替后这个方法的行为变化再补充一点版本层面的差异。在 Appium 1.x 时代isVisible()是MobileElement的常用方法但官方文档后来明确把它标记为废弃推荐使用isDisplayed()。原因很简单不同平台上isVisible()的实现不一致Android、iOS 各自为政语义模糊。而在 Appium 2.x 中isVisible()的行为更接近于getAttribute(visible)的封装。它在 Android 上读取的是 UiAutomator2 返回的 visible 属性在 iOS 上读取的又是 XCUITest 的 accessibility 信息。你看着是同一个方法底层却在不同驱动里各说各话。isDisplayed()的语义则更贴近元素是否在当前窗口中可见但它在滚动列表场景下也并非绝对可靠关于这一点我后面专门讲。3. 完整排查链路从怀疑结论到实锤根因3.1 第一轮交叉验证换 API、换断言、看现象分岔排查开始时我并没有直接去看底层源码而是先做了一轮交叉验证目的是搞明白到底是isVisible()的问题还是我代码的问题。我先在元素滚出屏幕后把 Appium 提供的所有可见性相关 API 都打了一遍API滚出屏幕后返回值说明is_visible()True异常点get_attribute(visible)true和is_visible()一致get_attribute(displayed)false比较符合直觉is_displayed()False比较符合直觉get_attribute(bounds)[0,-300][1080,-80]坐标已经超出屏幕从这张表可以看出来isDisplayed()和displayed属性在滚动列表场景下是正常的问题主要集中在isVisible()和visible属性上。这是我第一次确认不是脚本写错而是isVisible()的语义压根不适合判断滚动列表内元素是否在屏幕内。3.2 第二轮证据搜集抓 XML 快照对比 visible 与 displayed 的字段差交叉验证之后我还没急着下结论。因为displayed属性能正确返回False背后到底是因为它额外计算了屏幕坐标还是因为它走了另一套判断逻辑这需要看证据最好的证据就是 UiAutomator2 返回给 Appium 的 XML 页面快照。我在元素滚出屏幕后执行了下面这条命令手动抓取一次页面源码adb shell uiautomator dump /sdcard/window_dump.xml adb pull /sdcard/window_dump.xml ./window_dump.xml然后在 XML 里搜索那个商品标题对应的节点找到了类似这样的内容node index3 text商品标题1 resource-idcom.example.shop:id/tv_title classandroid.widget.TextView packagecom.example.shop content-desc checkablefalse checkedfalse clickabletrue enabledtrue focusabletrue focusedfalse scrollablefalse long-clickablefalse passwordfalse selectedfalse visibletrue displayedfalse bounds[0,-300][1080,-80] /注意看visibletrue和displayedfalse这两个字段。visibletrue说明 accessibility node 认为这个 View 自身的可见性是正常的没有被GONE、没有被父布局隐藏但displayedfalse结合bounds为负值可以推断屏幕并没有真正把它画出来。这一份 XML 快照基本算是铁证isVisible()返回True不是 Appium 随机抽风而是它在底层就是按visible这个字段来报的。visible字段只管 View 的可见状态不管它在不在屏幕内。3.3 第三轮源码对照UiAutomator2 驱动里的 visible 实现逻辑为了把根因锁死我又去翻了一下 Appium 的 UiAutomator2 驱动在 GitHub 上的源码实现。简单梳理下来对visible属性做转换的地方大致是这样一个逻辑链UiAutomator2 拿到 Android 的AccessibilityNodeInfo。把node.isVisibleToUser()或者node.isVisible()的结果映射成字符串true或false。Appium 的getAttribute(visible)直接返回这个字符串。isVisible()再基于这个字符串做布尔转换。而AccessibilityNodeInfo.isVisibleToUser()的实际含义是该节点在 accessibility 系统中是否对用户可见它并非严格判断该节点是否在当前屏幕视口内。源码里只要 View 的 visibility 状态是VISIBLE并且它的祖先链上没有任何一个节点把它隐藏就很可能被判定为可见。所以问题的根因可以落成一句话isVisible()连接的是一条状态可见性链路而不是屏幕可见性链路。在滚动列表中一个滚出屏幕的 item 依然保持着状态可见因为它没有被 GONE没有被父布局裁剪标记为不可见也没有被系统从 accessibility tree 中移除。这件事和它是否滚出屏幕根本没有直接关系。4. 落到实处的解决办法坐标 视口 父子裁剪的可见性判断4.1 为什么不直接无脑用 isDisplayed()看到这里你可能已经想说既然isDisplayed()在这个场景下是False那直接把项目里所有isVisible()替换成isDisplayed()不就行了吗我的建议是可以替换但绝不能无脑替换。原因有两个。第一isDisplayed()在 Android 上也并非完全可靠。它依赖 UiAutomator2 驱动返回的 displayed 字段而驱动在计算这个字段时对某些自定义 View、SurfaceView、WebView 内的元素可能会出现误判或漏判。比如 Flutter 渲染出来的部分语义节点displayed字段就可能出现与视觉不一致的情况。第二isDisplayed()的语义是元素在当前窗口上是否可见它不关心元素是否被其他视图遮挡。如果一个弹窗恰好盖住了某个按钮但按钮本身没有离开屏幕isDisplayed()依然可能返回True。这在某些交互用例里会造成明明被弹窗挡住了却还认为可以点击的隐患。所以我的结论是isDisplayed()比isVisible()靠谱很多但要针对滚动列表的元素是否真的在屏幕视口内这个具体需求最稳妥的方案还是自己写一个基于坐标的计算方法。4.2 自研 isActuallyVisible() 的方法设计与完整代码我们要判断的核心问题只有一个元素在不在当前屏幕可视区域里基于这个目标我可以借助 Appium 提供的getRect()和屏幕大小来计算。思路分三步获取元素的矩形坐标rect包括x、y、width、height。获取当前设备的视口大小。判断元素的矩形是否与视口矩形有真实的交集并且交集面积大于 0。Java 版本的封装长这样import io.appium.java_client.android.AndroidDriver; import io.appium.java_client.android.AndroidElement; import org.openqa.selenium.Rectangle; public class VisibilityUtils { private final AndroidDriverAndroidElement driver; public VisibilityUtils(AndroidDriverAndroidElement driver) { this.driver driver; } public boolean isActuallyVisible(AndroidElement element) { try { Rectangle rect element.getRect(); if (rect.width 0 || rect.height 0) { // 宽高为 0 说明元素根本没有被渲染 return false; } int screenWidth driver.manage().window().getSize().width; int screenHeight driver.manage().window().getSize().height; int left Math.max(rect.x, 0); int top Math.max(rect.y, 0); int right Math.min(rect.x rect.width, screenWidth); int bottom Math.min(rect.y rect.height, screenHeight); int visibleWidth Math.max(0, right - left); int visibleHeight Math.max(0, bottom - top); return visibleWidth 0 visibleHeight 0; } catch (Exception e) { // 元素在树中不存在或已回收直接视为不可见 return false; } } }Python 版本的思路一模一样def is_actually_visible(driver, element): try: rect element.rect if rect[width] 0 or rect[height] 0: return False window_size driver.get_window_size() screen_width window_size[width] screen_height window_size[height] left max(rect[x], 0) top max(rect[y], 0) right min(rect[x] rect[width], screen_width) bottom min(rect[y] rect[height], screen_height) visible_width max(0, right - left) visible_height max(0, bottom - top) return visible_width 0 and visible_height 0 except Exception: return False这套方法在实际项目中跑下来准确率非常高。它的核心逻辑其实特别朴素一个元素如果真实地显示在屏幕上它一定有一部分像素落在屏幕的具体坐标范围内。只要这个交集存在我们就可以认为它可见。这个思路比isVisible()的状态可见更贴近测试用例的真实目标。4.3 用拦截器和基类统一管控避免再被旧 API 误导有了工具方法下一步就是怎么让它落地到团队项目里避免其他人继续掉进isVisible()的坑。我当时的做法是三步走在项目的BasePage基类里新增一个统一的页面元素校验入口比如assertElementVisible()、assertElementNotVisible()内部全部走isActuallyVisible()不允许直接调用element.isVisible()。对所有历史存量代码做一次全局搜索把isVisible()的使用点全部替换成新的封装方法。在 Code Review 阶段约定凡是新提交的代码中出现isVisible()直接打回。在团队协作中单靠每个人都知道这个坑来规避风险是不可靠的。人脑会遗忘人员会流动只有把最佳实践固化到代码规范和公共基类里才能形成真正的防护。5. 这套思路在 iOS、Flutter、React Native 上的迁移与差异5.1 iOS 的 visible 与 hittable 语义错位如果你以为这个坑只在 Android 上有那就低估了跨平台测试的野性。在 iOS 的 XCUITest 体系里Appium 同样提供了isVisible()但它的底层实现又不一样。XCUITest 中一个元素是否 visible 是通过 XCUIElement 的isHittable属性来间接判断的。isHittable的含义是这个元素当前是否可以接收点击事件。它在大多数场景下和用户能不能看到它是一致的但在下面两类场景里会出现偏差元素被其他视图完全覆盖时用户看不到它但isHittable可能因为命中测试穿透逻辑返回True。元素在滚动视图里滚出屏幕一部分时只要中心点还在屏幕内isHittable就可能返回True但用户肉眼看到的只是半个元素。所以 iOS 侧的判断也不能只看isVisible()。我一般会在 iOS 上额外加上rect与window.frame的相交判断和 Android 侧保持同一套坐标逻辑这样跨平台用例的行为才能统一。5.2 Flutter 与 RN 的虚拟列表对 accessibility tree 的污染再往前一步如果你的 App 是 Flutter 或 React Native 开发的情况会更复杂一些。Flutter 的 integration_test 里元素可见性信息来自 SemanticsNode。Flutter 的语义树和原生 accessibility tree 之间有一个翻译层当列表项滚出视口后Flutter 为了性能会回收部分 SemanticsNode但也可能因为ListView的缓存策略保留一些不可见节点的语义信息。这时候isVisible()的行为既取决于 Flutter 版本又取决于驱动实现基本属于薛定谔的可见性。React Native 的FlatList同样有虚拟化机制。滚出屏幕的 item 有时被 unmount有时被 recycle有时还挂在 accessibility tree 里。RN 的 accessibility 属性传递还存在一套完全自定义的映射关系单纯依赖 Appium 的isVisible()判断基本靠不住。我在这类跨端项目上最终的解法还是回到了坐标法。不管是 Flutter 还是 RN只要 Appium 能通过 accessibility bridge 拿到元素的rect坐标相交判断就依然成立。因为视觉渲染的最终归宿一定是屏幕的像素坐标任何语义树层面的标记都比不上这个事实可靠。5.3 留给团队的工程化检查清单最后把我们团队沉淀下来的检查清单分享出来你可以直接复制到自己的项目文档里禁止在新用例中使用isVisible()判断元素是否在屏幕内。滚动列表场景统一使用 元素矩形与屏幕视口是否相交 作为可见性标准。必要时通过get_attribute(displayed)做辅助判断但不能作为唯一依据。iOS 场景特别注意isHittable的语义偏差结合坐标判断使用。Flutter、RN 等跨端框架优先依赖rect坐标不要依赖语义树中的 visible 状态。每次 UI 自动化代码评审时全局搜索isVisible和.visible发现即拦截。说到底isVisible always return True on item in a not visible scroll list这个问题真正教会我的不是某个 API 有 Bug而是测试框架里每一个方法的名称都只是它内部行为的简写真正的语义必须以底层平台的行为为准。写自动化测试的人如果不去深挖这一层大概率会被各种看起来理所当然的 API 狠狠坑上一次。我希望你用不上我这两天的排查时间但如果你已经踩进来了这篇文章能帮你少走一半弯路。
返回列表