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

陈恒捷 · April 11, 2015 · Last by hokor replied at October 27, 2016 · 4112 hits
本帖已被设为精华帖!

前言: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下怎么实现啊?

陈恒捷 #16 · April 14, 2015 作者

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

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

陈恒捷 #18 · April 14, 2015 作者

#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 [Topic was deleted] 中提及了此贴 30 Jun 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("已经同步到最新"));
29Floor has been deleted
30Floor has been deleted
小马 Appium1.7.2 android toast 消息测试 中提及了此贴 27 Jan 10:53
需要 Sign In 后方可回复, 如果你还没有账号请点击这里 Sign Up