【投屏】Scrcpy源码分析二(Client篇-连接阶段)

Scrcpy源码分析系列
【投屏】Scrcpy源码分析一(编译篇)
【投屏】Scrcpy源码分析二(Client篇-连接阶段)
【投屏】Scrcpy源码分析三(Client篇-投屏阶段)
【投屏】Scrcpy源码分析四(最终章 - Server篇)

前一篇我们分析了Scrcpy工程的目录结构和编译方法。这一篇开始我们就要深入去了解源码了。

正如上文说的,Scrcpy是一个投屏软件,分为Client端(电脑)和Server端(手机),本文我们先分析client端的部分。

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中我们只需要关注三个方法:

  1. scrcpy_parse_args(&args, argc, argv) - 解析用户传入的参数,用来替换默认参数。
  2. av_register_all() - Scrcpy用的是FFmpeg对手机传来的视频流进行解码。这个函数是FFmpeg的库函数,进行FFmpeg编程时,通常一上来都要调用这个函数,用于FFmpeg初始化。
  3. 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, &params, &cbs, NULL);

	// 启动Server端
	sc_server_start(&s->server);
	
	// 初始化SDL视频子系统
	SDL_Init(SDL_INIT_VIDEO);
	
	// 等待连接状态
	await_for_server(&connected);

	// [投屏阶段]
	// ...
}

连接阶段我们需要关注几个部分:

  1. SDL_Init(SDL_INIT_EVENTS) - SDL是一套开源的跨平台多媒体开发库。可以用来开发窗口程序,提供了丰富的事件系统。Scrcpy的窗口就是用SDL进行开发的。这里是SDL的库函数,用来初始化SDL事件子系统。
  2. struct sc_server_params params - 声明参数,用来将启动程序时的参数存储在结构体中。
  3. struct sc_server_callbacks cbs - 声明连接的状态回调函数。
  4. sc_server_init - 进行一些结构体的初始化,包括将参数和状态回调存入结构提中。
  5. sc_server_start - 启动Server端。
  6. SDL_Init(SDL_INIT_VIDEO) - 初始化SDL视频子系统。
  7. await_for_server - 等待连接状态。

其中需要重点关注的 57,我们着重来看。

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这个函数,我们需要关注几个部分:

  1. sc_adb_start_server() - 这个函数追下去其实就是调用命令adb start-server开启adb服务。

  2. sc_adb_select_device() - 与1类似,执行的是adb devices -l,查看设备列表,

  3. sc_server_configure_tcpip_unknown_address() - 如果是tcpip模式,即在启动scrcpy程序时带上了--tcpip参数。就会执行这个函数,追进去可以看到调用了两个子函数:

    3.1. sc_server_switch_to_tcpip() - 切换成tcpip连接模式,这里还有个小逻辑:

    1. 先通过adb shell ip route获取目标设备的ip地址;
    2. 执行adb -s serial shell getprop service.adb.tcp.port获取端口号。如果端口号存在就说明设备已经开启了tcpip连接功能,则跳过。
    3. 如果端口号为空,则执行adb tcpip 5555开启tcpip连接功能,并设置端口号为5555。

    3.2. sc_server_connect_to_tcpip() - 执行adb connect ip:port进行adb连接。

  4. push_server() - 通过adb -s serial push/usr/local/share/scrcpy/scrcpy-server(如编译篇提到,这个目录是安装时ninja将server.apk放在了这个目录中)上传至/data/local/tmp/scrcpy-server.jar(这是目录是固定的)。

  5. sc_adb_tunnel_open() - 执行adb reverse -s serial reverse localabstract:scrcpy tcp:<PORT>进行端口映射,这里是把手机侧名为scrcpy的Unix域套接字反向代理到PC上的PORT端口(默认端口号是27183)。然后创建一个27183端口号的socket。这里说明一下,在业务层面,手机侧是作为服务端提供视频流和控制指令。但在网络层的实现层面,是电脑侧作为服务端,监听socket连接并等待手机侧来连接。这样做的好处是,PC端可以先于手机端起服务,手机端直接来连即可。反之,可能需要PC端在连接时不断地重试,直到手机侧启动并开始监听才能连接上。

    关于adb端口映射,可以看我的另一篇文章 - ADB端口映射和LocalServerSocket介绍

  6. 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

  7. sc_process_observer_init() - 新启一个线程创建一个观察者监听进程结束,会一直阻塞在那,进程停止后会触发sc_process_observer_init回调。

  8. sc_server_connect_to() - 等待手机侧连接,前面也提到PC侧在网络层面作为服务端,所以此处最终会调用accept4等待手机侧的连接,手机侧会连接两次,分别可以拿到两个video_socket和control_socket(此处的socket对Linux和Windows的平台结构进行了封装,是一个统一的数据结构),用作传输视频流和控制指令。

  9. server->cbs->on_connected - 触发连接成功回调,这个回调你应该还记得,在前面scrcpy()函数中,有注册成sc_server_on_connected函数,在这个函数中,最终会调用SDL_PushEvent(),通过SDL的事件机制发送EVENT_SERVER_CONNECTED事件。这个事件会发送到下一节的await_for_server函数中,这些先预埋一个伏笔,我们有个印象。

  10. sc_cond_wait(&server->cond_stopped) - 等待条件变量cond_stopped发生改变,否则如果程序没有终止,就一直阻塞,不执行后续代码。该函数中对于条件变量的等待,使用的是SDL的内置函数SDL_CondWait,可见如果采用SDL开发上层应用,可以利用很多SDL本身提供的很多现成功能,比如上面的事件机制和这里的线程同步策略。

  11. 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_socketcontrol_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端连接阶段的时序图,图中不同颜色代表不同的线程。

scrcpy-client-connect

3. 小结

这一篇我们探究了Scrcpy Client端连接阶段的逻辑。涉及的点有FFmpeg的初始化、SDL的事件系统和同步机制、ADB端口映射、app_process运行安卓端程序等。下一篇我们会接续探究Client端的投屏阶段。

猜你喜欢

转载自blog.csdn.net/ZivXu/article/details/128873600
今日推荐