Windows遠程桌面實現之十 - 移植xdisp_virt之iOS平臺屏幕截取,聲音採集,攝像頭採集(二)

                                             by fanxiushu 2019-12-13 轉載或引用請註明原始作者。

接上文,
雖然這篇文章闡述的還是以xdisp_virt項目的移植爲基礎,但是這裏主要描述的是iOS平臺下的
屏幕圖像數據截取,攝像頭圖像採集,app內部聲音和麥克風聲音採集,基本上是通用的。
所以如果對xdisp_virt沒興趣,可以無需關注xdisp_virt部分,只專心與iOS相關的採集函數部分。

描述的內容看似比較多,其實與windows平臺中這些內容採集的複雜度比較起來,基本就屬於太輕鬆了。
本文基本以 iOS 13.3,iPhone11Pro設備,也就是寫這篇文章的時候最新的iOS系統爲準,
語言以Obj-C爲主, Xcode版本爲 11 。

首先來看看攝像頭圖像數據採集和麥克風音頻採集。
這裏統一使用 AVCaptureSession框架,其實在iOS系統中也有好些其他框架來實現音頻和攝像頭採集,比如AvAudioRecorder,AVAudioQueue等,這裏採用AVCaptureSession,
因爲看中了它的統一,既能錄製攝像頭,也能錄製麥克風。錄製兩種數據,只需改變少量參數就可以了。
正如上文所述,xdisp_virt提供的是的是每一類採集對象的接口C函數,爲了符合這種模式,
給每一類創建 一個類對象,比如攝像頭可以做如下聲明:

@interface camVideoCapture: NSObject<AVCaptureVideoDataOutputSampleBufferDelegate>
{
@public DP_FRAME  callback;  // 回調函數,xdisp_virt提供的回調函數,當捕獲到數據時候,調用此回調函數。此概念就跟
                                                      AVCaptureVideoDataOutputSampleBufferDelegate 提供的代理回調函數一個意思。因此我們直接在
                                                      AVCaptureVideoDataOutputSampleBufferDelegate代理回調函數中,做些格式轉換處理,
                                                       然後調用 callback 就算是給xdisp_virt提供了攝像頭數據源了。
@public void* param;                 // callback對應的擴展參數,
@private int width,height;           // 攝像頭實際寬和高。
}
@property(strong, nonatomic) AVCaptureSession* session; /// 捕獲Session

-(int)Create:(NSInteger)index // 創建攝像頭 index是序號,比如 0 是後置,1是前置攝像頭,
@end;
////////
AVCaptureVideoDataOutputSampleBufferDelegate 就是代理類, 當攝像頭捕獲到圖像數據時候,
代理類中的對應的回調函數會被調用。

實現大致如下:
@implementation camVideoCapture

-(int)Create:(NSInteger)index
{
    if(index <0 || index > 1)return -1;
    AVCaptureDevicePosition pos;
    if(index ==0 )pos =AVCaptureDevicePositionBack;// 後置攝像頭
    else pos = AVCaptureDevicePositionFront;  //前置攝像頭
   
    AVCaptureDevice *avCaptureDevice=nil;
    NSArray *cameras = [AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo];
               //這裏查找視頻,如果是查找音頻,使用 AVMediaTypeAudio
    for (AVCaptureDevice *device in cameras) {
        if (device.position == pos) {
            avCaptureDevice = device;
        }
    }
    ///創建 AVCaptureSesion,並且設置攝像頭寬和高,這裏假設爲1920X1080
     self.session = [[AVCaptureSession alloc] init];
     self.session.sessionPreset = AVCaptureSessionPreset1920x1080;
   ///根據上面查找到的攝像頭設備, 創建攝像頭輸入設備,並且把這個輸入設備添加到 AVCaptureSession中
    NSError *error = nil;
    AVCaptureDeviceInput *videoInput = [AVCaptureDeviceInput deviceInputWithDevice:avCaptureDevice error:&error];
    [self.session addInput:videoInput];

    ////接下來創建輸出設備,並且把這個輸出設備添加到 Session中, 並且設置輸出設備的代理類爲我們的類,
    這樣下面的  captureOutput 回調函數會被調用,
    AVCaptureVideoDataOutput *avCaptureVideoDataOutput = [[AVCaptureVideoDataOutput alloc] init];
    NSDictionary*settings =
            @{(__bridge id)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange)}; // NV12
    avCaptureVideoDataOutput.videoSettings = settings; //設置 攝像頭輸出圖像格式爲NV12,
     dispatch_queue_t queue = dispatch_queue_create("xdisp_virt_camera_io", NULL);
    [avCaptureVideoDataOutput setSampleBufferDelegate:self queue:queue]; ///  設置代理類爲我們的類
    [self.session addOutput:avCaptureVideoDataOutput];

    return 0;
}

