美团App页面视图可测性改造实践

一次编写多处运行的动态化容器技术给研发效率带来了极大的提升,但对于依旧需要多端验证的测试流程来说,在效率层面却面临着极大的挑战。本文围绕动态化容器中的动态布局技术,阐述了如何通过可测性改造来帮助达成提升测试效率的目标。希望可以给同样需要测试动态化页面的同学们带来一些启发和帮助。

  • 美团App的页面特点
  • 自动化测试实施中的技术挑战
    • 页面元素无法定位
    • Appium元素定位的原理
    • AccessibilityNodeInfo和Drawable
  • 页面视图可测性改造-XraySDK
    • 定位方案对比
    • 视图信息的获取和存储-XrayDumper
    • 视图信息的输出-XrayServer
    • SDK整体功能结构
    • 视图信息的增强
    • 动态布局自动化的收益
  • 未来展望
    • 使用视图解析原理解决WebView元素定位
    • 视图可测性改造更多的应用场景

美团App的页面特点

对于不同的用户,美团App页面的呈现方式其实多种多样,这就是所谓的“千人千面”。以美团首页的“猜你喜欢”模块为例,针对与不同的用户有单列、Tab、双列等多种不同形式。这么多不同的页面样式需求,如果要在1天内时间内完成开发、测试、上线流程,研发团队也面临着很大的挑战。所以测试工程师就需要重度依赖自动化测试来形成快速的验收机制。

image

图1 美团App首页多种页面布局样式

自动化测试实施中的技术挑战

接下来,本文将会从页面元素无法定位、Appium元素定位的原理、AccessibilityNodeInfo和Drawable等三个维度进行阐述。

页面元素无法定位

image

图2 页面元素审查情况

目前,美团App客户端自动化主要依托于Appium(一个开源、跨平台的测试框架,可以用来测试原生及混合的移动端应用)来实现页面元素的定位和操作,当我们通过Appium Inspector进行页面元素审查时,能通过元素审查找到的信息只有外面的边框和下方的两个按钮,其他信息均无法识别(如上图2所示)。中央位置的图片、左上角的文本信息都无法通过现有的UI自动化方案进行定位和解析。不能定位元素,也就无法进行页面的操作和断言,这就严重影响了自动化的实施工作。

经过进一步的调研,我们发现这些页面卡片中大量使用Drawable对象来绘制页面的信息,从而导致元素无法进行定位。为什么Drawable对象无法定位呢?下面我们一起研究一下UI自动化元素定位的原理。

Appium元素定位的原理

目前的UI自动化测试,使用Appium进行页面元素的定位和操作。如下图所示,AppiumServer和UiAutomator2的手机端进行通信后完成元素的操作。

image

图3 Appium的通信原理

通过阅读Appium源码发现完成一次定位的流程如下图所示:

图4 Appium定位元素的实现流程

  • 首先,Appium通过调用findElement 的方式进行元素定位。

  • 然后,调用Android提供UIDevice 对象的findObject 方法。

  • 最终,通过PartialMatch.accept 完成元素的查找。

接下来我们看一下,这个PartialMatch.accept 到底是如何完成元素定位的。通过对于源码的研究,我们发现元素的信息都是存储在一个叫做AccessibilityNodeInfo 的对象里面。源码中使用大量node.getXXX 方法中的信息,大家是否眼熟呢?这些信息其实就是我们日常自动化测试中可以获取UI元素的属性。

图片

图5 AppiumInspector审查元素获取信息示意

Drawable 无法获取元素信息,是否和AccessibilityNodeInfo 相关?我们进一步探究DrawableAccessibilityNodeInfo 的关系。

AccessibilityNodeInfo和Drawable

通过对于源码的研究,我们绘制了如下类图来解释AccessibilityNodeInfoDrawable 之间的关系。

图6 类关系示意图

View 实现了AccessibilityEventSource 接口并实现了一个叫做onInitializeAccessibilityNodeInfo 的方法来填充信息。我们也在Android官方文档中找到了对于此信息的说明:

