Scrcpy投屏原理淺析-嘗試用Flutter重寫它的客戶端

Nightmare_夢魘獸發表於2020-03-31

繼上篇控制篇的後續文章,其中還會用到Texture外接紋理,在Flutter中進行軟解碼

前面相關文章

Flutter-Texture外接紋理上手實現視訊播放

Scrcpy投屏原理淺析-裝置控制篇

參考文章

Android PC投屏簡單嘗試—最終章1

Scrcpy服務端

我們們還是從它的原始碼入手,scrcpy有一個main函式的,這樣才能被app_process直接啟動,所以我們直接看它main函式

    public static void main(String... args) throws Exception {
        System.out.println("service starting...");
        Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
            @Override
            public void uncaughtException(Thread t, Throwable e) {
                Ln.e("Exception on thread " + t, e);
                suggestFix(e);
            }
        });

//        unlinkSelf();
        Options options = createOptions(args);
        scrcpy(options);
    }
複製程式碼

它是沒有註釋的(頭大,不寫註釋),我自己加了些print來除錯,unlinkSelf是它刪除自己的一個方法,我方便除錯便註釋了 main函式都比較簡單,Options是它自己封裝的一個引數的物件,簡單說就是將命令列中啟動時的引數封裝到了物件裡面

CLASSPATH=/data/local/tmp/scrcpy-server.jar app_process ./ com.genymobile.scrcpy.Server 1.12.1 0 8000000 0 true - true true
複製程式碼

也就是

1.12.1 0 8000000 0 true - true true
複製程式碼

createOptions返回的就是這個物件 隨後呼叫了scrcpy這個函式,並將解析後的引數物件傳了進去 scrcpy函式

    private static void scrcpy(Options options) throws IOException {
        final Device device = new Device(options);
        boolean tunnelForward = options.isTunnelForward();
        try (DesktopConnection connection = DesktopConnection.open(device, tunnelForward)) {
            ScreenEncoder screenEncoder = new ScreenEncoder(options.getSendFrameMeta(), options.getBitRate(), options.getMaxFps());

            if (options.getControl()) {
                Controller controller = new Controller(device, connection);

                // asynchronous
                //
                startController(controller);
                startDeviceMessageSender(controller.getSender());
            }

            try {
                // synchronous
                screenEncoder.streamScreen(device, connection.getVideoFd());
            } catch (IOException e) {
                // this is expected on close
                Ln.d("Screen streaming stopped");
            }
        }
    }
複製程式碼

其中try中的語句呼叫的函式

    public static DesktopConnection open(Device device, boolean tunnelForward) throws IOException {
        LocalSocket videoSocket;
        LocalSocket controlSocket;
        if (tunnelForward) {
            LocalServerSocket localServerSocket = new LocalServerSocket(SOCKET_NAME);
            try {
                System.out.println("Waiting for video socket connection...");
                videoSocket = localServerSocket.accept();
                System.out.println("video socket is connected.");
                // send one byte so the client may read() to detect a connection error
                videoSocket.getOutputStream().write(0);
                try {

                    System.out.println("Waiting for input socket connection...");
                    controlSocket = localServerSocket.accept();

                    System.out.println("input socket is connected.");
                } catch (IOException | RuntimeException e) {
                    videoSocket.close();
                    throw e;
                }
            } finally {
                localServerSocket.close();
            }
        } else {
            videoSocket = connect(SOCKET_NAME);
            try {
                controlSocket = connect(SOCKET_NAME);
            } catch (IOException | RuntimeException e) {
                videoSocket.close();
                throw e;
            }
        }

        DesktopConnection connection = new DesktopConnection(videoSocket, controlSocket);
        Size videoSize = device.getScreenInfo().getVideoSize();
        connection.send(Device.getDeviceName(), videoSize.getWidth(), videoSize.getHeight());
        return connection;
    }

複製程式碼

