https://github.com/Genymobile/scrcpy
Scrcpy是genymobile开源的一款手机镜像软件,通过对手机音视频的采集和同步,可以实现在PC平台上控制手机的功能。
官方解释:此应用程序镜像通过 USB 或 TCP/IP 连接的 Android 设备(视频和音频),并允许使用计算机的键盘和鼠标控制设备。 它不需要任何根访问权限。 它适用于 Linux、Windows 和 macOS。
因为它的易用性,所以广受好评,那么,它又是怎么实现这个易用性的呢?还是得解读一下。
Scrcpy是通过app_process的方法,首先将dex或者jar文件push到Android设备中/data/local/tmp中,然后通过adb shell进行调用。一般来讲,访问/data文件夹都需要root权限,而tmp文件夹提供了shell权限就能够访问的方法,因此使用app_process的二进制文件大多数情况下都是放置在/data/local/tmp文件夹下。
这里scrcpy存在一个Feature。当我们运行scrcpy并且投屏成功后,用adb shell到/data/local/tmp文件夹下却无法找到对应的app_process文件,可以尝试着使用
top | grep scrcpy
进行查看,当前scrcpy已经完全加载到内存中并运行的时候,就会将/data/local/tmp文件夹下的二进制运行文件删除掉,不留下一点痕迹。
除此以外,其他的功能应该分为三大块,视频同步,音频同步以及控制同步,接下来我们一块块进行剖析。
视频流同步,主要的代码在ScreenEncoder.java里面的streamScreen()方法,通过do while循环的方式实时获取截图并且编码成视频流,具体代码如下:
do {
ScreenInfo screenInfo = device.getScreenInfo();
Rect contentRect = screenInfo.getContentRect();
// include the locked video orientation
Rect videoRect = screenInfo.getVideoSize().toRect();
format.setInteger(MediaFormat.KEY_WIDTH, videoRect.width());
format.setInteger(MediaFormat.KEY_HEIGHT, videoRect.height());
Surface surface = null;
try {
mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
surface = mediaCodec.createInputSurface();
// does not include the locked video orientation
Rect unlockedVideoRect = screenInfo.getUnlockedVideoSize().toRect();
int videoRotation = screenInfo.getVideoRotation();
int layerStack = device.getLayerStack();
setDisplaySurface(display, surface, videoRotation, contentRect, unlockedVideoRect, layerStack);
mediaCodec.start();
alive = encode(mediaCodec, streamer);
// do not call stop() on exception, it would trigger an IllegalStateException
mediaCodec.stop();
} catch (IllegalStateException | IllegalArgumentException e) {
Ln.e("Encoding error: " + e.getClass().getName() + ": " + e.getMessage());
if (!prepareRetry(device, screenInfo)) {
throw e;
}
Ln.i("Retrying...");
alive = true;
} finally {
mediaCodec.reset();
if (surface != null) {
surface.release();
}
}
} while (alive);
主要就是 截图 -> 编码两部分
截图的具体方式主要如下,开启一个surface的事务,设置displaySurface, 设置投影,设置层次,关闭事务。
private static void setDisplaySurface(IBinder display, Surface surface, int orientation, Rect deviceRect, Rect displayRect, int layerStack) {
SurfaceControl.openTransaction();
try {
SurfaceControl.setDisplaySurface(display, surface);
SurfaceControl.setDisplayProjection(display, orientation, deviceRect, displayRect);
SurfaceControl.setDisplayLayerStack(display, layerStack);
} finally {
SurfaceControl.closeTransaction();
}
}
这边需要查看一下相关的android源码 core/java/android/view/SurfaceControl.java - platform/frameworks/base.git - Git at Google (googlesource.com)
private static native void nativeSetDisplaySurface(long transactionObj, IBinder displayToken, long nativeSurfaceObject);
private static native void nativeSetDisplayLayerStack(long transactionObj, IBinder displayToken, int layerStack);
private static native void nativeSetDisplayProjection(long transactionObj, IBinder displayToken, int orientation, int l, int t, int r, int b, int L, int T, int R, int B);
从源码中可以看到,主要的这三个方法都是native方法,native的代码被封装起来是无法直接用Android的接口调用的,因此在scrcpy的SurfaceControl.java中,通过反射对native方法进行调用。对这三个方法的具体解释和原理,可以参考Android VirtualDisplay解析 - 简书 (jianshu.com),上面对整个录屏流程讲解得特别清楚,这边就不加赘述。
不过这样看来,实际上具体的步骤跟minicap是完全一样的,只是实现的方式不一样,在scrcpy中通过java反射的方式,调用java封装好的native静态方法,而minicap中通过参考AOSP的源码用C++的方法直接调用Android方法。
Minicap截图原理分析_Edward.W的博客-CSDN博客
相比minicap需要对所有不用的sdk版本打不同的包,scrcpy直接build成android应用,通过反射的方法获取截图会更加稳妥,兼容性强一些,但是本质的方法一样。
上一块内容把比较重要的截图内容实现了,之后就是如何将截图编码成视频流了。为什么要编码成视频流呢?我想这边主要是降低传输过程的压力。在minicap中,把截图YUV编码成JPEG图片,这是一个有损压缩的过程,而且JPEG图像依旧比较大,如果在FPS高的情况下,对传输压力特别大。而于此相比,视频流的压力就显得小了很多。
源码中主要是通过Android内置的多媒体操作框架MediaCodec实现的。
MediaCodec mediaCodec = createMediaCodec(codec, encoderName);
MediaFormat format = createFormat(codec.getMimeType(), videoBitRate, maxFps, codecOptions);
通过createMediaCodec的方法创建一个mediaCodec对象用于编码。
mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
surface = mediaCodec.createInputSurface();
通过createInputSurface方法设置好输入来源,这要只要surface每次获取截图,就能自动作为mediacodec的输入流。
然后在encode方法中进行编码,通过getOutputBuffer方法获取输出流,同时用streamer传输给接收流的端口。
private boolean encode(MediaCodec codec, Streamer streamer) throws IOException {
boolean eof = false;
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
while (!consumeRotationChange() && !eof) {
int outputBufferId = codec.dequeueOutputBuffer(bufferInfo, -1);
try {
if (consumeRotationChange()) {
// must restart encoding with new size
break;
}
eof = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
if (outputBufferId >= 0) {
ByteBuffer codecBuffer = codec.getOutputBuffer(outputBufferId);
boolean isConfig = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0;
if (!isConfig) {
// If this is not a config packet, then it contains a frame
firstFrameSent = true;
consecutiveErrors = 0;
}
streamer.writePacket(codecBuffer, bufferInfo);
}
} finally {
if (outputBufferId >= 0) {
codec.releaseOutputBuffer(outputBufferId, false);
}
}
}
return !eof;
}
音频同步的入口位置在Server.java文件中的这几行。主要有两种,一种是rarRecorder,一种是audioEncoder,分别为发送原始音频和发送编码后的音频。
Streamer为音频通道,就是要把音频最终发到哪里去, AudioCodec是音频编码器。
原始音频的内容比较简单,就是直接采集音频并且发送到streamer,内容包含在编码音频里,所以这边只讲编码音频这一块,也就是AudioEncoder。
if (audio) {
AudioCodec audioCodec = options.getAudioCodec();
Streamer audioStreamer = new Streamer(connection.getAudioFd(), audioCodec, options.getSendCodecMeta(),
options.getSendFrameMeta());
AsyncProcessor audioRecorder;
if (audioCodec == AudioCodec.RAW) {
audioRecorder = new AudioRawRecorder(audioStreamer);
} else {
audioRecorder = new AudioEncoder(audioStreamer, options.getAudioBitRate(), options.getAudioCodecOptions(),
options.getAudioEncoder());
}
asyncProcessors.add(audioRecorder);
}
在AudioEncoder开始的时候,就是起了一个新的thread执行encode操作(文件AudioEncoder.java),这个encode函数里,先判断是不是Android 11及以上的版本,然后主要是创建了4个线程分别执行4个内容
a). 开启一个workaround,由于android 11需要前台应用才可以获取音频,但是app_process并不是一个应用,所以必须启用workaround,本质上就是用Intent启动一个com.android.shell.HeapDumpActivity,这样系统就能把当前的app_process识别为一个前台应用。
Intent intent = new Intent(Intent.ACTION_MAIN);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.addCategory(Intent.CATEGORY_LAUNCHER);
intent.setComponent(new ComponentName(FakeContext.PACKAGE_NAME, "com.android.shell.HeapDumpActivity"));
ServiceManager.getActivityManager().startActivityAsUserWithFeature(intent);
// Wait for activity to start
SystemClock.sleep(150);
b).创建一个录音机(系统类,AudioRecord类),同样的,在sdk版本大于31时必须要设置context,录音机的Context也是借用shell的context来实现,同时设置录音源为"REMOTE_SUBBMIX",这个设置相当于把当前设备播放的声音直接截断了远程播放,而不在本地播放。
/**
* Audio source for a submix of audio streams to be presented remotely.
*
* An application can use this audio source to capture a mix of audio streams
* that should be transmitted to a remote receiver such as a Wifi display.
* While recording is active, these audio streams are redirected to the remote
* submix instead of being played on the device speaker or headset.
*
* Certain streams are excluded from the remote submix, including
* {@link AudioManager#STREAM_RING}, {@link AudioManager#STREAM_ALARM},
* and {@link AudioManager#STREAM_NOTIFICATION}. These streams will continue
* to be presented locally as usual.
*
* Capturing the remote submix audio requires the
* {@link android.Manifest.permission#CAPTURE_AUDIO_OUTPUT} permission.
* This permission is reserved for use by system components and is not available to
* third-party applications.
*
*/
@RequiresPermission(android.Manifest.permission.CAPTURE_AUDIO_OUTPUT)
public static final int REMOTE_SUBMIX = 8;
这个Thread只是一个handlerThread,用于mediaCodec的CallBack,真正运行的应该是EncoderCallback()这个函数。这个函数里面实现了两个方法,onInputBufferAvailable和onOutputBufferAvailable,实际上就是设置了两个队列,inputTask和outputTask,每次将Index写入到队列里以免经过编码器之后得到的结果在某个位置无序了。
inputAvailable -> 设置 index -> mediaCodec编码(取出index,并且进行编码)-> 编码结果 -> ouptutAvailable -> 写入队列(index, 编码结果)
private final BlockingQueue inputTasks = new ArrayBlockingQueue<>(64);
private final BlockingQueue outputTasks = new ArrayBlockingQueue<>(64);
public void onInputBufferAvailable(MediaCodec codec, int index) {
try {
inputTasks.put(new InputTask(index));
} catch (InterruptedException e) {
end();
}
}
@Override
public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo bufferInfo) {
try {
outputTasks.put(new OutputTask(index, bufferInfo));
} catch (InterruptedException e) {
end();
}
}
获取index(从inputTasks队列)->创建输入缓冲区->读取音频内容到缓冲区->开始排队编码
从InputTask队列里面获取一个Index,然后用mediaCodec.getInputBuffer,将取出来的task的序号作为音频编码器的输入的标记,创建一个缓冲区,这样只需要每次将获取到的音频数据读取到这个缓冲区都能够自动作为音频编码器的输入。
最后直接将capture的内容读取到该缓冲区,调用queueInputBuffer排队编码就行了。
InputTask task = inputTasks.take();
ByteBuffer buffer = mediaCodec.getInputBuffer(task.index);
int r = capture.read(buffer, READ_SIZE, bufferInfo);
if (r < 0) {
throw new IOException("Could not read audio: " + r);
}
mediaCodec.queueInputBuffer(task.index, bufferInfo.offset, bufferInfo.size, bufferInfo.presentationTimeUs, bufferInfo.flags);
和Input thread相对应的,input thread是把音频捕获的内容放置到编码器MediaCodec里,那output thread就是将编码完成的音频流从编码器中获取出来,不用过多解释。就是获取输出缓冲区,然后将缓冲器的数据通过streamer输出去。
获取index(从OutputTasks队列)-> 获取编码结果到缓冲区 -> 将缓冲区内容输出
private void outputThread(MediaCodec mediaCodec) throws IOException, InterruptedException {
streamer.writeAudioHeader();
while (!Thread.currentThread().isInterrupted()) {
OutputTask task = outputTasks.take();
ByteBuffer buffer = mediaCodec.getOutputBuffer(task.index);
try {
streamer.writePacket(buffer, task.bufferInfo);
} finally {
mediaCodec.releaseOutputBuffer(task.index, false);
}
}
}
控制器的入口位置在Server.java文件中的这两行。
if (control) {
Controller controller = new Controller(device, connection, options.getClipboardAutosync(), options.getPowerOn());
device.setClipboardListener(text -> controller.getSender().pushClipboardText(text));
asyncProcessors.add(controller);
}
for (AsyncProcessor asyncProcessor : asyncProcessors) {
asyncProcessor.start();
}
实际上相当于new一个Controller对象,然后调用这个controller对象的start方法,只是这边控制和音频写成了模块的形式,然后异步执行,使代码理解起来更加清晰。这样也方便了后续添加其他模块时可以直接add到AsyncProcessor里。
整个app_process的思想是,视频流在mainprocessor,然后其他的模块都处于asyncprocessor,只要视频流不断,最基础的功能就能够存在。
接下来我们来看Controller.java文件,在这里面开启了两个线程,control和sender,分别是从控制端口接收数据和发送数据。首先我们来看control。
当接收到命令请求的时候,就会开始进行handleEvent,根据不同的事件做不同的处理,同时也可以添加一些自己设定的操作,只需要保证接收端和Android端的命令能识别就行了。
while (!Thread.currentThread().isInterrupted()) {
handleEvent();
}
这边输入按键的方法,也就是injectKeyCode方法,我们追根溯源,最终是使用的反射的方法,在InputManager里面调用InjectInputEvent。
private Method getInjectInputEventMethod() throws NoSuchMethodException {
if (injectInputEventMethod == null) {
injectInputEventMethod = manager.getClass().getMethod("injectInputEvent", InputEvent.class, int.class);
}
return injectInputEventMethod;
源码在:core/java/android/hardware/input/InputManager.java - platform/frameworks/base - Git at Google (googlesource.com)
这边的话可以看到,这个injectInputEvent方法并不是native的方法,为什么需要用反射进行调用呢。这里涉及到一个问题,因为app_process是不带有manifest来获取Permission的,通过adb shell进行调用的话只具备了shell权限。由于反射的是这个不带有uid的方法,而该方法是@hide的而且unsupportedappusage,所以还是需要用反射的方法进行调用。
/**
* Injects an input event into the event system on behalf of an application.
* The synchronization mode determines whether the method blocks while waiting for
* input injection to proceed.
*
* Requires the {@link android.Manifest.permission.INJECT_EVENTS} permission.
*
* Make sure you correctly set the event time and input source of the event
* before calling this method.
*
*
* @param event The event to inject.
* @param mode The synchronization mode. One of:
* {@link android.os.InputEventInjectionSync.NONE},
* {@link android.os.InputEventInjectionSync.WAIT_FOR_RESULT}, or
* {@link android.os.InputEventInjectionSync.WAIT_FOR_FINISHED}.
* @return True if input event injection succeeded.
*
* @hide
*/
@RequiresPermission(Manifest.permission.INJECT_EVENTS)
@UnsupportedAppUsage
public boolean injectInputEvent(InputEvent event, int mode) {
return injectInputEvent(event, mode, Process.INVALID_UID);
}
输入文本的时候,在Android端解析出来就是一整个string,解析的方法也是简单粗暴,逐字符注入到手机上。具体调用的方法如下:
private boolean injectChar(char c) {
String decomposed = KeyComposition.decompose(c);
char[] chars = decomposed != null ? decomposed.toCharArray() : new char[]{c};
KeyEvent[] events = charMap.getEvents(chars);
if (events == null) {
return false;
}
for (KeyEvent event : events) {
if (!device.injectEvent(event, Device.INJECT_MODE_ASYNC)) {
return false;
}
}
return true;
}
将每个字符通过decompose的方法转变成为键盘输入,然后同上面一样的通过InjectInputEvent进行输入。就相当于就是在手机键盘上输入这些字符。但是问题同样存在,对于Unicode字符,如中文字符或者其他的语言文字就无法Inject成功,因为手机上找不到对应的按键直接生成。这边的话需要通过粘贴板实现,而不是通过InjectKeyCode实现。
这一块看起来像是最难的,实际上可能也是最简单的。在没看源码之前,可能会考虑得如何支持多点触控,按着屏幕移动或者说早期魅族的3d touch这种事件。而实际上这些内容在Android的操作系统层面都帮我们处理好了,只要我们按照一定的规范操作就行。
主要的方法就是通过创建一个event方法,然后用injectInputEvent注入相应的事件进去。而其中的难点就在于,我们得解析“触控位置”,“触控压力(3D触控)”,“多点触控(设置Index)”等问题。
//获取具体触控的位置
Point point = device.getPhysicalPoint(position);
//确定触控的点(多点触控)
int pointerIndex = pointersState.getPointerIndex(pointerId);
//设置触控压力(3D触控)
Pointer pointer = pointersState.get(pointerIndex);
pointer.setPoint(point);
pointer.setPressure(pressure);
//通过action确定是按下还是拖动还是弹起
// secondary pointers must use ACTION_POINTER_* ORed with the pointerIndex
if (action == MotionEvent.ACTION_UP) {
action = MotionEvent.ACTION_POINTER_UP | (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT);
} else if (action == MotionEvent.ACTION_DOWN) {
action = MotionEvent.ACTION_POINTER_DOWN | (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT);
}
//创建event事件
MotionEvent event = MotionEvent.obtain(lastTouchDown, now, action, pointerCount, pointerProperties, pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0, source, 0);
这个方法的难点在于scrcpy的客户端ui是在windows或者mac的窗口上,然后多点触控的话(比如触屏电脑,或者鼠标的左,中,右键同时有动作也会触发多点触控)在C++写的UI上不好自己辨别,所以这边需要在android上辨别pointerIndex。
由于多点触控更多的情况下是在移动终端下,比如平板或者手机,如果对应的控制UI是运行在web浏览器上或者是小程序(微信小程序ios的pointerId有问题)之类的东西里,那么对应的pointerIndex就无需再计算,可以直接由web端或者小程序端收集,这样生成的多点触控会更加稳定。
滑动这个操作可能比较不熟悉,大家在手机上的操作主要都是触控产生的上下滑动。这边所说的滑动实际上比较像是鼠标中键的上下滑动,或者说是macbook触控板的滑动。
这个实现方法就比较简单了,跟触控类似的,还是创建一个motionevent,然后去实现这个动作。只不过创建的类型不一样。类型是MotionEvent.ACTION_SCROLL,这里Android里面都帮我们实现好了,就不再详细讲述。
MotionEvent event = MotionEvent
.obtain(lastTouchDown, now, MotionEvent.ACTION_SCROLL, 1, pointerProperties, pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0,
InputDevice.SOURCE_MOUSE, 0);
return device.injectEvent(event, Device.INJECT_MODE_ASYNC);
主要的功能就这四个,另外的几个功能大同小异。