前一篇我们分析了Scrcpy工程的目录结构和编译方法。这一篇开始我们就要深入去了解源码了。
正如上文说的,Scrcpy是一个投屏软件,分为Client端(电脑)和Server端(手机),本文我们先分析client端的部分。
Client篇-连接阶段
- 1. 原理简介
- 2. Client端逻辑
- 2.1 连接阶段
- 2.1.1 ``sc_server_start`` - 启动Server端
- 2.2.2 ``await_for_server`` - 等待连接状态
- 2.2 时序图
- 3. 小结
1. 原理简介
在分析代码之前,我们先聊聊投屏这件事。投屏顾名思义就是把设备A的界面,投射到设备B上。现在投屏的应用领域越来越广,花样也越来越多,电视投屏、车载投屏、手机投屏等等。据笔者不精确地归类,市面上主要有三类方式的呈现都可以叫做“投屏”:
- 第一类:直接把A上整个界面原封不动进行投射,即镜像模式。这类投屏通常是进行录屏,传输视频流的方式。比如AirPlay的镜像模式、MiraCast、乐播投屏等;
- 第二类:推送模式,播视频的场景比较场景。即A把一个连接传给B,B自己进行播放,后学A可以传输一些简单控制指令。比如DLNA协议等;
- 第三类: 基于特殊协议投射部分应用或部分功能,车载领域居多。比如苹果的CarPlay、华为HiCar、百度CarLife等。
我们的主角Scrcpy就属于第一类,原理大致为手机侧和电脑侧建立连接,然后手机侧进行录屏,不断地将视频流发送给电脑端进行解码渲染至界面上。Scrcpy除了镜像投屏外,还支持电脑端反控和文件上传。现在我们就来跟着代码看具体细节。
2. Client端逻辑
因为整体的流程较长,我们分连接和投屏两个阶段来看:
2.1 连接阶段
程序main函数在main.c
中,随机立刻执行函数main_scrcpy()
:
// main.c
int
main(int argc, char *argv[]) {
#ifndef _WIN32
return main_scrcpy(argc, argv);
#else
// ...参数字符串相关的处理...
int ret = main_scrcpy(wargc, argv_utf8);
return ret;
#endif
}
可以看到,不管是否是windows平台,都会执行main_scrcpy方法。Scrcpy是跨平台的,可以运行在Windows、Linux、MacOS上,但代码中主流程基本都是一致的,涉及系统平台宏判断的多半是字符串处理、系统数据结构、和系统库函数的区别。对我们的分析不会产生影响。
// main.c
int
main_scrcpy(int argc, char *argv[]) {
// ...
struct scrcpy_cli_args args = {
.opts = scrcpy_options_default
};
scrcpy_parse_args(&args, argc, argv)
av_register_all();
scrcpy(&args.opts);
// ...
}
函数main_scrcpy
中我们只需要关注三个方法:
-
scrcpy_parse_args(&args, argc, argv)
- 解析用户传入的参数,用来替换默认参数。 -
av_register_all()
- Scrcpy用的是FFmpeg对手机传来的视频流进行解码。这个函数是FFmpeg的库函数,进行FFmpeg编程时,通常一上来都要调用这个函数,用于FFmpeg初始化。 -
scrcpy(&args.opts)
- 调到scrcpy.c
的函数,并传入参数。
下面是函数scrcpy()
的部分关键代码(scrcpy()
函数整体比较长,可以分为连接阶段和投屏阶段,我们先看连接阶段):
// scrcpy.c
enum scrcpy_exit_code
scrcpy(struct scrcpy_options *options) {
// [连接阶段]
// ...
// 初始化SDL事件子系统
SDL_Init(SDL_INIT_EVENTS)
// 声明参数
struct sc_server_params params = {
// 有很多参数,没有贴全,贴两个作为示例
.max_fps = options->max_fps,
.encoder_name = options->encoder_name,
}
// 声明连接状态回调
static const struct sc_server_callbacks cbs = {
.on_connection_failed = sc_server_on_connection_failed,
.on_connected = sc_server_on_connected,
.on_disconnected = sc_server_on_disconnected,
};
// 初始化,将参数和回调添加到相应结构体中
sc_server_init(&s->server, ¶ms, &cbs, NULL);
// 启动Server端
sc_server_start(&s->server);
// 初始化SDL视频子系统
SDL_Init(SDL_INIT_VIDEO);
// 等待连接状态
await_for_server(&connected);
// [投屏阶段]
// ...
}
连接阶段我们需要关注几个部分:
-
SDL_Init(SDL_INIT_EVENTS)
- SDL是一套开源的跨平台多媒体开发库。可以用来开发窗口程序,提供了丰富的事件系统。Scrcpy的窗口就是用SDL进行开发的。这里是SDL的库函数,用来初始化SDL事件子系统。 -
struct sc_server_params params
- 声明参数,用来将启动程序时的参数存储在结构体中。 -
struct sc_server_callbacks cbs
- 声明连接的状态回调函数。 -
sc_server_init
- 进行一些结构体的初始化,包括将参数和状态回调存入结构提中。 -
sc_server_start
- 启动Server端。 -
SDL_Init(SDL_INIT_VIDEO)
- 初始化SDL视频子系统。 -
await_for_server
- 等待连接状态。
其中需要重点关注的 5 和 7,我们着重来看。
2.1.1 sc_server_start
- 启动Server端
启动Server端,就是启动手机侧的程序。如果让我们自己实现从电脑上启动手机侧的程序,我们能想到什么办法呢?对,就是adb。Scrcpy也是通过adb启动Server端的,但其中还有很多细节部分,现在我们就来追一下代码。
函数sc_server_start
中,新启了一个线程,执行run_server
函数。
// server.c
bool
sc_server_start(struct sc_server *server) {
sc_thread_create(&server->thread, run_server, "scrcpy-server", server);
}
run_server
函数是启动Server的核心,其关键部分如下:
// server.c
static int
run_server(void *data) {
// 执行adb start-server
sc_adb_start_server(&server->intr, 0);
// 执行adb devices -l
sc_adb_select_device(&server->intr, &selector, 0, &device);
// tcpip方式连接,执行adb connect连接
if (params->tcpip) {
sc_server_configure_tcpip_unknown_address(server, device.serial);
}
// 执行adb push
push_server(&server->intr, serial);
// 执行adb reverse 或 adb forward进行端口映射
sc_adb_tunnel_open(&server->tunnel, &server->intr, serial,
params->port_range, params->force_adb_forward);
// 执行app_process
execute_server(server, params);
// 创建一个进程观察者监听进程结束
sc_process_observer_init(&observer, pid, &listener, server);
// 进行业务连接
sc_server_connect_to(server, &server->info);
// 触发on_connected回调
server->cbs->on_connected(server, server->cbs_userdata);
// 等待条件变量cond_stopped发生改变
while (!server->stopped) {
sc_cond_wait(&server->cond_stopped, &server->mutex);
}
// 结束进程
sc_process_terminate(pid);
}
run_server
这个函数,我们需要关注几个部分:
sc_adb_start_server()
- 这个函数追下去其实就是调用命令adb start-server
开启adb服务。sc_adb_select_device()
- 与1类似,执行的是adb devices -l
,查看设备列表,sc_server_configure_tcpip_unknown_address()
- 如果是tcpip模式,即在启动scrcpy程序时带上了--tcpip
参数。就会执行这个函数,追进去可以看到调用了两个子函数:
3.1.sc_server_switch_to_tcpip()
- 切换成tcpip连接模式,这里还有个小逻辑:
- 先通过
adb shell ip route
获取目标设备的ip地址;- 执行
adb -s serial shell getprop service.adb.tcp.port
获取端口号。如果端口号存在就说明设备已经开启了tcpip连接功能,则跳过。- 如果端口号为空,则执行
adb tcpip 5555
开启tcpip连接功能,并设置端口号为5555。
- 3.2.
sc_server_connect_to_tcpip()
- 执行adb connect ip:port
进行adb连接。 push_server()
- 通过adb -s serial push
将/usr/local/share/scrcpy/scrcpy-server
(如编译篇提到,这个目录是安装时ninja将server.apk放在了这个目录中)上传至/data/local/tmp/scrcpy-server.jar
(这是目录是固定的)。sc_adb_tunnel_open()
- 执行adb reverse -s serial reverse localabstract:scrcpy tcp:<PORT>
进行端口映射,这里是把手机侧名为scrcpy的Unix域套接字反向代理到PC上的PORT端口(默认端口号是27183)。然后创建一个27183端口号的socket。这里说明一下,在业务层面,手机侧是作为服务端提供视频流和控制指令。但在网络层的实现层面,是电脑侧作为服务端,监听socket连接并等待手机侧来连接。这样做的好处是,PC端可以先于手机端起服务,手机端直接来连即可。反之,可能需要PC端在连接时不断地重试,直到手机侧启动并开始监听才能连接上。
execute_server()
- 通过app_process
打开服务端程序,完整的命令是adb -s serial shell CLASSPATH=/data/local/tmp/scrcpy-server.jar app_process / com.genymobile.scrcpy.Server 1.25 [PARAMS]
。这样可以执行手机侧程序的Server类。因为在手机侧有很多需要hook系统的方法,通过app_process
可以提高执行权限,使用 app_process 来调用高权限 API。sc_process_observer_init()
- 新启一个线程创建一个观察者监听进程结束,会一直阻塞在那,进程停止后会触发sc_process_observer_init
回调。sc_server_connect_to()
- 等待手机侧连接,前面也提到PC侧在网络层面作为服务端,所以此处最终会调用accept4
等待手机侧的连接,手机侧会连接两次,分别可以拿到两个video_socket和control_socket(此处的socket对Linux和Windows的平台结构进行了封装,是一个统一的数据结构),用作传输视频流和控制指令。server->cbs->on_connected
- 触发连接成功回调,这个回调你应该还记得,在前面scrcpy()
函数中,有注册成sc_server_on_connected
函数,在这个函数中,最终会调用SDL_PushEvent()
,通过SDL的事件机制发送EVENT_SERVER_CONNECTED
事件。这个事件会发送到下一节的await_for_server
函数中,这些先预埋一个伏笔,我们有个印象。sc_cond_wait(&server->cond_stopped)
- 等待条件变量cond_stopped
发生改变,否则如果程序没有终止,就一直阻塞,不执行后续代码。该函数中对于条件变量的等待,使用的是SDL的内置函数SDL_CondWait
,可见如果采用SDL开发上层应用,可以利用很多SDL本身提供的很多现成功能,比如上面的事件机制和这里的线程同步策略。sc_process_terminate(pid)
- 结束进程。上一步中,如果条件变量未发生改变,则会一直阻塞走不到这一步。直到cond_stopped
发生改变,则会走到这一步来,结束进程。Unix平台最终调用的是库函数kill(_pid_t, int)
,Windows平台调用的是TerminateProcess(HANDLE, UINT)
。
至此,sc_server_start
函数的主流程就基本已经追的差不多了。稍稍总结一下,这个函数的作用就是通过adb将Server端的程序上传至目标设备,然后通过app_process
启动Server端的程序,和自身发起连接,得到两个socket,video_socket
和 control_socket
供后续使用。最后发送一个EVENT_SERVER_CONNECTED
事件。整体流程基本如下图,现在遗留的问题是EVENT_SERVER_CONNECTED
发到哪了。我们后面填充。
2.2.2 await_for_server
- 等待连接状态
下面我们继续追await_for_server
函数,如果有点记不得调用顺序了,没关系,我们再看下调用者scrcpy()
函数更精简的结构:
// scrcpy.c
enum scrcpy_exit_code
scrcpy(struct scrcpy_options *options) {
// [连接阶段]
// ...
// 启动Server端(异步子线程)
sc_server_start(&s->server);
// 等待连接状态
await_for_server(&connected);
// ...
// [投屏阶段]
}
scrcpy()
函数在sc_server_start()
中创建了个子线程执行操作后,就立刻会调用await_for_server()
函数。我们来看看await_for_server()
里做了什么:
// scrcpy.c
static bool
await_for_server(bool *connected) {
SDL_Event event;
while (SDL_WaitEvent(&event)) {
switch (event.type) {
case SDL_QUIT:
LOGD("User requested to quit");
*connected = false;
return true;
case EVENT_SERVER_CONNECTION_FAILED:
LOGE("Server connection failed");
return false;
case EVENT_SERVER_CONNECTED:
LOGD("Server connected");
*connected = true;
return true;
default:
break;
}
}
LOGE("SDL_WaitEvent() error: %s", SDL_GetError());
return false;
}
没错,上面就是await_for_server()
函数的全部内容了,也就是说是利用了SDL事件系统,一直在等连接结果的事件。如果等不到目标事件,就一直在这个函数里出不来。
还记得上一段落提到的EVENT_SERVER_CONNECTED
事件么?对的,就是这里在等的。在手机侧和PC侧建立连接之后,sc_server_on_connected
回调最终会发一个事件出来,这里收到目标事件之后就可以跳出循环往下走了。scrcpy()
函数也结束了连接阶段,进入到了投屏阶段。
那么我们的流程图可以填充完整了。
2.2 时序图
这里抛出一张Client端连接阶段的时序图,图中不同颜色代表不同的线程。
3. 小结
这一篇我们探究了Scrcpy Client端连接阶段的逻辑。涉及的点有FFmpeg的初始化、SDL的事件系统和同步机制、ADB端口映射、app_process运行安卓端程序等。下一篇我们会接续探究Client端的投屏阶段。