open函式負責建立兩個socket並阻塞一直等到這兩個socket分別被連線,隨後open函式內傳送了該裝置的名稱,寬度,高度,這些在投屏的視訊流的解析需要用到 傳送裝置名與寬高的函式

    private void send(String deviceName, int width, int height) throws IOException {
        byte[] buffer = new byte[DEVICE_NAME_FIELD_LENGTH + 4];

        byte[] deviceNameBytes = deviceName.getBytes(StandardCharsets.UTF_8);
        int len = StringUtils.getUtf8TruncationIndex(deviceNameBytes, DEVICE_NAME_FIELD_LENGTH - 1);
        System.arraycopy(deviceNameBytes, 0, buffer, 0, len);
        // byte[] are always 0-initialized in java, no need to set '\0' explicitly

        buffer[DEVICE_NAME_FIELD_LENGTH] = (byte) (width >> 8);
        buffer[DEVICE_NAME_FIELD_LENGTH + 1] = (byte) width;
        buffer[DEVICE_NAME_FIELD_LENGTH + 2] = (byte) (height >> 8);
        buffer[DEVICE_NAME_FIELD_LENGTH + 3] = (byte) height;
        IO.writeFully(videoFd, buffer, 0, buffer.length);
    }
複製程式碼

DEVICE_NAME_FIELD_LENGTH是一個常量為64,所以在視訊流的socket被連線後,首先傳送了一個字元0(貌似是為了客戶端檢測連線成功,類似於一次握手的感覺),隨後傳送了68個位元組長度的字元,64個位元組為裝置名,4個位元組為裝置的寬高,它這裡也是用的移位運算將大於255的數存進了兩個位元組,所以我們在客戶端也要對應的解析。

Flutter客戶端

理一下大致思路,第一個socket是視訊輸出,第二個是對裝置的控制 Flutter是不能直接解碼視訊的,這也是我上一篇文章提到的重要性。 所以就是

  • Flutter-->Plugin-->安卓原生呼叫ffmpeg相關解碼且連線第一個socket

  • dart中連線第二個socket對裝置進行控制

c++中

所以我們在native中去連線第一個socket,並立即解碼服務端的視訊流資料

自定義一個c++ socket類(網上找的輪子自己改了一點)

ScoketConnection.cpp

//
// Created by Cry on 2018-12-20.
//

#include "SocketConnection.h"

#include <string.h>
#include <unistd.h>

bool SocketConnection::connect_server() {
    //建立Socket
    client_conn = socket(PF_INET, SOCK_STREAM, 0);
    if (!client_conn) {
        perror("can not create socket!!");
        return false;
    }

    struct sockaddr_in in_addr;
    memset(&in_addr, 0, sizeof(sockaddr_in));

    in_addr.sin_port = htons(5005);
    in_addr.sin_family = AF_INET;
    in_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    int ret = connect(client_conn, (struct sockaddr *) &in_addr, sizeof(struct sockaddr));
    if (ret < 0) {
        perror("socket connect error!!\\n");
        return false;
    }
    return true;
}

void SocketConnection::close_client() {
    if (client_conn >= 0) {
        shutdown(client_conn, SHUT_RDWR);
        close(client_conn);
        client_conn = 0;
    }
}

int SocketConnection::send_to_(uint8_t *buf, int len) {
    if (!client_conn) {
        return 0;
    }
    return send(client_conn, buf, len, 0);
}

int SocketConnection::recv_from_(uint8_t *buf, int len) {
    if (!client_conn) {
        return 0;
    }
    //rev 和 read 的區別 https://blog.csdn.net/superbfly/article/details/72782264
    return recv(client_conn, buf, len, 0);
}

複製程式碼

標頭檔案

//
// Created by Cry on 2018-12-20.
//

#ifndef ASREMOTE_SOCKETCONNECTION_H
#define ASREMOTE_SOCKETCONNECTION_H

#include <sys/socket.h>
#include <arpa/inet.h>
#include <zconf.h>
#include <cstdio>

/**
 * 這裡的方法均阻塞
 */
class SocketConnection {
public:
    int client_conn;

    /**
     * 連線Socket
     */
    bool connect_server();

    /**
     * 關閉Socket
     */
    void close_client();

    /**
     * Socket 傳送
     */
    int send_to_(uint8_t *buf, int len);

    /**
     * Socket 接受
     */
    int recv_from_(uint8_t *buf, int len);
};


#endif //ASREMOTE_SOCKETCONNECTION_H
複製程式碼

為了方便在cpp中對socket的連線

連線與解碼對應的函式

