Texture ASCollectionNode 結合騰訊雲TRTC實現多人上臺直播

89

最近利用騰訊雲實時視音頻 TRTC SDK,學習如何開發教育直播類 APP,其中有一個需求是各個直播場景下多會用到的,那就是:html

如何實現多人同時在線直播畫面node

先放出效果圖:git

---「嚴肅臉」忽略畫面上那張油膩的臉 ---github

因此今天就來講一說如何利用 Texture CollectionNode 來完成這一功能的開發的。json

學習 Texture,可參考 Texture 官網小程序

在開始寫做以前,還須要先介紹使用到的騰訊實時音視頻 TRTC,經過 TRTC 能快速的將實時視頻數據渲染到視圖上,並不須要咱們本身來考慮這是如何實現實時視音頻直播互動的,從而可讓咱們把重心放到咱們本身的業務邏輯上。數組

騰訊實時音視頻 TRTC

騰訊實時音視頻(Tencent Real-Time Communication,TRTC)將騰訊 21 年來在網絡與音視頻技術上的深度積累,以多人音視頻通話和低延時互動直播兩大場景化方案,經過騰訊雲服務向開發者開放,致力於幫助開發者快速搭建低成本、低延時、高品質的音視頻互動解決方案。bash

實時音視頻 TRTC 主打全平臺互通的多人音視頻通話和低延時互動直播解決方案,提供小程序、Web、Android、iOS、Electron、Windows、macOS、Linux 等平臺的 SDK 便於開發者快速集成並與實時音視頻 TRTC 雲服務後臺連通。經過騰訊雲不一樣產品間的相互聯動,還能簡單快速地將實時音視頻 TRTC 與即時通訊 IM、雲直播 CSS、雲點播 VOD 等雲產品協同使用,擴展更多的業務場景。服務器

實時音視頻 TRTC 產品架構以下圖所示:網絡

在開發過程當中,發現集成騰訊實時音視頻 TRTC SDK 仍是很快速的,在實時體驗視頻直播和語音直播延遲都在可接受範圍內。目前咱們使用的核心功能有:

具體介紹和使用,可直接參考官網 實時音視頻 TRTC 產品詳細信息

騰訊 Demo

首先,由於是使用騰訊提供的騰訊實時音視頻 TRTC SDK,經過騰訊提供的配套 Demo,你會發現每一個在臺上直播的畫面,都是一個個 UIView,而後再根據上臺,或者下臺的狀況,動態去增長或移除直播畫面 UIView,具體代碼能夠參考下:

@property (weak, nonatomic) IBOutlet UIView *renderViewContainer;

@property (nonatomic, strong) NSMutableArray *renderViews;

#pragma mark - Accessor
- (NSMutableArray *)renderViews
{
    if(!_renderViews){
        _renderViews = [NSMutableArray array];
    }
    return _renderViews;
}

#pragma mark - render view
- (void)updateRenderViewsLayout
{
    NSArray *rects = [self getRenderViewFrames];
    if(rects.count != self.renderViews.count){
        return;
    }
    for (int i = 0; i < self.renderViews.count; ++i) {
        UIView *view = self.renderViews[i];
        CGRect frame = [rects[i] CGRectValue];
        view.frame = frame;
        if(!view.superview){
            [self.renderViewContainer addSubview:view];
        }
    }
}

- (NSArray *)getRenderViewFrames
{
    CGFloat height = self.renderViewContainer.frame.size.height;
    CGFloat width = self.renderViewContainer.frame.size.width / 5;
    CGFloat xOffset = 0;
    NSMutableArray *array = [NSMutableArray array];
    for (int i = 0; i < self.renderViews.count; i++) {
        CGRect frame = CGRectMake(xOffset, 0, width, height);
        [array addObject:[NSValue valueWithCGRect:frame]];
        xOffset += width;
    }
    return array;
}

- (TICRenderView *)getRenderView:(NSString *)userId streamType:(TICStreamType)streamType
{
    for (TICRenderView *render in self.renderViews) {
        if([render.userId isEqualToString:userId] && render.streamType == streamType){
            return render;
        }
    }
    return nil;
}

#pragma mark - event listener
- (void)onTICUserVideoAvailable:(NSString *)userId available:(BOOL)available
{
    if(available){
        TICRenderView *render = [[TICRenderView alloc] init];
        render.userId = userId;
        render.streamType = TICStreamType_Main;
        [self.renderViewContainer addSubview:render];
        [self.renderViews addObject:render];
        [[[TICManager sharedInstance] getTRTCCloud] startRemoteView:userId view:render];
    }
    else{
        TICRenderView *render = [self getRenderView:userId streamType:TICStreamType_Main];
        [self.renderViews removeObject:render];
        [render removeFromSuperview];
        [[[TICManager sharedInstance] getTRTCCloud] stopRemoteView:userId];
    }
    [self updateRenderViewsLayout];
}

