资讯

精准传达 • 有效沟通

从品牌网站建设到网络营销策划,从策略到执行的一站式服务

Robotium源码分析之运行原理

从上一章《Robotium源码分析之Instrumentation进阶》中我们了解到了Robotium所基于的Instrumentation的一些进阶基础,比如它注入事件的原理等,但Robotium作为一个测试框架,其功能远不止于只是方便我们注入事件,其应该还包含其他高级的功能,参照我们前面其他框架如MonkeyRunner,UiAutomator和Appium的源码分析,我们知道一个移动平台自动化测试框架的基本功能除了事件注入外起码还应该有控件获取的功能。所以,这篇文章我们主要是围绕Robotium的这几个功能做阐述。

创新互联主营吴桥网站建设的网络公司,主营网站建设方案,APP应用开发,吴桥h5小程序定制开发搭建,吴桥网站营销推广欢迎吴桥等地区企业咨询

Robotium作为一个自动化测试框架,做一个自动化测试库,其所要走的事情就是要封装好获取控件和操作控件的各种方法,而因为Robotium编写的脚本又是和目标测试应用运行在同一进程中的(参考《Robotium源码分析之Instrumentation进阶》第一节),所以就会让事情更简单了。比如获取一个TextView的文本,我们就可以直接在测试脚本这个子线程调用目标控件的个相应方法来获得文本,如果要设置控件的文本属性的话,我们也只是需要提供一个runnable让主线程UiThread去调用控件的设置Text属性的方法就完成了。当然,如果你是想要模拟用户键盘输入来设置文本,那么就需要操作事件的另外一个方式,去注入事件发送键盘事件来完成了。也就是说,你的脚本既可以运用作为UiThread子线程的优势来直接操控控件属性,也可以通过注入事件的方式来模拟用户层面对控件的操作。


1.通过注入事件操作控件 

在上一章《Robotium源码分析之Instrumentation进阶》中我们谈到了Instrumentation通过InputManager注入事件的几种方式:

Method

Description

Comment

Key Events

sendKeySync

发送一个键盘事件,注意同一时间只有一个action,或者是按下,或者是弹起,所有下面其他key相关的事件注入都是以这个方法为基础的


sendKeyDownUpSync

基于sendKeySync发送一个按键的按下和弹起两个事件


sendCharacterSync

发送键盘上的一个字符,完整的过程包括一个按下和弹起事件


sendStringSync

往应用发送一串字符串


Tackball Event

sendTrackballEventSync

发送轨迹球事件。个人没有用过,应该是像黑莓的那种轨迹球吧


Pointer Event

sendPointerSync

发送点击事件


今天我们就通过查看Solo类的clickOnText暴露出来的方法看下最终Robotium是如果通过Instrumentation调用InputManager的sendPointerSync来注入事件的,我们先定位到Solo类的该方法:
/*      */   public void clickOnText(String text) /*      */   { /* 1095 */     this.clicker.clickOnText(text, false, 1, true, 0); /*      */   }
直接跳转到Clicker类的clickOnText方法,各个参数的意义也一目了然:
/*     */   public void clickOnText(String regex, boolean longClick, int match, boolean scroll, int time) /*     */   { /* 427 */     TextView textToClick = this.waiter.waitForText(regex, match, Timeout.getSmallTimeout(), scroll, true, false); /*     */      /* 429 */     if (textToClick != null) { /* 430 */       clickOnScreen(textToClick, longClick, time); /*     */  /*     */  /*     */  /*     */     } /* 435 */     else if (match > 1) { /* 436 */       Assert.fail(match + " matches of text string: '" + regex + "' are not found!"); /*     */     } /*     */     else /*     */     { /* 440 */       ArrayList allTextViews = RobotiumUtils.removeInvisibleViews(this.viewFetcher.getCurrentViews(TextView.class, true)); /* 441 */       allTextViews.addAll(this.webUtils.getTextViewsFromWebView()); /*     */        /* 443 */       for (TextView textView : allTextViews) { /* 444 */         Log.d("Robotium", "'" + regex + "' not found. Have found: '" + textView.getText() + "'"); /*     */       } /* 446 */       allTextViews = null; /* 447 */       Assert.fail("Text string: '" + regex + "' is not found!"); /*     */     } /*     */   }