SocketConnection *socketConnection;
LOGD("正在連線");
socketConnection = new SocketConnection();
if (!socketConnection->connect_server()) {
    return;
}
LOGD("連線成功");
複製程式碼

LOGD是呼叫的java的Log.d

連線成功後需要將前面服務端傳出的一個字元0給讀取掉

uint8_t zeroChar[1];
    //這次接收是為了將服務端傳送過來的空位元組
socketConnection->recv_from_(reinterpret_cast<uint8_t *>(zeroChar), 1);
複製程式碼

隨即接收裝置資訊

uint8_t deviceInfo[68];
socketConnection->recv_from_(reinterpret_cast<uint8_t *>(deviceInfo), 68);
LOGD("裝置名===========>%s", deviceInfo);
int width=deviceInfo[64]<<8|deviceInfo[65];
int height=deviceInfo[66]<<8|deviceInfo[67];
LOGD("裝置的寬為%d",width);
LOGD("裝置的高為%d",height);
複製程式碼

看一下除錯圖

Scrcpy投屏原理淺析-嘗試用Flutter重寫它的客戶端
接著就是解碼了,這兒的解碼也有注意的地方,我將整個程式碼貼出來

    SocketConnection *socketConnection;
    LOGD("正在連線");
    socketConnection = new SocketConnection();
    if (!socketConnection->connect_server()) {
        return;
    }
    LOGD("連線成功");
    uint8_t zeroChar[1];
    //這次接收是為了將服務端傳送過來的空位元組
    socketConnection->recv_from_(reinterpret_cast<uint8_t *>(zeroChar), 1);
    uint8_t deviceInfo[68];
    socketConnection->recv_from_(reinterpret_cast<uint8_t *>(deviceInfo), 68);
    LOGD("裝置名===========>%s", deviceInfo);
    int width=deviceInfo[64]<<8|deviceInfo[65];
    int height=deviceInfo[66]<<8|deviceInfo[67];
    LOGD("裝置的寬為%d",width);
    LOGD("裝置的高為%d",height);
//    std::cout<<
    //初始化ffmpeg網路模組
    avformat_network_init();

    AVFormatContext *formatContext = avformat_alloc_context();
    unsigned char *buffer = static_cast<unsigned char *>(av_malloc(BUF_SIZE));
    AVIOContext *avio_ctx = avio_alloc_context(buffer, BUF_SIZE,
                                               0, socketConnection,
                                               read_socket_buffer, NULL,
                                               NULL);
    formatContext->pb = avio_ctx;
    int ret = avformat_open_input(&formatContext, NULL, NULL, NULL);
    if (ret < 0) {
        LOGD("avformat_open_input error :%s\n", av_err2str(ret));
        return;
    }
    LOGD("開啟成功");
    //下面內容為傳統解碼視訊必須的語句,用於此會一直獲取不到,阻塞執行緒
    //為分配的AVFormatContext 結構體中填充資料
//    if (avformat_find_stream_info(formatContext, NULL) < 0) {
//        LOGD("讀取輸入的視訊流資訊失敗。");
//        return;
//    }

    LOGD("當前視訊資料,包含的資料流數量:%d", formatContext->nb_streams);
    //下面是傳統解碼獲取解碼器的方法,這裡直接設定解碼器為h264
    //找到"視訊流".AVFormatContext 結構體中的nb_streams欄位儲存的就是當前視訊檔案中所包含的總資料流數量——
    //視訊流,音訊流,字幕流
//    for (int i = 0; i < formatContext->nb_streams; i++) {
//
//        //如果是資料流的編碼格式為AVMEDIA_TYPE_VIDEO——視訊流。
//        if (formatContext->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
//            video_stream_index = i;//記錄視訊流下標
//            break;
//        }
//    }
//    if (video_stream_index == -1) {
//        LOGD("沒有找到 視訊流。");
//        return;
//    }
    //通過編解碼器的id——codec_id 獲取對應(視訊)流解碼器





    AVCodec *videoDecoder = avcodec_find_decoder(AV_CODEC_ID_H264);

    if (videoDecoder == NULL) {
        LOGD("未找到對應的流解碼器。");
        return;
    }
    LOGD("成功找打解碼器。");
    //通過解碼器分配(並用  預設值   初始化)一個解碼器context
    AVCodecContext *codecContext = avcodec_alloc_context3(videoDecoder);

    if (codecContext == NULL) {
        LOGD("分配 解碼器上下文失敗。");
        return;
    }

    LOGD("分配 解碼器上下文成功。");
    //更具指定的編碼器值填充編碼器上下文
