问题来源

Appium 自动化框架在 android 端有两种模式,Seledroid 和 Uiautomator。Seledroid 本身就提供了 Toast 信息检查的接口,所以无需考虑。但 Uiautomator 模式下,Toast 信息无法获取,google 官方也只是列为一个需求在后期实现,暂时没有提供合适的解决方案。

之前,我们运用了文字识别的方式来进行检查,但可以预见,稳定性和准确性都不是很高,而且文字识别工具依赖的一个应用在 Android5.0 之后的系统无法安装,重新适配也很麻烦。因此,干脆另寻出路。

解决方案:

安卓系统提供了一个辅助功能服务 AccessibilityService,通过它可以监听设备与用户之间的交互事件,包括各类界面操作、信息变化、通知等,而 Toast 即是通知事件中的一种。

因此,可以开发一个辅助应用,在脚本运行之前开启服务,自动记录运行过程中出现的 Toast 信息,然后返回到脚本进行检查。

具体实现:

1、 新建一个服务类,继承 AccessibilityService,重写 onAccessibilityEvent 方法:

public class ToastRecorder extends AccessibilityService {
     @Override
     public void onAccessibilityEvent(AccessibilityEvent event) {
        // TODO Auto-generated method stub
        // System.out.println("Enter->onAccessibilityEvent");
        //判断是否是通知事件
        if(event.getEventType() != AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED)
                return; 
        //获取消息来源
        String sourcePackageName = (String)event.getPackageName();
       //获取事件具体信息
        Parcelable parcelable = event.getParcelableData();
       //如果是下拉通知栏消息
        if(parcelable instanceof Notification){
         } else {
        //其它通知信息,包括Toast
                String toastMsg = (String) event.getText().get(0);
                String log = "Latest Toast Message: "+toastMsg+" [Source: "+sourcePackageName+"]";
                System.out.println(log);
          }
     }
    @Override
    public void onInterrupt() {
         // TODO Auto-generated method stub
     }
}

2、 新建一个入口 activity,启动上述服务

public class MainActivity extends Activity {
       @Override
       protected void onCreate(Bundle savedInstanceState) {
              super.onCreate(savedInstanceState);
              setContentView(R.layout.activity_main);

              Intent toastIntent = new Intent(MainActivity.this, ToastRecorder.class);
             startService(toastIntent);
        }
}

3、 在 AndroidManifest 文件中注册服务,添加权限并通过 meta-data 配置该服务

<service android:name="com.xbin.toastchecker.ToastRecorder"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:enabled="true">
        <intent-filter>
               <action android:name="android.accessibilityservice.AccessibilityService" />
        </intent-filter>
        <meta-data android:name="android.accessibilityservice" android:resource="@xml/accessibilityservice" />
</service>

Accessibilityservice 文件内容:

<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeNotificationStateChanged"
android:accessibilityFeedbackType="feedbackAllMask"
android:notificationTimeout="100" />

以上,该辅助应用的基本功能就完成了,安装到终端上后,还需到 “设置——辅助功能——服务” 下开启对应的服务。

然后,就可以检查系统的 Toast 信息了:

查看应用打印的日志:

说明已经能捕获成功,那么下一步就是如何使用获取到得 Toast 信息,有两种比较简单的方法:
1) 在该辅助应用中再建一个 socket 服务,将每次获取到的 Toast 信息赋值给一个全局变量,自动化脚本中通过发送 socket 指令获取最新的 Toast 信息值进行检查。
2) 脚本直接通过 adb logcat 读取日志缓存,匹配获取最新一次 Toast 信息进行检查。

存在的问题:

该应用进程被杀后,开启的服务会自动关闭,下次启动应用时要重新在辅助功能中启动该服务,暂时也没有找到能通过代码启动的办法,除非有系统级权限直接去写配置。

临时解决办法:

在 MainActivity 中添加以下代码:

Intent intent = new Intent(android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS);
startActivityForResult(intent, 0);

它会在应用启动的时候,直接调出辅助功能设置界面,然后就通过 GUI 自动化的办法点两下就好了。


重要说明:

很抱歉,发帖之前没有仔细验证,今天放在脚本中用的时候,发现有个致命问题:
Appium 启用 Uiautomator 建立 session 之后,所有的 AccessibilityService 服务全都挂死了,看上去是 Uiautomator 非常霸道的直接独占了 AccessibilityService,毕竟底层就是用它来执行操作的,当然,也可能是 Uiautomator 的一个 Bug。但不管怎样,这种方式貌似是行不通了,让大家见笑,权当是给大家提供一种思路吧。


不过,为此,我今天又试了另一种黑科技——Xposed 框架,这个就更好玩了。

关于 Xposed 框架我就不多做说明了,网上一搜一大堆资料,ROM 玩家的必备神技,简单点说就是通过 Hook 系统方法来注入用户行为。

所以,我们要做的就是 Hook Toast 对象的 show 方法,在它执行之前将其中的文本取出打印即可。

代码也不多,如下:

public class XposedHook  implements IXposedHookZygoteInit {

    @Override
    public void initZygote(StartupParam startupParam) throws Throwable {
        //设定hook目标类和方法
        XposedHelpers.findAndHookMethod(Toast.class, "show", new XC_MethodHook() {
            @Override
            protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
                //获取Toast对象
                Toast t = (Toast) param.thisObject;
                try {
                    //获取唯一的TextView,即Toast文本
                    View view  = t.getView();
                    List<TextView> list = new ArrayList<TextView>();
                    if (view instanceof TextView) {
                        list.add((TextView) view);
                    } else if (view instanceof ViewGroup) {
                        finaAllTextView(list, (ViewGroup) view);
                    }
                    if (list.size() != 1) {
                        throw new RuntimeException("number of TextViews in toast is not 1");
                    }
                    TextView text = list.get(0);
                   //获取文本内容
                    CharSequence toastMsg = text.getText();
                    System.out.println("XposedHookToast:"+toastMsg);

                } catch (RuntimeException e) {
                    XposedBridge.log(e);
                }
            }
        });
    }
    //获取对象中的所有TextView
    private void finaAllTextView(List<TextView> addTo, ViewGroup view) {
        int count = view.getChildCount();
        for (int i = 0; i < count; ++i) {
            View child = view.getChildAt(i);
            if (child instanceof TextView) {
                addTo.add((TextView) child);
            } else if (child instanceof ViewGroup) {
                finaAllTextView(addTo, view);
            }
        }
    }
}

安装 Xposed 框架、激活再安装该模块后,也能起到获取 Toast 文本信息的作用。

Line 5251: I/System.out(  815): XposedHookToast:登录失败,可能原因是用户名或密码错误、密码过期或者帐号锁定  
Line 5959: I/System.out(  815): XposedHookToast:连接服务器失败  

但由于需要 root,而且也不是所有终端都能顺利安装 Xposed 框架,这也不算是一种可通用的方法。

容我再想想。


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