- (void)onTICUserSubStreamAvailable:(NSString *)userId available:(BOOL)available
{
    if(available){
        TICRenderView *render = [[TICRenderView alloc] init];
        render.userId = userId;
        render.streamType = TICStreamType_Sub;
        [self.renderViewContainer addSubview:render];
        [self.renderViews addObject:render];
        [[[TICManager sharedInstance] getTRTCCloud] startRemoteSubStreamView:userId view:render];
    }
    else{
        TICRenderView *render = [self getRenderView:userId streamType:TICStreamType_Sub];
        [self.renderViews removeObject:render];
        [render removeFromSuperview];
        [[[TICManager sharedInstance] getTRTCCloud] stopRemoteSubStreamView:userId];
    }
    [self updateRenderViewsLayout];
}
複製代碼

主要經過數組增長或者移除一個個 TICRenderView 來達到目標,我不知道騰訊 Demo 這麼寫的好處在哪,但給個人感受代碼不太舒服,雖然從代碼字面上理解,這麼寫沒問題,有人上臺了,那就增長一個 UIView,移動 frame,嵌入數組中,放入 renderViewContainer,而後藉助 騰訊實時音視頻 TRTC SDK,把遠端流或者本地流渲染到 UIView 上就好。

但,結合到咱們具體的業務場景下,咱們很直觀的發現,每個直播畫面,不只僅只有直播推流,它還包含有其餘互動的東西和狀態,如每一個直播畫面的上臺用戶暱稱、是否有權限說話、是否是正在說話等等,因此每個直播畫面 UIView 更像一個個 UICollectionViewitem

因此我須要對這塊代碼進行改造,或者叫「重構」。

如今開始咱們的主角登場:ASCollectionNode

ASCollectionNode

ASCollectionNode is equivalent to UIKit’s UICollectionView and can be used in place of any UICollectionView.

使用介紹能夠直接看官網,只要用過 UICollectionView 對操做這個就很簡單了,具體看官網連接

初始化

@interface ZJRendersView : UIView <ASCollectionDataSourceInterop, ASCollectionDelegate, ASCollectionViewLayoutInspecting>

@property (nonatomic, strong) ASCollectionNode *collectionNode;;
@property (nonatomic, strong) NSMutableDictionary<NSString*, NSMutableDictionary*> *onlineUsers;
複製代碼

建立一個鍵值對 NSMutableDictionary類型的數組onlineUsers用於保存上臺用戶信息。

- (instancetype)init {
    self = [super init];
    if (self) {
        _onlineUsers = [NSMutableDictionary new];
        UICollectionViewFlowLayout* flowLayout = [[UICollectionViewFlowLayout alloc] init];
        flowLayout.minimumInteritemSpacing = 0.1;
        flowLayout.minimumLineSpacing = 0.1;
        _collectionNode = [[ASCollectionNode alloc] initWithCollectionViewLayout:flowLayout];
        _collectionNode.dataSource = self;
        _collectionNode.delegate = self;
        _collectionNode.backgroundColor = UIColorClear;
        _collectionNode.layoutInspector = self;
        [self addSubnode:_collectionNode];
        [_collectionNode.view mas_makeConstraints:^(MASConstraintMaker *make) {
            make.left.mas_equalTo(self);
            make.top.mas_equalTo(self);
            make.right.mas_equalTo(self);
            make.bottom.mas_equalTo(self);
        }];
    }
    return self;
}
複製代碼

初始化比較簡單,這裏的佈局主要用到的是 Masonry,能夠省很多心思在佈局上,由於是團隊項目,因此咱們儘量的不用 storyboard,佈局和 UIView 等都儘量的用代碼完成。

ASCollectionDataSource

#pragma mark - ASCollectionNode data source.

- (ASCellNodeBlock)collectionNode:(ASCollectionNode *)collectionNode nodeBlockForItemAtIndexPath:(NSIndexPath *)indexPath
{
    NSString* key = _keys[indexPath.item];
    NSDictionary *user = _onlineUsers[key];
    ASCellNode *(^cellNodeBlock)() = ^ASCellNode *() {
        return [[ZJRenderNode alloc] initWithHashID:key user:user];
    };

    return cellNodeBlock;
}