//    if (avcodec_parameters_to_context(codecContext, codecParameters) < 0) {
//        LOGD("填充編解碼器上下文失敗。");
//        return;
//    }
//
//    LOGD("填充編解碼器上下文成功。");
    //通過所給的編解碼器初始化編解碼器上下文
    if (avcodec_open2(codecContext, videoDecoder, NULL) < 0) {
        LOGD("初始化 解碼器上下文失敗。");
        return;
    }
    LOGD("初始化 解碼器上下文成功。");
    AVPixelFormat dstFormat = AV_PIX_FMT_RGBA;
    codecContext->pix_fmt = AV_PIX_FMT_YUV420P;
    //分配儲存壓縮資料的結構體物件AVPacket
    //如果是視訊流,AVPacket會包含一幀的壓縮資料。
    //但如果是音訊則可能會包含多幀的壓縮資料
    AVPacket *packet = av_packet_alloc();
    //分配解碼後的每一資料資訊的結構體(指標)
    AVFrame *frame = av_frame_alloc();
    //分配最終顯示出來的目標幀資訊的結構體(指標)
    AVFrame *outFrame = av_frame_alloc();

    codecContext->width = width;
    codecContext->height = height;
    uint8_t *out_buffer = (uint8_t *) av_malloc(
            (size_t) av_image_get_buffer_size(dstFormat, codecContext->width, codecContext->height,
                                              1));
    //更具指定的資料初始化/填充緩衝區
    av_image_fill_arrays(outFrame->data, outFrame->linesize, out_buffer, dstFormat,
                         codecContext->width, codecContext->height, 1);
    //初始化SwsContext
    SwsContext *swsContext = sws_getContext(
            codecContext->width   //原圖片的寬
            , codecContext->height  //源圖高
            , codecContext->pix_fmt //源圖片format
            , codecContext->width  //目標圖的寬
            , codecContext->height  //目標圖的高
            , dstFormat, SWS_BICUBIC, NULL, NULL, NULL
    );
    if (swsContext == NULL) {
        LOGD("swsContext==NULL");
        return;
    }
    LOGD("swsContext初始化成功")
    //Android 原生繪製工具
    ANativeWindow *nativeWindow = ANativeWindow_fromSurface(env, surface);
    //定義繪圖緩衝區
    ANativeWindow_Buffer outBuffer;
    //通過設定寬高限制緩衝區中的畫素數量,而非螢幕的物流顯示尺寸。
    //如果緩衝區與物理螢幕的顯示尺寸不相符,則實際顯示可能會是拉伸,或者被壓縮的影像
    ANativeWindow_setBuffersGeometry(nativeWindow, codecContext->width, codecContext->height,
                                     WINDOW_FORMAT_RGBA_8888);
    //迴圈讀取資料流的下一幀
    LOGD("解碼中");


    while (av_read_frame(formatContext, packet) == 0) {

        //講原始資料傳送到解碼器
        int sendPacketState = avcodec_send_packet(codecContext, packet);
        if (sendPacketState == 0) {
            int receiveFrameState = avcodec_receive_frame(codecContext, frame);
            if (receiveFrameState == 0) {
                //鎖定視窗繪圖介面
                ANativeWindow_lock(nativeWindow, &outBuffer, NULL);
                //對輸出影像進行色彩,解析度縮放,濾波處理
                sws_scale(swsContext, (const uint8_t *const *) frame->data, frame->linesize, 0,
                          frame->height, outFrame->data, outFrame->linesize);
                uint8_t *dst = (uint8_t *) outBuffer.bits;
                //解碼後的畫素資料首地址
                //這裡由於使用的是RGBA格式,所以解碼影像資料只儲存在data[0]中。但如果是YUV就會有data[0]
                //data[1],data[2]
                uint8_t *src = outFrame->data[0];
                //獲取一行位元組數
                int oneLineByte = outBuffer.stride * 4;
                //複製一行記憶體的實際數量
                int srcStride = outFrame->linesize[0];
                for (int i = 0; i < codecContext->height; i++) {
                    memcpy(dst + i * oneLineByte, src + i * srcStride, srcStride);
                }
                //解鎖
                ANativeWindow_unlockAndPost(nativeWindow);
                //進行短暫休眠。如果休眠時間太長會導致播放的每幀畫面有延遲感,如果短會有加速播放的感覺。
                //一般一每秒60幀——16毫秒一幀的時間進行休眠

            } else if (receiveFrameState == AVERROR(EAGAIN)) {
                LOGD("從解碼器-接收-資料失敗:AVERROR(EAGAIN)");
            } else if (receiveFrameState == AVERROR_EOF) {
                LOGD("從解碼器-接收-資料失敗:AVERROR_EOF");
            } else if (receiveFrameState == AVERROR(EINVAL)) {
                LOGD("從解碼器-接收-資料失敗:AVERROR(EINVAL)");
            } else {
                LOGD("從解碼器-接收-資料失敗:未知");
            }
        } else if (sendPacketState == AVERROR(EAGAIN)) {//傳送資料被拒絕,必須嘗試先讀取資料
            LOGD("向解碼器-傳送-資料包失敗:AVERROR(EAGAIN)");//解碼器已經重新整理資料但是沒有新的資料包能傳送給解碼器
        } else if (sendPacketState == AVERROR_EOF) {
            LOGD("向解碼器-傳送-資料失敗:AVERROR_EOF");
        } else if (sendPacketState == AVERROR(EINVAL)) {//遍解碼器沒有開啟,或者當前是編碼器,也或者需要重新整理資料
            LOGD("向解碼器-傳送-資料失敗:AVERROR(EINVAL)");
        } else if (sendPacketState == AVERROR(ENOMEM)) {//資料包無法壓如解碼器佇列,也可能是解碼器解碼錯誤
            LOGD("向解碼器-傳送-資料失敗:AVERROR(ENOMEM)");
        } else {
            LOGD("向解碼器-傳送-資料失敗:未知");
        }

        av_packet_unref(packet);
    }
    ANativeWindow_release(nativeWindow);
    av_frame_free(&outFrame);
    av_frame_free(&frame);
    av_packet_free(&packet);
    avcodec_free_context(&codecContext);
    avformat_close_input(&formatContext);
    avformat_free_context(formatContext);
