Appium 探究如何获取界面出现的 toast

陈恒捷 · 2015年04月11日 · 最后由 hokor 回复于 2016年10月27日 · 4257 次阅读
本帖已被设为精华帖!

前言:appium/uiautomator 原生都没带有能获取 toast 的 api ,因此 UI 测试中获取 toast 暂时无解。但最近在论坛看到了这个帖子http://testerhome.com/topics/2346,里面提到其实可以获取到 Accessibility Service 其实是可以获取到 toast 的,所以今天探究一下到底是什么情况。

调试环境:

测试 apk:自制 apk,包含多个可以触发 toast 的按钮。
Android:Genymotion 模拟器:4.4.4, 4.1.1。真机:Flyme4.2.2.2C(base on Android 4.4.2)

验证是否能获取到 toast

1、按照帖子指引,下载并安装 Toaster应用(在 google play 上,请科学上网。)
(由于我需要验证不带有 google play 服务的模拟器,因此模拟器上用的是我从项目主页下载源码并用 gradle 自行编译的 apk 。)

编译过程:

$ git clone https://github.com/mars3142/toaster.git

修改toaster/app/build.gradle,在第 16 行插入:

lintOptions {
   abortOnError false
}

以忽略 lint 语法检查的错误。
然后运行gradle clean build就能编译出 app 了(由于只是 debug 用途,没有加签名)。apk 默认放在toast/app/build/apk/下,用 adb 安装 debug 版本的即可。
提示找不到 gradle 命令的同学,麻烦手动下载 gradle 后在 path 中加上 gradle/bin/的路径。

首次打开 Toaster 会提示你需要允许 Toaster 访问 Accessibility Service (需要跳转到系统设置菜单设置。无论原生 Rom 还是二次开发的 Rom,都必须由用户手动赋予权限)。允许后即可获取到所有(包括系统桌面、应用)toast 。

2、 打开被测 app ,手动通过点击按钮触发 toast

3、打开 Toaster 应用,查看记录到的 toast

兼容性:
真机(4.4.2):可用
模拟器(4.4.4):可用
模拟器(4.1.1):可用

探究关键代码

其实http://stackoverflow.com/questions/10659734/detecting-toast-messages已经说得比较清晰了。Toaster 中对应的实际代码:

src/main/java/org/mars3142/android/toaster/service/ToasterService.java

import android.accessibilityservice.AccessibilityService;
...
import android.view.accessibility.AccessibilityEvent;

...
public class ToasterService extends AccessibilityService {

private final static String TAG = ToasterService.class.getSimpleName();

@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
if (event.getEventType() != AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED) {
Log.d(TAG, "Unexpected event type");
return; // event is not a notification
}

// get notification infos
Calendar calendar = Calendar.getInstance();
long timestamp = calendar.getTimeInMillis();
String sourcePackageName = (String) event.getPackageName();
String message = "";
for (CharSequence text : event.getText()) {
message += text + "\n";
}
if (message.length() > 0) {
message = message.substring(0, message.length() - 1);
}

// record notification infos
Parcelable parcelable = event.getParcelableData();
if (!(parcelable instanceof Notification)) { // confirm it should be toast
ContentResolver cr = getContentResolver();
ContentValues cv = new ContentValues();
cv.put(ToasterTable.PACKAGE, sourcePackageName);
cv.put(ToasterTable.MESSAGE, message);
cv.put(ToasterTable.TIMESTAMP, timestamp);
cr.insert(ToasterTable.TOASTER_URI, cv);

Intent intent = new Intent("org.mars3142.android.toaster.APPWIDGET_UPDATE");
sendBroadcast(intent);
}
}

@Override
public void onInterrupt() {
// Nothing
}
}

