hierarchyviewer和uiautomatorviewer获取控件原理

通过对hierarchyview的源码分析,我尝试用java写了一个测试工具,该测试工具简单的实现了连接ViewServer获取控件信息,然后根据控件信息的坐标属性来点击按钮。

        1.RunTime执行CMD命令,连接ViewServer。

        2.获取控件信息以后,得到可点击的按钮。

        3.Java调用Monkeyrunner API对按钮进行操作。

        4.判断点击后的视图类型。

 

  第一节 Runtime执行CMD命令

 

        因为我要连接ViewServer,所以得实现执行cmd命令。方法如下:

 

[java]  view plain copy 在CODE上查看代码片 派生到我的代码片
  1. public boolean preCofig() {  
  2.         boolean flag = false;  
  3.         String cmd = "adb -s " + deviceId + " forward tcp:" + port + " tcp:4939";  
  4.         CMDUtils.runCMD(cmd, null);  
  5.         cmd = "adb -s " + deviceId + " shell service call window 3";  
  6.         String result = CMDUtils.runCMD(cmd, null);  
  7.         int index = result.indexOf("1");  
  8.         if (index > -1) {  
  9.             flag = true;  
  10.         } else {  
  11.             cmd = "adb -s " + deviceId + " shell service call window 1 i32 " + port;  
  12.             result = CMDUtils.runCMD(cmd, null);  
  13.             index = result.indexOf("1");  
  14.             if (index > -1) {  
  15.                 flag = true;  
  16.             }  
  17.         }  
  18.         return flag;  
  19.     }  
  20.   
  21.     public boolean connectDevice() {  
  22.         boolean flag = false;  
  23.         if (preCofig() == true) {  
  24.             try {  
  25.                 socket = new Socket();  
  26.                 socket.connect(new InetSocketAddress("127.0.0.1", port), 40000);  
  27.                 if (socket.isConnected()) {  
  28.                     out = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));  
  29.                     in = new BufferedReader(new InputStreamReader(socket.getInputStream(), "utf-8"));  
  30.                     try {  
  31.                         fw = new FileWriter(  
  32.                                 new File(Const.LOCA_PATH + "/" + deviceId + "_dump.txt"));  
  33.                     } catch (IOException e) {  
  34.                         e.printStackTrace();  
  35.                     }  
  36.                     flag = true;  
  37.                 }  
  38.             } catch (Exception e) {  
  39.                 e.printStackTrace();  
  40.             }  
  41.         }  
  42.         return flag;  
  43.     }  


       这样,给不同的设备映射不同的端口,然后通过socket访问。这2个方法主要是2个目的:

       1.确定viewServer是否打开,如果没打开,执行打开命令。

       2.确定viewServer打开后,执行socket连接操作,获得写入写出对象,等待命令的发出与读取。

       上面调用了CMDUtils类中的方法runCMD()。

 

[java]  view plain copy 在CODE上查看代码片 派生到我的代码片
  1. public static String runCMD(String cmd, String flag) {  
  2.         BufferedReader in = null;  
  3.         String result = null;  
  4.         Process process = null;  
  5.         try {  
  6.             process = Runtime.getRuntime().exec(cmd);  
  7.             in = new BufferedReader(new InputStreamReader(process.getInputStream(), "utf-8"));  
  8.         } catch (IOException e) {  
  9.             e.printStackTrace();  
  10.         }  
  11.         String line = null;  
  12.         try {  
  13.             while ((line = in.readLine()) != null) {  
  14.                 if (null != flag) {  
  15.                     int index = line.indexOf(flag);  
  16.                     if (index != -1)  
  17.                         result = line;  
  18.                 } else  
  19.                     result += line;  
  20.             }  
  21.         } catch (IOException e) {  
  22.             e.printStackTrace();  
  23.         } finally {  
  24.             if (in != null) {  
  25.                 try {  
  26.                     in.close();  
  27.                     process.destroy();  
  28.                 } catch (IOException e) {  
  29.                     // TODO Auto-generated catch block  
  30.                     e.printStackTrace();  
  31.                 }  
  32.             }  
  33.         }  
  34.         return result;  
  35.     }  


       

        通过这个方法,调用java的Runtime环境执行cmd方法,得到返回结果。

        到这一步结束,我们就通过执行了CMD命令,连接了Viewserver。

        其实简单就是你在dos下执行下面3个命令:

        adb -s emulator-5554 forward tcp:4939 tcp:4939  :映射端口到本地。

        adb -s emulator-5554 shell service call window 3 :判断viewserver是否打开。

        adb -s emulator-5554 shell service call window 1 i32 4939 :打开viewserver。

        连接ViewServer以后,我们就要获取数据啦。

 

  第二节 获取控件信息以后,得到可点击的按钮。

 

        这个我直接用Hierarchyviewer里的方法,不多解释了。

 

