• Web Test Mobile / openSTF / minicap


    s

    python+minicap的使用

    https://www.cnblogs.com/qiangayz/p/9580389.html

    minicap介绍

    https://www.cnblogs.com/xiand/p/6724399.html

    minicap是开源项目STF(Smartphone Test Farm)中的一个工具,负责屏幕显示。

    stf自己写了一个工具叫minicap用来替代原生的screencap,这个工具是stf框架的依赖工具之一,最近手头上的项目刚好由于帧率卡顿需要优化,刚好来在testerhome社区看到对STF的介绍,WEB 端批量移动设备管理控制工具 STF 的环境搭建和运行

    minicap工具是用NDK开发的,属于Android的底层开发,该工具分为两个部分,一个是动态连接库.so文件,一个是minicap可执行文件。但不是通用的,因为CPU架构的不同分为不同的版本文件,STF提供的minicap文件根据CPU 的ABI分为如下4种:

    arm64-v8aarmeabi-v7a,x86,x86_64 架构。而minicap.so文件在这个基础上还要分为不同的sdk版本。这些都可以从Github地址:链接地址下载而来

    结构树目录.
    ├── bin
    │   ├── arm64-v8a
    │   │   ├── minicap
    │   │   └── minicap-nopie
    │   ├── armeabi-v7a
    │   │   ├── minicap
    │   │   └── minicap-nopie
    │   ├── x86
    │   │   ├── minicap
    │   │   └── minicap-nopie
    │   └── x86_64
    │       ├── minicap
    │       └── minicap-nopie
    └── shared
        ├── android-10
        │   └── armeabi-v7a
        │       └── minicap.so
        ├── android-14
        │   ├── armeabi-v7a
        │   │   └── minicap.so
        │   └── x86
        │       └── minicap.so
        ├── android-15
        │   ├── armeabi-v7a
        │   │   └── minicap.so
        │   └── x86
        │       └── minicap.so
        ├── android-16
        │   ├── armeabi-v7a
        │   │   └── minicap.so
        │   └── x86
        │       └── minicap.so
        ├── android-17
        │   ├── armeabi-v7a
        │   │   └── minicap.so
        │   └── x86
        │       └── minicap.so
        ├── android-18
        │   ├── armeabi-v7a
        │   │   └── minicap.so
        │   └── x86
        │       └── minicap.so
        ├── android-19
        │   ├── armeabi-v7a
        │   │   └── minicap.so
        │   └── x86
        │       └── minicap.so
        ├── android-21
        │   ├── arm64-v8a
        │   │   └── minicap.so
        │   ├── armeabi-v7a
        │   │   └── minicap.so
        │   ├── x86
        │   │   └── minicap.so
        │   └── x86_64
        │       └── minicap.so
        ├── android-22
        │   ├── arm64-v8a
        │   │   └── minicap.so
        │   ├── armeabi-v7a
        │   │   └── minicap.so
        │   ├── x86
        │   │   └── minicap.so
        │   └── x86_64
        │       └── minicap.so
        ├── android-9
        │   └── armeabi-v7a
        │       └── minicap.so
        └── android-M
            ├── arm64-v8a
            │   └── minicap.so
            ├── armeabi-v7a
            │   └── minicap.so
            ├── x86
            │   └── minicap.so
            └── x86_64
                └── minicap.so

    准备对应文件

    a、查看CPU架构(adb shell getprop ro.product.cpu.abi)及查看android版本level(adb shell getprop ro.build.version.sdk)

    b、根据上面获取的信息,将适合设备的可执行文件和.so文件push到手机的/data/local/tmp目录下,或者在STF框架的源码下找到vendor/minicap文件夹下

    c、adb shell进入到目录下chmod 777 minicap

    d、测试一下minicap是否可用:(-P后面跟的参数为你屏幕的尺寸)

       adb shell LD_LIBRARY_PATH=/data/local/tmp /data/local/tmp/minicap -P 1080x1920@1080x1920/0 -t

    安装运行环境

    a、安装nodejs:

      查看版本号:node -v

    b、安装运行依赖 ws和express包 

      npm install ws –g

      npm install express -g

    启动手机端服务

     就是启动了一个socket服务器

     adb shell LD_LIBRARY_PATH=/data/local/tmp /data/local/tmp/minicap -P 1080x1920@1080x1920/0

    本地端口转发

    a、跟上面的socket服务通信,首先我们要将本地的端口映射到minicap工具上,端口随意:

      adb forward tcp:1717 localabstract:minicap

     b、输入 node app.js 回车启动服务:

      控制台显示  Listening on port 9002 表示启动成功

    c、浏览器打开本地 localhost:9002  链接地址,查看

    获取信息

    然后使用命令nc localhost 1717来与minicap通信,然后你会发现好多乱码。官方提供了一个demo来看效果,在minicap项目下的example目录

    但是这些信息是有规则的,只是我们无法实际查看。但是我们做的工具需要用java来获得该信息,所以弄懂这些格式是很有必要的,结果分析后得出这些信息分3部分

    Banner模块(第一部分)

    这一部分的信息只在连接后,只发送一次,是一些汇总信息,一般为24个16进制字符,每一个字符都表示不同的信息:

    位置信息
    0 版本
    1 该Banner信息的长度,方便循环使用
    2,3,4,5 相加得到进程id号
    6,7,8,9 累加得到设备真实宽度
    10,11,12,13 累加得到设备真实高度
    14,15,16,17 累加得到设备的虚拟宽度
    18,19,20,21 累加得到设备的虚拟高度
    22 设备的方向
    23 设备信息获取策略

    携带图片大小信息和图片二进制信息模块(第二部分)

    得到上面的Banner部分处理完成后,以后不会再发送Banner信息,后续只会发送图片相关的信息。那么接下来就接受图片信息了,第一个过来的图片信息的前4个字符不是图片的二进制信息,而是携带着图片大小的信息,我们需要累加得到图片大小。这一部分的信息除去前四个字符,其他信息也是图片的实际二进制信息,比如我们接受到的信息长度为n,那么4~(n-4)部分是图片的信息,需要保存下来。

    只携带图片二进制信息模块(第三部分)

    每一个变化的界面都会有上面的[携带图片大小信息和图片二进制信息模块],当得到大小后,或许发送过来的数据都是要组装成图片的二进制信息,知道当前屏幕的数据发送完成。 
    有2种方式可以看出来图片组装完成了:

    • 又遇到第二部分
    • 设定大小的数据已经装满了

    java的实现:

    import java.awt.image.BufferedImage;
    import java.io.ByteArrayInputStream;
    import java.io.DataInputStream;
    import java.io.File;
    import java.io.IOException;
    import java.io.InputStream;
    import java.net.Socket;
    import java.net.UnknownHostException;
    import java.util.ArrayList;
    import java.util.List;
    import java.util.Queue;
    import java.util.Stack;
    import java.util.concurrent.ConcurrentLinkedQueue;
    
    import javax.imageio.ImageIO;
    
    import org.apache.log4j.Logger;
    
    import com.android.ddmlib.AdbCommandRejectedException;
    import com.android.ddmlib.CollectingOutputReceiver;
    import com.android.ddmlib.IDevice;
    import com.android.ddmlib.IDevice.DeviceUnixSocketNamespace;
    import com.android.ddmlib.ShellCommandUnresponsiveException;
    import com.android.ddmlib.SyncException;
    import com.android.ddmlib.TimeoutException;
    import com.wuba.utils.DirStructureUtil;
    import com.wuba.utils.TimeUtil;
    
    /**
     * @date 2015年8月12日 上午11:02:53
     */
    public class MiniCapUtil {
        private Logger LOG = Logger.getLogger(MiniCapUtil.class);
        // CPU架构的种类
        public static final String ABIS_ARM64_V8A = "arm64-v8a";
        public static final String ABIS_ARMEABI_V7A = "armeabi-v7a";
        public static final String ABIS_X86 = "x86";
        public static final String ABIS_X86_64 = "x86_64";
    
        private Queue<byte[]> dataQueue = new ConcurrentLinkedQueue<byte[]>();
    
        private Banner banner = new Banner();
        private static final int PORT = 1717;
        private IDevice device;
        private String REMOTE_PATH = "/data/local/tmp";
        private String ABI_COMMAND = "ro.product.cpu.abi";
        private String SDK_COMMAND = "ro.build.version.sdk";
        private String MINICAP_BIN = "minicap";
        private String MINICAP_SO = "minicap.so";
        private String MINICAP_CHMOD_COMMAND = "chmod 777 %s/%s";
        private String MINICAP_WM_SIZE_COMMAND = "wm size";
        private String MINICAP_START_COMMAND = "LD_LIBRARY_PATH=/data/local/tmp /data/local/tmp/minicap -P %s@%s/0";
        private boolean isRunning = false;
    
        public MiniCapUtil(IDevice device) {
            this.device = device;
            init();
        }
    
        /**
         * 将minicap的二进制和.so文件push到/data/local/tmp文件夹下,启动minicap服务
         */
        private void init() {
    
            String abi = device.getProperty(ABI_COMMAND);
            String sdk = device.getProperty(SDK_COMMAND);
            File minicapBinFile = new File(DirStructureUtil.getMinicapBin(), abi
                    + File.separator + MINICAP_BIN);
            File minicapSoFile = new File(DirStructureUtil.getMinicapSo(),
                    "android-" + sdk + File.separator + abi + File.separator
                            + MINICAP_SO);
            try {
                // 将minicap的可执行文件和.so文件一起push到设备中
                device.pushFile(minicapBinFile.getAbsolutePath(), REMOTE_PATH
                        + File.separator + MINICAP_BIN);
                device.pushFile(minicapSoFile.getAbsolutePath(), REMOTE_PATH
                        + File.separator + MINICAP_SO);
                executeShellCommand(String.format(MINICAP_CHMOD_COMMAND,
                        REMOTE_PATH, MINICAP_BIN));
                // 端口转发
                device.createForward(PORT, "minicap",
                        DeviceUnixSocketNamespace.ABSTRACT);
                // 获取设备屏幕的尺寸
                String output = executeShellCommand(MINICAP_WM_SIZE_COMMAND);
                String size = output.split(":")[1].trim();
                final String startCommand = String.format(MINICAP_START_COMMAND,
                        size, size);
                // 启动minicap服务
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        LOG.info("minicap服务器启动");
                        executeShellCommand(startCommand);
                    }
                }).start();
    
            } catch (SyncException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            } catch (IOException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            } catch (AdbCommandRejectedException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            } catch (TimeoutException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
        }
    
        private String executeShellCommand(String command) {
            CollectingOutputReceiver output = new CollectingOutputReceiver();
            try {
                device.executeShellCommand(command, output, 0);
            } catch (TimeoutException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            } catch (AdbCommandRejectedException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            } catch (ShellCommandUnresponsiveException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            } catch (IOException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
            return output.getOutput();
        }
    
        public void startScreenListener() {
            isRunning = true;
            new Thread(new ImageConverter()).start();
            new Thread(new ImageBinaryFrameCollector()).start();
        }
    
        public void stopScreenListener() {
            isRunning = false;
        }
    
        private synchronized void createImageFromByte(byte[] binaryData) {
            InputStream in = new ByteArrayInputStream(binaryData);
            try {
                BufferedImage bufferedImage = ImageIO.read(in);
                ImageIO.write(bufferedImage, "jpg", new File("screen.jpg"));
            } catch (IOException e) {
                e.printStackTrace();
            }
    
        }
    
        // java合并两个byte数组
        private static byte[] byteMerger(byte[] byte_1, byte[] byte_2) {
            byte[] byte_3 = new byte[byte_1.length + byte_2.length];
            System.arraycopy(byte_1, 0, byte_3, 0, byte_1.length);
            System.arraycopy(byte_2, 0, byte_3, byte_1.length, byte_2.length);
            return byte_3;
        }
    
        private static byte[] subByteArray(byte[] byte1, int start, int end) {
            byte[] byte2 = new byte[end - start];
            System.arraycopy(byte1, start, byte2, 0, end - start);
            return byte2;
        }
    
        class ImageBinaryFrameCollector implements Runnable {
            private Socket socket;
    
            @Override
            public void run() {
                LOG.debug("图片二进制数据收集器已经开启");
                // TODO Auto-generated method stub
                InputStream stream = null;
                DataInputStream input = null;
                try {
    
                    socket = new Socket("localhost", PORT);
                    stream = socket.getInputStream();
                    input = new DataInputStream(stream);
                    while (isRunning) {
                        byte[] buffer;
                        int len = 0;
                        while (len == 0) {
                            len = input.available();
                        }
                        buffer = new byte[len];
                        input.read(buffer);
                        dataQueue.add(buffer);
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                } finally {
                    if (socket != null && socket.isConnected()) {
                        try {
                            socket.close();
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                    if (stream != null) {
                        try {
                            stream.close();
                        } catch (IOException e) {
                            // TODO Auto-generated catch block
                            e.printStackTrace();
                        }
                    }
                }
    
                LOG.debug("图片二进制数据收集器已关闭");
            }
        }
    
        class ImageConverter implements Runnable {
            private int readBannerBytes = 0;
            private int bannerLength = 2;
            private int readFrameBytes = 0;
            private int frameBodyLength = 0;
            private byte[] frameBody = new byte[0];
    
            @Override
            public void run() {
                LOG.debug("图片生成器已经开启");
                long start = System.currentTimeMillis();
                while (isRunning) {
                    byte[] binaryData = dataQueue.poll();
                    if (binaryData == null)
                        continue;
                    int len = binaryData.length;
                    for (int cursor = 0; cursor < len;) {
                        int byte10 = binaryData[cursor] & 0xff;
                        if (readBannerBytes < bannerLength) {
                            cursor = parserBanner(cursor, byte10);
                        } else if (readFrameBytes < 4) {
                            // 第二次的缓冲区中前4位数字和为frame的缓冲区大小
                            frameBodyLength += (byte10 << (readFrameBytes * 8)) >>> 0;
                            cursor += 1;
                            readFrameBytes += 1;
                        } else {
                            if (len - cursor >= frameBodyLength) {
                                byte[] subByte = subByteArray(binaryData, cursor,
                                        cursor + frameBodyLength);
                                frameBody = byteMerger(frameBody, subByte);
                                if ((frameBody[0] != -1) || frameBody[1] != -40) {
                                    LOG.error(String
                                            .format("Frame body does not start with JPG header"));
                                    return;
                                }
                                byte[] finalBytes = subByteArray(frameBody, 0,
                                        frameBody.length);
                                // 转化成bufferImage
                                createImageFromByte(finalBytes);
                                long current = System.currentTimeMillis();
                                LOG.info("图片已生成,耗时: "
                                        + TimeUtil.formatElapsedTime(current
                                                - start));
                                start = current;
                                cursor += frameBodyLength;
                                frameBodyLength = 0;
                                readFrameBytes = 0;
                                frameBody = new byte[0];
                            } else {
                                byte[] subByte = subByteArray(binaryData, cursor,
                                        len);
                                frameBody = byteMerger(frameBody, subByte);
                                frameBodyLength -= (len - cursor);
                                readFrameBytes += (len - cursor);
                                cursor = len;
                            }
                        }
                    }
                }
                LOG.debug("图片生成器已关闭");
            }
    
            private int parserBanner(int cursor, int byte10) {
                switch (readBannerBytes) {
                case 0:
                    // version
                    banner.setVersion(byte10);
                    break;
                case 1:
                    // length
                    bannerLength = byte10;
                    banner.setLength(byte10);
                    break;
                case 2:
                case 3:
                case 4:
                case 5:
                    // pid
                    int pid = banner.getPid();
                    pid += (byte10 << ((readBannerBytes - 2) * 8)) >>> 0;
                    banner.setPid(pid);
                    break;
                case 6:
                case 7:
                case 8:
                case 9:
                    // real width
                    int realWidth = banner.getReadWidth();
                    realWidth += (byte10 << ((readBannerBytes - 6) * 8)) >>> 0;
                    banner.setReadWidth(realWidth);
                    break;
                case 10:
                case 11:
                case 12:
                case 13:
                    // real height
                    int realHeight = banner.getReadHeight();
                    realHeight += (byte10 << ((readBannerBytes - 10) * 8)) >>> 0;
                    banner.setReadHeight(realHeight);
                    break;
                case 14:
                case 15:
                case 16:
                case 17:
                    // virtual width
                    int virtualWidth = banner.getVirtualWidth();
                    virtualWidth += (byte10 << ((readBannerBytes - 14) * 8)) >>> 0;
                    banner.setVirtualWidth(virtualWidth);
    
                    break;
                case 18:
                case 19:
                case 20:
                case 21:
                    // virtual height
                    int virtualHeight = banner.getVirtualHeight();
                    virtualHeight += (byte10 << ((readBannerBytes - 18) * 8)) >>> 0;
                    banner.setVirtualHeight(virtualHeight);
                    break;
                case 22:
                    // orientation
                    banner.setOrientation(byte10 * 90);
                    break;
                case 23:
                    // quirks
                    banner.setQuirks(byte10);
                    break;
                }
    
                cursor += 1;
                readBannerBytes += 1;
    
                if (readBannerBytes == bannerLength) {
                    LOG.debug(banner.toString());
                }
                return cursor;
            }
        }
    }

    总结

    1.在实际过程由于minicap发送信息的速度很快,如果不及时处理,会造成某一次获取的数据是将minicap多次发送的数据一起处理了,这就会造成错误。所以上面的代码是将生成BufferImage的操作放到了线程中,但是最好是将获取socket数据部分和解析数据部分独立开来,获取socket数据将获取到的数据立即放到队列中,然后立马得到下一次数据的获取,数据解析部分在独立线程中来获取队列中的信息来解析。这样就能避免上面提到的问题。 
    2.目前不支持下面三款机器和模拟器

    • Xiaomi “HM NOTE 1W” (Redmi Note 1W), 
    • Huawei “G750-U10” (Honor 3X) 
    • Lenovo “B6000-F” (Yoga Tablet 8).

    3.我们实测的速度(针对N6)原生为5秒左右,minicap在1秒内。

    python+minicap的使用

    https://www.cnblogs.com/qiangayz/p/9580389.html

    说起Minicap,不得不提到STF,STF (Smartphone Test Farm) 是一个开源的web架构应用,用户可通过浏览器远程操作Android设备、调试Android应用、在设备上进行测试,实现真正意义云端使用、调试、测试、管理真机器。STF出现以后,国内几个大互联网公司也纷纷跟进效仿,出现了类似的真机调试、管理平台,较为知名的有腾讯Wetest、阿里MQC、百度MTCTestIn等。可见远程真机调试在移动研发领域的作用还是受到了比较高的重视,也能为公司以及用户带来比较直接的收益。下面是STF官方的介绍动画。

     
    STF

    minicap简介

    minicap属于STF框架的一个工具,由STF团队自身开发,属于较为核心的一部分,minicap运行于android设备端,负责设备屏幕视频的实时采集并通过socket接口发送,github下载地址:https://github.com/openstf/minicapmicicap。minicap采集屏幕的原理很简单:通过ndk的截屏接口不停的截屏并通过socket接口实时发送,这样客户端便可以得到一序列的图片流,图片流合成后就成为视频;

    构建minicap

    micicap由Android ndk开发,包含一个可执行的二进制文件以及一个so文件,运行minicap前,需要通过adb命令将设备对应CPU架构以及设备对应SDK版本的minicap文件拷贝到设备后,再执行。由于github上并没有上传编译完成后的产物,因此我们需要自行编译。

    编译依赖环境:

    1)、NDK;

    2)、make;

    3)、git;

    环境依赖较为简单,如果没有NDK以及make环境的,可自行百度安装;

    构建过程:

    1)、通过git下载minicamp源码:

    git clone https://github.com/openstf/minicap.git

    2)、micicap项目还依赖于libjpeg-turbo,首先我们需要在minicap引入libjpeg-turbo项目源码:

    git submodule init

    git submodule update

     
     

    3)、执行ndk-build,构建完成后,minicap编译后的产物将会在libs目录下找到;

    ndk-build

     
     

    运行minicap

    1)、获取设备CPU支持的ABI,minicap针对4种不同的ABI构建了不同的so文件和可执行文件,分别是:x86_64/x86/arm64-v8a/armeabi-v7a;

    ABI=$(adb shell getprop ro.product.cpu.abi|tr -d' ')

    2)、拷贝对应ABI版本的文件到设备,这里使用的是adb push;

    adb push libs/$ABI/minicap /data/local/tmp/

    3)、获取设备对应的SDK版本;

    SDK=$(adb shell getprop ro.build.version.sdk|tr -d' ')

    4)、只有可执行文件是不够的,我们还需要拷贝对应sdk版本的共享库到设备;

    adb push jni/minicap-shared/aosp/libs/android-$SDK/$ABI/minicap.so /data/local/tmp/

    5)、每次启动minicap,我们都需要设置LD_LIBRARY_PATH,不然会提示找不到公共库,-P后面的参数为:{RealWidth}x{RealHeight}@{VirtualWidth}x{VirtualHeight}/{Orientation},可以指定采集的实际大小、虚拟大小以及屏幕方向,实际大小一般设置成设备物理分辨率大小,虚拟大小是通过socket接口发送的大小,屏幕实际窗口大小我们可以通过adb命令获取;

    adb shell dumpsys window | grep -Eo 'init=d+xd+' | head -1 | cut -d= -f 2

    6)、启动minicap,下面我们假设获取到的实际屏幕大小是1080x1920,需要发送的虚拟窗口大小是540x960,采集的屏幕方向是纵向;

    adb shell LD_LIBRARY_PATH=/data/local/tmp /data/local/tmp/minicap -P 1080x1920@1080x1920/0

    7)、端口转发,通过adb forward命令,可以把minicap端口映射到我们PC指定的端口,localabstract:minicap是UNIX域名的SOCKET名称,把minicap的socket端口映射到PC的1313端口,这样我们就可以在PC通过连接1313端口获取到设备的实时视频流;

    adb forward tcp:1313 localabstract:minicap

    minicap协议解析

    minicap启动并用adb forward命令映射端口后,我们就可以通过socket与minicap建立连接。

    1)、Global header

    minicap协议是一种简单的二进制流推送流协议,一旦与minicap建立连接,minicap首先会推送长度为24字节的global header,global header只会推送一次,后续推送的数据不会再包括global header,而是不断的推送实时图片流数据,直到客户端关闭socket连接。

     
    Global header binary format

    Global header说包含了基本的一些信息,如minicap的版本信息、头长度、实际大小以及虚拟大小、设备方向等,这些信息我们可以保存起来,方便后面使用,这里我使用python解析了Global header,代码参考如下:

     
     

    2)、Frame binary format

    接下来,minicap会不断的推送一帧一帧的图片流,每一帧都包含两部分信息:0-3字节,表示这一帧图片的长度n,由4个字节的32位整型小端格式存储;4-(n+4)字节,是具体的图片数据,由JPG格式存储,这部分才是我们想要的最关键数据;

     
    Frame binary format
     
     

    至此,我们完成了minicap协议的解析,并获取到了minicap推送过来的每一帧图片。需要注意的是,由于minicap是实时推送流,因此流的数据可能会比较大,客户端获取的buffer需要尽可能的大,不然我们在渲染每一帧的时候,可能会出现卡顿的现象,具体多大合适,我们可以稍微推算一下,一张由minicap推送过来的1080x1920大小的png图片,大概是100-200KB,minicap宣称帧率可以达到20 FPS左右,因此我们的buffer可以设置为:200KB * 20 = 4096000字节,每隔一秒recv()一次;

    PyQt实时渲染

    获取到图片流数据后,我们可以使用PyQt中的paintEvent进行渲染,下面的refreshFrame()方法,关联了获取图片线程中的一个信号槽,一旦获取图片线程从minicap解析到一帧的图片,便会通知refreshFrame()中的self.update()方法,self.update()方法则会调用paintEvent进行界面的刷新:

     
     
     本文转载地址自:https://www.jianshu.com/p/777af34b4f21
    参考博客 https://www.cnblogs.com/xiand/p/6724399.html

    end

  • 相关阅读:
    爱链笔记-openid获取
    爱链笔记-后端设置环境变量
    爱链笔记-以太坊连接
    爱链笔记-linux操作
    爱链笔记-git
    爱链笔记-后台文件上传
    Dynamics CRM 安全模型的性能问题
    Dynamics 365 登录后网页显示空白
    记D365开发的最佳实践
    Dynamics 365 incident原生实体特点
  • 原文地址:https://www.cnblogs.com/lindows/p/13806938.html
Copyright © 2020-2023  润新知