第一步当然是先获得控件了,具体怎么获得往后章节会详细描述。现在重点看430行clickOnScreen方法,注意参数longCilck代表用户想注入的点击方法是长按还是短按:

/*     */   public void clickOnScreen(View view, boolean longClick, int time) /*     */   { /* 182 */     if (view == null) { /* 183 */       Assert.fail("View is null and can therefore not be clicked!"); /*     */     } /* 185 */     float[] xyToClick = getClickCoordinates(view); /* 186 */     float x = xyToClick[0]; /* 187 */     float y = xyToClick[1]; /*     */      /* 189 */     if ((x == 0.0F) || (y == 0.0F)) { /* 190 */       this.sleeper.sleepMini(); /*     */       try { /* 192 */         view = this.viewFetcher.getIdenticalView(view); /*     */       } /*     */       catch (Exception ignored) {} /* 195 */       if (view != null) { /* 196 */         xyToClick = getClickCoordinates(view); /* 197 */         x = xyToClick[0]; /* 198 */         y = xyToClick[1]; /*     */       } /*     */     } /*     */      /* 202 */     if (longClick) { /* 203 */       clickLongOnScreen(x, y, time, view); /*     */     } else { /* 205 */       clickOnScreen(x, y, view); /*     */     } /*     */   }
先根据控件获得控件点击坐标:其实就是控件的中心点的绝对坐标值了,该转换在getClickCoordinates方法进行,没有什么特别的地方,就不跳进去分析了。

然后根据是否是长按考虑调用clickLongOnScreen或者clickOnScreen方法,我们这里挑clicOnScreen往下展开:

/*     */   public void clickOnScreen(float x, float y, View view) /*     */   { /*  77 */     boolean successfull = false; /*  78 */     int retry = 0; /*  79 */     SecurityException ex = null; /*     */      /*  81 */     while ((!successfull) && (retry < 10)) { /*  82 */       long downTime = SystemClock.uptimeMillis(); /*  83 */       long eventTime = SystemClock.uptimeMillis(); /*  84 */       MotionEvent event = MotionEvent.obtain(downTime, eventTime, 0, x, y, 0); /*     */        /*  86 */       MotionEvent event2 = MotionEvent.obtain(downTime, eventTime, 1, x, y, 0); /*     */       try /*     */       { /*  89 */         this.inst.sendPointerSync(event); /*  90 */         this.inst.sendPointerSync(event2); /*  91 */         successfull = true; /*     */       } catch (SecurityException e) { /*  93 */         ex = e; /*  94 */         this.dialogUtils.hideSoftKeyboard(null, false, true); /*  95 */         this.sleeper.sleep(200); /*  96 */         retry++; /*  97 */         View identicalView = this.viewFetcher.getIdenticalView(view); /*  98 */         if (identicalView != null) { /*  99 */           float[] xyToClick = getClickCoordinates(identicalView); /* 100 */           x = xyToClick[0]; /* 101 */           y = xyToClick[1]; /*     */         } /*     */       } /*     */     } /* 105 */     if (!successfull) { /* 106 */       Assert.fail("Click at (" + x + ", " + y + ") can not be completed! (" + (ex != null ? ex.getClass().getName() + ": " + ex.getMessage() : "null") + ")"); /*     */     } /*     */   }
所做的事情就是根据点击坐标组建两个分别按下和弹起的事件,然后在89和90行分别调用Instrumentation的sendPointerSync方法触发InputManager注入这两个按下和弹起的事件就完了。有了以前文章的基础,这些代码分析起来就很流畅了,不然到了这里还要去跟大家解析各种事件注入的情况就会显得很冗长乏味了。

2. 通过runOnMainSync操作控件