// The below 2 methods are required by ASCollectionViewLayoutInspecting, but ASCollectionLayout and its layout delegate are the ones that really determine the size ranges and directions
// TODO Remove these methods once a layout inspector is no longer required under ASCollectionLayout mode
- (ASSizeRange)collectionView:(ASCollectionView *)collectionView constrainedSizeForNodeAtIndexPath:(NSIndexPath *)indexPath
{
    return ASSizeRangeMake(CGSizeMake([UIScreen mainScreen].bounds.size.width / 7.0, self.bounds.size.height), CGSizeMake([UIScreen mainScreen].bounds.size.width / 7.0, self.bounds.size.height));
}

- (ASScrollDirection)scrollableDirections
{
    return ASScrollDirectionHorizontalDirections;
}

- (NSInteger)numberOfSectionsInCollectionNode:(ASCollectionNode *)collectionNode
{
    return 1;
}

- (NSInteger)collectionNode:(ASCollectionNode *)collectionNode numberOfItemsInSection:(NSInteger)section
{
    return _keys.count;
}
複製代碼

這裏,根據咱們業務的須要,整個上臺的直播界面放在同一行上,也就是設置滾動方向爲:ASScrollDirectionHorizontalDirections,一行顯示:numberOfSectionsInCollectionNode 爲 1。每一個直播界面的大小一致,將整個手機橫屏寬度 7 等分,CGSizeMake([UIScreen mainScreen].bounds.size.width / 7.0, self.bounds.size.height)

接下來就是如何作每一個 item 的佈局。

ZJRenderNode

就以下圖所示,每一個直播界面包含的元素挺多的,有講師標記、用戶名、語音音量條、得到的獎盃數等。

在以前的文章中有介紹過 ASButtonNodeASAbsoluteLayoutSpecASInsetLayoutSpec

咱們今天來看看用到的其餘的。

- (instancetype)init {
    self = [super init];
    if (self) {
        _backgroundNode = [[ASDisplayNode alloc] init];
        [self addSubnode:_backgroundNode];

        _bottomBackgroundNode = [[ASDisplayNode alloc] init];
        _bottomBackgroundNode.backgroundColor = [UIColorMakeWithHex(@"#3d3d3d") colorWithAlphaComponent:0.522];
        [self addSubnode:_bottomBackgroundNode];

        _nicknameNode = [[ASTextNode alloc] init];
        _nicknameNode.maximumNumberOfLines = 1;
        _nicknameNode.backgroundColor = [UIColorMakeWithHex(@"#3d3d3d") colorWithAlphaComponent:0];
        [_bottomBackgroundNode addSubnode:_nicknameNode];

        _permissionNode = [ASImageNode new];
        _permissionNode.image = UIImageMake(@"icon_permission");
        _permissionNode.backgroundColor = [UIColorMakeWithHex(@"#3d3d3d") colorWithAlphaComponent:0];
        [self addSubnode:_permissionNode];

        _microNode = [ASImageNode new];
        _microNode.backgroundColor = [UIColorMakeWithHex(@"#3d3d3d") colorWithAlphaComponent:0];
        [_bottomBackgroundNode addSubnode:_microNode];

        _zanNode = [[ASButtonNode alloc] init];
        [_zanNode setImage:UIImageMake(@"icon_zan") forState:UIControlStateNormal];
        [_zanNode setContentHorizontalAlignment:ASHorizontalAlignmentMiddle];
        [_zanNode setContentSpacing:2];
        _zanNode.backgroundColor = [UIColorMakeWithHex(@"#3d3d3d") colorWithAlphaComponent:0];
        _zanNode.hidden = YES;
        [_bottomBackgroundNode addSubnode:_zanNode];

        _volumnNode = [[ASDisplayNode alloc] init];
        _volumnNode.backgroundColor = [UIColorMakeWithHex(@"#3d3d3d") colorWithAlphaComponent:0];
        [self addSubnode:_volumnNode];

        _teacherIconNode = [ASImageNode new];
        _teacherIconNode.image = UIImageMake(@"icon_jiangshi");
        _teacherIconNode.backgroundColor = [UIColorMakeWithHex(@"#3d3d3d") colorWithAlphaComponent:0];
        [self insertSubnode:_teacherIconNode aboveSubnode:_volumnNode];

        [self updatePermission:user];
    }
    return self;
}
複製代碼

主要有三個佈局須要思考。

第一個就是設置一個 backgroundNode 用來接受遠端流和本地流的視頻流的,顯示直播畫面。在咱們的設計中,咱們將視頻流當作背景層,而後在之上去添加咱們的其餘元素。因此這裏咱們使用到了 ASBackgroundLayoutSpec

ASBackgroundLayoutSpec

ASBackgroundLayoutSpec lays out a component (blue), stretching another component behind it as a backdrop (red).

