stf
minicap
从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种方式可以看出来图片组装完成了:
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.目前不支持下面三款机器和模拟器
3.我们实测的速度(针对N6)原生为5秒左右,minicap在1秒内。