一次编写多处运行的动态化容器技术给研发效率带来了极大的晋升,但对于仍旧须要多端验证的测试流程来说,在效率层面却面临着极大的挑战。本文围绕动态化容器中的动静布局技术,论述了如何通过可测性革新来帮忙达成晋升测试效率的指标。心愿能够给同样须要测试动态化页面的同学们带来一些启发和帮忙。

美团App的页面特点

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

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

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

页面元素无奈定位

目前,美团App客户端自动化次要依靠于Appium(一个开源、跨平台的测试框架,能够用来测试原生及混合的挪动端利用)来实现页面元素的定位和操作,当咱们通过Appium Inspector进行页面元素审查时,能通过元素审查找到的信息只有里面的边框和下方的两个按钮,其余信息均无奈辨认(如上图2所示)。地方地位的图片、左上角的文本信息都无奈通过现有的UI自动化计划进行定位和解析。不能定位元素,也就无奈进行页面的操作和断言,这就重大影响了自动化的施行工作。

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

Appium元素定位的原理

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

通过浏览Appium源码发现实现一次定位的流程如下图所示:

  • 首先,Appium通过调用findElement的形式进行元素定位。
  • 而后,调用Android提供UIDevice对象的findObject办法。
  • 最终,通过PartialMatch.accept实现元素的查找。

接下来咱们看一下,这个PartialMatch.accept到底是如何实现元素定位的。通过对于源码的钻研,咱们发现元素的信息都是存储在一个叫做AccessibilityNodeInfo的对象外面。源码中应用大量node.getXXX办法中的信息,大家是否眼生呢?这些信息其实就是咱们日常自动化测试中能够获取UI元素的属性。

Drawable无奈获取元素信息,是否和AccessibilityNodeInfo相干?咱们进一步探索DrawableAccessibilityNodeInfo的关系。

AccessibilityNodeInfo和Drawable

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

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)来实现这部分性能。

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

模块名性能划分运行环境产品状态
Xray-Client1.和Xray-Server进行交互进行指令发送和数据的接管
2.裸露对外的Api给自动化或者其余零碎
App外部客户端SDK(AAR和Pod-Library)
Xray-SDK1.进行页面信息的获取以及结构化(Xray-Dumper)
2.接管用户指令来进行结构化数据输入(Xray-Server)
自动化外部或者三方零碎外部JAR包或基于其余语言的依赖包

XraySDK如何能力获取到咱们须要的Drawable信息呢?咱们先来钻研一下动静布局的实现计划。

动静布局的视图出现过程分为:解析模板->绑定数据->计算布局->页面绘制,计算布局完结后,元素在页面上的地位就曾经确定了,那么只有拦挡这个阶段信息就能够实现视图信息的获取。

通过对于代码的钻研,咱们发现在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);        }}@Nonnullprivate 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/su...),经由XrayServer,通过拜访门路将申请转发至XrayDumper进行信息的提取和输入。而后布局解析器将布局信息序列化成JSON数据,再经由XrayServer,通过网络以HTTP响应的形式传到给自动化测试脚本。

视图信息的加强

除了惯例的地位、内容、类型等信息,咱们还通过查看工夫监听器的形式,进一步判断视图元素是否能够进行交互,进一步加强了页面视图构造的无效信息。

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

动静布局自动化的收益

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

将来瞻望

页面视图信息作为客户端测试最根底且重要的属性之一,是对用户视觉信息的一种代码级的示意。它对于机器辨认页面元素信息有着十分重要的作用,对于它的可测性革新将会给技术团队带来很大的收益。咱们会列举了几个视图可测性革新的摸索方向,仅供大家参考。

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

利用同样的思维,咱们还能够用来解决WebView元素定位的问题。

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

在WebView是否也有同样适合的根节点呢?基于对于HTML的了解咱们能够想到HTML中所有的标签都是挂在BODY标签上面的,BODY标签就是咱们须要选取的根节点。咱们能够通过WebElement["attrName"]的形式来进行属性的获取。

视图可测性革新的更多利用场景

  • 晋升功能测试可靠性:在功能测试自动化中,通过外部更加稳固和迅速的视图信息输入,能够无效晋升自动化测试的稳定性。防止因为元素无奈获取或者元素获取迟缓导致的自动化测试失败。
  • 晋升可靠性测试效率:对于依附随机或者依照视图信息进行页面随机操作的可靠性测试,依赖对于视图信息的过滤,也能够只操作能够交互的元素(通过过滤元素事件监听器是否为空)。这样就能够无效晋升可靠性测试的效率,在单位工夫内能够实现更多页面的检测。
  • 减少兼容性测试检测伎俩:在页面兼容性方面,通过对页面组件地位信息和属性来扫描页面内是否存在不合理的重叠、空白区域、形态异样等UI出现异样。也能够获取内容信息,例如图片、文本,来查看是否存在不合适内容出现。能够作为图像比照计划的无效补充。

招聘信息

美团平台品质技术核心,负责美团 App 业务和大前端(挪动客户端和Web前端)根底技术品质工作,积淀流程标准和配套工具、晋升研发效率。团队技术一流、气氛良好,感兴趣的同学简历能够发送至: zhangjie63@meituan.com

浏览美团技术团队更多技术文章合集

前端 | 算法 | 后端 | 数据 | 平安 | 运维 | iOS | Android | 测试

| 在公众号菜单栏对话框回复【2020年货】、【2019年货】、【2018年货】、【2017年货】等关键词,可查看美团技术团队历年技术文章合集。

| 本文系美团技术团队出品,著作权归属美团。欢送出于分享和交换等非商业目标转载或应用本文内容,敬请注明“内容转载自美团技术团队”。本文未经许可,不得进行商业性转载或者应用。任何商用行为,请发送邮件至tech@meituan.com申请受权。