The background spec’s size is calculated from the child’s size. In the diagram below, the child is the blue layer. The child’s size is then passed as the constrainedSize to the background layout element (red layer). Thus, it is important that the child (blue layer) must have an intrinsic size or a size set on it.

ASInsetLayoutSpec* backgroundInsetLayoutSpec =  [ASInsetLayoutSpec
            insetLayoutSpecWithInsets:UIEdgeInsetsMake(0, 0, 0, 0)
                                child:_backgroundNode];

    return [ASBackgroundLayoutSpec backgroundLayoutSpecWithChild:contentSpec background:backgroundInsetLayoutSpec];
複製代碼

第二個是底部視圖 bottomBackgroundNode 用來佈局麥克風按鈕、暱稱、點贊等信息,這一塊佈局咱們用 Masonry 來作約束。

dispatch_async(dispatch_get_main_queue(), ^{
    // 更新音頻
    NSString* voiceIcon = [_user[@"voice"] boolValue] ? @"icon_microphone_good" : @"icon_microphone_bad";
    _microNode.image = UIImageMake(voiceIcon);

    if ([_key isEqualToString:_my_key]) {
        // 更新本身的音頻狀態
        if ([_user[@"voice"] boolValue]) {
            [[[TICManager sharedInstance] getTRTCCloud] startLocalAudio];
        } else {
            [[[TICManager sharedInstance] getTRTCCloud] stopLocalAudio];
        }
        [[[TICManager sharedInstance] getTRTCCloud] muteLocalAudio:![_user[@"voice"] boolValue]];
    }

    // 更新點贊
    if (_user && [_user[@"zan"] intValue] > 0) {
        _zanNode.hidden = NO;
        [_zanNode setTitle:_user[@"zan"] withFont:UIFontMake(10) withColor:UIColor.ZJ_tintColor forState:UIControlStateNormal];
    }

    // 用戶暱稱信息
    if (_user[@"nickname"] != nil) {
        NSString *nickname = [_user[@"nickname"] stringValue].length > 7 ? [[_user[@"nickname"] stringValue] substringWithRange:NSMakeRange(0, 7)] : [_user[@"nickname"] stringValue];
        _nicknameNode.attributedText = [[NSAttributedString alloc] initWithString:nickname attributes:@{
                NSFontAttributeName : UIFontMake(10),
                NSForegroundColorAttributeName: UIColor.ZJ_tintColor,
        }];
    }
    _teacherIconNode.hidden = ![_user[@"isteacher"] boolValue];

    _permissionNode.hidden = [_user[@"isteacher"] boolValue] || ![_user[@"board"] boolValue];

    [_permissionNode.view mas_updateConstraints:^(MASConstraintMaker *make) {
        make.top.mas_equalTo(self.view.mas_top).offset(4);
        make.right.mas_equalTo(self.view.mas_right).offset(-4);
        make.width.mas_equalTo(11);
        make.height.mas_equalTo(10);
    }];

    [_microNode.view mas_updateConstraints:^(MASConstraintMaker *make) {
        make.centerY.mas_equalTo(_bottomBackgroundNode.view.mas_centerY);
        make.left.mas_equalTo(_bottomBackgroundNode.view).offset(4);
        make.width.mas_equalTo(7.5);
        make.height.mas_equalTo(9);
    }];

    [_zanNode.view mas_updateConstraints:^(MASConstraintMaker *make) {
        make.centerY.mas_equalTo(_bottomBackgroundNode.view.mas_centerY);
        make.right.mas_equalTo(_bottomBackgroundNode.view.mas_right).offset(-4);
        make.width.mas_equalTo(18);
        make.height.mas_equalTo(13.5);
    }];

    CGSize size = [_nicknameNode calculateSizeThatFits:CGSizeMake(20, 16)];
    [_nicknameNode.view mas_updateConstraints:^(MASConstraintMaker *make) {
        make.left.mas_equalTo(self.microNode.view.mas_right).offset(4);
        make.centerY.mas_equalTo(_bottomBackgroundNode.view.mas_centerY);
        make.right.mas_equalTo(_zanNode.view.mas_left);
        make.height.mas_equalTo(size.height);
    }];
});
複製代碼

當本身的麥克風沒邀請打開([_user[@"voice"] boolValue]),則關閉本地音頻:

[[[TICManager sharedInstance] getTRTCCloud] stopLocalAudio];
複製代碼

不然打開本地音頻:

[[[TICManager sharedInstance] getTRTCCloud] startLocalAudio];

// 同時注意禁止或者放開推流
[[[TICManager sharedInstance] getTRTCCloud] muteLocalAudio:![_user[@"voice"] boolValue]];
複製代碼

