美團App頁面視圖可測性改造實踐

一次編寫多處運行的動態化容器技術給研發效率帶來了極大的提升,但對於依舊需要多端驗證的測試流程來說,在效率層面卻面臨着極大的挑戰。本文圍繞動態化容器中的動態佈局技術,闡述瞭如何通過可測性改造來幫助達成提升測試效率的目標。希望可以給同樣需要測試動態化頁面的同學們帶來一些啓發和幫助。

美團App的頁面特點

對於不同的用戶,美團App頁面的呈現方式其實多種多樣,這就是所謂的“千人千面”。以美團首頁的“猜你喜歡”模塊爲例,針對與不同的用戶有單列、Tab、雙列等多種不同形式。這麼多不同的頁面樣式需求,如果要在1天內時間內完成開發、測試、上線流程,研發團隊也面臨着很大的挑戰。所以測試工程師就需要重度依賴自動化測試來形成快速的驗收機制。

圖1美團App首頁多種頁面佈局樣式

自動化測試實施中的技術挑戰

接下來,本文將會從頁面元素無法定位、Appium元素定位的原理、AccessibilityNodeInfo和Drawable等三個維度進行闡述。

頁面元素無法定位

圖2 頁面元素審查情況

目前,美團App客戶端自動化主要依託於Appium(一個開源、跨平臺的測試框架,可以用來測試原生及混合的移動端應用)來實現頁面元素的定位和操作,當我們通過Appium Inspector進行頁面元素審查時,能通過元素審查找到的信息只有外面的邊框和下方的兩個按鈕,其他信息均無法識別(如上圖2所示)。中央位置的圖片、左上角的文本信息都無法通過現有的UI自動化方案進行定位和解析。不能定位元素,也就無法進行頁面的操作和斷言,這就嚴重影響了自動化的實施工作。

經過進一步的調研,我們發現這些頁面卡片中大量使用Drawable對象來繪製頁面的信息,從而導致元素無法進行定位。爲什麼Drawable對象無法定位呢?下面我們一起研究一下UI自動化元素定位的原理。

Appium元素定位的原理

目前的UI自動化測試,使用Appium進行頁面元素的定位和操作。如下圖所示,AppiumServer和UiAutomator2的手機端進行通信後完成元素的操作。

圖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進行交互進行指令發送和數據的接收<br>2.暴露對外的Api給自動化或者其他系統 App內部 客戶端SDK(AAR和Pod-Library)
Xray-SDK 1.進行頁面信息的獲取以及結構化(Xray-Dumper)<br>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元素定位的問題。

圖11 WebView頁面示例

通過運行在App內部的SDK,可以獲取到對應的WebView實例。通過獲取到根節點,從根節點開始進行循環遍歷,同時把每個節點的信息存儲下來就可以得到所有的視圖信息了。

在WebView是否也有同樣合適的根節點呢?基於對於HTML的理解我們可以想到HTML中所有的標籤都是掛在BODY標籤下面的,BODY標籤就是我們需要選取的根節點。我們可以通過WebElement["attrName"]的方式來進行屬性的獲取。

圖12 遍歷WebView節點的代碼示例

視圖可測性改造的更多應用場景

  • 提升功能測試可靠性:在功能測試自動化中,通過內部更加穩定和迅速的視圖信息輸出,可以有效提升自動化測試的穩定性。避免由於元素無法獲取或者元素獲取緩慢導致的自動化測試失敗。
  • 提升可靠性測試效率:對於依靠隨機或者按照視圖信息進行頁面隨機操作的可靠性測試,依賴對於視圖信息的過濾,也可以只操作可以交互的元素(通過過濾元素事件監聽器是否爲空)。這樣就可以有效提升可靠性測試的效率,在單位時間內可以完成更多頁面的檢測。
  • 增加兼容性測試檢測手段:在頁面兼容性方面,通過對頁面組件位置信息和屬性來掃描頁面內是否存在不合理的堆疊、空白區域、形狀異常等UI呈現異常。也可以獲取內容信息,例如圖片、文本,來檢查是否存在不適宜內容呈現。可以作爲圖像對比方案的有效補充。

招聘信息

美團平臺質量技術中心,負責美團 App 業務和大前端(移動客戶端和Web前端)基礎技術質量工作,沉澱流程規範和配套工具、提升研發效率。團隊技術一流、氛圍良好,感興趣的同學簡歷可以發送至: [email protected]

閱讀美團技術團隊更多技術文章合集

前端 | 算法 | 後端 | 數據 | 安全 | 運維 | iOS | Android | 測試

| 在公衆號菜單欄對話框回覆【2020年貨】、【2019年貨】、【2018年貨】、【2017年貨】等關鍵詞,可查看美團技術團隊歷年技術文章合集。

| 本文系美團技術團隊出品,著作權歸屬美團。歡迎出於分享和交流等非商業目的轉載或使用本文內容,敬請註明“內容轉載自美團技術團隊”。本文未經許可,不得進行商業性轉載或者使用。任何商用行爲,請發送郵件至[email protected]申請授權。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章