Robotium除了可以通过InputManager注入事件的方式来操作控件的点击输入等之外,还可以通过在主线程直接运行修改控件属性的代码来操作控件,比如修改一个TextView控件的文本,我们通过上一章的《Robotium源码分析之Instrumentation进阶》知道在主线程控制控件有两种方法,一种runOnMainSync的同步运行方式,一种runOnUiThread的异步运行方式,这里我们先看第一种的例子,Solo类的enterText方法:
/*      */   public void enterText(EditText editText, String text) /*      */   { /* 1748 */     editText = (EditText)this.waiter.waitForView(editText, Timeout.getSmallTimeout()); /* 1749 */     this.textEnterer.setEditText(editText, text); /*      */   }
首先还是如上获得控件,然后调用TextEnterer类的setEditText方法:
/*    */   public void setEditText(final EditText editText, final String text) /*    */   { /* 45 */     if (editText != null) { /* 46 */       final String previousText = editText.getText().toString(); /*    */        /* 48 */       this.inst.runOnMainSync(new Runnable() /*    */       { /*    */         public void run() /*    */         { /* 52 */           editText.setInputType(0); /* 53 */           editText.performClick(); /* 54 */           TextEnterer.this.dialogUtils.hideSoftKeyboard(editText, false, false); /* 55 */           if (text.equals("")) { /* 56 */             editText.setText(text); /*    */           } else { /* 58 */             editText.setText(previousText + text); /* 59 */             editText.setCursorVisible(false); /*    */           } /*    */         } /*    */       }); /*    */     } /*    */   }
毫无意外的这个方法在48行开始调用的就是runOnMainSync的方法来给主线程执行,所做的事情大致如下:
  • 设置控件的输入类型,比如如果是密码输入框的话就不要显示输入过的字符,以*号代替等
  • 模拟点击设置焦点到目标输入控件
  • 因为是直接对控件设置,所以不需要通过键盘驱动,所以也就没有必要显示键盘出来了
  • 直接设置控件的Text

3.通过runOnUiThread操作控件

看完了同步设置EditText文本属性Solo的enterText方法的例子,那么我们继续往下看下异步的直接在主线程设置控件属性的一个例子,我们这里挑的是Solo的setProgressBar这个方法:
/*      */   public void setProgressBar(ProgressBar progressBar, int progress) /*      */   { /* 1691 */     progressBar = (ProgressBar)this.waiter.waitForView(progressBar, Timeout.getSmallTimeout()); /* 1692 */     this.setter.setProgressBar(progressBar, progress); /*      */   
直接跳入到Setter类的setProgressBar方法:
/*     */   public void setProgressBar(final ProgressBar progressBar, final int progress) /*     */   { /* 101 */     if (progressBar != null) /*     */     { /* 103 */       this.activityUtils.getCurrentActivity(false).runOnUiThread(new Runnable() /*     */       { /*     */         public void run() /*     */         { /*     */           try { /* 108 */             progressBar.setProgress(progress); /*     */           } /*     */           catch (Exception ignored) {} /*     */         } /*     */       }); /*     */     } /*     */   }
103行可以看到是使用了runOnUiThread的方法在主线程直接提交修改控件属性的消息然后放到UiThread MainLooper来排队修改进度条属性的。
迄今为止我们看到到了以下几种控制控件的方法:
  • 通过Instrumentation调用InputManager注入事件
  • 通过runOnMainSync在主线程同步直接修改控件属性
  • 通过runOnUiThread在主线程异步修改控件属性
至于后两者我们只知道一个是同步的一个是异步的,但是为什么在例子中设置TextView的Text属性需要的就是同步的,而在设置ProgressBar的Progress需要的就是异步的呢?这里本人并没有很好的解析为Robotium是根据什么策略决定的,希望有知道的朋友评论一下,以防有朋友如我般容易钻牛角尖的同学可以释疑。

4. Robotium跨应用

Robotium可以通过Android4.3之后Instrumentation引入得getUiAutomation接口获得UiAutomation得实例进行跨应用测试,具体用户脚本实现留给大家练习,因为整个系列本人描述的都不是实战经验了,至于具体怎么个回事请查看《Robotium源码分析之Instrumentation进阶》第7节。

5. 通过WindowManager获取控件

本来打算像往常一样按照自己的逻辑重新分析Robotium获取控件的原理的,但发现网上已有先驱撰文《Robotium 5.0.1 源码解析之控件搜索》做了相应的分析了,且近来身体欠佳,牙痛,头痛,发烧干嘛来袭的,所以就干脆直接引用了,本人觉得写的还ok,大家阅读应该不会存在问题的了,这里就先谢过作者了。其实获取一个控件的方法无非是先获取得ui界面上得根控件,然后从根控件开始搜索下面指定的控件,在UiAutomator中我们用AccessibilityNodeInfo来封装一个view,而在Robotium中我们还是使用view和ViewGroup本身而已,注意这里View是描述一个控件的最小单位,而ViewGroup是view的容器,比如最上层的DecorView就是包含了界面所有控件的容器,所以获得这个容器就能获得所有的子控件。再次说明,以下分析是摘录自网上的,本人只做了排版调整。

     众所周知,Robotium是基于Android的单元测试框架Instrumentation,而robotium对于Instrumentation封装的比较强的地方便是控件搜索,这部分的源码主要位于ViewFetcher.java中。     

5.1  mViews的获取

    要先搜索控件,必须先得到Activity的rootView。在Android中,对于一般的Activity或其对话框,其rootView叫做DecorView,其实就是Activity和Dialog外面的那层框(关于Activity或dialog的层次可以用HierarchyViewer来查看)。

   虽然通过Activity类的getWindow().getDecorView可以获取到Activity自身的DecorView,但是无法获取到对话框的,因此Robotium中界面控件是从WindowManagerGlobal(或WindowManagerImpl)中的mViews获取到的。当然mViews中不但包含DecorView,还包含同进程内的所有界面的根节(如悬浮框的根节点)。mView的值的获取过程主要如下: 

   1) 确定mViews所在类:android 4.2之前,获取类为android.view.WindowManagerImpl,4.2及之后,获取类为WindowManagerGlobal

 String windowManagerClassName;    if (android.os.Build.VERSION.SDK_INT >= 17) {           windowManagerClassName = "android.view.WindowManagerGlobal";    } else {           windowManagerClassName = "android.view.WindowManagerImpl";    }   windowManager = Class.forName(windowManagerClassName)      

    2). 获得类的实例:此类是个单例类,有直接的静态变量可以获取到其实例, 4.2及之后的版本其变量名为sDefaultWindowManager,3.2至4.1,其变量名为sWindowManager,3.2之前,其变量名为mWindowManager。