[java]  view plain copy 在CODE上查看代码片 派生到我的代码片
  1. /* 
  2.      * 获取控件信息 
  3.      */  
  4.     public ViewNode parseViewHierarchy() {  
  5.         if (socket == null || socket.isConnected() == false) {  
  6.             connectDevice();  
  7.         }  
  8.         try {  
  9.             out.write("DUMP -1");  
  10.             out.newLine();  
  11.             out.flush();  
  12.         } catch (IOException e) {  
  13.             e.printStackTrace();  
  14.         }  
  15.         ViewNode currentNode = null;  
  16.         int currentDepth = -1;  
  17.         String line;  
  18.         try {  
  19.             while ((line = in.readLine()) != null && !"DONE.".equalsIgnoreCase(line)) {  
  20.                 // System.out.println(line);  
  21.                 int depth;  
  22.                 for (depth = 0; line.charAt(depth) == ' '; depth++)  
  23.                     ;  
  24.                 for (; depth <= currentDepth; currentDepth--)  
  25.                     if (currentNode != null)  
  26.                         currentNode = currentNode.parent;  
  27.                 fw.write(line + "\n");  
  28.                 currentNode = new ViewNode(currentNode, line.substring(depth));  
  29.                 currentDepth = depth;  
  30.             }  
  31.         } catch (IOException e) {  
  32.             e.printStackTrace();  
  33.         } finally {  
  34.             close();  
  35.         }  
  36.         if (currentNode == null)  
  37.             return null;  
  38.         for (; currentNode.parent != null; currentNode = currentNode.parent)  
  39.             ;  
  40.         return currentNode;  
  41.     }  

       

        得到这些控件信息以后,我们要把它保存在一个视图对象中,这样转换为对当前视图对象进行操作。


        可以通过命令:adb shell dumpsys window,从得到的数据中提取有用的信息。

 

[html]  view plain copy 在CODE上查看代码片 派生到我的代码片
  1. ..............  
  2.   Display: init=480x854 base=480x854 cur=480x854 app=480x854 raw=480x854  
  3.   
  4.   mCurConfiguration={1.0 460mcc2mnc zh_CN layoutdir=0 sw320dp w320dp h544dp nrml long port finger -keyb/v/h -nav/h s.5}  
  5.   
  6.   mCurrentFocus=Window{4189d1d0 com.android.settings/com.android.settings.SubSettings paused=false}  
  7.   
  8.   mFocusedApp=AppWindowToken{4167cac0 token=Token{4184ffc8 ActivityRecord{418f6a60 com.android.settings/.SubSettings}}}  
  9.   
  10.   mInputMethodTarget=Window{41719db8 添加网络 paused=false}  
  11.   
  12.   mInTouchMode=true mLayoutSeq=186  

       

       在信息的最后一段里,发现了2个有用的属性:mCurrentFocus和mFocusedApp,这两个属性分别代表当前Window的信息和activity信息;然后根据window的hascode值可以得到当前窗口的其他信息。

 

