本文完整阅读约需 16 分钟,如时间较长请考虑收藏后慢慢阅读~

移动互联网时代下,手机能干的事情越来越多,但如果想要让工作更高效,鼠标键盘依旧是必不可少的。可许多软件(点名阿里系)并没有提供对应的桌面版本,也不兼容基于x86架构的Android模拟器,这就使得我们要用投屏软件来在电脑上操作手机。scrcpy就是众多投屏软件中最具特色的一款,作为一款开源软件,它拥有极佳的性能和丰富的功能,但这款软件在中文输入方面却存在较大的问题。本文将为读者介绍如何让scrcpy正常输入中文,让这款非常好用的投屏软件变得更好用。

本文撰写时scrcpy最新为1.14版本,依旧存在下文所述的问题,当你阅读本文时也许scrcpy已经解决了这一问题,因此本文内容仅供思路参考和技术分享。

0x01 问题重现

scrcpy相对于其他仅依靠adb shell screencapadb shell input进行设备控制的软件,拥有更加优秀的性能,这得益于它的系统架构:

其中Server在每次启动scrcpy的时候运行于Android端,使用MediaCodec的API对采集到的画面进行编码,并使用多线程,通过Socket传输到PC。PC端则使用ffmpeg和SDL2对画面进行实时解码显示。其中Server使用Java开发,Client使用C开发。具体技术细节可以参考官方文档,此处不再赘述。