整個底部佈局都使用 Masonry 來約束佈局,保證這幾個控件是垂直居中對齊的:

make.centerY.mas_equalTo(_bottomBackgroundNode.view.mas_centerY);
複製代碼

這裏須要注意的是 _nicknameNode 佈局,由於須要先計算這個佈局的大小,而後才能去佈局。

這裏的佈局須要再主線程執行:

dispatch_async(dispatch_get_main_queue(), ^{});
複製代碼

第三個是咱們語音音量條的佈局

[_volumnNode.view mas_updateConstraints:^(MASConstraintMaker *make) {
    make.left.equalTo(self.view.mas_left).offset(5);
    make.bottom.mas_equalTo(_bottomBackgroundNode.view.mas_top);
    make.height.mas_equalTo(30);
    make.width.mas_equalTo(5.5);
}];

for (NSUInteger i = 0; i < 10; i++) {
    ASImageNode *itemView = [[ASImageNode alloc] init];
    itemView.image = UIImageMake(@"icon_voiced");
    [itemView setHidden:YES];
    [_volumnNode addSubnode:itemView];
    [_renderNodes addObject:itemView];
    [_renderViews addObject:itemView.view];
}
[_renderViews mas_distributeViewsAlongAxis:MASAxisTypeVertical withFixedSpacing:0.5 leadSpacing:0 tailSpacing:0];

[_renderViews mas_updateConstraints:^(MASConstraintMaker *make) {
    //垂直方向能夠設置水平居中
    make.centerX.mas_equalTo(self.volumnNode.view.mas_centerX);
    make.width.mas_equalTo(5.5);
    make.height.mas_equalTo(2.5);
}];
複製代碼

咱們把音量 10 等分,每一個用 ASImageNode 表示,而後縱向疊加在一塊兒。這裏咱們使用 mas_distributeViewsAlongAxis 垂直佈局,空隙佔用 0.5。每個音量佔用 2.5 高度,整個佈局高度控制在 30,恰好佔滿 volumnNode 佈局。

完整佈局

NSMutableArray *mainStackContent = [[NSMutableArray alloc] init];
    if ([_user[@"isteacher"] boolValue]) {
        _teacherIconNode.style.preferredSize = CGSizeMake(22, 22.5);
        _teacherIconNode.style.layoutPosition = CGPointMake(0, 0);
        UIEdgeInsets insets = UIEdgeInsetsMake(0, 0, 0, 0);
        ASInsetLayoutSpec *teacherIconSpec = [ASInsetLayoutSpec insetLayoutSpecWithInsets:insets child:_teacherIconNode];
        [mainStackContent addObject:teacherIconSpec];
    }
    _volumnNode.style.preferredSize = CGSizeMake(8.5, 50);
    _volumnNode.style.layoutPosition = CGPointMake(5, 20);

    _bottomBackgroundNode.style.preferredSize = CGSizeMake(constrainedSize.max.width, 16);
    _bottomBackgroundNode.style.layoutPosition = CGPointMake(0, constrainedSize.max.height - 16);

    [mainStackContent addObject:_volumnNode];
    [mainStackContent addObject:_bottomBackgroundNode];

    ASAbsoluteLayoutSpec *contentSpec = [ASAbsoluteLayoutSpec absoluteLayoutSpecWithChildren:mainStackContent];

    ASInsetLayoutSpec* backgroundInsetLayoutSpec =  [ASInsetLayoutSpec
            insetLayoutSpecWithInsets:UIEdgeInsetsMake(0, 0, 0, 0)
                                child:_backgroundNode];

    return [ASBackgroundLayoutSpec backgroundLayoutSpecWithChild:contentSpec background:backgroundInsetLayoutSpec];
複製代碼

由於佈局結構簡單,並且定位清晰,因此咱們採用了 ASAbsoluteLayoutSpec,這個在上一篇文章有介紹,這裏就不作更多介紹了。

結合 TRTC

有了 ASCollectionNode 佈局,接下來就是結合 TRTC 完成推流和上下臺邏輯。

初始化 TRTC

// Podfile
use_frameworks!
pod 'TEduBoard_iOS','2.4.6.1'
pod 'TXIMSDK_iOS','4.6.101'
pod 'TXLiteAVSDK_TRTC','6.9.8341'
複製代碼

根據騰訊雲提供的教育解決方案 TIC 的說明,推薦安裝以上三個插件 (白板功能、IM 聊天、騰訊實時視音頻 TRTC)。

AppDelegate 初始化:

[[TICManager sharedInstance] init:sdkAppid callback:^(TICModule module, int code, NSString *desc) {
    if(code == 0){
        [[TICManager sharedInstance] addStatusListener:self];
    }
}];
複製代碼