[java]  view plain copy 在CODE上查看代码片 派生到我的代码片
  1. Window #4 Window{4189d1d0 com.android.settings/com.android.settings.SubSettings paused=false}:  
  2.   
  3.     mSession=Session{4179f4e8 uid 1000} mClient=android.os.BinderProxy@41953720  
  4.   
  5.     mAttrs=WM.LayoutParams{(0,0)(fillxfill) sim=#110 ty=1 fl=#810100 pfl=0x8 wanim=0x1030298}  
  6.   
  7.     Requested w=480 h=854 mLayoutSeq=186  
  8.   
  9.     Surface: shown=true layer=21020 alpha=1.0 rect=(0.0,0.0480.0 x 854.0  
  10.   
  11.     mShownFrame=[0.0,0.0][480.0,854.0]  

      

        这样方便我们以后使用这些属性,我们同样需要执行cmd命令然后删选这些信息。

 

[java]  view plain copy 在CODE上查看代码片 派生到我的代码片
  1. public static Map<String, String> runCMD(String cmd) {  
  2.         Map<String, String> map = new HashMap<String, String>();  
  3.         BufferedReader in = null;  
  4.         Process process = null;  
  5.         String result = null;  
  6.         try {  
  7.             process = Runtime.getRuntime().exec(cmd);  
  8.             in = new BufferedReader(new InputStreamReader(process.getInputStream(), "utf-8"));  
  9.         } catch (IOException e) {  
  10.             e.printStackTrace();  
  11.         }  
  12.         String line = null;  
  13.         try {  
  14.             while ((line = in.readLine()) != null) {  
  15.                 int index = line.indexOf("mCurrentFocus");  
  16.                 if (index > -1) {  
  17.                     index = line.indexOf("=");  
  18.                     line = line.substring(index + 1);  
  19.                     System.out.println("CMDUtils----------------------------------window:" + line);  
  20.                     map.put("window", line);  
  21.                 }  
  22.                 index = line.indexOf("mFocusedApp");  
  23.                 if (index > -1) {  
  24.                     index = line.indexOf("ActivityRecord");  
  25.                     int startIndex = line.indexOf("{", index);  
  26.                     int endIndex = line.indexOf("}", index);  
  27.                     line = line.substring(startIndex + 1, endIndex);  
  28.                     System.out.println("CMDUtils----------------------------------activity:" + line);  
  29.                     map.put("activity", line);  
  30.                 }  
  31.                 result += line;  
  32.             }  
  33.         } catch (IOException e) {  
  34.             e.printStackTrace();  
  35.         } finally {  
  36.             if (in != null) {  
  37.                 try {  
  38.                     in.close();  
  39.                     process.destroy();  
  40.                 } catch (IOException e) {  
  41.                     // TODO Auto-generated catch block  
  42.                     e.printStackTrace();  
  43.                 }  
  44.             }  
  45.         }  
  46.         int index = result.indexOf(map.get("window") + ":");  
  47.         result = result.substring(index + 1);  
  48.         index = result.indexOf("mShownFrame", index);  
  49.         int startIndex = result.indexOf("[", index);  
  50.         index = result.indexOf("]", startIndex);  
  51.         String startPoint = result.substring(startIndex + 1, index);  
  52.         System.out.println("CMDUtils----------------------------------startPoint:" + startPoint);  
  53.         int endIndex = result.indexOf("]", index + 1);  
  54.         String endPoint = result.substring(index + 2, endIndex);  
  55.         System.out.println("CMDUtils----------------------------------endPoint:" + endPoint);  
  56.         map.put("startPoint", startPoint);  
  57.         map.put("endPoint", endPoint);  
  58.         return map;  
  59.     }  

       

        这样我们就得到了我们需要的信息,测试一下,命令行输出如下:

 

[java]  view plain copy 在CODE上查看代码片 派生到我的代码片
  1. CMDUtils----------------------------------activity:420ce328 u0 com.android.launcher3/.Launcher t1  
  2. CMDUtils----------------------------------window:Window{420cd0c8 u0 com.android.launcher3/com.android.launcher3.Launcher}  
  3. CMDUtils----------------------------------activity:420ce328 u0 com.android.launcher3/.Launcher t1  
  4. CMDUtils----------------------------------startPoint:0.0,0.0  
  5. CMDUtils----------------------------------endPoint:480.0,854.0  

       

        有的人会疑惑,我们取这些信息有什么用。

        window:唯一标识当前界面;activity并不能唯一标识,因为弹出框的activity和父视图的activity是一样的。

        activity:可以区分当前窗口是否是新窗口。

        startPoint和endPoint可以获得窗口的坐标和范围,因为弹出框的起始坐标不是以设备的左上顶点为起始坐标的;在我们获得控件信息时得到的坐标,如果是弹出框,它无法确定准确的坐标值,因为它把自己的边界当成了起始坐标点。这样我们点击的时候就会出现问题;通过这个startPoint和endPoint可以在原来的基础上加上起始值,这样得到的坐标点才是正确的。

        在获得这些信息以后,加上上面Viewserver获得的控件信息,我们就可以创建View对象啦。

 

[java]  view plain copy 在CODE上查看代码片 派生到我的代码片
  1. private ViewNode rootViewNode;  
  2. private IChimpImage iChimpImage;  
  3. private View parent;  
  4. private String window;  
  5. private String activity;  
  6. private List<View> children = new ArrayList<View>();  
  7. private List<ViewNode> canTouchViewNodes = new ArrayList<ViewNode>();  
  8. private ViewNode FromViewNode;  
  9. private Point startPoint = new Point();  
  10. private Point endPoint = new Point();  
  11.   
  12. public View(View view, ViewNode viewNode, IChimpImage iChimpImage) {  
  13.     this.parent = view;  
  14.     this.rootViewNode = viewNode;  
  15.     this.iChimpImage = iChimpImage;  
  16.     if (parent != null) {  
  17.         parent.children.add(this);  
  18.     }  
  19.     if (rootViewNode != null) {  
  20.         getCanTouchWidgets(rootViewNode);  
  21.     }  
  22.   
  23. }  
  24.   
  25. public void getCanTouchWidgets(ViewNode viewNode) {  
  26.     if (viewNode.width * viewNode.height != 0 && viewNode.isClickable == true) {  
  27.         canTouchViewNodes.add(viewNode);  
  28.     }  
  29.     if (viewNode.children.size() != 0) {  
  30.         for (ViewNode sonNode : viewNode.children) {  
  31.             getCanTouchWidgets(sonNode);  
  32.         }  
  33.     }  
  34. }  


        在View类中,我定义了很多属性。

        ViewNode rootViewNode:视图中控件的跟节点。

        IChimpImage iChimpImage: 当前界面的截图,为了以后生成报告的时候用,还可以用图片比对。

        View parent:父视图。

        String window:界面ID。

        String activity:activity名。

        List<View> children:子视图。

        List<ViewNode> canTouchViewNodes:存放可点击的控件。

        ViewNode fromViewNode:该视图是点击父视图的那个按钮出现的,可以绘制轨迹。

        在方法getCanTouchWidgets中递归循环得到可点击的控件,必须是可见且isclickable的属性为true的。

        得到这些以后,我们就可以以控件名为关键字分类处理:

 

[java]  view plain copy 在CODE上查看代码片 派生到我的代码片
  1. public void getAllViewForApp(View view) {  
  2.         // ListView  
  3.         boolean hasListView = false;  
  4.         int currentListContainItem = 0;  
  5.         int itemCountOfList = 0;  
  6.         int startIndexOfList = 0;  
  7.         ViewNode listViewNode = null;  
  8.         View currentView = view;  
  9.         List<ViewNode> clickNodes = currentView.getCanTouchViewNodes();  
  10.         int size = clickNodes.size();  
  11.         for (int i = 0; i < size; i++) {  
  12.             ViewNode clickNode = clickNodes.get(i);  
  13.             String clickNodeName = clickNode.widgetName;  
  14.             // System.out.println("ViewClient ----------" +clickNodeName);  
  15.             int x = clickNode.xPoint + clickNode.width / 2;  
  16.             int y = clickNode.yPoint + clickNode.height / 2;  
  17.             clickNode.hasClick = true;  
  18.             switch (clickNodeName) {  
  19.             case "EditText":  
  20.                 System.out  
  21.                         .println("ViewClient---------------------------------WidgetName:EditText");  
  22.                 break;  
  23.             case "TextView":  
  24.                 System.out  
  25.                         .println("ViewClient---------------------------------WidgetName:TextView");  
  26.                 break;  
  27.             case "Button":  
  28.                 System.out.println("ViewClient---------------------------------WidgetName:Button");  
  29.                 break;  
  30.             case "ListView":  
  31.                 hasListView = true;  
  32.                 listViewNode = clickNode;  
  33.                 List<ViewNode> children = clickNode.children;  
  34.                 currentListContainItem = children.size();  
  35.                 itemCountOfList = clickNode.itemCount;  
  36.                 startIndexOfList = clickNode.firstIndex;  
  37.                 int n = 1;  
  38.                 for (ViewNode item : children) {  
  39.                     // analyze  
  40.                     List<ViewNode> needToDeleteNodesFromItem = new ArrayList<ViewNode>();  
  41.                     for (int j = i + 1; j < size; j++) {  
  42.                         ViewNode viewNode = clickNodes.get(j);  
  43.                         for (; viewNode.parent != null; viewNode = viewNode.parent) {  
  44.                             if (viewNode.parent.equals(item)) {  
  45.                                 System.out  
  46.                                         .println("ViewClient---------------------------------contains other clickable widget");  
  47.                                 needToDeleteNodesFromItem.add(viewNode);  
  48.                             }  
  49.                         }  
  50.                     }  
  51.                     if (needToDeleteNodesFromItem.size() != 0) {  
  52.                         Point touchPoint = toDeleteNodesFromItem(item, needToDeleteNodesFromItem);  
  53.                         x = touchPoint.x;  
  54.                         y = touchPoint.y;  
  55.                     } else {  
  56.                         x = item.xPoint + item.width / 2;  
  57.                         y = item.yPoint + item.height / 2;  
  58.                     }  
  59.                     x = x <= deviceManager.getWidth() ? x : deviceManager.getWidth();  
  60.                     y = y <= deviceManager.getHeight() ? y : deviceManager.getHeight();  
  61.                     deviceManager.touch(x, y);  
  62.                     System.out  
  63.                             .println("ViewClient---------------------------------current Click No:"  
  64.                                     + n + "/" + currentListContainItem);  
  65.                     getActionType(currentView);  
  66.                     n++;  
  67.                 }  
  68.                 System.out.println("ViewClient---------------------------------finish clicked:"  
  69.                         + currentListContainItem + "/" + itemCountOfList);  
  70.                 break;  
  71.             case "CheckBox":  
  72.                 System.out  
  73.                         .println("ViewClient---------------------------------WidgetName:CheckBox");  
  74.                 break;  
  75.             case "Spinner":  
  76.                 System.out.println("ViewClient---------------------------------WidgetName:Spinner");  
  77.                 break;  
  78.             case "Switch":  
  79.                 System.out.println("ViewClient---------------------------------WidgetName:Switch");  
  80.                 if (clickNode.isChecked == true) {  
  81.                     deviceManager.touch(x, y);  
  82.                     deviceManager.touch(x, y);  
  83.                 } else {  
  84.                     deviceManager.touch(x, y);  
  85.                 }  
  86.                 break;  
  87.             case "ImageView":  
  88.                 System.out  
  89.                         .println("ViewClient---------------------------------WidgetName:ImageView");  
  90.                 break;  
  91.             case "LinearLayout":  
  92.                 System.out.println(x + "," + y);  
  93.                 System.out  
  94.                         .println("ViewClient---------------------------------WidgetName:LinearLayout:"  
  95.                                 + clickNode.width + ",:" + clickNode.height);  
  96.                 deviceManager.touch(x, y);  
  97.                 getActionType(currentView);  
  98.                 break;  
  99.             default:  
  100.                 System.out.println("ViewClient---------------------------------error WidgetName:"  
  101.                         + clickNodeName);  
  102.                 break;  
  103.             }  
  104.         }  


        上面的方法中,我只列举了一些常见的控件,其中实现的只有ListView控件;其实这里需要一个算法,可以判断界面的类型,然后得到点击的顺序,但是我做的是最简单的;逻辑也简单,所以已经暂停了(安心做最简单的dump研究啦。)。

        上面的方法中用到了deviceManager.touch和type方法,DeviceManager是我调用MonkeyRunner的类。

 


  第三节 Java调用Monkeyrunner API对按钮进行操作

 

DeviceManager.java:

 

[java]  view plain copy 在CODE上查看代码片 派生到我的代码片
  1. private AdbChimpDevice device;  
  2. private AdbBackend adb;  
  3. private int width;  
  4. private int height;  
  5.   
  6. public DeviceManager(String deviceId) {  
  7.     if (adb == null) {  
  8.         adb = new AdbBackend();  
  9.         device = (AdbChimpDevice) adb.waitForConnection(8000, deviceId);  
  10.         this.width = Integer.parseInt(device.getProperty("display.width"));  
  11.         this.height = Integer.parseInt(device.getProperty("display.height"));  
  12.         System.out.println("DeviceManager------------------------------device width:"  
  13.                 + device.getProperty("display.width"));  
  14.     }  
  15. }  
  16.   
  17. public boolean startActivity(String activity) throws Throwable {  
  18.     boolean flag = false;  
  19.     String action = "android.intent.action.MAIN";  
  20.     Collection<String> categories = new ArrayList<String>();  
  21.     categories.add("android.intent.category.LAUNCHER");  
  22.     device.startActivity(null, action, nullnull, categories, new HashMap<String, Object>(),  
  23.             activity, 0);  
  24.     sleep(3000);  
  25.     flag = true;  
  26.     return flag;  
  27. }  
  28.   
  29. public void touch(int x, int y) {  
  30.     device.touch(x, y, TouchPressType.DOWN_AND_UP);  
  31.     sleep(3000);  
  32. }  
  33.   
  34. public void drag(int startX, int startY, int endX, int endY) {  
  35.     device.drag(startX, startY, endX, endY, 110);  
  36. }  
  37.   
  38. public void press(String keycode) {  
  39.     device.press(keycode, TouchPressType.DOWN_AND_UP);  
  40. }  

 

        这里面简单封装了touch,type,press,drag方法,没做过多的处理,这也是在网上查找了一些前人的教程得到的,其中用到的4个jar包。



      

        之前试过自己本地的jar包,但是可能因为版本不一样,里面有的类缺少,所以如果你的jar不对,可以留邮箱,我传给你。

 

 

  第四节 判断点击后的视图类型

 


        在点击一个控件以后,我们需要判断点击后发生了什么,因为我们要深度遍历一个APP里所有的视图的。

 

[java]  view plain copy 在CODE上查看代码片 派生到我的代码片
  1. public void getActionType(View currentView) {  
  2.         Map<String, String> map = CMDUtils.runCMD(windowMsg);  
  3.         String window = map.get("window");  
  4.         String activity = map.get("activity");  
  5.         // hold on current view  
  6.         if (window.equals(currentView.getWindow())) {  
  7.             System.out.println("ViewClient---------------------------------no action");  
  8.         } else {  
  9.             System.out.println("ViewClient---------------------------------different window");  
  10.             // different window but same activity:dialog  
  11.             if (activity.equals(currentView.getActivity())) {  
  12.                 System.out.println("ViewClient---------------------------------dialog");  
  13.                 deviceManager.press("KEYCODE_BACK");  
  14.             } else { // different activity  
  15.                 boolean goNew = true;  
  16.                 // back to father View  
  17.                 View view = currentView;  
  18.                 for (; view.getParent() != null; view = view.getParent()) {  
  19.                     if (view.getParent().getWindow().equals(window)) {  
  20.                         System.out.println("ViewClient---------------------------------back to father view");  
  21.                         goNew = false;  
  22.                     }  
  23.                 }  
  24.                 // same son view  
  25.                 if (currentView.getChildren().size() != 0) {  
  26.                     List<View> children = currentView.getChildren();  
  27.                     for (View sonView : children) {  
  28.                         if (sonView.getWindow().equals(window)) {  
  29.                             System.out.println("ViewClient---------------------------------this view has showed");  
  30.                             goNew = false;  
  31.                         }  
  32.                     }  
  33.                 }  
  34.                 // new view  
  35.                 if (goNew == true) {  
  36.                     System.out.println("ViewClient---------------------------------this view is new");  
  37.                     deviceManager.press("KEYCODE_BACK");  
  38.                 }  
  39.             }  
  40.         }  
  41.     }  

       

        首先判断View对象里的window属性和当前视图的window是否一样,如果一样,毫无疑问点击无反应,至少没动,点击开关按钮啊,拖拉ListView这些操作。

如果window不同,我们得判断activity是否一样,如果activity一样,说明有弹出框或者对话框。如果activity不一样。我们还要做判断:

        1.是否返回进入到父视图。

        2.是否之前点击出现过。

        3.是否是新视图。

        总之越深入判断越繁琐啊。

        在我写到这些的时候,总之被论证HierarchyViewer不适合做这个工具,我对比了一下总结如下:

 

  总结

 

 


       

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值