起初我真的想过自己单独写一套来着,后来发现时间真的不够,所以有了对scrcpy源码的浅析,服务端我就用scrcpy现有的了,客户端scrcpy采用ffmpeg+sdl2.0进行了跨平台的播放,我准备用Flutter重构客户端部分
Scrcpy与vysor是都是投屏中比较优秀的项目了,非侵入性,不需要设备单独安装软件来配合,能低延迟的控制,较高的fps等等,scrcpy的star更是到了2.6w
它到底是怎么做到执行scrcpy命令,在较短的时间内就立马获取到了安卓设备的屏幕的?并没有向设备申请任何的获取屏幕的权限,并且还能对设备进行较低延迟的控制。 有过使用adb经验的开发者,当PC端调试安卓设备时 我们可以输入
adb shell /system/bin/screencap -p PATH 复制代码
就能直接截取手机屏幕,去掉-p这个开关,更改成>,就可以直接截图并重定向到电脑本地,包括使用screenrecorder命令对手机进行录屏。
在scrcpy的wiki中也其实提到了,原因就是adb shell的权限是非常高的,去设备的/system/app/shell也能看到shell.apk的uid与gid都是shell,这就可以直接拿到屏幕截取的权限 就好像uid与gid都为root的su命令,可以拿到一切权限一样的。
所以在scrcpy启动时,将自身sdk中的一个jar上传到了安卓设备上,这个jar并不是java的.class文件,是class java字节码经过dx工具转换成了dex文件,所以这个jar解压后就有一个dex,这个是安卓上的字节码,可以直接运行的。
adb push $sdk/scrcpy-server.jar /data/local/tmp 复制代码
CLASSPATH=/data/local/tmp/scrcpy-server.jar app_process ./ com.genymobile.scrcpy.Server 1.12.1 0 8000000 0 true - true true 复制代码
这样就会 后面的那堆参数是我从源码抠出来的默认参数,也是单独执行scrcpy会跟的参数,这些参数就会由com.genymobile.scrcpy.Server类的main函数接收到 main函数收到参数遍会开启两个socket等待客户端来连接本设备,一个是视频流的socket,一个是设备控制的socket
由于adb提供了端口转发的功能,能转发设备本地的端口到pc端,pc端就能跟这个转发的端口进行连接并收发数据。 需要android版本大于5.0 转发端口:
adb forward tcp:5005 localabstract:scrcpy #PC上所有5005端口通信数据将被重定向到手机端UNIX类型localabstract上 复制代码
上面部分也是所有这类投屏软件的原理,包括vysor
上面提到一点,scrcpy的jar在设备上就作为一个服务端,会创建一个本地视频流的socket等待客户端连接,如下
public static DesktopConnection open(Device device, boolean tunnelForward) throws IOException { LocalSocket videoSocket; LocalSocket controlSocket; if (tunnelForward) { LocalServerSocket localServerSocket = new LocalServerSocket(SOCKET_NAME); try { System.out.println("Waiting for video socket connection..."); videoSocket = localServerSocket.accept(); System.out.println("video socket is connected."); // send one byte so the client may read() to detect a connection error videoSocket.getOutputStream().write(0); try { System.out.println("Waiting for input socket connection..."); controlSocket = localServerSocket.accept(); System.out.println("input socket is connected."); } catch (IOException | RuntimeException e) { videoSocket.close(); throw e; } } finally { localServerSocket.close(); } } else { videoSocket = connect(SOCKET_NAME); try { controlSocket = connect(SOCKET_NAME); } catch (IOException | RuntimeException e) { videoSocket.close(); throw e; } } DesktopConnection connection = new DesktopConnection(videoSocket, controlSocket); Size videoSize = device.getScreenInfo().getVideoSize(); connection.send(Device.getDeviceName(), videoSize.getWidth(), videoSize.getHeight()); return connection; } 复制代码
加了一点打印信息
等到有客户端连接这个socket后,它就会立即开启新的线程进行屏幕录制,并及时通过这个socket将录屏编码后的数据传到客户端去,再同时等待一个新的socket来连接,这个新的socket则用来接收客户端对设备的控制信息
scrcpy自己定义了一套协议,不过里面的注释实在太少了,这些个函数的调用跳来跳去的,半天都看不到个啥,就像有人也用qt的图形界面重写了scrcpy,也是需要从它的源码下手的 我就直接把我最后从源码中解析出来的分享出来了
由于socket传输的是byte[],
1 byte有8位,无符号0~255,有符号就会少一个1来表示正负号所以范围就是-128~127
1 short = 2 byte = 16位
1 int = 4 byte = 32位
1 long = 8 byte = 64位
代码部分
private ControlMessage parseInjectTouchEvent() { // System.out.println("接收到的remaining长度===>"+buffer.remaining()); if (buffer.remaining() < INJECT_TOUCH_EVENT_PAYLOAD_LENGTH) { return null; } int action = toUnsigned(buffer.get()); long pointerId = buffer.getLong(); Position position = readPosition(buffer); // 16 bits fixed-point int pressureInt = toUnsigned(buffer.getShort()); // convert it to a float between 0 and 1 (0x1p16f is 2^16 as float) float pressure = pressureInt == 0xffff ? 1f : (pressureInt / 0x1p16f); int buttons = buffer.getInt(); return ControlMessage.createInjectTouchEvent(action, pointerId, position, pressure, buttons); } 复制代码
private static Position readPosition(ByteBuffer buffer) { int x = buffer.getInt(); int y = buffer.getInt(); int screenWidth = toUnsigned(buffer.getShort()); int screenHeight = toUnsigned(buffer.getShort()); return new Position(x, y, screenWidth, screenHeight); } 复制代码
ControlMessage是自定义的封装类,buffer即为来自socket的缓存区,是Java nio包下的类,可以看到它有get(),getLong(),getShort(),这些方法,会取buffer中指定的字节数来组成int,long,short这些类型 所以当我们需要对设备发起按压事件时(以1080x2280分辨率为例)
下面是dart的代码,其他语言进行控制也能类似
[ 2, 0, 0,0,0,0,0,0,0,0, x >> 24,x << 8 >> 24,x << 16 >> 24,x << 24 >> 24, y >> 24,y << 8 >> 24,y << 16 >> 24,y << 24 >> 24, 1080 >> 8,1080 << 8 >> 8,2280 >> 8,2280 << 8 >> 8, 0, 0, 0, 0, 0, 0 ] 复制代码
[ 2, 1, 0,0,0,0,0,0,0,0, x >> 24,x << 8 >> 24,x << 16 >> 24,x << 24 >> 24, y >> 24,y << 8 >> 24,y << 16 >> 24,y << 24 >> 24, 1080 >> 8,1080 << 8 >> 8,2280 >> 8,2280 << 8 >> 8, 0, 0, 0, 0, 0, 0 ] 复制代码
[ 2, 2, 0,0,0,0,0,0,0,0, x >> 24,x << 8 >> 24,x << 16 >> 24,x << 24 >> 24, y >> 24,y << 8 >> 24,y << 16 >> 24,y << 24 >> 24, 1080 >> 8,1080 << 8 >> 8,2280 >> 8,2280 << 8 >> 8, 0, 0, 0, 0, 0, 0 ] 复制代码
x,y即为想要点击的屏幕坐标,这次数据被scrcpy服务端接收到会按压屏幕的x,y位置,用了较多的移位运算
如java nio调用getShort来获取屏幕的宽度
int screenWidth = toUnsigned(buffer.getShort()); private static int toUnsigned(short value) { return value & 0xffff; } 复制代码
上面提到了1 short有8位 无符号数最大就是
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 复制代码
上限就是2的16次方-1为:65535,用来传分辨率肯定够了
如果屏幕的宽为1080,大于了1 byte中能存的大小,所以我们只有按字节存进byte[],并发送给socket
那如何将屏幕分辨率的宽按字节传输给socket呢?举个:chestnut:
将1080这个数转为二进制:
1 0 0 0 0 1 1 1 0 0 0 复制代码
我们需要把它存进2个字节,那么我们只需要把它每一个字节(8位字长的值)找出来
我们用0填充进它前面
0 0 0 0 0 1 0 0 0 0 1 1 1 0 0 0 复制代码
那么2个字节分别为
0 0 0 0 0 1 0 0 0 0 1 1 1 0 0 0 复制代码
将这2个字节的值传进socket,就能通过buffer的getShort方法得到1080这个数的值。
第一个字节:将1080往右移动8个字长即可得到
0 0 0 0 0 1 0 0 0 0 1 1 1 0 0 0 >>8 = 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 复制代码
第二个字节:将1080往左移动8个字长(清零前面的部分),再往右移动8个字长
0 0 0 0 0 1 0 0 0 0 1 1 1 0 0 0 <<8 = 0 0 1 1 1 0 0 0 0 0 0 0 0 0 0 0 >>8 = 0 0 0 0 0 0 0 0 0 0 1 1 1 0 0 0 复制代码
第二个字节还可以借助按位与运算
0 0 0 0 0 1 0 0 0 0 1 1 1 0 0 0 & 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 = 0 0 0 0 0 0 0 0 0 0 1 1 1 0 0 0 复制代码
ByteBuffer是抽象类,getShort我没见再哪儿被重写的,不过可以自己实现
short getShort(ByteBuffer buffer){ return buffer.get() <<8 | buffer.get(); } 复制代码
当然ByteBuffer中有我们就直接用就是了,以上部分全是经过验证的,目前已经能在Flutter开发的客户端对局域网内设备进行较低延迟的控制了,希望能给到有同样需求的人帮助,后续会继续出关于投屏的文章。