複製程式碼

留意上面的註釋都是不能放開的,對音視訊解碼有過了解的就會知道註釋部分其實是正常播放視訊一定會走的流程,在這是行不通的

如何渲染

通過前面文章提到的Flutter建立Surface方法,獲取到對應的textureId後交給Flutter渲染出對應的畫面,將其物件傳到上面函式體內,由cpp直接操作這個視窗,

dart中

  init() async {
    SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom]);
    texTureId = await videoPlugin.invokeMethod("");
    setState(() {});
    networkManager = NetworkManager("127.0.0.1", 5005);
    await networkManager.init();
  }
複製程式碼

NetworkManager是一個簡單的socket封裝類

class NetworkManager {
 final String host;
 final int port;
 Socket socket;
 static Stream<List<int>> mStream;
 Int8List cacheData = Int8List(0);
 NetworkManager(this.host, this.port);

 Future<void> init() async {
   try {
     socket = await Socket.connect(host, port, timeout: Duration(seconds: 3));
   } catch (e) {
     print("連線socket出現異常,e=${e.toString()}");
   }
   mStream = socket.asBroadcastStream();
   // socket.listen(decodeHandle,
   //     onError: errorHandler, onDone: doneHandler, cancelOnError: false);
 }
 ***
複製程式碼

我們在C++中獲取到了裝置的大小,但是dart這邊並沒有拿到,而Flutter Texture這個Widget會預設撐滿整個螢幕 所以我們在dart也需要拿到裝置的寬高