/// 代理回調函數,當採集到攝像頭圖像數據之後,這個函數被調用
- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection
{
       ///這裏我們需要獲取到具體的圖像數據,因爲先前設置的是NV12格式的圖像數據,
       /// CVPixelBufferRef 這裏存儲的就是具體的圖像數據,我們再進一步調用對應函數,獲取NV12每個平面的數據
       CVPixelBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
     
        int status = CVPixelBufferLockBaseAddress(pixelBuffer,0);  /// 鎖定
        if(status !=0) return;

        char data[4]; int stride[4];
        for(int i=0;i<2;++i){
             data[i] = (char*)CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, i);
             stride[i] = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer,i);
        }
        ----- data存儲的就是每個平面的圖像數據, stride是對應的步距。
       ------ 然後就是做些轉換,轉換到xdisp_virt對應的數據格式上
      dp_frame_t frame; ///xdisp_virt對應的數據結構。
       ........
     
      self->callback(&frame);//調用xdisp_virt對應回調函數,
     
        CVPixelBufferUnlockBaseAddress(pixelBuffer, 0); ///解鎖
}

@end

整個攝像頭的採集就這麼搞定了,比起windows平臺使用DirectShow實現攝像頭採集的複雜度,簡直就不在一個層面。
 麥克風的採集,基本跟上面的代碼差不多,只把 AVMediaTypeVideo改成 Audio,也就是Video改成Audio就差不多了,
不過 iOS中麥克風設置不支持,也就是 audioSeting屬性不支持,好像iOS中麥克風採集的格式永遠是  1個聲道,44100,16位的PCM。
另外上面的回調函數中,不再是 CMSampleBufferGetImageBuffer 獲取 CVPixelBufferRef,
而是
  CMBlockBufferRef blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
  size_t pcmLength = 0;
  char *pcmData = NULL;
  //獲取blockbuffer中的pcm數據的指針和長度
  OSStatus status = CMBlockBufferGetDataPointer(blockBuffer, 0, NULL, &pcmLength, &pcmData);

這樣來獲取PCM數據和PCM數據長度。獲得這些數據之後,我們再提供給 xdisp_virt就可以了。

上文說過,xdisp_virt提供的全是C函數接口,這裏需要把obj-c轉成 C標準函數,其實也不難,類似如下:
extern "C" void* cameraVideoCreate(struct cm_create_t* ct)
{
    camVideoCapture* cm = [[camVideoCapture alloc]init];
    cm->callback = ct->frame;
    cm->param=ct->param;
   
    if([cm Create:ct->idevice] <0){
        return nil;
    }
   
    if(ct->is_run){//  如果是要求立即運行,則啓動
        [cm.session startRunning];
    }
    printf("ptr=%p\n", cm);
   
    ///增加引用,防止函數退出後對象被自動回收,在 cameraVideoDestroy接口函數中調用 CFRelease來解除引用。
    CFRetain((__bridge void*)cm); ///
   
    return (__bridge void*)cm; ///轉成 C函數的  void* 指針。
}
以上全部代碼都寫到 後綴是 mm的文件中,這樣保證 C,C++, OBJ-C都能編譯。

下面再來看看如何採集 iOS系統的屏幕數據和iOS系統APP程序的聲音。
其實這個更簡單,因爲全部工作其實是iOS系統幫我們做好了。
使用 ReplayKit 框架。
說起這個框架,在iOS經歷了很大的變化時期,iOS9之前是沒有ReplayKit框架的,
爲了要在iOS9之前的系統採集屏幕數據,基本是想出各類奇招,比如破解AirPlay的通訊協議,iOS系統越獄等。
反正就是非常麻煩。
在iOS9雖然提供了ReplayKit,但只支持APP內錄屏,不能在整個系統範圍內錄屏,直到iOS11,才支持系統範圍內錄屏。
但是iOS11雖然支持,但做起來稍微麻煩,直到到了iOS12,iOS13,終於可以以最簡單的方式來錄屏了。
簡單到何種程度呢?
在我們的Xcode工程中,File ->New -> Target...
彈出的對話框中,選擇 BroadCast Uplaod Extension 就能在我們的工程中添加一個擴展程序,
然後自動生成兩個文件 SampleHandler.h 和 SampleHandler.m
然後安裝到手機之後,自動會在手機的屏幕錄製裏邊出現我們剛添加的BroadCast Uplaod Extension名字。
SampleHandler.m出現如下四個回調函數,一看就明白:

- (void)broadcastStartedWithSetupInfo:(NSDictionary<NSString *,NSObject *> *)setupInfo {
    // User has requested to start the broadcast. Setup info from the UI extension can be supplied but optional.
    ///
}
- (void)broadcastPaused {
    // User has requested to pause the broadcast. Samples will stop being delivered.
}
- (void)broadcastResumed {
    // User has requested to resume the broadcast. Samples delivery will resume.
}
- (void)broadcastFinished {
    // User has requested to finish the broadcast.
}
-(void)processSampleBuffer:(CMSampleBufferRef)sampleBuffer withType:(RPSampleBufferType)sampleBufferType {
   
    switch (sampleBufferType) {
        case RPSampleBufferTypeVideo:
            // Handle video sample buffer
            break;
        case RPSampleBufferTypeAudioApp:
            // Handle audio sample buffer for app audio
            break;
        case RPSampleBufferTypeAudioMic:
            // Handle audio sample buffer for mic audio
            break;      
        default:
            break;
    }
}