onInitializeAccessibilityNodeInfo() :此方法为无障碍服务提供有关视图状态的信息。默认的View 实现具有一组标准的视图属性,但如果您的自定义视图提供除了简单的 TextViewButton 之外的其他互动控件,则您应替换此方法并将有关视图的其他信息设置到由此方法处理的AccessibilityNodeInfo 对象中。

Drawable 并没有实现对应的方法,所以也就无法被自动化测试找到。探究了元素查找原理之后,我们就要开始着手解决问题了。

页面视图可测性改造-XraySDK

定位方案对比

既然知道了Drawable 没有填充AccessibilityNodeInfo ,也就说明我无法接入目前的自动化测试方案来完成页面内容的获取。那我们可以想到如下三种方案来解决问题:

实现方案 影响范围
改造Appium定位方式,让Drawable可以被识别 需要改动底层的AccessibilityNodeInfo obtain(View,int)方法和为Drawable添加AccessibilityNodeInfo这样就需要对于所有的Android系统做兼容,影响范围过大
使用View替代Drawable 动态布局卡片使用Drawable进行绘制就是因为Drawable比View使用资源更少,绘制性能更好,放弃使用Drawable就等于放弃了性能的改进
使用图像识别进行定位 动态卡片中有很多图像中包含文字,还有多行文本都会对图像识别的准确性带来很大的影响

上面的三种方案,目前看来都无法有效地解决动态卡片元素定位的问题。如何在影响范围较小的前提下,达成获取视图信息的目标呢?接下来,我们将进一步研究动态布局的实现方案。

视图信息的获取和存储-XrayDumper

我们的应用场景非常明确,自动化测试通过集成Client来获得和客户端交互能力,通过Client向App发送指令来页面信息的获取。那我们可以考虑内嵌一个SDK(XraySDK)来完成视图的获取,然后再向自动化提供一个客户端(XrayClient)来完成这部分功能。

图片

图7 XraySDK的工作流程示意图

对于XraySDK的功能划分,如下表所示:

模块名 功能划分 运行环境 产品形态
Xray-Client 1.和Xray-Server进行交互进行指令发送和数据的接收
2.暴露对外的Api给自动化或者其他系统 App内部 客户端SDK(AAR和Pod-Library)
Xray-SDK 1.进行页面信息的获取以及结构化(Xray-Dumper)
2.接收用户指令来进行结构化数据输出(Xray-Server) 自动化内部或者三方系统内部 JAR包或基于其他语言的依赖包

XraySDK如何才能获取到我们需要的Drawable信息呢?我们先来研究一下动态布局的实现方案。

图8 动态卡片的页面绘制流程

动态布局的视图呈现过程分为:解析模板->绑定数据->计算布局->页面绘制,计算布局结束后,元素在页面上的位置就已经确定了,那么只要拦截这个阶段信息就可以实现视图信息的获取。

通过对于代码的研究,我们发现在com.sankuai.litho.recycler.AdapterCompat 这个类中控制着视图布局行为,在bindViewHolder 中完成视图的最终的布局和计算。首先,我们通过在此处插入一个自定义的监听器来拦截布局信息。

public final void bindViewHolder(BaseViewHolder<Data> viewHolder, int position) {
        if (viewHolder != null) {
            viewHolder.bindView(context, getData(position), position);

            //自动化测试回调
            if (componentTreeCreateListeners != null) {
                if (viewHolder instanceof LithoViewHolder) {
                    DataHolder holder = getData(position);
                    //获取视图布局信息
                    LithoView view = ((LithoViewHolder<Data>) viewHolder).lithoView;
                    LayoutController layoutController = ((LithoDynamicDataHolder) holder).getLayoutController(null);
                    VirtualNodeBase node = layoutController.viewNodeRoot;
                    //通过监听器将视图信息向外传递给可测性SDK
                    componentTreeCreateListeners.onComponentTreeCreated(node, view.getRootView(), view.getComponentTree());
                }
            }
        }
    }

然后,通过暴露一个静态方法给可测性SDK,完成监听器的初始化。

