Android投屏-scrcpy投屏流程简单分析-python

4,864 阅读7分钟

scrcpy投屏真的很爽

最近在做一个云真机平台,参考sonic的投屏方式, 研究了下scrcpy,此处进行记录备忘

过程中主要参考的文章:scrcpy官网, python的一个三方库scrcpy-client

为什么会去参考python的三方库: 因为我的代码水平太烂了, 官方自带的客户端源码我看不懂。

话不多说,进入主题。

先说结论:

通过阅读官方文档可知:

1、scrcpy主要分为两部分:客户端和服务端, 客户端主要用于展示服务端传递过来的对象画面和音频(音频是最近版本才加的,好像是2.0之后,只支持Android11以上系统)

2、scrcpy服务端其实就是一个jar包。将jarpush到手机设备, 然后启动jar服务,最后客户端和服务端建立通信, 服务端给客户端返回, 再由客户端将消息内容展示出来。构成了一个完整闭环。

此次主要分析的是scrcpy的闭环过程, 期望可以用python代码实现客户端部分的功能。

先来看下三方库的一段简单代码:

import scrcpy

client = scrcpy.Client()

import cv2
def on_frame(frame):
    if frame is not None:
        cv2.imshow("viz", frame)
        cv2.waitKey(1)


client.add_listener(scrcpy.EVENT_FRAME, on_frame)
client.start(threaded=True)

client.control.touch(100, 200, scrcpy.ACTION_DOWN)
client.control.touch(100, 200, scrcpy.ACTION_UP)

代码不是很复杂,大概是:

1、先使用scrcpy.Client()创建一个客户端对象

2、然后通过client.add_listener(scrcpy.EVENT_FRAME, on_frame)添加了一个监听器,监听器拿到每一帧的画面然后使用cv2将画面渲染出来

3、使用client.start启动客户端

4、使用client.control进行客户端输入操作(上述代码是按下和弹起的操作)


下面按照上述的顺序进行简单分析:

1、创建客户端对象

image.png

追到源码:创建对象的时候可以传这么多参数过去。

image.png init初始化方法完整代码如上:

device参数有一小段逻辑:如果没有传参数则使用device_list列表的第一个对象赋值给device,否则使用另外的逻辑(传进来的值)去获取设备对象赋值给device。这里adb的方法它使用了另外一个三方库:adbutils

if device is None:
    device = adb.device_list()[0]
elif isinstance(device, str):
    device = adb.device(serial=device)
self.device = device

其他的初始化参数和内容可以暂时不管,后面主逻辑中用到会提。

2、添加监听器

demo代码如下:

client.add_listener(scrcpy.EVENT_FRAME, on_frame)

方法源码如下: image.png

初始化代码如下: image.png

这部分看起来不难理解:

初始化的时候创建了一个空的字典对象,

add_listener方法会将接收到的方法给添加进对应对象中。

demo代码中向scrcpy.EVENT_FRAME中添加了一个on_frame方法。scrcpy.EVENT_FRAME定义如下图所示:

image.png

此时打印一下字典对象输出:

image.png

3、启动客户端client.start

这里是本文最重点的地方了。

源码如下:

image.png

源码主要做了几个事情:

1)__deploy_server(): 初始化scrcpy服务

2)__init_server_connection():启动scrcpy服务,建立通信

3)__send_to_listeners(EVENT_INIT):监听器管理

4)__stream_loop():开始通信


3.1、__deploy_server(): 初始化scrcpy服务

源码如下:

image.png

这里主要做了两件事情:

a、使用self.device.sync.pushjarpush到了移动设备的/data/local/tmp目录下, self.device上面提到过,这里就不再叙述了。sync.push也是adbutils三方库的方法

b、使用self.device.shell。去执行了启动jar服务的命令。.shell方法也是adbutils三方库的方法。 执行的命令如下:

image.png

这部分跟scrcpy官方的说法基本一致

image.png

这里有个特别注意的地方:版本号

使用不同版本的jar包启动的时候需要使用对应的版本号才可以。比如scrcpy官网当前最新的版本是2.1.1,三方库使用的版本则是1.20,这也是我为什么要去读源码而不直接使用三方库的原因。为了防止后续可能出现的兼容性问题,可能需要迭代jar包版本来解决

3.2、__init_server_connection():启动scrcpy服务,建立通信

源码如下:

image.png

这个方法里,使用adbutils三方库创建了两个socket连接 self.__video_socketself.control_socket。然后对self.__video_socket进行了一些初始化操作。

这里可以结合scrcpy官方文档协助理解。

image.png

image.png

3.3、__send_to_listeners(EVENT_INIT):监听器管理

image.png

这里是根据传进来的字典的key执行key对应的方法list下的每一个方法。 这里可以不用关注,因为EVENT_INIT对应的监听器为空。三方库官方给了一个添加init监听器的示例,只是因为个人感觉没实际使用需求所以略过了。示例如下图所示

image.png

3.4、__stream_loop():开始通信

这一段开始前有一个判断,如果调用start的时候有传入参数,则创建一个线程执行,否则在主进程执行。执行方法都是一样的__stream_loop,逻辑代码如下图所示:

image.png

__stream_loop方法代码如下图所示: image.png

方法一开始使用av.codec.CodecContext.create创建了一个h264格式的读取器,这里跟官方文档的介绍基本相符,默认使用h264格式:

image.png

然后是一个死循环,循环条件是判断self.alive的值是否为False, 这个应该是停止的时候会设置为False

然后使用__video_socket接收了0x10000的数据.这里其实我现在也还没搞懂为啥要设置这么多。翻阅官方文档也没有找到答案。另外找到一个使用了scrcpy的三方库 adbblitz看了下,他设置的默认值是131072那我就更疑惑了。

image.png

大胆猜测一下:应该是因为不同图片复杂度数据大小不一样,所以采用一个固定的值去取,最多前后两帧数据有问题无法生成有效的图片。

接下来,使用了av.codec.CodecContext.create("h264", "r").parse对数据进行处理。并将处理后的结果赋值给packets

然后在packets中进行循环,通过av.codec.CodecContext.create("h264", "r").decode 将数据转换成frames数据

最后, 通过一个for循环拿到每一帧的图片数据frame

最最后, 使用__send_to_listeners方法去执行存在字典中的key=frame的方法,就是执行一开始演示三方库使用的代码里定义的on_frame方法

image.png

image.png

再看一眼一开始的演示代码就明白了。

import cv2 
def on_frame(frame): 
    if frame is not None: 
        cv2.imshow("viz", frame) 
        cv2.waitKey(1) 
        
client.add_listener(scrcpy.EVENT_FRAME, on_frame)

4、整理代码

至此,整个流程捋了一遍。附上我自己捋出来的代码片段:

import struct
import time

from av.codec import CodecContext
from av.error import InvalidDataError
from adbutils import adb, Network, AdbError

# __deploy_server()
jarPath = r"C:\Python\Python37\Lib\site-packages\scrcpy\scrcpy-server.jar"
cmd = ['CLASSPATH=/data/local/tmp/scrcpy-server.jar', 'app_process', '/', 'com.genymobile.scrcpy.Server', '1.20', 'info', '0', '8000000', '0', '-1', 'true', '-', 'false', 'true', '0', 'false', 'false', '-', '-', 'false']

jarPath = r"scrcpy/scrcpy-server.jar"
cmd = ['CLASSPATH=/data/local/tmp/scrcpy-server.jar', 'app_process', '/', 'com.genymobile.scrcpy.Server', '2.1.1', 'log_level=info', 'tunnel_forward=true', 'video=true', 'audio=false', 'control=true', 'video_bit_rate=8000000', 'send_frame_meta=false']


device = adb.device_list()[0]
print(f'device = device')

device.sync.push(jarPath, '/data/local/tmp/scrcpy-server.jar')
print('scrcpy-server.jar pushed')

serverCon = device.shell(cmd, stream=True)
print(f'serverCon = {serverCon.read(10)}')  # 这里返回 b'[server] I' 才表示服务启动成功

# __init_server_connection
videoSocket = None
for _ in range(100):
    try:
        videoSocket = device.create_connection(Network.LOCAL_ABSTRACT, "scrcpy")    # 有可能一次创建不成功
        print(f'videoSocket = {videoSocket}')
        break
    except AdbError:
        time.sleep(0.1)
dummy_byte = videoSocket.recv(1)
print(f'dummy_byte = {dummy_byte}')

controlSocket = device.create_connection(Network.LOCAL_ABSTRACT, "scrcpy")  # 这里一定要有
print(f'controlSocket = {controlSocket}')

deviceName = videoSocket.recv(64).decode("utf-8").rstrip("\x00")
print(f'deviceName = {deviceName}')

res = videoSocket.recv(4)
print(f'res = {res}')

resolution = struct.unpack(">HH", res)
print(f'resolution = {resolution}')
videoSocket.setblocking(False)

# __send_to_listeners 什么都没做
import cv2
def on_frame(frame):
    if frame is not None:
        cv2.imshow("viz", frame)
    cv2.waitKey(1)
# __send_to_listeners('__send_to_listeners')

#__stream_loop
codec = CodecContext.create("h264", "r")
print(f'codec = {codec}')
lastFrame = None
while True:
    try:
        # raw_h264 = videoSocket.recv(0x10000)    # 131072
        raw_h264 = videoSocket.recv(131072)
        # print(f'raw_h264 = {len(raw_h264)}')
        print(f'raw_h264 = {len(raw_h264)}')
        packets = codec.parse(raw_h264)
        print(f'packets = {len(packets)}')
        for packet in packets:
            frames = codec.decode(packet)
            print(f'frames = {len(frames)}')
            for frame in frames:
                frame = frame.to_ndarray(format="bgr24")
                lastFrame = frame
                resolution = (frame.shape[1], frame.shape[0])
                on_frame(frame)

    except (BlockingIOError, InvalidDataError):
        time.sleep(0.01)
        pass
        # on_frame(None)

上述代码__deploy_server中开头定义的jarcmd:上面的是使用三方库自带的jar执行的cmd, 下面的是使用scrcpy官方最新版本2.1.1执行的jarcmd

测试下效果:

20231017100458_rec_.gif

经过跟北京时间对比。看起来没什么毛病。

4.1 拓展

可以利用上述的代码进行android的截图保存, 实际测试下来, 使用上述方式截图的效率要远高于使用adb进行截图.

image.png

5、篇外-control的使用

最后的最后,看下control三方库类支持的控制方法有哪些,因为主要是记录投屏分析的,所以这里就不展开来说了。

image.png