言归正传,scrcpy在Unicode文字输入方面一直存在巨大问题,从很久之前就有用户反馈无法输入ascii以外的文字(如#632,表现为PC端输入文字后,手机不显示,终端报错,见图1),而作者则在最近正式加入了对ascii字符的支持,但尚未合并到master分支(见#1426)。

图1. 无法正常输入汉字,并提示无法插入字符

笔者尝试拉取代码库,并按照开发文档#1426所在分支d613b10efcdf0d1cf76e30871e136ba0ff444e6e进行构建。

构建之后运行,我们会发现问题略有改善,但输入过程中的字母也被传入scrcpy,如图2所示:

图2. 笔者想输入“测试”,但输入过程中的ceshi也被输入到了Android中

因为是尚未发布的功能,没有相关的资料可供参考,我们只能自行阅读源码查找原因。

0x02 问题分析

首先我们要了解一下SDL处理用户输入(以及其他事件)的流程:

  1. 使用SDL_WaitEvent(&event)获取事件队列中的事件
  2. 根据event->type对不同事件进行区分处理

这里的事件类型可以参考SDL2的官方文档:SDL_Event

顺着这样的思路,我们很快就能找到scrcpy处理事件的相关代码:app/src/scrcpy.c,这里截取一段:

static enum event_result
handle_event(SDL_Event *event, bool control) {
    switch (event->type) {
        case EVENT_STREAM_STOPPED:
            LOGD("Video stream stopped");
            return EVENT_RESULT_STOPPED_BY_EOS;
        case SDL_QUIT:
            LOGD("User requested to quit");
            return EVENT_RESULT_STOPPED_BY_USER;
        case EVENT_NEW_FRAME:
            if (!screen.has_frame) {
                screen.has_frame = true;
                // this is the very first frame, show the window
                screen_show_window(&screen);
            }
            if (!screen_update_frame(&screen, &video_buffer)) {
                return EVENT_RESULT_CONTINUE;
            }
            break;
        case SDL_WINDOWEVENT:
            screen_handle_window_event(&screen, &event->window);
            break;
        case SDL_TEXTINPUT:
            if (!control) {
                break;
            }
            input_manager_process_text_input(&input_manager, &event->text);
            break;
        case SDL_KEYDOWN:
        case SDL_KEYUP:
            // some key events do not interact with the device, so process the
            // event even if control is disabled
            input_manager_process_key(&input_manager, &event->key, control);
            break;
...

结合代码和SDL文档,可以发现SDL_KEYDOWNSDK_KEYUP事件会被传递到input_manager_process_key()函数,这两个事件只针对按键,不针对输入法;而SDL_TEXTINPUT事件则会被传递到input_manager_process_text_input()函数,这个事件处理的是输入法确认输入后所发送的文本。

这里我们可以使用调试工具(或万能的printf)了解上面『测试』这个文字的输入过程中,事件传递的流程。最终得到如下所示的事件顺序:

2020-06-17 17:16:49.831 scrcpy[57759:5396589] INFO: KEYINPUT # c
2020-06-17 17:16:49.919 scrcpy[57759:5396589] INFO: KEYINPUT # c抬起
2020-06-17 17:16:50.731 scrcpy[57759:5396589] INFO: KEYINPUT # e
2020-06-17 17:16:50.840 scrcpy[57759:5396589] INFO: KEYINPUT # e抬起
2020-06-17 17:16:51.341 scrcpy[57759:5396589] INFO: KEYINPUT # s
2020-06-17 17:16:51.440 scrcpy[57759:5396589] INFO: KEYINPUT # s抬起
2020-06-17 17:16:51.657 scrcpy[57759:5396589] INFO: KEYINPUT # h
2020-06-17 17:16:51.719 scrcpy[57759:5396589] INFO: KEYINPUT # h抬起
2020-06-17 17:16:51.933 scrcpy[57759:5396589] INFO: KEYINPUT # i
2020-06-17 17:16:52.041 scrcpy[57759:5396589] INFO: KEYINPUT # i抬起
2020-06-17 17:16:52.408 scrcpy[57759:5396589] INFO: KEYINPUT # 空格
2020-06-17 17:16:52.408 scrcpy[57759:5396589] INFO: TEXTINPUT # 测试
2020-06-17 17:16:52.519 scrcpy[57759:5396589] INFO: KEYINPUT # 空格抬起

可以发现,除了倒数第二行的TEXTINPUT事件,其他均为KEYDOWNKEYUP事件。那么问题来了,我们如何屏蔽掉这些多余的事件呢?

继续阅读input_manager_process_key()函数和input_manager_process_text_input()函数,我们会发现它们都对『某种特殊情况』做了判断,并会抛弃掉特殊情况下的一些输入:

// input_manager_process_key()
...
struct control_msg msg;
// convert_input_key()返回true才会真正插入字符,可是为什么会存在返回false的情况?
if (convert_input_key(event, &msg, im->prefer_text)) {
    if (!controller_push_msg(controller, &msg)) {
        LOGW("Could not request 'inject keycode'");
    }
}
...

// input_manager_process_text_input()
...
void
input_manager_process_text_input(struct input_manager *im,
                                 const SDL_TextInputEvent *event) {
    // 为什么要在prefer_text为假的时候提前返回呢?
    if (!im->prefer_text) {
        char c = event->text[0];
        if (isalpha(c) || c == ' ') {
            assert(event->text[1] == '\0');
            // letters and space are handled as raw key event
            return;
        }
    }
...

是的!这两处可疑的地方都与prefer_text这个参数有关,其值为真或为假会对scrcpy处理按键输入和文本输入的行为进行截然相反的控制:

  1. prefer_text为假:
    • TEXTINPUT事件所得到的字符长度如果为1,直接抛弃事件
    • KEYDOWNKEYUP不受影响
  2. prefer_text为真:
    • TEXTINPUT事件不受影响
    • KEYDOWNKEYUP事件对应的字符如果是字母或空格,直接抛弃事件

需要补充说明的是,根据SDL官方文档和我们的实际测试,在输入英文(不使用输入法)的时候,按下一个键会触发三个事件:

  1. KEYDOWN事件
  2. TEXTINPUT事件
  3. KEYUP事件

也就是说如果没有prefer_text参数的控制,按下一个键我们将会得到两个键。继续翻阅代码,我们会发现prefer_text参数的默认值为false,即满足上文第一种情况。

结合我们上面输入中文的测试,可以发现KEYDOWNKEYUP事件在prefer_text默认为false情况下是会被直接输入到Android端的;而如果我们想屏蔽这两个事件,就必须保证prefer_text为真。

怎么样,读到这里,相信读者们已经发现一些端倪了吧?

0x03 问题解决

继续阅读源码,会发现prefer_text参数来源于启动时传入的--prefer-text选项,文档中这样描述这个选项:

Text injection preference
There are two kinds of events generated when typing text:

key events, signaling that a key is pressed or released;
text events, signaling that a text has been entered.
By default, letters are injected using key events, so that the keyboard behaves as expected in games (typically for WASD keys).

But this may cause issues. If you encounter such a problem, you can avoid it by:

scrcpy --prefer-text

(but this will break keyboard behavior in games)

关于这个参数的解释写得非常抽象,仅描述了这一参数的行为和特殊情况下后果,并没有介绍这一参数的一般用途,难怪笔者一开始并未发现。

这里我们使用上文提到的分支d613b10efcdf0d1cf76e30871e136ba0ff444e6e进行重新构建,并在启动时携带--prefer-text参数:

此时问题不再出现!

0x04 后续

鉴于scrcpy这部分文档过于简略,且不容易被注意到,笔者向作者提交了一个Issue(#1516)说明这一情况,希望引起作者重视,完善相关文档(或者考虑默认启用这一选项?)。如果作者有意,笔者也希望能够为其撰写中文文档,方便其他有同样需求的用户快速上手,毕竟在国内互联网生态下,太多软件只有手机版本,有此需求的中国用户数不胜数,而能够读懂源码,并能在机缘巧合之下读到本文的人确实少之又少。

也许当读者读到本文的时候,这一软件版本已经高于1.14,相关问题也早已得以解决,但笔者依旧希望通过这篇文章,为读者提供一种解决问题的思路,即:

多读源码!