processSampleBuffer 回調函數就是對應採集到的數據的回調函數,而且一分就是三類,
一類 RPSampleBufferTypeVideo 是系統屏幕圖像數據,
一類 RPSampleBufferTypeAudioApp 是系統內的App發出的聲音數據
一類   RPSampleBufferTypeAudioMic   是麥克風採集的數聲音數據。

主要工作就是在 processSampleBuffer 回調函數中處理這些數據,
比如直接在這裏把錄屏數據編碼,然後做直播推流,或者傳遞到服務端等。

不過 BroadCast Uplaod Extension 是一個完全獨立的程序,與我們的App程序就是兩個程序,而且運行的權限不同,
我們的App是宿主程序,運行在沙盒中。
BroadCast Uplaod Extension程序採集的是系統範圍內的屏幕和聲音,需要更高的權限,
同時iOS對這個程序的內存佔用限制也很大,一般不超過50M,否則就會被kill掉。

在我們的xdisp_virt項目中,所有數據都是在xdisp_virt中進行處理的,
所以這裏必須把 BroadCast Uplaod Extension採集的數據傳輸到我們的宿主xdisp_virt中,這裏牽涉到的就是兩個進程通訊的問題。
iOS提供了一個叫 App Group來通訊,但是這裏不採用這種辦法,而是採用萬能的socket來通訊。
簡單的說,在BroadCast Uplaod Extension 創建一個偵聽到127.0.0.1的某個端口比如13579端口的服務端socket,
接收來自xdisp_virt的鏈接,xdisp_virt創建客戶端socket,並且鏈接到 127.0.0.1:13579, 這樣,BroadCast Uplaod Extension 在
procesSampleBuffer回調函數中,把採集到的數據通過socket發送到xdisp_virt宿主程序上。

至此,我們採集到了我們需要的屏幕數據,系統內部聲音,麥克風聲音,攝像頭數據。
可惜iOS不支持鼠標鍵盤模擬,否則就可以遠程控制手機了。

但是還有一些必須做的額外工作要做。
在攝像頭採集中,我們的App必須處於前臺,一旦切換到後臺之後或者屏幕鎖定之後,攝像頭採集就自動停止,
也就是在採集攝像頭數據的時候,必須讓我們的App處於前臺,不讓系統自動鎖屏,
要解決這個問題,直接調用
[UIApplication sharedApplication].idleTimerDisabled=YES; //

還有一個問題,就是屏幕採集的時候,是傳遞到我們的宿主程序再來處理的。
而默認情況下,App切換到後臺之後,會自動休眠,iOS系統還會根據情況殺掉某些休眠的App。
爲了錄屏的時候,讓我們的App切換到後臺也保持存活狀態,就必須採取某些措施。
一個比較常用的辦法就是讓App在後臺播放一個無聲的wav聲音,因爲iOS認爲播放聲音的App即使切換到後臺也應該保持存活的。
在工程的info.list中添加 Required background modes ,然後在工程設置的Signing& Capabilities添加Background Mode 爲
Audio,Airplay, and Picture In Picture,
做好這樣的配置之後,就是在我們的代碼中,循環播放一個聲音文件。大致代碼如下
-(void) playBackSound
{
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        ///
        AVAudioSession* session = [AVAudioSession sharedInstance];
        NSError* err=nil;     
        [session setCategory:AVAudioSessionCategoryPlayback
          withOptions:AVAudioSessionCategoryOptionMixWithOthers | AVAudioSessionCategoryOptionDuckOthers   
          error:&err];    

        NSString* path = [[NSBundle mainBundle] pathForResource:@"wusheng" ofType:@"wav"];
        NSData * data = [NSData dataWithContentsOfFile:path]; 
        self.audioPlayer =[[ AVAudioPlayer alloc]initWithData:data error:nil];
        if(self.audioPlayer){
            ///
            self.audioPlayer.delegate = self;        
            self.audioPlayer.numberOfLoops = -1; // loop for ever          
           [self.audioPlayer prepareToPlay];
           [self.audioPlayer play];
        //////
    });
}
同時還注意出現 播放聲音的時候,Interrupt的情況,比如打電話會中斷聲音的播放,這個時候在中斷結束的時候,需要重新再播放。

因爲iOS發佈還得買賬號,還得去AppStore過審,甚是麻煩,因此目前暫時不打算髮布iOS版本的xdisp_virt程序了。
不過以後的macOS和CentOS版本的xdisp_virt程序會發布到 GITHUB上,有興趣可關注。

下圖是手機屏幕採集的xdisp_virt,


來個更熱鬧的。
這個圖是在本地電腦上瀏覽器上顯示iOS屏幕內容,然後在遠端電腦的chrome瀏覽器中顯示iOS,同時使用VLC顯示RTMP推流的屏幕。

 

發佈了76 篇原創文章 · 獲贊 101 · 訪問量 35萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章