/**   * Sets the window manager string.   */   private void setWindowManagerString(){               if (android.os.Build.VERSION.SDK_INT >= 17) {                     windowManagerString = "sDefaultWindowManager";            } else if(android.os.Build.VERSION.SDK_INT >= 13) {                     windowManagerString = "sWindowManager";            } else {                     windowManagerString = "mWindowManager";            }   }  

    3). 获取mViews变量的值了,从4.4开始类型变为ArrayList,之前为View[]

viewsField = windowManager.getDeclaredField("mViews");   instanceField = windowManager.getDeclaredField(windowManagerString);   viewsField.setAccessible(true);   instanceField.setAccessible(true);   Object instance = instanceField.get(null);   View[] result;   if (android.os.Build.VERSION.SDK_INT >= 19) {             result = ((ArrayList) viewsField.get(instance)).toArray(new View[0]);   } else {             result = (View[]) viewsField.get(instance);   }  

5.2 mViews的过滤

   mViews中会包含三种类型的View:

   1) 当前显示的以及没有显示的Activity的DecorView

   2) 当前对话框的DecorView

   3) 悬浮框View等其他不属于DecorView的独立View 

     在搜索控件时,显然需要在最上层界面中搜索,所以搜索范围为: 

              最上层的Activity/Dialog + 悬浮框

     对于悬浮框,robotium中的处理是找出mViews中不属于DecorView类的View,并将其所有子控件引入。

private final View[] getNonDecorViews(View[] views) {            View[] decorViews = null;               if(views != null) {                decorViews = new View[views.length];                   int i = 0;                View view;                   for (int j = 0; j < views.length; j++) {                    view = views[j];                    if (view != null && !(view.getClass().getName()                            .equals("com.android.internal.policy.impl.PhoneWindow$DecorView"))) {                        decorViews[i] = view;                        i++;                    }                }            }            return decorViews;        }  

    对于Activity/Dialog的筛选,Robotium采取对比DrawingTime的方法选出最后绘制的DecorView,其即为最上层Activity/Dialog的DecorView:

/**       * Returns the most recent view container       *       * @param views the views to check       * @return the most recent view container       */           private final View getRecentContainer(View[] views) {            View container = null;            long drawingTime = 0;            View view;               for(int i = 0; i < views.length; i++){                view = views[i];                if (view != null && view.isShown() && view.hasWindowFocus() && view.getDrawingTime() > drawingTime) {                    container = view;                    drawingTime = view.getDrawingTime();                }            }            return container;        }  

