zoukankan      html  css  js  c++  java
  • android自动化测试中hierarchyviewer和uiautomatorviewer获取控件信息的方式比对(2)

    原文地址http://blog.csdn.net/itfootball/article/details/21788573

     在上一篇我简单的了解了一下hierarchyviewer和uiautomatorviewer,如需访问,点击以下链接:

           android自动化测试中hierarchyviewer和uiautomatorviewer获取控件信息的方式比对(1)

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

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

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

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

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

      第一节 Runtime执行CMD命令

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

    [java] view plain copy
     
    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
     
    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
     
    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 + " ");  
    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
     
    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
     
    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.0) 480.0 x 854.0  
    10.   
    11.     mShownFrame=[0.0,0.0][480.0,854.0]  

          

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

     

    [java] view plain copy
     
    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
     
    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
     
    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
     
    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
     
    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 "  
    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, null, null, 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, 1, 10);  
    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
     
    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不适合做这个工具,我对比了一下总结如下:

      总结

  • 相关阅读:
    09安装运行redis-trib.rb所需的环境
    08Redis入门指南笔记(集群)
    win10 64位安装memcache扩展和开启redis扩展
    ubuntu 安装 lamp 和配置虚拟机
    Ubuntu 安装phpmyadmin 和配置
    ubuntu的 mysql 存储目录迁移
    ubuntu16.04安装php5
    PHP文件操作功能函数大全
    PHP 通用检测函数集
    PHP文件操作的经典案例
  • 原文地址:https://www.cnblogs.com/111testing/p/7841998.html
Copyright © 2011-2022 走看看