public static void setComponentTreeCreateListener(ComponentTreeCreateListener l) {
        AdapterCompat.componentTreeCreateListeners = l;
        try {
            // 兼容mbc的动态布局自动化测试,为避免循环依赖,采用反射调用
            Class<?> mbcDynamicClass = Class.forName("com.sankuai.meituan.mbc.business.item.dynamic.DynamicLithoItem");
            Method setComponentTreeCreateListener = mbcDynamicClass.getMethod("setComponentTreeCreateListener", ComponentTreeCreateListener.class);
            setComponentTreeCreateListener.invoke(null, l);

        } catch (Exception e) {
            e.printStackTrace();
        }

        try {
            // 搜索新框架动态布局自动化测试
            Class<?> searchDynamicClass = Class.forName("com.sankuai.meituan.search.result2.model.DynamicItem");
            Method setSearchComponentTreeCreateListener = searchDynamicClass.getMethod("setComponentTreeCreateListener", ComponentTreeCreateListener.class);
            setSearchComponentTreeCreateListener.invoke(null, l);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

最后,自动化通过设置自定义的监听器来完成视图信息的获取和存储。

//通过静态方法设置一个ComponentTreeCreateListener来监听布局事件
AdapterCompat.setComponentTreeCreateListener(new AdapterCompat.ComponentTreeCreateListener() {
            @Override
            public void onComponentTreeCreated(VirtualNodeBase node, View rootView, ComponentTree tree) {
                //将信息存储到一个自定义的ViewInfoObserver对象中
                ViewInfoObserver vif = new ViewInfoObserver();
                vif.update(node, rootView, tree);
            }
        });

我们将视图信息存储在ViewInfoObserver这样一个对象中。

public class ViewInfoObserver implements AutoTestObserver{
    public static HashMap<String, View> VIEW_MAP = new HashMap<>();
    public static HashMap<VirtualNodeBase, View> VIEW = new HashMap<>();
    public static HashMap<String, ComponentTree> COMPTREE_MAP = new HashMap<>();
    public static String uri = "http://dashboard.ep.dev.sankuai.com/outter/dynamicTemplateKeyFromJson";

    @Override
    public void update(VirtualNodeBase vn, View view,ComponentTree tree) {
        if (null != vn && null != vn.jsonObject) {
            try {
                String string = vn.jsonObject.toString();
                Gson g = new GsonBuilder().setPrettyPrinting().create();
                JsonParser p = new JsonParser();
                JsonElement e = p.parse(string);

                String templateName = null;
                String name1 = getObject(e,"templateName");
                String name2 = getObject(e,"template_name");
                String name3 = getObject(e,"template");
                templateName = null != name1 ? name1 : (null != name2 ? name2 : (null != name3 ? name3 : null));

                if (null != templateName) {
                //如果已经存储则更新视图信息
                    if (VIEW_MAP.containsKey(templateName)) {
                        VIEW_MAP.remove(templateName);
                    }
                    //存储视图编号
                    VIEW_MAP.put(templateName, view);
                    if (VIEW.containsKey(templateName)) {
                        VIEW.remove(templateName);
                    }
                    //存储视图信息
                    VIEW.put(vn, view);
                    if (COMPTREE_MAP.containsKey(templateName)) {
                        COMPTREE_MAP.remove(templateName);
                    }
                    COMPTREE_MAP.put(templateName, tree);
                    System.out.println("autotestDyn:update success");

                } 

            } catch (Exception e) {
                System.out.println(e.toString());
                System.out.println("autotestDyn:templateName not exist!");
            }
        }
    }

当需要查询这些信息的时候,就可以通过XrayDumper来完成信息的输出。

public class SubViewInfo {
    public JSONObject getOutData(String template) throws JSONException {
        JSONObject outData = new JSONObject();
        JSONObject componentTouchables = new JSONObject();

        if (!COMPTREE_MAP.isEmpty() && COMPTREE_MAP.containsKey(template) && null != COMPTREE_MAP.get(template)) {
            ComponentTree cpt = COMPTREE_MAP.get(template);
            JSONArray componentArray = new JSONArray();

            ArrayList<View> touchables = cpt.getLithoView().getTouchables();
            LithoView lithoView = cpt.getLithoView();
            int[] ls = new int[2];
            lithoView.getLocationOnScreen(ls);
            int pointX = ls[0];
            int pointY = ls[1];

            for (int i = 0; i < touchables.size(); i++) {
                JSONObject temp = new JSONObject();
                int height = touchables.get(i).getHeight();
                int width = touchables.get(i).getWidth();
                int[] tl = new int[2];
                touchables.get(i).getLocationOnScreen(tl);
                temp.put("height",height);
                temp.put("width",width);
                temp.put("pointX",tl[0]);
                temp.put("pointY",tl[1]);

                String url = "";
                try {
                    EventHandler eh = (EventHandler) getValue(getValue(touchables.get(i), "mOnClickListener"), "mEventHandler");
                    DynamicClickListener listener = (DynamicClickListener) getValue(getValue(eh, "mHasEventDispatcher"), "listener");
                    Uri clickUri = (Uri) getValue(listener, "uri");
                    if (null != clickUri) {
                        url = clickUri.toString();
                    }
                } catch (Exception e) {
                    Log.d("autotest", "get click url error!");
                }

                temp.put("url",url);
                componentArray.put(temp);
            }
            componentTouchables.put("componentTouchables",componentArray);
            componentTouchables.put("componentTouchablesCount", cpt.getLithoView().getTouchables().size());

            View[] root = (View[])getValue(cpt.getLithoView(),"mChildren");
            JSONArray allComponentArray = new JSONArray();
            if (root.length > 0) {
                for (int i = 0; i < root.length; i++) {
                    try {
                        if (null != root[i]) {
                            Object items[] = (Object[]) getValue(getValue(root[i], "mMountItems"), "mValues");
                            componentTouchables.put("componentCount", items.length);
                            for (int itemIndex = 0; itemIndex < items.length; itemIndex++) {
                                getMountItems(allComponentArray, items[itemIndex], pointX, pointY);
                            }
                        }
                    } catch (Exception e) {

                    }
                }
            }
            componentTouchables.put("componentUntouchables",allComponentArray);
        } else {
            Log.d("autotest","COMPTREE_MAP is null!");
        }
        outData.put(template,componentTouchables);
        System.out.println(outData);
        return outData;
    }
    }
}

视图信息的输出-XrayServer

我们获取到了信息,接下来就要考虑如何将视图信息传递给自动化测试脚本,我们参考了Appium的设计。

Appium通过在手机上安装的InstrumentsClient启动了一个SocketServer通过HTTP协议来完成自动化和底层测试框架的数据通信。我们也可以借鉴上述思路,在美团App中启动一个WebServer来完成信息的输出。

第一步,我们实现了一个继承了Service组件,这样就可以方便的通过命令行的方式的启动和停止可测性的功能。

public class AutoTestServer extends Service  {
    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
    ....
        return super.onStartCommand(intent, flags, startId);
    }
}

第二步,通过HttpServer的方式对外暴露通信的接口。

public class AutoTestServer extends Service  {
    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        // 创建对象,端口通过参数传入
        if (intent != null) {
            int randNum = intent.getIntExtra("autoTestPort",8999);
            HttpServer myServer = new HttpServer(randNum);
            try {
                // 开启HTTP服务
                myServer.start();
                System.out.println("AutoTestPort:" + randNum);
            } catch (IOException e) {
                System.err.println("AutoTestPort:" + e.getMessage());
                myServer = new HttpServer(8999);
                try {
                    myServer.start();
                    System.out.println("AutoTestPort:8999");
                } catch (IOException e1) {
                    System.err.println("Default:" + e.getMessage());
                }
            }
        }
        return super.onStartCommand(intent, flags, startId);
    }
}