5.3 控件过滤&控件列表生成

     得到悬浮框的根节点和最上层的DecorView后,robotium会将所有View统一添加到一个ArrayList中生成控件列表。添加方法本身很简单,就是一个简单的递归,但需要注意的是此处有一个onlySufficientlyVisible的判断。onlySufficientlyVisible是ViewFetcher中最常见的一个变量,其表示是否过滤掉显示不完全的控件,即onlySufficientlyVisible为true时表示只在显示完全的控件中搜索目标,为false时表示在所有控件中搜索目标。具体代码为下面的addChildren函数:

private void addChildren(ArrayList views, ViewGroup viewGroup, boolean onlySufficientlyVisible) {           if(viewGroup != null){               for (int i = 0; i < viewGroup.getChildCount(); i++) {                   final View child = viewGroup.getChildAt(i);                      if(onlySufficientlyVisible && isViewSufficientlyShown(child))                       views.add(child);                      else if(!onlySufficientlyVisible)                       views.add(child);                      if (child instanceof ViewGroup) {                       addChildren(views, (ViewGroup) child, onlySufficientlyVisible);                   }               }           }       }  

从上面的代码可以看出,当onlySufficientlyVisible为true时,robotium会对控件的可见不可见进行检查。不过这里的可见不可见不是指Visible或Invisible(Robotium过滤Invisible控件的方法是RobotiumUtils.removeInvisibleViews,原理是利用view.isShown()方法),而是指由于界面滚动而导致的没有显示或显示不完全。继续看Robotium对SufficientlyVisible是怎么判断的:

public final boolean isViewSufficientlyShown(View view){           final int[] xyView = new int[2];           final int[] xyParent = new int[2];              if(view == null)               return false;              final float viewHeight = view.getHeight();           final View parent = getScrollOrListParent(view);           view.getLocationOnScreen(xyView);              if(parent == null){               xyParent[1] = 0;           }           else{               parent.getLocationOnScreen(xyParent);           }              if(xyView[1] + (viewHeight/2.0f) > getScrollListWindowHeight(view))               return false;              else if(xyView[1] + (viewHeight/2.0f) < xyParent[1])               return false;              return true;       }  

    代码中getScrollOrListParent是获取控件所属的ListView或ScrollView,可能是控件本身也可能是空。getScrollListWindowHeight函数用于获取控件所属的ListView或ScrollView最下面边界的Y坐标。因此
xyView[1] + (viewHeight/2.0f) > getScrollListWindowHeight(view)  
这个判断就表示控件有超过一半的面积被隐藏在了父控件的下方,而
(xyView[1] + (viewHeight/2.0f) < xyParent[1]  

则表示控件有超过一半的面积被隐藏在了父控件的上方,这两种情况都被Robotium判断为不满足SufficientlyVisible的(不过好像没有判断横向的?)。

 根据onlySufficientlyVisible过滤掉相应控件后,robotium便完成了控件列表的生成工作,之后的搜索就可直接在列表中进行查找了。

 有的时候要搜索指定类型的控件,可以按照类型对控件列表进行再一次的过滤,ViewFetcher中的代码如下:

public  ArrayList getCurrentViews(Class classToFilterBy, View parent) {             ArrayList filteredViews = new ArrayList();             List allViews = getViews(parent, true);             for(View view : allViews){                    if (view != null && classToFilterBy.isAssignableFrom(view.getClass())) {                           filteredViews.add(classToFilterBy.cast(view));                    }             }             allViews = null;             return filteredViews;      }  

可以看到,robotium直接利用了Class. isAssignableFrom进行类型的匹配。

 

5. 4.文本搜索

获得了控件列表,可以开始搜索指定的目标控件了,先从我们最常用的文本搜索开始,看看robotium的搜索流程。搜索过程的代码主要位于Searcher.java中,主要功能在两个searchFor函数中实现,通过嵌套完成目标的搜索。

第一层

    public  T searchFor(final Class viewClass, final String regex, int expectedMinimumNumberOfMatches, final long timeout, final boolean scroll, final boolean onlyVisible) {           //修正非法的expectedMinimumNumberOfMatches           if(expectedMinimumNumberOfMatches < 1) {               expectedMinimumNumberOfMatches = 1;           }              //定义一个Callable给下层searchFor使用,可以直接获取到符合条件的控件列表           final Callable> viewFetcherCallback = new Callable>() {               @SuppressWarnings("unchecked")               public Collection call() throws Exception {                   sleeper.sleep();                   //从当前的Android View中获取到符合viewClass的控件列表                   ArrayList viewsToReturn = viewFetcher.getCurrentViews(viewClass);                                         if(onlyVisible){                       //过滤掉Invisible的控件                       viewsToReturn = RobotiumUtils.removeInvisibleViews(viewsToReturn);                   }                               //robotium支持在webView中查找网页控件,因此若目标控件是TextView或是TextView的子类,                   //会把网页中的文本框也加到控件列表中。                   if(viewClass.isAssignableFrom(TextView.class)) {                       viewsToReturn.addAll((Collection) webUtils.getTextViewsFromWebView());                   }                   return viewsToReturn;               }           };              try {               //调用下层searchFor继续搜索               return searchFor(viewFetcherCallback, regex, expectedMinimumNumberOfMatches, timeout, scroll);           } catch (Exception e) {               throw new RuntimeException(e);           }       }     

   这个函数的主要功能有二,一是对非法的expectedMinimumNumberOfMatches进行修正,二是为下一层searchFor提供一个Callable,里面定义好了控件列表的获取过程。

   1)      expectedMinimumNumberOfMatches:这个参数表示搜索目标最小发现数目,当一个界面中有多个控件满足搜索条件,通过此参数可以指定想要获取的是第几个。

  2)      Callable> viewFetcherCallback:定义了控件列表(即搜索范围)的获取过程。首先利用前面提到的viewFetcher.getCurrentViews(viewClass)获取一个初步的列表;再通过RobotiumUtils.removeInvisibleViews(viewsToReturn)过滤掉不可见控件;最后由于Robotium支持webView内部搜索(Robotium的名字貌似也是来源于Selenium),所以当搜索目标是一个TextView时,Robotium还会调用webUtils.getTextViewsFromWebView()把网页中的文本框加入到搜索范围中。

 