直接引入官方 Demo 提供代碼,在根據業務須要去擴展,本文沒對他們作二次處理,方便跟隨官網插件更新迭代。

注:官方提供的鏈接插件是 CocoaAsyncSocket,可參考網站 robbiehanson/CocoaAsyncSocket

接下來就是登陸「房間」了。

[[TICManager sharedInstance] login:userId userSig:userSig callback:^(TICModule module, int code, NSString *desc) {
    if(code == 0){
        [JMLoadingHUD hide];
        [QMUITips showSucceed:@"登陸成功" inView:[[UIApplication sharedApplication] keyWindow] hideAfterDelay:3];
        ZJClassRoomViewController *classRoom = [ZJClassRoomViewController new];
        TICClassroomOption *option = [[TICClassroomOption alloc] init];
        option.classId = (UInt32) [json[@"room"][@"id"] intValue];
        classRoom.option = option;
        [ws.navigationController pushViewController:classRoom animated:YES];
    }
    else{
        [JMLoadingHUD hide];
        [[JMToast sharedToast] showDialogWithMsg:[NSString stringWithFormat:@"登陸失敗: %d,%@",code, desc]];
    }
}];
複製代碼

這裏 userSig 須要配合後臺去生成,參考生成規則和接口文檔。

[[TICManager sharedInstance] addMessageListener:self];
[[TICManager sharedInstance] addEventListener:self];
__weak typeof(self) ws = self;
[[TICManager sharedInstance] joinClassroom:option callback:^(TICModule module, int code, NSString *desc) {
    if(code == 0) {
//            [JMLoadingHUD hide];
        [QMUITips showSucceed:@"課堂準備完畢" inView:[[UIApplication sharedApplication] keyWindow] hideAfterDelay:3];
        //其它業務代碼
        // ...
        //
    } else {
        [[JMToast sharedToast] showDialogWithMsg:[NSString stringWithFormat:@"加入課堂失敗: %d,%@",code, desc]];
        if(code == 10015){
            [[JMToast sharedToast] showDialogWithMsg:@"課堂不存在,請\"建立課堂\""];
        }
        else {
            [[JMToast sharedToast] showDialogWithMsg:[NSString stringWithFormat:@"加入課堂失敗:%d %@", code, desc]];
        }
        [JMLoadingHUD hide];
    }
}];
複製代碼

這裏進入課堂,主要是初始化白板、加入 IM 羣等邏輯,參考騰訊提供的 Demo:

- (void)joinClassroom:(TICClassroomOption *)option callback:(TICCallback)callback
{
    _option = option;
    _enterCallback = callback;
    
    //白板初始化
    __weak typeof(self) ws = self;
    void (^createBoard)(void) = ^(void){
        TEduBoardAuthParam *authParam = [[TEduBoardAuthParam alloc] init];
        authParam.sdkAppId = ws.sdkAppId;
        authParam.userId = ws.userId;
        authParam.userSig = ws.userSig;
        TEduBoardInitParam *initParam = option.boardInitParam;
        if(!initParam){
            initParam = [[TEduBoardInitParam alloc] init];
        }
        [ws report:TIC_REPORT_INIT_BOARD_START];
        ws.boardController = [[TEduBoardController alloc] initWithAuthParam:authParam roomId:ws.option.classId initParam:initParam];
        [ws.boardController addDelegate:ws];
        if(option.boardDelegate){
            [ws.boardController addDelegate:option.boardDelegate];
        }
    };
    
    [self report:TIC_REPORT_JOIN_GROUP_START];
    //IM進房
    void (^succ)(void) = ^{
        [ws report:TIC_REPORT_JOIN_GROUP_END];
        createBoard();
    };
    
    void (^fail)(int, NSString*) = ^(int code, NSString *msg){
        [ws report:TIC_REPORT_JOIN_GROUP_END code:code msg:msg];
        TICBLOCK_SAFE_RUN(callback, TICMODULE_IMSDK, code, msg);
    };
    
    [self joinIMGroup:[@(_option.classId) stringValue] succ:^{
        if(ws.option.compatSaas){
            NSString *chatGroup = [self getChatGroup];
            [self joinIMGroup:chatGroup succ:^{
                succ();
            } fail:^(int code, NSString *msg) {
                fail(code, msg);
            }];
        }
        else{
            succ();
        }
    } fail:^(int code, NSString *msg) {
        fail(code, msg);
    }];
};
複製代碼

白板

白板是教育直播的一個核心功能,講師或者用戶能夠根據受權,參與白板操做和交流:

UIView *boardView = [[[TICManager sharedInstance] getBoardController] getBoardRenderView];

