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

95次阅读

共计 11962 个字符,预计需要花费 30 分钟才能阅读完成。

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

美团 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-Client 1. 和 Xray-Server 进行交互进行指令发送和数据的接管
2. 裸露对外的 Api 给自动化或者其余零碎
App 外部 客户端 SDK(AAR 和 Pod-Library)
Xray-SDK 1. 进行页面信息的获取以及结构化(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);
        }
}

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

视图信息的加强

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

// setGestures
ArrayList<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 申请受权。

正文完
 0