前言

最近 QA 小姐姐和我反馈说自动化测试程序经常卡死在某个界面,一看原来是那个界面在一些情况下会弹出 toast,需要根据 toast 信息进行下一步的操作, 因此把我这几天检查 toast 的经历在这里分享一下。

运行环境

1.appium 1.8.2(选择 UIAutomator2 作为测试驱动)
2.java 测试脚本
3.设备 android 版本 8.0

抓取过程

早有耳闻,Appium 是可以通过 UIAutomator2 来抓取 toast,于是我基本上只需要准备测试代码就 OK 了 (观看了社区内很多帖子的实践),代码如下

public String checkToast(String by) {
    WebElement webElement = null;

    try {
        webElement = (WebElement)this.waitCondition(ExpectedConditions.presenceOfElementLocated(By.xpath(by)), 6, false);
    } catch (Exception var4) {
        LOG.info(var4.getMessage());
    }

    return null != webElement ? webElement.getText() : null;
}

其中传入的定位参数如下格式 (在订单详情位置填写自己的定位内容)

String by = "//*[contains(@text,'订单详情')]";

按理说应该算是大功告成了才对,但结果是怎么样都抓取不到。

实验

开始我觉得可能是 Toast 中含有中文,所以导致抓取不到,于是自己弹了个 toast 试试 (在 android 工程中)

Toast.makeText(MainActivity.this, "订单详情", Toast.LENGTH_LONG).show();


发现是可以抓取到中文 Toast 的


可能是 APP 应用中弹 Toast 的方式和我不一样,问了 android 开发,说是简单的自定义了一下,代码如下


而直接使用系统的方式代码如下

Toast result = new Toast(context, looper);

LayoutInflater inflate = (LayoutInflater)
        context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
TextView tv = (TextView)v.findViewById(com.android.internal.R.id.message);
tv.setText(text);

可以看到其实代码都差不多,看起来唯一有些不同的是 inflate(可以把它看作是一个用来将布局文件转化成 view 的对象)的获取方式,后面发现其实是他们是一样的,View.inflate() 方法下面的实现

public static LayoutInflater from(Context context) {
     LayoutInflater LayoutInflater =
             (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
     if (LayoutInflater == null) {
         throw new AssertionError("LayoutInflater not found.");
     }
     return LayoutInflater;
 }

于是我还是叫开发把 inflate 获取方式改成和系统的一样,发现这种情况下 Toast 是可以被抓到的。虽然解决了问题,但还是很疑惑

源码探索

来看一下 appium.uiautomator2.server 的实现(关于 toast 抓取部分),大家知道使用 appium 的 uiautomator2 进行测试是会在设备上安装两个 apk,其中一个 uiautomator2.server.apk 相当于是开启了一个设备端的监听服务,用来接收处理 appium server 发送的脚本操作请求,其中有一个 NotificationListener 监听器的作用就是用来抓取 Toast 的,主要代码如下
1.实现 OnAccessibilityEventListener,用来监听系统的 AccessibilityEvent,这些事件大概可以概括为和用户交互相关的事件(界面更改,控件点击等等,其中也包括弹窗,toast)
在开启 server 的 session 时会将此监听器打开

public Session(String sessionId) {
    this.sessionId = sessionId;
    this.knownElements = new KnownElements();
    this.commandConfiguration = new ConcurrentHashMap<>();
    JSONObject configJsonObject = new JSONObject();
    this.commandConfiguration.put(SEND_KEYS_TO_ELEMENT, configJsonObject);
    NotificationListener.getInstance().start();
}

当系统发生了上面所说的用户交互事件,就会触发 NotificationListener 的回调方法,onAccessibilityEvent(AccessibilityEvent event)

@Override
public synchronized void onAccessibilityEvent(AccessibilityEvent event) {
    if (event.getEventType() == AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED) {
        Logger.debug("Catch toast message: " + event);
        List<CharSequence> text = event.getText();
        if (text != null && !text.isEmpty()) {
            setToastMessage(text);
        }
    }

    if (originalListener != null) {
        originalListener.onAccessibilityEvent(event);
    }
}

在这里可以看到所捕捉的事件类型是 AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED,那么这个事件是从哪里触发的呢,继续往下看 Toast.show() 这个方法干了什么

public void show() {
    if (mNextView == null) {
        throw new RuntimeException("setView must have been called");
    }

    INotificationManager service = getService();
    String pkg = mContext.getOpPackageName();
    TN tn = mTN;
    tn.mNextView = mNextView;

    try {
        service.enqueueToast(pkg, tn, mDuration);
    } catch (RemoteException e) {
        // Empty
    }
}

这里可以看到涉及到跨进程调用(IPC),首先获取 INotificationManager 对象通过它来调用系统的 NotificationService(系统服务),可以将其比作是一个调度中心,想要弹 toast 的应用给它发一个请求,它帮大家排队,之后再给你答复(确认你到底能不能弹),来看一下 NotificationManagerService.enqueueToast(String pkg, ITransientNotification callback, int duration) 方法,方法中主要是它对请求的处理逻辑,在这里我们就不细看了,主要的是参数方面,我们可以看到调用这个方法的时候传入的一个 callback 方法

@Override
public void enqueueToast(String pkg, ITransientNotification callback, int duration)

刚刚有提到 NotificationService 只是一个调度中心,那么最好它调度好了,怎么通知客户端应用进行 notification 操作响应呢,就是通过传入的这个回调方法,我们看一下它的实现
它定义为 Toast 类中的一个静态内部类

private static class TN extends ITransientNotification.Stub {

其中的 handleShow(IBinder windowToken) 方法则是用来处理 Toast 出现的,看到方法的结尾

try {
    mWM.addView(mView, mParams);
    trySendAccessibilityEvent();
} catch (WindowManager.BadTokenException e) {
    /* ignore */
}

看了这么久,总算看到了触发 AccessibilityEvent 的方法了,赶紧点进去看一下

private void trySendAccessibilityEvent() {
    AccessibilityManager accessibilityManager =
            AccessibilityManager.getInstance(mView.getContext());
    if (!accessibilityManager.isEnabled()) {
        return;
    }
    // treat toasts as notifications since they are used to
    // announce a transient piece of information to the user
    AccessibilityEvent event = AccessibilityEvent.obtain(
            AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED);
    event.setClassName(getClass().getName());
    event.setPackageName(mView.getContext().getPackageName());
    mView.dispatchPopulateAccessibilityEvent(event);
    accessibilityManager.sendAccessibilityEvent(event);
}

可以看到,这边用同样的方法(IPC,同调用 NotificationService 一样),往 AccessibilityService(也是一个系统服务) 发送了一个事件通知,在 AccessibilityService 收到了事件通知之后,会将这个事件转发给注册在 AccessibilityService 下的所有监听者(开头提到的 NotificationListener),说了这么一大串,大致的流程图如下

疑问

回到最初我提的问题,是什么导致两种 toast 的定义方法,一种可以被捕获,一种却不可以被捕获 。说实话,我还是不太清楚(惭愧),有一点楼主不是学 android 的,所以对一些底层的实现也不是太了解(IPC,Binder),另外是想直接对 android 程序进行 debug 的,但是发现好像底层的代码无法断点无法进入,所以楼主发这个贴子最终目的是希望有了解过的人可否给我一点启示,之前也在 TesterHome 看过很多帖子,也受到了不少启发,感谢。


↙↙↙阅读原文可查看相关链接,并与作者交流