第二层

    public  T searchFor(Callable> viewFetcherCallback, String regex, int expectedMinimumNumberOfMatches, long timeout, boolean scroll) throws Exception {           final long endTime = SystemClock.uptimeMillis() + timeout;             Collection views;              while (true) {                              final boolean timedOut = timeout > 0 && SystemClock.uptimeMillis() > endTime;                  if(timedOut){                   logMatchesFound(regex);                   return null;               }                   //获取符合条件的控件列表               views = viewFetcherCallback.call();                  for(T view : views){                   if (RobotiumUtils.getNumberOfMatches(regex, view, uniqueTextViews) == expectedMinimumNumberOfMatches) {                       uniqueTextViews.clear();                       return view;                   }               }               if(scroll && !scroller.scrollDown()){                   logMatchesFound(regex);                   return null;                }               if(!scroll){                   logMatchesFound(regex);                   return null;                }           }       }  

    这一层的主要功能就是循环在控件列表中找到含有指定文本的控件,直至超时或发现了   expectedMinimumNumberOfMatches数目的目标控件,这个过程中需要注意的有四点:

   1)    uniqueTextViews:为了防止找到的控件存在重复,此处用了一个uniqueTextViews集合来存储搜索到的结果。

   2)    文本的匹配:直接利用了Pattern进行正则匹配,但比对的内容不只包括view.getText(),还包括 view.getError()以及view.getHint()

   3)    自动滚动:当开启了scroll选项,并且在当前的界面没有找到足够的目标时,Robotium会自动滚动界面 (不过好像只会向下?):

if(scroll && !scroller.scrollDown()  

   4)     滚动时robotium只会滚动drawingTime最大的控件(通过ViewFetcher.getFreshestView()),所以一个界面中有两个可滚动控件时,robotium只会滚动其中一个。


6.引用

第四节引用出处:《Robotium 5.0.1 源码解析之控件搜索》,这里同时对作者表达本人感谢之情,省了我去重新分析的时间!


 

作者

自主博客

微信

CSDN

天地会珠海分舵

http://techgogogo.com


服务号:TechGoGoGo

扫描码:

Robotium源码分析之运行原理

http://cdkjz.cn/article/ppipih.html

多年建站经验

多一份参考,总有益处

联系快上网,免费获得专属《策划方案》及报价

咨询相关问题或预约面谈,可以通过以下方式与我们联系

大客户专线   成都:13518219792   座机:028-86922220