主要做的事情就是继承`AccessibilityService`后重写了`onAccessibilityEvent`方法。在该方法内对`parceable`类型不是`Notification`(否则[它是手机顶部status bar的信息](http://tonysun3544.iteye.com/blog/1273055)。)且事件类型为`AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED`的消息进行记录(包括应用名、提示内容、出现时间、)。

`AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED` 官网相关文档:http://developer.android.com/reference/android/view/accessibility/AccessibilityEvent.html#TYPE_NOTIFICATION_STATE_CHANGED

# 集成到 appium 的可行性

由于需要使用AccessibilityService,所以应该不能集成到 bootstrap 中(bootstrap本质上是一个UIAutomator的测试用例,不能像普通应用那样调用系统服务)。但可以做成额外的 apk 集成到 appium 中(参考 [io.appium.settings](https://github.com/appium/io.appium.settings),一个用来实现wifi,数据连接及飞行模式切换的 app  ),然后通过 adb 命令进行控制。

后面我会参考 io.appium.settings 尝试制作一个可以记录和获取 toast 的简单 app 试试效果如何。不过如何获取返回值是个问题。。。








如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 26 条回复 时间 点赞

selendroid 貌似已经支持了 有个 api

嗯。刚查到:
https://github.com/selendroid/selendroid/issues/86
晚些试验一下

toast 不是之前有方法可以获取么 剪烛 mm 写过

#3 楼 @kasi 是的,我找到她的帖子了:
http://testerhome.com/topics/1483
不过她写的是 robotium 的。我这个主要面向 appium 。我这个文章主旨是通过 appium 那个帖子探究一下能通过什么方式获取到应用 toast 。

#2 楼 @chenhengjie123
waitForElement(By.partialLinkText("Your Toast message"), 4, driver);
可以看看他的实现

#5 楼 @seveniruby 好,后面我再详细看看它的实现。

#6 楼 @chenhengjie123 应该是这个吧。

public static WebElement waitForElement(By by, int timeout, WebDriver driver) {
    WebDriverWait wait = new WebDriverWait(driver, timeout);
    WebElement element = wait.until(ExpectedConditions.presenceOfElementLocated(by));

    return element;
  }

until 的用法

Sample usage: <pre>{@code
 *   // Waiting 30 seconds for an element to be present on the page, checking
 *   // for its presence once every 5 seconds.
 *   Wait&lt;WebDriver&gt; wait = new FluentWait&lt;WebDriver&gt;(driver)
 *       .withTimeout(30, SECONDS)
 *       .pollingEvery(5, SECONDS)
 *       .ignoring(NoSuchElementException.class);
 *
 *   WebElement foo = wait.until(new Function&lt;WebDriver, WebElement&gt;() {
 *     public WebElement apply(WebDriver driver) {
 *       return driver.findElement(By.id("foo"));
 *     }
 *   });
 * }</pre>

这个就是解析界面的 DOM


/**
 * An expectation for checking that an element is present on the DOM of a
 * page. This does not necessarily mean that the element is visible.
 *
 * @param locator used to find the element
 * @return the WebElement once it is located
 */
public static ExpectedCondition<WebElement> presenceOfElementLocated(
    final By locator) {
  return new ExpectedCondition<WebElement>() {
    @Override
    public WebElement apply(WebDriver driver) {
      return findElement(locator, driver);
    }

    @Override
    public String toString() {
      return "presence of element located by: " + locator;
    }
  };
}

总体来讲设置一个超时时间,然后每隔 5s 来抓取界面的 dom,然后 waitfor 么。。我理解是则样的

#7 楼 @monkey 你这个是 waitForElement 这个方法的通用实现,确实和你说的一样,隔一段时间去 find 一下元素,直到超时或符合预期。但用在 toast 上默认 5s 的等待间隔太长了 (long 的 toast 是 3.5s,short 的是 2s)。所以对于 toast 应该有特殊处理,把时间间隔调低。

我觉得思寒想了解的是 selendroid 的 waitForElement 在查找By.partialLinkText("Your Toast message")时是怎么进行查找的,即怎么调低默认时间间隔,findElement怎么做到能支持查找toast的吧。

从这个 API 的 By 属性来看,应该有做什么特殊处理的。否则光靠partialLinkText内容来查找 toast 信息不是很准确,原因是上面 Accessibility Service 返回的 Event 信息除了包含 toast 还包含 Notification,不做进一步判断分不出来。

#8 楼 @chenhengjie123 @monkey 我是怀疑这个方法的有效性. 可以实验下. 我自己没验证.

#3 楼 @kasi accessibility api 是不是也可以抓取到, 试过没?

#9 楼 @seveniruby 我猜测吧。其实就是比如每隔 5s,就如心跳一样,如果 toast 出现的时候,正好心跳时间点的话那么返回 true,否则就是 false,也就是说不稳定

#2 楼 @chenhengjie123
试验成功了,分享下给大家学习啊

waitForElement(By.partialLinkText("Your Toast message"), 4, driver);

where first parameter is your toast message. Second parameter is Time duration in Seconds,third is driver.

试验了下, selendroid 下面是能找到 toast 的。只要你等待的时间足够长。

感谢分享,分析很好啊。 也刚好在https://github.com/appium/appium/issues/4824 看到, appium 应该很快会支持 toast 了。

#13 楼 @lihuazhang 那 uiautomator 下怎么实现啊?

#15 楼 @nancy2896 就是因为 uiautomator 没有这个 API,所以我们要另外想办法搞。。。

#16 楼 @chenhengjie123 我是这么想的,设置一个 flag,把 toast 内设为一个 uiobject,用 while 判断是否 toast 存在,不存在就一直循环,存在了就改变 flag 的值跳出循环。。。这样不知道可行不, 我尝试一下

#17 楼 @nancy2896 想法不错,你这个逻辑其实就是 waitForElement 的逻辑,问题在于 UIObject 取不到 toast。。。toast 不会出现在 dump 出来的 xml 中。
目前能获取 toast 的框架(Robotium,selendroid)都是通过 instrumentation 内的 getView() 方法获取(方法名我不是很确定是不是这个),不是 uiautomator。

#18 楼 @chenhengjie123 试完了不行,还得想别的方法

参照 waitForElement,我写了一个 python 的,在 selendroid 下可以找到 toast
WebDriverWait(driver,timeout,poll_frequency).until(expected_conditions.presence_of_element_located((By.PARTIAL_LINK_TEXT,message)))
具体见http://testerhome.com/topics/2715

之前一直用的截图对比。。。

toast 获取的方法,截图也不行,当 toast 显示时候,用 save_screenshot 有时候截不下来,很奇怪,尤其是有键盘弹出的时候,更截不下来

xdlhy [该话题已被删除] 中提及了此贴 06月30日 20:27

UiAutomator 的 events 命令就可以抓取到 toast 上的文字,可以不用重复造轮子,直接拿来用
#22 楼 @xiaoan_2131

#24 楼 @actionwind 你好,能解释一下具体怎么用嘛,最后贴一段示例代码什么的,O(∩_∩) O 谢谢

#24 楼 @actionwind
是吗?怎么做的?

不需要自己实现 onAccessibilityEvent 方法,直接使用 UiAutomator 的 UiDevice.performActionAndWait() 方法就可以了处理 AccessibilityEvent 事件。
http://android.xsoftlab.net/reference/android/support/test/uiautomator/UiDevice.html
核心是实现一个检查 Toast 的 EventCondition 类。可以参考 android.support.test.uiautomator.Until.scrollFinished 实现。
可以通过命令行工具查看具体的 event 信息。

adb shell uiautomator events > d:/events.log 

由于 EventCondition 的两个抽象方法是包访问权限,因此必须新建一个 android.support.test.uiautomator 同名的包。
具体代码如下:

package android.support.test.uiautomator;
import android.view.accessibility.AccessibilityEvent;
import java.util.ArrayList;
import java.util.List;
public class MyUntil {
    //检查给定Toast文案是否展示
    public static EventCondition<Boolean> toastShown(final CharSequence toastText) {
        return new EventCondition<Boolean>() {
            private int mMask = AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED;
            private boolean mIsClassMatch = false;
            private static final String FILTER_CLASS_NAME = "android.widget.Toast$TN";
            private boolean mResult = false;
            private List<CharSequence> mText;

            @Override
            Boolean apply(AccessibilityEvent event) {
                mMask &= ~event.getEventType();
                mIsClassMatch = FILTER_CLASS_NAME.equals(event.getClassName());
                mText = event.getText();
                mResult = mMask == 0 && mIsClassMatch && mText.contains(toastText);
                return mResult;
            }

            @Override
            Boolean getResult() {
                return mResult;
            }
        };
    }
    //获取Toast的文案
    public static EventCondition<String> getToastText() {
        return new EventCondition<String>() {
            private int mMask = AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED;
            private boolean mIsClassMatch = false;
            private static final String FILTER_CLASS_NAME = "android.widget.Toast$TN";
            private List<CharSequence> mText = new ArrayList<>();

            @Override
            Boolean apply(AccessibilityEvent event) {
                mMask &= ~event.getEventType();
                mIsClassMatch = FILTER_CLASS_NAME.equals(event.getClassName());
                mText = new ArrayList<>(event.getText());//必须要复制getText否则,在getResult中获取不到结果。
                return mMask == 0 && mIsClassMatch;
            }

            @Override
            String getResult() {
                String result = null;
                if (mText.size() > 0) {
                    result = mText.get(0).toString();
                }
                return result;
            }
        };
    }
}

使用方法:

import android.support.test.uiautomator.MyUntil;//导入类

Runnable action = new Runnable() {
    @Override
    public void run() {

    }
};
EventCondition<Boolean> condition = MyUntil.toastShown("已经同步到最新");
assertTrue(mDevice.performActionAndWait(action,condition,3000));
//EventCondition<Boolean> condition = MyUntil.getToastText();
//assertThat(mDevice.performActionAndWait(action,condition,3000),is("已经同步到最新"));
29楼 已删除
30楼 已删除
老马 Appium1.7.2 android toast 消息测试 中提及了此贴 01月27日 10:53
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册