// 默認不能操做白板
[[[TICManager sharedInstance] getBoardController] setDrawEnable:NO];
boardView.frame = self.boardBackgroudView.bounds;
[self.boardBackgroudView addSubview:boardView];
[[[TICManager sharedInstance] getBoardController] addDelegate:self];
複製代碼

在實際業務場景下須要用到白板的一些功能:

/**
 * @brief 設置要使用的白板工具
 * @param type                 要設置的白板工具
*/
- (void)onSelectToolType:(int)toolType
{
    [[[TICManager sharedInstance] getBoardController] setToolType:(TEduBoardToolType)toolType];
}

/**
 * @brief 設置畫筆顏色
 * @param color             要設置的畫筆顏色
 *
 * 畫筆顏色用於全部塗鴉繪製
*/
- (void)onSelectBrushColor:(UIColor *)color
{
    [[[TICManager sharedInstance] getBoardController] setBrushColor:color];
}

/**
 * @brief 設置畫筆粗細
 * @param thin                 要設置的畫筆粗細
 *
 * 畫筆粗細用於全部塗鴉繪製,實際像素值取值(thin * 白板的高度 / 10000)px,若是結果小於1px,則塗鴉的線條會比較虛
*/
- (void)onBrushThinChanged:(float)thin
{
    [[[TICManager sharedInstance] getBoardController] setBrushThin:thin];
}

/**
 * @brief 設置文本顏色
 * @param color             要設置的文本顏色
*/
- (void)onSelectTextColor:(UIColor *)color
{
    [[[TICManager sharedInstance] getBoardController] setTextColor:color];
}

/**
 * @brief 設置當前白板頁的背景色
 * @param color             要設置的背景色
 *
 * 白板頁建立之後的默認背景色由 SetDefaultBackgroundColor 接口設定
*/
- (void)onSelectBackgroundColor:(UIColor *)color
{
    [[[TICManager sharedInstance] getBoardController] setBackgroundColor:color];
}

/**
 * @brief 設置文本大小
 * @param size                 要設置的文本大小
 *
 * 實際像素值取值(size * 白板的高度 / 10000)px
*/
- (void)onTextSizeChanged:(float)thin
{
    [[[TICManager sharedInstance] getBoardController] setTextSize:thin];
}

/**
 * @brief 設置白板是否容許塗鴉
 * @param enable             是否容許塗鴉,true 表示白板能夠塗鴉,false 表示白板不能塗鴉
 *
 * 白板建立後默認爲容許塗鴉狀態
*/
- (void)onDrawStateChanged:(BOOL)state
{
    [[[TICManager sharedInstance] getBoardController] setDrawEnable:state];
}

/**
 * @brief 設置白板是否開啓數據同步
 * @param enable    是否開啓
 *
 * 白板建立後默認開啓數據同步,關閉數據同步,本地的全部白板操做不會同步到遠端和服務器
*/
- (void)onSyncDataChanged:(BOOL)state
{
    [[[TICManager sharedInstance] getBoardController] setDataSyncEnable:state];
}

/**
 * @brief 設置當前白板頁的背景 H5 頁面
 * @param url                要設置的背景 H5 頁面 URL
 *
 * 該接口與 SetBackgroundImage 接口互斥
*/
- (void)onSetBackgroundH5:(NSString *)url
{
    [[[TICManager sharedInstance] getBoardController] setBackgroundH5:url];
}

/**
 * @brief 設置文本樣式
 * @param style             要設置的文本樣式
*/
- (void)onSetTextStyle:(int)style
{
    [[[TICManager sharedInstance] getBoardController] setTextStyle:(TEduBoardTextStyle)style];
}

/**
 * @brief 撤銷當前白板頁上一次動做
*/
- (void)onUndo
{
    [[[TICManager sharedInstance] getBoardController] undo];
}

/**
 * @brief 重作當前白板頁上一次撤銷
*/
- (void)onRedo
{
    [[[TICManager sharedInstance] getBoardController] redo];
}

/**
 * @brief 清除塗鴉,同時清空背景色以及背景圖片
 */
- (void)onClear
{
    [[[TICManager sharedInstance] getBoardController] clear];
}

/**
 * @brief 清除塗鴉
 */
- (void)onClearDraw
{
    [[[TICManager sharedInstance] getBoardController] clearDraws];
}

/**
 * @brief 重置白板
 *
 * 調用該接口後將會刪除全部的白板頁和文件
*/
- (void)onReset
{
    [[[TICManager sharedInstance] getBoardController] reset];
}

