【投屏】Scrcpy源码分析四(最终章 - Server篇)
Scrcpy源码分析系列
【投屏】Scrcpy源码分析一(编译篇)
【投屏】Scrcpy源码分析二(Client篇-连接阶段)
【投屏】Scrcpy源码分析三(Client篇-投屏阶段)
【投屏】Scrcpy源码分析四(最终章 - Server篇)
在前两篇我们探究了Scrcpy Client的连接和投屏逻辑,本篇我们就要继续探究Server端的逻辑了。
1. 入口函数
我们先来回忆下,还记得Server端是怎么运行起来的么?
答:由Client端执行
adb push
把Server程序上传到设备侧,然后执行app_process
将Server端程序运行起来的。完整的命令是adb -s serial shell CLASSPATH=/data/local/tmp/scrcpy-server.jar app_process / com.genymobile.scrcpy.Server 1.25 [PARAMS]
。
app_process
的好处一个是方便我们在安卓侧运行一个纯java程序(是dalvik的字节码,不是jvm字节码),二个是提权,使程序拥有root权限或者shell同等权限。
因为Client指定的类是com.genymobile.scrcpy.Server
,所以Server的入口方法就是Server.java
类的main()
方法,其关键代码是:
// Server.javapublic static void main(String... args) {// 解析参数Options options = createOptions(args);// scrcpy方法scrcpy(options);}private static void scrcpy(Options options) {// 调用DesktopConnection的open函数DesktopConnection connection = DesktopConnection.open(tunnelForward, control, sendDummyByte);// 控制逻辑Controller controller = new Controller(device, connection,);startController(controller);// 投屏逻辑ScreenEncoder screenEncoder = new ScreenEncoder();screenEncoder.streamScreen(device, connection.getVideoFd());}
我们看到,入口函数里主要的逻辑有:
-
createOptions
- 解析参数。 -
DesktopConnection.open
- 连接PC端(第二篇有提到,所以业务上安卓设备是Server,PC是Client,但网络层面安卓设备的Client, PC是Server):// DeskopConnection.javaprivate static final String SOCKET_NAME = "scrcpy";public static DesktopConnection open(boolean tunnelForward, boolean control, boolean sendDummyByte) {videoSocket = connect(SOCKET_NAME);controlSocket = connect(SOCKET_NAME);return new DesktopConnection(videoSocket, controlSocket);}private static LocalSocket connect(String abstractName) {LocalSocket localSocket = new LocalSocket();localSocket.connect(new LocalSocketAddress(abstractName));return localSocket;}
因为PC端通过adb用
localabstract:scrcpy
开启了端口映射,所以这里通过LocalServerSocket
或LocalSocket
指定Unix Socket Name就可以连接上PC了,这里的Unix Socket Name是"scrcpy",必须和adb指定的保持一致。配合PC侧的逻辑,这里需要连接两次,可以得到videoSocket和controlSocket,同时因为这两个是基于Unix Domain Socket的LocalSocket,所以可以直接拿到其对应的文件描述符FileDescription
,后续可以直接通过读写文件描述符进行网络数据传输。对这部分不了解的同学可以回顾下第二篇文章Client端这部分的逻辑描述。 -
startController
- 事件控制相关逻辑,基于controlSocket。 -
streamScreen
- 投屏相关逻辑,基于videoSocket。
看到这里,我们应该知道了,在Sever程序起来后就会去连接PC端,拿到两个Socket。
下面我们继续看下投屏和控制逻辑。
2. 投屏逻辑
投屏逻辑的入口是streamScreen
方法:
// ScreenEncoder.javapublic void streamScreen(Device device, FileDescriptor fd) {internalStreamScreen(device, fd);}private void internalStreamScreen(Device device, FileDescriptor fd) {// MediaCodec录屏的模板代码MediaFormat format = createFormat(bitRate, maxFps, codecOptions);MediaCodec codec = createCodec(encoderName);IBinder display = createDisplay();surface = codec.createInputSurface(); setDisplaySurface(display, surface, videoRotation, contentRect, unlockedVideoRect, layerStack); codec.start(); // 编码 encode(codec, fd);}private boolean encode(MediaCodec codec, FileDescriptor fd) {while (!consumeRotationChange() && !eof) {int outputBufferId = codec.dequeueOutputBuffer(bufferInfo, -1);ByteBuffer codecBuffer = codec.getOutputBuffer(outputBufferId);// 写fd即发送给PC侧IO.writeFully(fd, codecBuffer);}}
我们看到,投屏这部分其实就是利用录屏和利用MediaCodec
硬编码。这部分偏模板代码,基本就是设置MediaCodec
的参数,通过硬编码拿到H264的packet数据,然后通过IO.writeFully
对fd进行写操作将数据发出。
大致的流程图下:
3. 控制逻辑
控制逻辑的入口是startController
方法:
private static Thread startController(final Controller controller) { Thread thread = new Thread(new Runnable() { @Override public void run() { controller.control(); } }); thread.start();}public void control() {while (true) { handleEvent(); }}private void handleEvent() {// 从controlSocket的inputStream读数据ControlMessage msg = connection.receiveControlMessage();switch (msg.getType()) { case ControlMessage.TYPE_INJECT_KEYCODE: injectKeycode(msg.getAction(), msg.getKeycode(), msg.getRepeat(), msg.getMetaState()); case ControlMessage.TYPE_INJECT_TOUCH_EVENT: injectTouch(msg.getAction(), msg.getPointerId(), msg.getPosition(), msg.getPressure(), msg.getButtons()); // ... }}
我们看到控制部分是开启子线程,不断地从controlSocket中PC传来的读控制事件数据,然后根据事件类型的不同做不同的处理。这里我们看到键盘事件或鼠标事件最终都是调用到```injectXXX``方法。其实我们也能猜到,这里肯定是将PC传来的事件转成Android的事件,然后分发事件。那么Scrcpy是怎么实现这个步骤的呢?
3.1 事件注入
我们先来看下injectKeyCode
方法:
// Controller.javaprivate boolean injectKeycode(int action, int keycode, int repeat, int metaState) {// 调用Device的injectKeycode方法device.injectKeyEvent(action, keycode, repeat, metaState, Device.INJECT_MODE_ASYNC);}// Device.javapublic static boolean injectKeyEvent(int action, int keyCode, int repeat, int metaState, int displayId, int injectMode) {long now = SystemClock.uptimeMillis();// 构建一个KeyEventKeyEvent event = new KeyEvent(now, now, action, keyCode, repeat, metaState, KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, InputDevice.SOURCE_KEYBOARD);return injectEvent(event, displayId, injectMode);}public static boolean injectEvent(InputEvent inputEvent, int displayId, int injectMode) { InputManager.setDisplayId(inputEvent, displayId) return ServiceManager.getInputManager().injectInputEvent(inputEvent, injectMode);}
injectKeyCode
的调用链中构建了一个KeyEvent
,然后调用到了最后这两个方法:
-
InputManager.setDisplayId()
- 通过反射调用InputEvent
的setDisplayMethod
方法,为事件指定目标Display:// InputManager.javapublic static boolean setDisplayId(InputEvent inputEvent, int displayId) {Method method = getSetDisplayIdMethod();method.invoke(inputEvent, displayId);return true;}private static Method getSetDisplayIdMethod() throws NoSuchMethodException { if (setDisplayIdMethod == null) { setDisplayIdMethod = InputEvent.class.getMethod("setDisplayId", int.class); } return setDisplayIdMethod;}
-
ServiceManager.getInputManager().injectInputEvent()
- 通过反射的方式获取到系统中InputManager
的实例,并用工程里的InputManager
类包装一下:// ServiceManager.javapublic static InputManager getInputManager() { if (inputManager == null) { try { // 反射调用系统InputManager的getInstance方法 Method getInstanceMethod = android.hardware.input.InputManager.class.getDeclaredMethod("getInstance"); android.hardware.input.InputManager im = (android.hardware.input.InputManager) getInstanceMethod.invoke(null); // 将系统的InputManager实例传入工程自己的InputManager类,包装一下 inputManager = new InputManager(im); } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { throw new AssertionError(e); } } return inputManager;}
然后通过反射调用系统
InputManager
的injectInputEvent
方法,进行事件注入处理,即通过系统InputManagerService
将事件发到了目标Display上:private Method getInjectInputEventMethod() throws NoSuchMethodException { injectInputEventMethod = manager.getClass().getMethod("injectInputEvent", InputEvent.class, int.class); return injectInputEventMethod;}public boolean injectInputEvent(InputEvent inputEvent, int mode) { Method method = getInjectInputEventMethod(); return (boolean) method.invoke(manager, inputEvent, mode);}
injectTouch
方法大同小异,注入的是MotionEvent
。但Android中MotionEvent
和KeyEvent
都是继承于InputEvent
,所以最终都是走的injectInputEvent
将事件发送到目标Display上。
所以我们的流程图可以填充完整了:
至此,Server端的连接、投屏、和控制逻辑就已经分析完了。
4. 时序图
照例附上时序图,不同颜色代表不同的线程。
5. 小结
本篇我们探究了Scrcpy Server端的逻辑,相较Client端而言,Server端的逻辑比较清晰简单。涉及的点有Android录屏、LocalSocket、MediaCode硬编码、事件注入。
到此,关于Scrcpy软件我们就全部分析完了,我们从项目结构开始,研究了其编译系统Meson,然后到Client端(PC端)的建立连接和投屏过程,最后到Server端(Android端)的连接、投屏和控制过程。主线流程还是比较清晰的。
其实最让我个人感到收获的有三个地方:
- ADB端口映射,这种方式为PC和手机的相互访问提供了便利,结合Unix Domain Socket,大大拓展了使用场景,应用非常广泛。
- SDL,笔者之前对SDL了解不深,只知道他可以用来做多媒体相关的界面。但Scrcpy中广泛地运用了SDL的库函数,比较同步、事件机制等和多媒体不太相关的功能。可以说是一套强大的工具库。所以目前笔者已经果断地将SDL加入了自己的后续学习清单。
- Android事件注入,Client端的事件注入机制主要是用了
InputEvent
的私有API,setDisplay
和injectInputEvent
。这种方式可以实现自己构建KeyEvent
或MotionEvent
后发到指定的屏上。刚巧笔者最近有在做的一个项目是有关多屏的,其中有个需要攻克的技术难点,就是其实要用户在真实物理屏上的触摸事件转发到一个我们自己创建的VirtualDisplay
上。于是就借鉴上了Scrcpy中关于事件注入的方法,将Event事件的Display设置成VirtualDisplay的ID,然后通过事件注入的方式实现了转发。
所以,没事多研究成功的开源软件还是有好处的~
免责声明:
① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。
② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341