从WEB 端批量移动设备管理控制工具 STF 的环境搭建和运行文章了解到 STF 这个工具,然后试用了一下。最近在做一个测试工具,发现 Android 原生的截图工具截图非常缓慢,然后想起了 stf 工具中截图非常快,甚至连执行 monkey 的动作都能在 web 端查看,这就很爽了,所以在 github 上提了一个Issue,询问这个是如何实现的,很快得到答复,stf 自己写了一个工具叫 minicap 用来替代原生的 screencap,这个工具是 stf 框架的依赖工具。
minicap 工具是用 NDK 开发的,属于 Android 的底层开发,该工具分为两个部分,一个是动态连接库.so 文件,一个是 minicap 可执行文件。但不是通用的,因为 CPU 架构的不同分为不同的版本文件,STF 提供的 minicap 文件根据 CPU 的 ABI 分为如下 4 种:
.
├── 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
从上面可以看出,minicap 可执行文件分为 4 种,分别针对arm64-v8a
、armeabi-v7a
,x86
,x86_64
架构。而 minicap.so 文件在这个基础上还要分为不同的 sdk 版本。
adb shell getprop ro.product.cpu.abi | tr -d '\r'
58deMacBook-Pro:minicap wuxian$ adb shell getprop ro.product.cpu.abi | tr -d '\r'
armeabi-v7a
adb shell getprop ro.build.version.sdk | tr -d '\r'
58deMacBook-Pro:minicap wuxian$ adb shell getprop ro.build.version.sdk | tr -d '\r'
22
根据上面获取的信息,将适合设备的可执行文件和.so 文件 push 到手机的/data/local/tmp
目录下,如果你不想自己 build 这些文件可以去 STF 框架的源码下找到 vendor/minicap 文件夹下找到这些文件,我上面的 tree 信息就是我在 stf 根目录 vendor/minicap 下打印的,所以我们将这两个文件导入到我手机的/data/local/tmp 目录下:
shell@shamu:/data/local/tmp $ ls -l
-rw-rw-r-- shell shell 1053609 2015-08-07 19:19 1.png
-rwxr-xr-x shell shell 1062992 2015-08-03 12:02 busybox
-rwxr-xr-x shell shell 358336 2015-08-03 12:02 busybox1
drwxrwxrwx shell shell 2015-07-21 15:16 dalvik-cache
-rw-r--r-- shell shell 193 2015-08-13 19:44 krperm.txt
-rwxrwxrwx shell shell 370424 2015-08-07 18:16 minicap
-rw-rw-rw- shell shell 13492 2015-08-07 18:26 minicap.so
-rw------- shell shell 11192 2015-08-06 10:46 ui.xml
-rw------- shell shell 2501 2015-08-07 10:36 uidump.xml
首先我们测试一下我们的 minicap 工具是否可用,命令如下 (其中-P 后面跟的参数为你屏幕的尺寸,你可以修改成你自己设备的尺寸):
adb shell LD_LIBRARY_PATH=/data/local/tmp /data/local/tmp/minicap -P 1440x2560@1440x2560/0 -t
最后输出 OK 就表明 minicap 可用:
58deMacBook-Pro:minicap wuxian$ adb shell LD_LIBRARY_PATH=/data/local/tmp /data/local/tmp/minicap -P 1440x2560@1440x2560/0 -t
PID: 7105
INFO: Using projection 1440x2560@1440x2560/0
INFO: (external/MY_minicap/src/minicap_22.cpp:240) Creating SurfaceComposerClient
INFO: (external/MY_minicap/src/minicap_22.cpp:243) Performing SurfaceComposerClient init check
INFO: (external/MY_minicap/src/minicap_22.cpp:250) Creating virtual display
INFO: (external/MY_minicap/src/minicap_22.cpp:256) Creating buffer queue
INFO: (external/MY_minicap/src/minicap_22.cpp:261) Creating CPU consumer
INFO: (external/MY_minicap/src/minicap_22.cpp:265) Creating frame waiter
INFO: (external/MY_minicap/src/minicap_22.cpp:269) Publishing virtual display
INFO: (jni/minicap/JpgEncoder.cpp:64) Allocating 11061252 bytes for JPG encoder
INFO: (external/MY_minicap/src/minicap_22.cpp:284) Destroying virtual display
OK
然后我们启动 minicap 工具,命令如下 (就比上面的检测工具少了个-t):
adb shell LD_LIBRARY_PATH=/data/local/tmp /data/local/tmp/minicap -P 1440x2560@1440x2560/0
上面其实是启动了一个 socket 服务器,我们需要跟该 socket 服务通信,首先我们要将本地的端口映射到 minicap 工具上,端口自己随意:
adb forward tcp:1717 localabstract:minicap
然后使用命令nc localhost 1717
来与minicap
通信,然后你会发现好多乱码。
上面的乱码我们也看不懂,官方提供了一个 demo 来看效果,在minicap项目下的 example 目录,我们来启动该例子:
58deMacBook-Pro:example wuxian$ PORT=9002 node app.js
Listening on port 9002
然后我们在浏览器下输入localhost:9002
就可以看到如下效果了:
我们在上面的nc localhost 1717
那一步可以看出来,minicap 工具会不断的向命令行下输出乱码信息,但是这些信息是有规则的,只是我们无法实际查看。但是我们做的工具需要用 java 来获得该信息,所以弄懂这些格式是很有必要的,结果分析后得出这些信息分 3 部分
这一部分的信息只在连接后,只发送一次,是一些汇总信息,一般为 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 种方式可以看出来图片组装完成了:
/**
*
*/
package com.wuba.utils.screenshot;
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.SocketAddress;
import java.net.UnknownHostException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;
import java.util.Stack;
import javax.imageio.ImageIO;
import org.apache.log4j.Logger;
import com.android.ddmlib.AdbCommandRejectedException;
import com.android.ddmlib.IDevice;
import com.android.ddmlib.IDevice.DeviceUnixSocketNamespace;
import com.android.ddmlib.TimeoutException;
import com.sun.org.apache.bcel.internal.generic.NEW;
import com.wuba.utils.TimeUtil;
/**
* @date 2015年8月12日 上午11:02:53
*/
public class MiniCapUtil implements ScreenSubject{
private Stack<Byte[]> stack = new Stack<Byte[]>();
private Logger LOG = Logger.getLogger(MiniCapUtil.class);
private Banner banner;
private static final int PORT = 1717;
private Socket socket;
private int readBannerBytes = 0;
private int bannerLength = 2;
private int readFrameBytes = 0;
private int frameBodyLength = 0;
private byte[] frameBody = new byte[0];
private IDevice device;
private int total;
private boolean debug = false;
private List<AndroidScreenObserver> observers = new ArrayList<AndroidScreenObserver>();
private byte[] finalBytes = null;
private BufferedImage bufferedImage;
public MiniCapUtil(IDevice device) {
this.device = device;
init();
}
private void init() {
banner = new Banner();
try {
this.device.createForward(PORT, "minicap",
DeviceUnixSocketNamespace.ABSTRACT);
} catch (TimeoutException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (AdbCommandRejectedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
public void takeBufferedImageByMinicap() {
InputStream stream = null;
DataInputStream input = null;
try {
socket = new Socket("localhost", PORT);
while (true) {
stream = socket.getInputStream();
input = new DataInputStream(stream);
byte[] buffer;
int len = 0;
while (len == 0) {
len = input.available();
}
buffer = new byte[len];
input.read(buffer);
LOG.info("length=" + buffer.length);
if (debug) {
continue;
}
byte[] currentBuffer = subByteArray(buffer, 0, buffer.length);
for (int cursor = 0; cursor < len;) {
int byte10 = buffer[cursor] & 0xff;
if (readBannerBytes < bannerLength) {
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.info(banner.toString());
}
} else if (readFrameBytes < 4) {
// 第二次的缓冲区中前4位数字和为frame的缓冲区大小
frameBodyLength += (byte10 << (readFrameBytes * 8)) >>> 0;
cursor += 1;
readFrameBytes += 1;
total = frameBodyLength;
} else {
LOG.info("图片大小 : " + total);
// LOG.info("frame body部分");
// LOG.info(String.format("设想图片的大小 : %d", total));
if (len - cursor >= frameBodyLength) {
byte[] subByte = subByteArray(currentBuffer,
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;
}
LOG.info(String.format("实际图片的大小 : %d",
frameBody.length));
if (finalBytes == null) {
finalBytes = subByteArray(frameBody, 0,
frameBody.length);
new Thread(new Runnable() {
@Override
public void run() {
// TODO Auto-generated method stub
try {
createImageFromByte();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}).start();
}
cursor += frameBodyLength;
frameBodyLength = 0;
readFrameBytes = 0;
frameBody = new byte[0];
} else {
// LOG.debug(String.format("body(len=%d)", len
// - cursor));
byte[] subByte = subByteArray(currentBuffer,
cursor, len);
frameBody = byteMerger(frameBody, subByte);
frameBodyLength -= (len - cursor);
readFrameBytes += (len - cursor);
cursor = len;
}
}
}
}
} catch (UnknownHostException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
finally {
if (socket != null && socket.isConnected()) {
try {
socket.close();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
if (stream != null) {
try {
stream.close();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
}
private synchronized void createImageFromByte() throws IOException {
if (finalBytes.length == 0) {
LOG.info("frameBody大小为0");
}
InputStream in = new ByteArrayInputStream(finalBytes);
BufferedImage bufferedImage = ImageIO.read(in);
notifyObservers(bufferedImage);
// String filePath = String.format("0.jpg");
// LOG.info(filePath);
// ImageIO.write(bufferedImage, "jpg", new File(filePath));
finalBytes = null;
}
// 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;
}
private String bytesToHexString(byte[] src) {
StringBuilder stringBuilder = new StringBuilder();
if (src == null || src.length <= 0) {
return null;
}
for (int i = 0; i < src.length; i++) {
int v = src[i] & 0xFF;
String hv = Integer.toHexString(v);
if (hv.length() < 2) {
stringBuilder.append(0);
}
stringBuilder.append(hv + " ");
}
return stringBuilder.toString();
}
/*
* (non-Javadoc)
*
* @see
* com.wuba.utils.screenshot.AndroidScreenSubject#registerObserver(com.wuba
* .utils.screenshot.AndroidScreenObserver)
*/
@Override
public void registerObserver(AndroidScreenObserver o) {
// TODO Auto-generated method stub
observers.add(o);
}
/*
* (non-Javadoc)
*
* @see
* com.wuba.utils.screenshot.AndroidScreenSubject#removeObserver(com.wuba
* .utils.screenshot.AndroidScreenObserver)
*/
@Override
public void removeObserver(AndroidScreenObserver o) {
// TODO Auto-generated method stub
int index = observers.indexOf(o);
if (index != -1) {
observers.remove(o);
}
}
/*
* (non-Javadoc)
*
* @see com.wuba.utils.screenshot.AndroidScreenSubject#notifyObservers()
*/
@Override
public void notifyObservers(BufferedImage image) {
// TODO Auto-generated method stub
for (AndroidScreenObserver observer : observers) {
observer.frameImageChange(image);
}
}
}
1.在实际过程由于 minicap 发送信息的速度很快,如果不及时处理,会造成某一次获取的数据是将 minicap 多次发送的数据一起处理了,这就会造成错误。所以上面的代码是将生成 BufferImage 的操作放到了线程中,但是最好是将获取 socket 数据部分和解析数据部分独立开来,获取 socket 数据将获取到的数据立即放到队列中,然后立马得到下一次数据的获取,数据解析部分在独立线程中来获取队列中的信息来解析。这样就能避免上面提到的问题。
2.目前不支持下面三款机器和模拟器
3.我们实测的速度 (针对 N6) 原生为 5 秒左右,minicap 在 1 秒内。
用 java 写了一个小 demo,有兴趣可以下载体验下
minicap_java