/**
 * @brief 設置當前白板頁的背景圖片
 * @param url                 要設置的背景圖片 URL,編碼格式爲 UTF8
 * @param mode                要使用的圖片填充對齊模式
 *
 * 當URL是一個有效的本地文件地址時,該文件會被自動上傳到 COS
*/
- (void)onSetBackgroundImage:(NSString *)path
{
    [[[TICManager sharedInstance] getBoardController] setBackgroundImage:path mode:TEDU_BOARD_IMAGE_FIT_MODE_CENTER];
}
複製代碼

視頻推流和拉流

當遠端流有推流過來時,會觸發咱們的消息事件:

/**
 * userId對應的遠端主路(即攝像頭)畫面的狀態通知
 * @param userId    用戶標識
 * @param available 畫面是否開啓
 **/
- (void)onTICUserVideoAvailable:(NSString *)userId available:(BOOL)available
{
    NSLog(@"onTICUserVideoAvailable userId: %@, available = %d", userId, available);
    [self.rendersView onTICUserVideoAvailable:userId available:available];
}
複製代碼

對應的咱們操做,接受或者中止接受遠端流:

- (void)onTICUserVideoAvailable:(NSString *)userId available:(BOOL)available {
    [[[TICManager sharedInstance] getTRTCCloud] muteRemoteVideoStream:userId mute:!available];
}
複製代碼

當咱們服務器推送咱們說有用戶上臺時,咱們先增長一個 ASCollectionNode item,即在咱們的 ZJRenderNode 作打開和關閉流的開關操做:

- (void)updateVideoStatus:(bool)available {
    dispatch_async(dispatch_get_main_queue(), ^{
        if ([_key isEqualToString:_key]) {
            if (available) {
                NSLog(@"startLocalPreview:");
                [[[TICManager sharedInstance] getTRTCCloud] startLocalPreview:YES view:_backgroundNode.view];
            } else {
                NSLog(@"stopLocalPreview:");
                [[[TICManager sharedInstance] getTRTCCloud] stopLocalPreview];
            }
        } else {
            if (available) {
                [[[TICManager sharedInstance] getTRTCCloud] startRemoteView:_hash_id view:_backgroundNode.view];
            } else {
                [[[TICManager sharedInstance] getTRTCCloud] stopRemoteView:_hash_id];
            }
        }
    });
}
複製代碼

最後在拿到服務器推送時,若是下臺名單裏包含本身,則直接關閉本身的本地流推送:

// 本身下臺,中止推送視音頻和操做白板
if ([key isEqualToString:_my_key]) {
    // 中止本地視頻推流  
    [[[TICManager sharedInstance] getTRTCCloud] stopLocalPreview];
    // 中止本地音頻推流
    [[[TICManager sharedInstance] getTRTCCloud] stopLocalAudio];
    // 中止操做白板權限
    [[[TICManager sharedInstance] getBoardController] setDrawEnable:NO];
}
複製代碼

音頻音量操做

// 硬件設備事件回調
- (void)onUserVoiceVolume:(NSArray<TRTCVolumeInfo *> *)userVolumes totalVolume:(NSInteger)totalVolume {
    [self.rendersView onUserVoiceVolume:userVolumes totalVolume:totalVolume];
}

// ZJRendersView.m
- (void)onUserVoiceVolume:(NSArray<TRTCVolumeInfo *> *)userVolumes totalVolume:(NSInteger)totalVolume {
    for (TRTCVolumeInfo *info in userVolumes) {
        if (keys[info.userId]) {
            ZJRenderNode *node = [_collectionNode nodeForItemAtIndexPath:keys[info.userId]];
            [node updateVolumn:(info.volume / 10)];
        }
    }
}

// ZJRenderNode
// 更新音量 UI,經過 hidden 屬性值來設置音量的變化
- (void)updateVolumn:(NSUInteger)count {
    dispatch_async(dispatch_get_main_queue(), ^{
        NSUInteger i = 0;
        for (i = 0; i < 10 - count; ++i) {
            [_renderNodes[i] setHidden:YES];
        }

        for (NSUInteger j = i; j < 10; ++j) {
            [_renderNodes[j] setHidden:NO];
        }
    });
}
複製代碼

總結

至此,咱們的核心功能就算開發完成了,這裏缺乏的 IM 那一塊,能夠結合上一篇的文章聊天界面設計來動手試試。

結合 ASCollectionNode 和騰訊雲實時視音頻 TRTC SDK 完成一個教育類多人上臺互動直播從體驗和直播效果來看,騰訊雲實時視音頻能力仍是很不錯的。連開着多人直播一點都不卡,延遲在幾百毫秒可接受範圍內,值得你們推薦使用。

相關文章
相關標籤/搜索