源碼地址:https://github.com/deepsadness/AppRemote
上一章中,我們簡單實現了PC的投屏功能。 但是還是存在這一些缺陷。
屏幕的尺寸數據是寫死的 不能通過PC來對手機進行控制 直接在主線程中進行解碼和顯示,存在較大的延遲。
所以這邊文章。我們需要根據上面的需求。來對我們的代碼進行優化。
1. 屏幕信息發送
其實在上一章中,我們已經獲取了屏幕信息。只是沒有發送給client端。這邊文章中,我們進行發送。
android端 Android端在Socket連接成功后,就開啟發送
private static void sendScreenInfo(Size size, ByteBuffer buffer, FileDescriptor fileDescriptor) throws IOException {//將尺寸數據先發送過去int width = size.getWidth();int height = size.getHeight();byte wHigh = (byte) (width >> 8);byte wLow = (byte) (width & 0xff);byte hHigh = (byte) (height >> 8);byte hLow = (byte) (height & 0xff);buffer.put(wHigh);buffer.put(wLow);buffer.put(hHigh);buffer.put(hLow);// System.out.println("發送尺寸 size result = " + write);
// int write = Os.write(fileDescriptor, buffer);byte[] buffer_size = new byte[4];buffer_size[0] = (byte) (width >> 8);buffer_size[1] = (byte) (width & 0xff);buffer_size[2] = (byte) (height >> 8);buffer_size[3] = (byte) (height & 0xff);writeFully(fileDescriptor, buffer_size, 0, buffer_size.length);System.out.println("發送尺寸 size result ");buffer.clear();}
//從客戶端接受屏幕數據uint8_t size[4];socketConnection->recv_from_(reinterpret_cast<uint8_t *>(size), 4);//這里先寫死,后面從客戶端內接受int width = (size[0] << 8) | (size[1]);int height = (size[2] << 8) | (size[3]);printf("width = %d , height = %d \n", width, height);
這樣就可以獲得屏幕的尺寸信息,保證不同手機分辨率也能正常使用了。
盡管我們通過這樣獲取了正確的屏幕信息,但是SDL顯示的畫面,還是有些奇怪。比我們預期的胖了一點。
通過下面的方式,來重新計算窗口的尺寸。這樣才能顯示正常。
//這里是給四周留空隙。
#define DISPLAY_MARGINS 96
struct size {int width;int height;
};
// get the preferred display bounds (i.e. the screen bounds with some margins)
static SDL_bool get_preferred_display_bounds(struct size *bounds) {SDL_Rect rect;
#if SDL_VERSION_ATLEAST(2, 0, 5)
# define GET_DISPLAY_BOUNDS(i, r) SDL_GetDisplayUsableBounds((i), (r))
#else
# define GET_DISPLAY_BOUNDS(i, r) SDL_GetDisplayBounds((i), (r))
#endif//獲取顯示的大小if (GET_DISPLAY_BOUNDS(0, &rect)) {
// LOGW("Could not get display usable bounds: %s", SDL_GetError());printf("Could not get display usable bounds: %s\n", SDL_GetError());return SDL_FALSE;}//設置大小bounds->width = MAX(0, rect.w - DISPLAY_MARGINS);bounds->height = MAX(0, rect.h - DISPLAY_MARGINS);return SDL_TRUE;
}// return the optimal size of the window, with the following constraints:
// - it attempts to keep at least one dimension of the current_size (i.e. it crops the black borders)
// - it keeps the aspect ratio
// - it scales down to make it fit in the display_size
static struct size get_optimal_size(struct size current_size, struct size frame_size) {if (frame_size.width == 0 || frame_size.height == 0) {// avoid division by 0return current_size;}struct size display_size;// 32 bits because we need to multiply two 16 bits valuesint w;int h;if (!get_preferred_display_bounds(&display_size)) {// cannot get display bounds, do not constraint the sizew = current_size.width;h = current_size.height;} else {w = MIN(current_size.width, display_size.width);h = MIN(current_size.height, display_size.height);}SDL_bool keep_width = static_cast<SDL_bool>(frame_size.width * h > frame_size.height * w);//縮放之后,保持長寬比if (keep_width) {// remove black borders on top and bottomh = frame_size.height * w / frame_size.width;} else {// remove black borders on left and right (or none at all if it already fits)w = frame_size.width * h / frame_size.height;}// w and h must fit into 16 bitsSDL_assert_release(w < 0x10000 && h < 0x10000);return (struct size) {w, h};
}//調用
void set(){struct size frame_size = {.height=screen_h,.width=screen_w};struct size window_size = get_optimal_size(frame_size, frame_size);//創建windowsdl_window = SDL_CreateWindow(name,SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED,window_size.width, window_size.height,SDL_WINDOW_RESIZABLE);
}
這樣才能顯示正常的窗口了。
?
正常的比例.png
2. 對Android手機進行控制
我們知道在Android中有幾種方式可以對手機的Android發起模擬按鍵。
通過AccessibilityService的方式。通過注冊該服務,可以捕獲所有的窗口變化,捕獲控鍵,進行模擬點擊。 但是它需要額外的權限。 通過adb的方式 我們可以簡單的通過adb shell input方法來完成模擬
Usage: input [<source>] <command> [<arg>...]The sources are: dpadkeyboardmousetouchpadgamepadtouchnavigationjoysticktouchscreenstylustrackballThe commands and default sources are:text <string> (Default: touchscreen)keyevent [--longpress] <key code number or name> ... (Default: keyboard)tap <x> <y> (Default: touchscreen)swipe <x1> <y1> <x2> <y2> [duration(ms)] (Default: touchscreen)draganddrop <x1> <y1> <x2> <y2> [duration(ms)] (Default: touchscreen)press (Default: trackball)roll <dx> <dy> (Default: trackball)
就可以對屏幕上(100,100)的位置,進行模擬點擊。
通過InputManager實現 我們這里也是通過這個方式來實現的。
InputManager 模擬點擊事件
當API 15之后,我們使用InputManager。
獲取InputManager 同樣可以通過Server Manager中就可以進行獲取。
public InputManager getInputManager() {if (inputManager == null) {IInterface service = getService(Context.INPUT_SERVICE, "android.hardware.input.IInputManager");inputManager = new InputManager(service);}return inputManager;}
我們知道Android中的按鍵事件對應的是KeyEvent,而手勢事件對應的是MotionEvent。
public class KeyEventFactory {/*創建一個KeyEvent*/public static KeyEvent keyEvent(int action, int keyCode, int repeat, int metaState) {long now = SystemClock.uptimeMillis();/*** 1. 點擊的時間 The time (in {@link android.os.SystemClock#uptimeMillis}) at which this key code originally went down.* 2. 事件發生的時間 The time (in {@link android.os.SystemClock#uptimeMillis}) at which this event happened.* 3. UP DOWN MULTIPLE 中的一個: either {@link #ACTION_DOWN},{@link #ACTION_UP}, or {@link #ACTION_MULTIPLE}.* 4. code The key code. 輸入的鍵盤事件* 5. 重復的事件次數。點出次數? A repeat count for down events (> 0 if this is after the initial down) or event count for multiple events.* 6. metaState Flags indicating which meta keys are currently pressed. 暫時不知道什么意思* 7. The device ID that generated the key event.* 8. Raw device scan code of the event. 暫時不知道什么意思* 9. The flags for this key event 暫時不知道什么意思* 10. The input source such as {@link InputDevice#SOURCE_KEYBOARD}.*/KeyEvent event = new KeyEvent(now, now, action, keyCode, repeat, metaState,KeyCharacterMap.VIRTUAL_KEYBOARD,0,0,InputDevice.SOURCE_KEYBOARD);return event;}/*通過送入一個ACTION_DOWN 和ACTION_UP 來模擬一次點擊的事件*/public static KeyEvent[] clickEvent(int keyCode) {return new KeyEvent[]{keyEvent(KeyEvent.ACTION_DOWN, keyCode, 0, 0), keyEvent(KeyEvent.ACTION_UP, keyCode, 0, 0)};}
}
創建MotionEvent Android中的手勢事件的觸發。
private static long lastMouseDown;private static final MotionEvent.PointerCoords[] pointerCoords = {new MotionEvent.PointerCoords()};private static final MotionEvent.PointerProperties[] pointerProperties = {new MotionEvent.PointerProperties()};public static MotionEvent createMotionEvent(int type, int x, int y) {long now = SystemClock.uptimeMillis();int action;if (type == 1) {lastMouseDown = now;action = MotionEvent.ACTION_DOWN;} else {action = MotionEvent.ACTION_UP;}MotionEvent.PointerCoords[] pointerCoords = {new MotionEvent.PointerCoords()};MotionEvent.PointerCoords coords = pointerCoords[0];coords.x = 2 * x;coords.y = 2 * y;MotionEvent.PointerProperties[] pointerProperties = {new MotionEvent.PointerProperties()};MotionEvent.PointerProperties props = pointerProperties[0];props.id = 0;props.toolType = MotionEvent.TOOL_TYPE_FINGER;coords = pointerCoords[0];coords.orientation = 0;coords.pressure = 1;coords.size = 1;return MotionEvent.obtain(lastMouseDown, now,action,1, pointerProperties, pointerCoords,0, 1,1f, 1f,0, 0,InputDevice.SOURCE_TOUCHSCREEN, 0);}
public static MotionEvent createScrollEvent(int x, int y, int hScroll, int vScroll) {long now = SystemClock.uptimeMillis();MotionEvent.PointerCoords[] pointerCoords = {new MotionEvent.PointerCoords()};MotionEvent.PointerCoords coords = pointerCoords[0];coords.x = 2 * x;coords.y = 2 * y;MotionEvent.PointerProperties[] pointerProperties = {new MotionEvent.PointerProperties()};MotionEvent.PointerProperties props = pointerProperties[0];props.id = 0;props.toolType = MotionEvent.TOOL_TYPE_FINGER;coords = pointerCoords[0];coords.orientation = 0;coords.pressure = 1;coords.size = 1;coords.setAxisValue(MotionEvent.AXIS_HSCROLL, hScroll);coords.setAxisValue(MotionEvent.AXIS_VSCROLL, vScroll);return MotionEvent.obtain(lastMouseDown, now, MotionEvent.ACTION_SCROLL, 1, pointerProperties, pointerCoords, 0, 0, 1f, 1f, 0,0, InputDevice.SOURCE_MOUSE, 0);}
public boolean injectInputEvent(InputEvent inputEvent, int mode) {try {return (Boolean) injectInputEventMethod.invoke(service, inputEvent, mode);} catch (InvocationTargetException | IllegalAccessException e) {e.printStackTrace();throw new AssertionError(e);}}
值得注意的是:一次點擊事件是由一個DOWN 和UP事件組成的。
進行通信
Client端(PC端)發送事件
通過SDL2的事件循環來監聽,對輸入的事件進行相應
開啟事件循環
需要注意 的是:
必須在主線程內(main方法所在的線程內)開啟事件循環 否則分分鐘給你一個異常。 開啟事件循環后,窗口上就出現按鈕了
? 開啟事件循環前
? 開啟事件循環后出現窗口上的按鈕.png
開啟事件循環代碼 :
//開啟Event Loopfor (;;) {SDL_WaitEvent(&event);//這里我們主要相應了if (event.type == SDL_MOUSEBUTTONDOWN) { //點擊事件的DOWNhandleButtonEvent(sc, &event.button);} else if (event.type == SDL_MOUSEBUTTONUP) { //點擊事件的UPhandleButtonEvent(sc, &event.button);} else if (event.type == SDL_KEYDOWN) { //按鍵事件DOWNhandleSDLKeyEvent(sc, &event.key);} else if (event.type == SDL_KEYUP) { //按鍵事件UPhandleSDLKeyEvent(sc, &event.key);} else if (event.type == SDL_MOUSEWHEEL) { // 滾輪事件//處理滑動事件handleScrollEvent(sc, &event.wheel);} else if (event.type == SDL_QUIT) { // 點擊窗口上的關閉按鈕printf("rev event type=SDL_QUIT\n");sc->destroy();break;}
事件處理代碼 : 其實就是將這些事件解析成坐標,然后通過socket發送
//對應點擊事件
void handleButtonEvent(SDL_Screen *screen, SDL_MouseButtonEvent *event) {int width = screen->screen_w;int height = screen->screen_h;int x = event->x;int y = event->y;//是否超過來邊界bool outside_device_screen = x < 0 || x >= width ||y < 0 || y >= height;if (event->type == SDL_MOUSEBUTTONDOWN) {}printf("outside_device_screen =%d\n", outside_device_screen);if (outside_device_screen) {// ignorereturn;}char buf[6];memset(buf, 0, sizeof(buf));printf("event x =%d\n", event->x);printf("event y =%d\n", event->y);printf("event char size =%zu\n", sizeof(char));buf[0] = 0;if (event->type == SDL_MOUSEBUTTONDOWN) {//發送down 事件buf[1] = 1;} else {// 發送UP事件buf[1] = 0;}//高8位buf[2] = event->x >> 8;//低8位buf[3] = event->x & 0xff;//高8位buf[4] = event->y >> 8;//低8位buf[5] = event->y & 0xff;int result = send(client_event, buf, 6, 0);printf("send result = %d\n", result);
}// 對應滑動事件
// Convert window coordinates (as provided by SDL_GetMouseState() to renderer coordinates (as provided in SDL mouse events)
//
// See my question:
// <https://stackoverflow.com/questions/49111054/how-to-get-mouse-position-on-mouse-wheel-event>
void handleScrollEvent(SDL_Screen *sc, SDL_MouseWheelEvent *event) {//處理滑動事件int x_c;int y_c;int *x = &x_c;int *y = &y_c;SDL_GetMouseState(x, y);SDL_Rect viewport;float scale_x, scale_y;SDL_RenderGetViewport(sc->sdl_renderer, &viewport);SDL_RenderGetScale(sc->sdl_renderer, &scale_x, &scale_y);*x = (int) (*x / scale_x) - viewport.x;*y = (int) (*y / scale_y) - viewport.y;int width = sc->screen_w;int height = sc->screen_h;//是否超過來邊界bool outside_device_screen = x_c < 0 || x_c >= width ||y_c < 0 || y_c >= height;printf("outside_device_screen =%d\n", outside_device_screen);if (outside_device_screen) {// ignorereturn;}SDL_assert_release(x_c >= 0 && x_c < 0x10000 && y_c >= 0 && y_c < 0x10000);//使用這個來記錄滑動的方向// SDL behavior seems inconsistent between horizontal and vertical scrolling// so reverse the horizontal// <https://wiki.libsdl.org/SDL_MouseWheelEvent#Remarks>// SDL 的滑動情況,兩個方向不一致int mul = event->direction == SDL_MOUSEWHEEL_NORMAL ? 1 : -1;int hs = -mul * event->x;int vs = mul * event->y;char buf[14];memset(buf, 0, sizeof(buf));printf(" x_c =%d\n", x_c);printf(" y_c =%d\n", y_c);printf(" hs =%d\n", hs);printf(" vs =%d\n", vs);buf[0] = 0;//滾動事件buf[1] = 2;//高8位buf[2] = x_c >> 8;//低8位buf[3] = x_c & 0xff;//高8位buf[4] = y_c >> 8;//低8位buf[5] = y_c & 0xff;//繼續滾動距離buf[6] = hs >> 24;//低8位buf[7] = hs >> 16;buf[8] = hs >> 8;buf[9] = hs;//高8位buf[10] = vs >> 24;//低8位buf[11] = vs >> 16;buf[12] = vs >> 8;buf[13] = vs;int result = send(client_event, buf, 14, 0);printf("send result = %d\n", result);}//對應鍵盤上的按鈕事件。
void handleSDLKeyEvent(SDL_Screen *sc, SDL_KeyboardEvent *event) {//分別對應 mac 上的 control option commandint ctrl = event->keysym.mod & (KMOD_LCTRL | KMOD_RCTRL);int alt = event->keysym.mod & (KMOD_LALT | KMOD_RALT);int meta = event->keysym.mod & (KMOD_LGUI | KMOD_RGUI);printf("ctrl = %d,", ctrl);printf("meta = %d,", meta);printf("alt = %d,\n", alt);因為我是mac鍵盤,期望control+ H = home鍵 control+b = back鍵//再去取keycodeSDL_Keycode keycode = event->keysym.sym;printf("keycode = %d, action type = %d\n", keycode, event->type);printf("b = %d, action type = %d\n", SDLK_b, event->type);if (event->type == SDL_KEYDOWN && ctrl != 0) {//這個時候發送的是按下的狀態if (keycode == SDLK_h) {char buf[4];memset(buf, 0, sizeof(buf));buf[0] = 0;//自定義的案件事件buf[1] = 3;//1 是 downbuf[2] = 1;//key code home 鍵對應的是 3buf[3] = 3;int result = send(client_event, buf, 4, 0);printf("send result = %d\n", result);} else if (keycode == SDLK_b) {char buf[4];memset(buf, 0, sizeof(buf));buf[0] = 0;//自定義的案件事件buf[1] = 3;//1 是 downbuf[2] = 1;//key code back 鍵對應的是 4buf[3] = 4;int result = send(client_event, buf, 4, 0);printf("send result = %d\n", result);}}if (event->type == SDL_KEYUP && keycode != 0) {if (keycode == SDLK_h) {char buf[4];memset(buf, 0, sizeof(buf));buf[0] = 0;//自定義的案件事件buf[1] = 3;//1 是 upbuf[2] = 0;//key code home 鍵對應的是 3buf[3] = 3;int result = send(client_event, buf, 4, 0);printf("send result = %d\n", result);} else if (keycode == SDLK_b) {char buf[4];memset(buf, 0, sizeof(buf));buf[0] = 0;//自定義的案件事件buf[1] = 3;//1 是 upbuf[2] = 0;//key code back 鍵對應的是 4buf[3] = 4;int result = send(client_event, buf, 4, 0);printf("send result = %d\n", result);}}
}
這里可以看到,根據每一種事件,都定義了對應的方式進行發送。那Android端,可以通過對應的方式進行接收就可以了~
Server端(Android端)接收事件 接收client端發送的事件。將其解析,注入
do {//讀到數據int read = Os.read(fileDescriptor, buffer);System.out.println("read=" + read + ",position=" + buffer.position() + "," +"limit=" + buffer.limit() + ",remaining " + buffer.remaining());//當讀到的長度為0,就結束了。if (read == -1 || read == 0) {//如果這個時候read 0 的話。就結束break;} else {buffer.flip();//上面定義的,如果是按鈕事件,第一個必須是0byte b = buffer.get(0);//進入對應的事件if (b == 0 && read > 1) { //如果是0 的話,就當作是Action//第2個是判斷事件的類型byte type = buffer.get(1);//按鍵事件。它發送時定義的長度是6if (type < 2 && read == 6) {//action down 1 down 0 upSystem.out.println("enter key event");buffer.position(1);int x = buffer.get(2) << 8 | buffer.get(3) & 0xff;int y = buffer.get(4) << 8 | buffer.get(5) & 0xff;//接受到事件進行處理boolean key = createKey(serviceManager, type, x, y);buffer.clear();} else if (type == 2 && read == 14) { //滾動事件.定義的長度是14buffer.position(1);//x,y是接觸的點,hs是水平的滑動,vs 是上下的滑動int x = buffer.get(2) << 8 | buffer.get(3) & 0xff;int y = buffer.get(4) << 8 | buffer.get(5) & 0xff;int hs = buffer.get(6) << 24 | buffer.get(7) << 16 | buffer.get(8) <<8 | buffer.get(9);int vs = buffer.get(10) << 24 | buffer.get(11) << 16 | buffer.get(12) <<8 | buffer.get(13);//接受到事件進行處理boolean b1 = injectScroll(serviceManager, x, y, hs, vs);// 處理完,記得清楚bufferbuffer.clear();} else if (type == 3 && read == 4) { //接受按鍵事件,長度是4System.out.println("enter key code event");int action = buffer.get(2) == 1 ? KeyEvent.ACTION_DOWN : KeyEvent.ACTION_UP;int keyCode = buffer.get(3);boolean key = injectKeyEvent(serviceManager, action, keyCode);// 處理完,記得清楚bufferbuffer.clear();}}}} while (!eof);
這樣就可以進行事件的相應了。
顯示和處理事件的優化
梳理優化邏輯
解碼線程異步 雖然我們已經通過Android的Api實現了按鍵注入,并且定義了Socket兩端對按鍵通信的協議。但是我們之前將解碼的循環已經寫在主線程中了。這樣我們需要將事件的循環加入到主線程中,才能對事件發起響應。 所以我們需要為我們的解碼循環,創建一個解碼線程,在異步進行解碼。 Socket通信異步 同時,和上一章相同,結合我們豐富的開發經驗知道,我們不能將耗時任務,放在主線程當中。所以事件通信。我們也需要放到異步處理。 隊列操作 我們知道事件循環會源源不斷的送入,而我們的事件發送只能一個一個的發送。所以我們需要為事件循環加入隊列的緩存。從主線程中接受事件,從發送線程中,對隊列中的事件進行一個一個的處理。 同時,根據之前的學習,我們也知道,我們的ffmpeg解碼和顯示其實也應該加入隊列顯示。這樣我們就可以防止丟幀的存在。 但是我們這里為了簡單顯示,只是緩存了兩幀。 一幀負責送顯。一幀負責接受解碼的幀。
線程模型
優化后的線程模型如下:
- client端(PC)- event_loopSDL的EventLoop。復制渲染上屏和分發事件- event_sender(Socket send)接受SDL分發的事件。并把對應的事件通過Socket分發給Android手機。- screen_receiver(Socket recv)通過Socket接受的 H264 Naul,使用FFmpeg進行解碼。- server端(Android)- screen record (Socket InputStream)使用SurfaceControl和MediaCodec進行屏幕錄制,錄制的結果通過Socket發送- event_loop (Socket OutputStream)接受Socket發送過來的事件。并調用對應的API進行事件的注入(InputManager)### 線程通信
- frames
兩塊緩存區域。- decode_frame解碼放置的frame- render_frame渲染需要的frame.使用該frame 進行render
數據流動- 生產的過程screen_receiver 負責生產。- 消費的過程event_loop 負責消費。將兩塊緩存區域進行交換,并把render_frame上屏- event
一個event_queue隊列來接受。可以使用鏈表
數據流動- 生產的過程event_loop 負責生產。并把數據送入隊列當中- 消費的過程event_sender 負責消費。如果隊列不為空,則進行發送
這里就不詳細說明了。具體可以看代碼就明白了。
最后的結果
?
最后的結果.gif
就和Vysor和scrcpy一樣,我們可以通過投屏PC ,并操作手機了。而且在很低的延遲下。
?
源碼地址:https://github.com/deepsadness/AppProcessDemo
還有更多的細節處理,可以參考scrcpy
總結
Android PC投屏簡單嘗試 這一系列文章,終于到了尾聲。總共橫跨了大半年的事件。 最后分成下面幾個方面來進行一下總結
數據源
截屏數據的獲取
Android的MediaProjection API 通過MediaProjection的權限的獲取和調用其API就能創建一個屏幕的錄制屏幕 直接反射調用SurfaceControl的系列方法 因為在app_process下,我們有較高的權限。所以可以直接通過反射調用SurfaceControl 的方法,來完成錄制屏幕數據的獲取。(參考adb screenrecord 命令)
截屏數據的處理
MediaCodec硬件編碼 使用MediaCodec結合Surface ,能容易就能得到編碼后的H264數據。 使用ImageReader的方式。 使用ImageReader 的方式,可以獲取一幀一幀的數據。之后我們可以選擇直接發送Bitmap數據。或者結合自己的軟件解碼器(FFmpeg或者X264)來編碼獲得H264數據。
發送的協議
自己定義的Socket協議
就是適合簡單的發送Bitmap。只要接受端能夠解析這個bitmap數據,就可以完成數據的展示。
RTMP協議
可以通過在服務端建立RTMP協議,然后通過這個協議進行。使用RTMP協議發送的好處在于,需要播放的端只要支持該協議,就可以輕松的進行拉流播放。
通過USB和ADB協議進行連接
這個僅僅適合于PC能夠直接用ADB和手機連接的場景。 但是在這個場景下,投屏的效果清晰,流暢,延遲很低。 暫時部分,因為直接發送H264數據,只要進行解碼后,就可以進行播放了。(文章使用了SDL2的方式進行了方便的播放。)
知識點
整個過程中 我們對Media Codec和ImageReader/RTMP協議/FFmpeg/SDL2/Gradle進行了知識點的串聯。 其實還是挺好玩的。
另外
如果是需要改成手機和手機連接。我們要怎么實現呢? 其實從上面不難看出。如果是手機和手機連接。 在近距離,我們可以簡單的使用藍牙進行Socket(類似ADB和USB的通信方式)。 如果是遠距離,就可以通過RMTP的方式,來進行推流和拉流。
最后,完結撒花?~~
投屏嘗試系列文章
Android PC投屏簡單嘗試- 自定義協議章(Socket+Bitmap) Android PC投屏簡單嘗試(錄屏直播)2—硬解章(MediaCodec+RMTP) Android PC投屏簡單嘗試(錄屏直播)3—軟解章(ImageReader+FFMpeg with X264)
?
作者:deep_sadness 鏈接:https://www.jianshu.com/p/c2da5174d5f7 來源:簡書 簡書著作權歸作者所有,任何形式的轉載都請聯系作者獲得授權并注明出處。
與50位技術專家面對面 20年技術見證,附贈技術全景圖
總結
以上是生活随笔 為你收集整理的Android PC投屏简单尝试—最终章2 的全部內容,希望文章能夠幫你解決所遇到的問題。
如果覺得生活随笔 網站內容還不錯,歡迎將生活随笔 推薦給好友。