第三步,将之前设置好的监听器进行注册。

public class AutoTestServer extends Service  {
    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
    //注册监听器
        AdapterCompat.setComponentTreeCreateListener(new AdapterCompat.ComponentTreeCreateListener() {
            @Override
            public void onComponentTreeCreated(VirtualNodeBase node, View rootView, ComponentTree tree) {
                ViewInfoObserver vif = new ViewInfoObserver();
                vif.update(node, rootView, tree);
            }
        });

        // 创建对象,端口通过参数传入
        .....
        return super.onStartCommand(intent, flags, startId);
    }
}

最后,在HttpServer中通过不同的路径来实现接收不同的指令。

private JSONObject getResponseByUri(@Nonnull IHTTPSession session) throws JSONException {
        String uri = session.getUri();
        if (isFindCommand(uri)) {
            return getResponseByFindUri(uri);
        }
}

@Nonnull
private JSONObject getResponseByFindUri(@Nonnull String uri) throws JSONException {
    String template = uri.split("/")[2];
    String protocol = uri.split("/")[3];
    switch (protocol) {
        case "frame":
            TemplateLayoutFrame tlf = new TemplateLayoutFrame();
            return tlf.getOutData(template);
        case "subview":
            SubViewInfo svi = new SubViewInfo();
            return svi.getOutData(template);
        //省略了部分的代码处理逻辑    
        ....
        default:
            JSONObject errorJson = new JSONObject();
            errorJson.put("success", false);
            errorJson.put("message", "输入find链接地址有误");
            return errorJson;
    }
}