    ProcessResult _result = await Process.run(
      "sh",
      ["-c", "adb -s $currentIp shell wm size"],
      environment: {"PATH": EnvirPath.binPath},
      runInShell: true,
    );
    var tmp=_result.stdout.replaceAll("Physical size: ", "").toString().split("x");
    int width=int.tryParse(tmp[0]);
    int height=int.tryParse(tmp[1]);
複製程式碼

$currentIp是當前裝置的ip,為了防止有多個裝置連線的情況,adb是交叉編譯到安卓裝置的, 最後我們用AspectRatio將Texture包起來,

AspectRatio(
    aspectRatio:
    width / height,
    ***
複製程式碼

這樣在客戶端Texture的顯示也會保持遠端裝置螢幕的比例

控制

當然會用GestureDetector 如下

 GestureDetector(
            behavior: HitTestBehavior.translucent,
            onPanDown: (details) {
              onPanDown = Offset(details.globalPosition.dx / fulldx,
                  (details.globalPosition.dy) / fulldy);
              int x = (onPanDown.dx * fulldx*window.devicePixelRatio).toInt();
              int y = (onPanDown.dy * fulldy*window.devicePixelRatio).toInt();

              networkManager.sendByte([
                2,
                0,
                2,
                3,
                4,
                5,
                6,
                7,
                8,
                9,
                x >> 24,
                x << 8 >> 24,
                x << 16 >> 24,
                x << 24 >> 24,
                y >> 24,
                y << 8 >> 24,
                y << 16 >> 24,
                y << 24 >> 24,
                1080 >> 8,
                1080 << 8 >> 8,
                2280 >> 8,
                2280 << 8 >> 8,
                0,
                0,
                0,
                0,
                0,
                0
              ]);
              newOffset = Offset(details.globalPosition.dx / fulldx,
                  (details.globalPosition.dy) / fulldy);
            },
            onPanUpdate: (details) {
              newOffset = Offset(details.globalPosition.dx / fulldx,
                  (details.globalPosition.dy) / fulldy);
              int x = (newOffset.dx * fulldx*window.devicePixelRatio).toInt();
              int y = (newOffset.dy * fulldy*window.devicePixelRatio).toInt();
              networkManager.sendByte([
                2,
                2,
                2,
                3,
                4,
                5,
                6,
                7,
                8,
                9,
                x >> 24,
                x << 8 >> 24,
                x << 16 >> 24,
                x << 24 >> 24,
                y >> 24,
                y << 8 >> 24,
                y << 16 >> 24,
                y << 24 >> 24,
                1080 >> 8,
                1080 << 8 >> 8,
                2280 >> 8,
                2280 << 8 >> 8,
                0,
                0,
                0,
                0,
                0,
                0
              ]);
            },
            onPanEnd: (details) async {
              int x = (newOffset.dx * fulldx*window.devicePixelRatio).toInt();
              int y = (newOffset.dy * fulldy*window.devicePixelRatio).toInt();
              networkManager.sendByte([
                2,
                1,
                2,
                3,
                4,
                5,
                6,
                7,
                8,
                9,
                x >> 24,
                x << 8 >> 24,
                x << 16 >> 24,
                x << 24 >> 24,
                y >> 24,
                y << 8 >> 24,
                y << 16 >> 24,
                y << 24 >> 24,
                1080 >> 8,
                1080 << 8 >> 8,
                2280 >> 8,
                2280 << 8 >> 8,
                0,
                0,
                0,
                0,
                0,
                0
              ]);
            },
            child: Container(
              alignment: Alignment.topLeft,
              // color: MToolkitColors.appColor.withOpacity(0.5),
              // child: Image.memory(Uint8List.fromList(list)),
              width: fulldx,
              height: fulldy,
            ),
          ),
複製程式碼

ok,最後效果(允許我用上篇帖子的gif)

Scrcpy投屏原理淺析-嘗試用Flutter重寫它的客戶端
個人感覺是因為Texture cpu->gpu->cpu的開銷,畫面顯示有延遲,控制其實是沒有延遲的 最後總結一下

  • 顯示有延遲
  • 目前只能在安卓區域網或者otg控制安卓
  • Linux等桌面端由於沒有視訊播放的方案(我正嘗試能否也建立一個opengl的surface,但最後都沒有成功,實現了一個極其劣質的播放器沒法用),但linux通過這樣的方案控制端是行得通的 開源需要等我處理本地的一些問題才行

相關文章