SDK整体功能结构

自动化脚本通过访问设备的特定端口(例如:http://localhost:8899/find/subview),经由XrayServer,通过访问路径将请求转发至XrayDumper进行信息的提取和输出。然后布局解析器将布局信息序列化成JSON数据,再经由XrayServer,通过网络以HTTP响应的方式传到给自动化测试脚本。

图9 XraySDK功能结构示意图

视图信息的增强

除了常规的位置、内容、类型等信息,我们还通过检查时间监听器的方式,进一步判断视图元素是否可以进行交互,进一步增强了页面视图结构的有效信息。

// setGestures
ArrayList<String> gestures = new ArrayList<>();
if (view.isClickable()){
   gestures.add("isClickable");
}
if (view.isLongClickable()){
   gestures.add("isLongClickable");
}
//省略部分代码
.....

动态布局自动化的收益

基于视图可测性的提升,美团动态化卡片的自动化测试覆盖度有了大幅的提升,从原来无法做自动化测试,到目前80%以上的动态化卡片都实现了自动化测试,而且效率也得到了明显的提升。

图10 自动化效率提升收益

未来展望

页面视图信息作为客户端测试最基础且重要的属性之一,是对用户视觉信息的一种代码级的表示。它对于机器识别页面元素信息有着非常重要的作用,对于它的可测性改造将会给技术团队带来很大的收益。我们会列举了几个视图可测性改造的探索方向,仅供大家参考。

使用视图解析原理解决WebView元素定位

应用同样的思想,我们还可以用来解决WebView元素定位的问题。

image

图11 WebView页面示例

通过运行在App内部的SDK,可以获取到对应的WebView实例。通过获取到根节点,从根节点开始进行循环遍历,同时把每个节点的信息存储下来就可以得到所有的视图信息了。

在WebView是否也有同样合适的根节点呢?基于对于HTML的理解,我们可以想到HTML中所有的标签都是挂在BODY标签下面的,BODY标签就是我们需要选取的根节点。我们可以通过WebElement[“attrName”]的方式来进行属性的获取。

图12 遍历WebView节点的代码示例

视图可测性改造更多的应用场景

  • 提升功能测试可靠性:在功能测试自动化中,通过内部更加稳定和迅速的视图信息输出,可以有效提升自动化测试的稳定性。避免由于元素无法获取或者元素获取缓慢导致的自动化测试失败。

  • 提升可靠性测试效率:对于依靠随机或者按照视图信息进行页面随机操作的可靠性测试,依赖对于视图信息的过滤,也可以只操作可以交互的元素(通过过滤元素事件监听器是否为空)。这样就可以有效提升可靠性测试的效率,在单位时间内可以完成更多页面的检测。

  • 增加兼容性测试检测手段:在页面兼容性方面,通过对页面组件位置信息和属性来扫描页面内是否存在不合理的堆叠、空白区域、形状异常等UI呈现异常。也可以获取内容信息,例如图片、文本,来检查是否存在不适宜内容呈现。可以作为图像对比方案的有效补充。

文章转自美团技术团队微信公众号