产品文档 直播技术文档 iOS 直播 Core SDK

iOS 直播 Core SDK

git 链接:http://git.baijiashilian.com/open-ios/BJLiveCore.git

App 下载:https://itunes.apple.com/app/id1146697098?ls=1&mt=8

旧版 SDK 文档:iOS 直播 Core SDK 1.x

SDK 升级文档:iOS 1.x > 2.x SDK 升级整体说明

功能介绍

百家云 iOS 直播 Core SDK 提供直播间场景及相应的一系列直播功能, 包括音视频推拉流、信令服务器通信、聊天服务器通信等,不包含 UI 资源,提供的 demo 可以较为完整的体验各个功能模块。包含 UI 的 SDK 请参考 iOS 直播 UI SDK, 该 SDK 提供了一个针对教育场景下师生互动模板,包含一套完整的直播间 UI,集成工作量小,便于快速开发。

1. 概念

老师 主讲人,拥有直播间最高权限,可以 设置上下课、发公告、处理他人举手、远程开关他人麦克风和摄像头、开关录课、开关聊天禁言
助教 管理员,拥有部分老师的权限,不包含上、下课等改变教室状态的功能
学生 听讲人,权限受限,无法对他人的直播间内容进行管理
教室 直播间,提供创建、管理等一系列功能。提供上课、下课等接口,大多数功能模块只有在上课状态下有效
举手 学生申请发言,老师和管理员可以允许或拒绝
发言 发布音频、视频,SDK 层面发言不要求举手状态
采集 通过设备摄像头、麦克风获取自己的本地视频、音频数据
播放 播放他人发布的视频,支持同时播放多个人的视频
录课 云端录制课程
聊天 直播间内的群聊功能,支持发送图片、表情
课件 课件第一页是白板,主要用于添加画笔;老师可上传图片格式的课件,上传成功之后可在直播间内显示;支持 PPT 动画(需要在 PC 端上传)
画笔 老师、助教或发言状态的学生可以在 白板和 PPT 上添加、清除画笔;添加画笔的用户当前的 PPT 页必须与老师保持一致
公告 由老师编辑、发布,可包含跳转链接,即时更新
测验 学生收到老师发布的测验、进行答题

2.主要功能

模块 功能 对应文件
教室管理 进入 / 退出教室及相应的事件监听 BJLRoom.h
断开重连
进入教室的加载状态监听 BJLLoadingVM.h
老师:上课 / 下课 BJLRoomVM.h
在线用户信息管理 加载在线用户信息 BJLOnlineUsersVM.h
监听用户进入、退出教室
音视频采集 开启/关闭音视频采集 BJLRecordingVM.h
音视频采集状态监听
采集设置:视频方向,清晰度,美颜
视频播放 播放、关闭指定用户的视频 BJLPlayingVM.h
老师:远程开关用户麦克风、摄像头
监听对象音视频开关状态、音视频用户列表变化
音视频链路设置 设置用于音视频的 上行 / 下行 链路的类型:UDP/TCP BJLMediaVM.h
举手、发言邀请 学生举手、取消举手,老师处理举手申请 BJLSpeakingRequestVM.h
学生接收、处理发言邀请
课件管理 上传、添加课件,删除课件,课件翻页 BJLDocumentVM.h
加载所有课件 BJLDocumentVM.h
监听课件添加、删除
画笔 开关、编辑画笔 BJLDrawingVM.h
聊天 发送消息(文字、图片、表情) BJLChatVM.h
监听收到消息
禁言
录课 老师:监听云端录课不可用的通知,获取云端录课状态,开启/停止云端录课 BJLServerRecordingVM.h
公告 发布公告 BJLRoomVM.h
获取教室公告,监听公告变化
测验 获取历史题目、新题目、答题统计 BJLRoomVM.h
学生:答题

Demo

1. Demo 源文件

git 上下载最新版本的 SDK,demo 源文件 在 BJLiveCore/demo/BJLiveCore 文件夹中。

2. Demo 编译、运行

  • 在 demo 的工程目录下执行 pod update

  • 使用 Xcode 打开 demo 文件夹下的 BJLiveCore.xcworkspace 文件。

  • 选择运行设备:模拟器运行 demo 时无法采集音视频;真机运行时,需要设置好 development team

picture

  • 使用 Xcode 运行 demo。

3. Demo 体验

  • demo 运行成功后将进入如下登录界面,需要输入参加码及用户名才能进入教室。其中参加码通过使用 百家云后台 或者 API 创建一个教室获得,用户名可自定义。

    登陆界面

  • 教室加载成功之后进入如下主界面,包含课件、采集、播放、控制台(显示教室动态及聊天消息)等部分,参考红色标注。

    教室界面 采集

引入 SDK

SDK 支持 iOS 9.0 及以上 的系统,iPhone、iPad 等设备,集成 2.0.0 或以上版本的 SDK 要求 Xcode 的版本至少为 9.0,由于 SDK 依赖关系复杂、手动配置繁琐,建议使用 CocoaPods 方式引入。

  • Podfile 中设置 source:
source 'https://github.com/CocoaPods/Specs.git'
source 'http://git.baijiashilian.com/open-ios/specs.git'
  • Podfile 中引入 BJLiveCore:
pod 'BJLiveCore', '~> 2.2.0'

# 用于动态引入 Framework,避免冲突问题
script_phase \
:name => '[BJLiveCore] Embed Frameworks',
:script => 'Pods/BJLiveCore/frameworks/EmbedFrameworks.sh',
:execution_position => :after_compile

# 用到了点播回放 SDK 时需要加上
script_phase \
:name => '[BJVideoPlayerCore] Embed Frameworks',
:script => 'Pods/BJVideoPlayerCore/frameworks/EmbedFrameworks.sh', 
:execution_position => :after_compile

# 用于清理动态引入的 Framework 用不到的架构,避免发布 AppStore 时发生错误,需要写在动态引入 Framework 的 script 之后
script_phase \
:name => '[BJLiveBase] Clear Archs From Frameworks',
:script => 'Pods/BJLiveBase/script/ClearArchsFromFrameworks.sh "BJHLMediaPlayer.framework" "BJYIJKMediaFramework.framework"',
:execution_position => :after_compile
  • 注意如果先集成了直播,后集成点播回放的时候,再引入点播回放 SDK 所需的 script 会导致工程 build phases 中顺序不对的情况,需要在 build phases 删除一下已经设置好的 script,重新 pod install 来保证直播和点播回放拷贝 framework 的前二个 script 先运行,清理 framework 的模拟器架构的 script 后运行。

  • 工程目录下执行 pod install,初次集成需要执行 pod update 更新 CocoaPods 的索引。

版本升级

版本号格式为 大版本.中版本.小版本[-alpha(测试版本)/beta(预览版本)]

  • 测试版本和预览版本可能不稳定,请勿随意尝试。

  • 小版本升级只改 BUG、UI 样式优化,不会影响功能。

  • 中版本升级、修改功能,更新 UI 风格、布局,会新增 API、标记 API 即将废弃,但不会导致现有 API 不可用。

  • 大版本任何变化都是有可能的。

首次集成建议选择最新正式版本(版本号中不带有 alphabeta 字样),版本升级后请仔细阅读 ChangeLog,指定版本的方式有一下几种:

  • 固执型:pod update 时不会做任何升级,但可能无法享受到最新的 BUG 修复,建议用于 0.x 版本。
pod 'BJLiveCore', '2.0.0'
  • 稳妥型(推荐):pod update 时只会升级到更稳定的小版本,而不会升级中版本和大版本,不会影响功能和产品特性,升级后需要 适当测试
pod 'BJLiveCore', '~> 2.0.0'
  • 积极型:pod update 时会升级中版本,但不会升级大版本,及时优化,但不会导致编译出错不可用,升级后需要 全面测试
pod 'BJLiveCore', '~> 2.0'
  • 激进型(不推荐):pod update 时会升级大版本,可能导致编译出错、必须调整代码,升级后需要 严格测试
pod 'BJLiveCore'

工程设置

  • 隐私权限:在 Info.plist 中添加麦克风、摄像头、相册访问描述。
Privacy - Microphone Usage Description       用于语音上课、发言
Privacy - Camera Usage Description           用于视频上课、发言,拍照传课件、聊天发图
Privacy - Photo Library Usage Description    用于上传课件、聊天发图

picture

<key>NSMicrophoneUsageDescription</key>
<string>用于语音上课、发言</string>
<key>NSCameraUsageDescription</key>
<string>用于视频上课、发言,拍照传课件、聊天发图</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>用于上传课件、聊天发图</string>
  • 后台任务(打开这一选项之后,在 App 提交审核时,强烈建议录制一个视频,说明 App 确实用到了后台播放,否则审核很有可能不通过): 在 Project > Target > Capabilities 中打开 Background Modes 开关,选中 Audio, Airplay, and Picture in Picture

picture

Hello World

可参考 demo 中的 BJRoomViewController

1. 要点说明

1.1 流程概述

SDK 所有的功能需要在 教室 中完成,进入教室成功之后才能正常使用各个功能模块。进入教室需要创建一个 BJLRoom 的实例,然后调用它的相关方法实现业务逻辑。进入教室成功之后, 可通过 BJLRoom 中定义的各种 ViewModel 管理相应的功能模块,对应关系参考 主要功能。流程要点总结如下:

  • BJLRoom 是直播功能的入口,用于创建、进入、退出教室。
  • 教室内各个功能通过对应的 ViewModel(以下简称 VM)来管理。
  • 所有 VM 及其所有属性支持 KVO 以便监听状态变化(除非额外注释说明),返回类型值为 BJLObservable 的方法表示可监听、用于监听事件。
  • VM 在创建教室时被初始化,此时可以添加监听,但 VM 的属性没有与服务端同步,也不能调用 VM 的方法与服务端交互。
  • 调用 enter 方法开始发起网络请求、进教室,可以通过 room.loadingVM 获取加载进度、成功和失败等,成功或失败后 room.loadingVM 被设置为空。
  • room.state 是教室的在线状态,进入教室成功状态为 BJLRoomState_connected,此时 VM 的属性与服务端完成同步,同时可调用 VM 方法与服务端交互。

1.2 属性、方法监听方式:Block

我们使用 NSObject+BJLObserving.h 中的 Block 监听方式监听属性变化和方法,相比 RAC 更简单、高效、方便调试;self 和被监听对象 dealloc 时都会自动取消监听,相比普通 KVO 更简单、易用、且安全。

1.2.1 通过 Block 方式进行 KVO
  • KVO 调用方式,添加 KVO 监听时的对象不能为空,必须在对象存在时才能对对象的属性就行监听,block 接收的返回值必须声明为对象,数值类型的接收值使用 NSValue 或者 泛型,支持 filter - 可选:
// example: 监听教室上课状态
bjl_weakify(self);
[self bjl_kvo:BJLMakeProperty(self.room.roomVM, // 对象,不能为 nil
                              liveStarted) // 属性名,支持代码自动完成
       filter:^BOOL(NSNumber *value, NSNumber *oldValue, BJLPropertyChange * _Nullable change) { // 过滤
           return oldValue.boolValue != value.boolValue;  // 返回 NO 丢弃
       }
     observer:^BOOL(NSNumber *value, NSNumber *oldValue, BJLPropertyChange * _Nullable change) { // 处理
         bjl_strongify(self);
         // console 为自定义的控制台视图
         [self.console printFormat:@"liveStarted: %@", NSStringFromBOOL(value.boolValue)];
         return YES; // 返回 NO 取消 KVO
     }];
  • 支持两种方式取消某次 KVO,并且 self 或被监听对象 dealloc 时都会自动取消监听。
// example: 监听教室上课状态
bjl_weakify(self);
id<BJLObservation> observation =
    [self bjl_kvo:BJLMakeProperty(self.room.roomVM, liveStarted)
         observer:^BOOL(NSNumber *value, NSNumber *oldValue, BJLPropertyChange * _Nullable change) {
           bjl_strongify(self);
           [self.console printFormat:@"liveStarted: %@", NSStringFromBOOL(value.boolValue)];
           return YES; // 1. 返回 NO 取消 KVO
       }];
[observation stopObserving]; // 2. 取消 KVO
1.2.2 通过 Block 方式监听方法调用
  • 监听方法调用,参数要求是支持 KVO 的方法,如果方法的参数是数值类型,可以使用 NSValue 或者泛型的方式接收返回值,或者在 block 返回值前添加 (BJLMethodObserver),支持 filter - 可选:
// example: 监听 '即将退出教室' 事件
bjl_weakify(self);
[self bjl_observe:BJLMakeMethod(self.room, // 对象,不能为 nil
                                roomWillExitWithError:) // 方法
           filter:(BJLMethodFilter)^BOOL(BJLError *error) { // 过滤
               return !!error; // 返回 NO 丢弃
           }];
         observer:(BJLMethodObserver)^BOOL(BJLError *error) { // 处理
             bjl_strongify(self);
             [self.console printFormat:@"roomWillExitWithError: %@", error];
             return YES; // 返回 NO 取消监听
         }];
  • 支持 多个参数
// example: 监听发言用户的状态变化
bjl_weakify(self);
[self bjl_observe:BJLMakeMethod(self.room.playingVM, playingUserDidUpdate:old:)
             observer:^BOOL(BJLUser *now,
                            BJLUser *old) {
                 bjl_strongify(self);
                 [self.console printFormat:@"playingUserDidUpdate:old: %@, %@", now, old];
             }];
  • 支持两种方式取消某次监听,并且 self 或被监听对象 dealloc 时都会自动取消监听。
// example: 监听 '即将退出教室' 事件
bjl_weakify(self);
id<BJLObservation> observation =
    [self bjl_observe:BJLMakeMethod(self.room, roomWillExitWithError:)
             observer:(BJLMethodObserver)^BOOL(BJLError *error) {
                 bjl_strongify(self);
                 [self.console printFormat:@"roomWillExitWithError: %@", error];
                 return YES; // 1. 返回 NO 取消监听
             }];
[observation stopObserving]; // 2. 取消监听

1.3 新版小班课支持的设备

教室类型为新版小班课(参考 BJLRoomType)的教室,只支持 64-bit 设备进入教室,32-bit 设备进教室时通过 enterRoomFailureWithError: 返回错误码 BJLErrorCode_enterRoom_unsupportedDevice,参考 iOS device summary

  • iPad: 1、2、3、4、mini 1 是 32-bit,其它都是 64-bit。
  • iPhone: 5、5C 之前的设备是 32-bit,5S 开始是 64-bit。
  • iPod Touch: 1、2、3、4、5 是 32-bit,6 开始是 64-bit。

2. 引入头文件

#import <BJLiveCore/BJLiveCore.h>

3. 创建、进入教室

创建、进入教室的整体流程如下:

  • 设置专属域名前缀。
  • 在自己定义的相关文件中定义一个 BJLRoom 的属性 room,用于管理教室。
  • 使用教室相关信息将 room 属性实例化。
  • 为教室的加载、进入、退出等事件添加监听和相应的回调处理。其中对加载任务的监听可以获取进教室的加载过程中每一个步骤的执行状态和出错时的错误信息,便于调试,也可以用来展示加载进程;对进入、退出的监听获取出现异常时的 error 信息。回调处理可以根据自身需求进行自定义,为教室管理做好准备。
  • 添加断开重连处理。如果不添加断开重连的回调处理,SDK 会默认在断开时自动重连,重连过程中遇到错误将退出教室、抛出异常。
  • 调用 BJLRoom 定义的 enter 方法进入教室,监听到进入成功之后,身份为老师的用户可以发送上课通知。

3.1 设置专属域名前缀

  • BJLiveCore SDK 1.3.5 及之后版本支持设置专属域名。
  • 设置专属域名前缀,需要在创建 BJLRoom 实例之前设置。例如专属域名为 demo123.at.baijiayun.com,则前缀为 demo123,参考 专属域名说明
[BJLRoom setPrivateDomainPrefix:@"yourDomainPrefix"];

3.2 定义教室属性

@property (nonatomic) BJLRoom *room;

3.3 创建教室:可通过教室 ID 或参加码两种方式进行

/**
教室ID方式
@param userNumber   用户编号,合作方账号体系下的用户ID号,必须是数字
@param userName     用户名
@param userAvatar   用户头像 URL(nullable)
@param userRole     用户角色:老师、学生等
@param roomID       教室 ID
@param groupID      分组 ID, 不分组传0
@param apiSign      签名
*/

// 创建用户实例
BJLUser *user = [self userWithNumber:number
                                name:name
                             groupID:groupID
                              avatar:avatar
                                role:role];
// 创建教室
self.room = [BJLRoom roomWithID:roomID
                        apiSign:apiSign
                           user:user];
  • 参加码方式:参加码同样通过使用 百家云后台 或者 API 创建一个教室获得。
/**
参加码方式
@param roomSecret   教室参加码
@param userName     用户名
@param userAvatar   用户头像 URL,可传空值
*/
self.room = [BJLRoom roomWithSecret:roomSecret
                           userName:userName
                         userAvatar:nil];

3.4 准备进入教室:添加状态监听

  • 监听进入、退出教室等事件。
// 监听进入教室成功
bjl_weakify(self);
[self bjl_observe:BJLMakeMethod(self.room, enterRoomSuccess)
         observer:^BOOL() {
             bjl_strongify(self);
             if (self.room.loginUser.isTeacher) {
                 // 身份为老师,通知学生上课
                 [self.room.roomVM sendLiveStarted:YES];
             }
             else {
                 // 身份非老师,监听老师上课状态变化
                 [self bjl_kvo:BJLMakeProperty(self.room.roomVM, liveStarted)
                        filter:^BOOL(NSNumber *value, NSNumber *oldValue, BJLPropertyChange * _Nullable change) {
                            return oldValue.boolValue != value.boolValue;
                        }
                      observer:^(, NSNumber *valueNSNumber *oldValue, BJLPropertyChange * _Nullable change) {
                        // 上课状态发生变化后的响应操作
                        // bjl_strongify(self);
                        NSLog(@"liveStarted: %@", value.boolValue? @"YES" : @"NO");
                    }];
             }
             // 处理进教室后的逻辑
             [self didEnterRoom]; 
             return YES;
         }];
// 监听进入教室失败
[self bjl_observe:BJLMakeMethod(self.room, enterRoomFailureWithError:)
         observer:^BOOL(BJLError *error) {
             NSLog(@"进入教室失败:%@", error);
             return YES;
         }];
// 监听准备退出教室,error 为 nil 表示主动退出
bjl_weakify(self);
[self bjl_observe:BJLMakeMethod(self.room, roomWillExitWithError:)
         observer:^BOOL(BJLError *error) {
             bjl_strongify(self);
             if (self.room.loginUser.isTeacher) {
                 // 通知学生下课
                 [self.room.roomVM sendLiveStarted:NO];
             }
             return YES;
         }];
// 监听退出教室,error 为 nil 表示主动退出
bjl_weakify(self);
[self bjl_observe:BJLMakeMethod(self.room, roomDidExitWithError:)
         observer:^BOOL(BJLError *error) {
             bjl_strongify(self);
             if (error) {
                 // 获取错误信息
                 NSString *message = error ? [NSString stringWithFormat:@"%@ - %@",
                                             error.localizedDescription,
                                             error.localizedFailureReason] : @"错误";
                 NSLog(@"error: %@", message);
             }
             // 自定义的退教室处理
             [self exit];
             return YES;
         }];
  • 监听加载任务:加载任务的相关属性及方法包含于 self.room.loadingVM 中。
// 监听进入教室的加载任务的变化
bjl_weakify(self);
[self bjl_kvo:BJLMakeProperty(self.room, loadingVM)
       filter:^BOOL(id value, id oldValue, BJLPropertyChange * _Nullable change) {
            return !!value;
       }
     observer:^BOOL(BJLLoadingVM *value, id oldValue, BJLPropertyChange * _Nullable change) {
         bjl_strongify(self);
         // 自定义方法,处理当前的加载任务,可参考 demo
         [self makeEventsForLoadingVM:value];
         return YES;
     }];
// 断开重连
bjl_weakify(self);
[self.room setReloadingBlock:^(BJLLoadingVM * _Nonnull reloadingVM, void (^ _Nonnull callback)(BOOL)) {
        bjl_strongify(self);
        [self showAlertWithTitle:@"加载失败"
                         message:@"是否重连?"
                  reloadCallback:^{
                      NSLog(@"网络连接断开,正在重连 ...");
                      // 自定义方法,处理重连的加载任务,可参考 demo
                      [self makeEventsForLoadingVM:reloadingVM]; 
                      NSLog(@"网络连接断开:重连");
                      callback(YES);
                  }
                  cancelCallback:^{
                      callback(NO);
                  }];
    }];
// 监听加载进度
[self bjl_observe:BJLMakeMethod(self.room.loadingVM, loadingUpdateProgress:)
         observer:(BJLMethodObserver)^BOOL(CGFloat progress) {
             NSLog(@"loading progress: %f", progress);
             return YES;
         }];
/** 加载任务每一步骤停止时的回调
 @param step             当前加载步骤
 @param reason           停止原因
 @param error            具体错误 
 @param continueCallback  回调:continueCallback(NO): 取消加载;
                               continueCallback(YES): 错误可忽略? 继续下一步骤 : 重试当前步骤
*/
bjl_weakify(self);
// 加载任务暂停时的回调
self.room.loadingVM.suspendBlock = ^(BJLLoadingStep step,
                                     BJLLoadingSuspendReason reason,
                                     BJLError *error,
                                     void (^continueCallback)(BOOL isContinue)) {
    bjl_strongify(self);
    // 单步完成,无错误,继续执行下一个加载步骤
    if (reason == BJLLoadingSuspendReason_stepOver) {
        NSLog(@"loading step over: %td", step);
        continueCallback(YES);
        return;
    }

    // 暂停原因
    NSLog(@"loading step suspend: %td; suspend reason: %td", step,reason);

    // 错误信息
    NSString *message;
    if (reason == BJLLoadingSuspendReason_askForWWANNetwork) {
        message = @"WWAN 网络";
    }
    else if (reason == BJLLoadingSuspendReason_errorOccurred) {
        message = error ? [NSString stringWithFormat:@"%@ - %@",
                           error.localizedDescription,
                           error.localizedFailureReason] : @"错误";
    }

    // 暂停原因为发生错误,不可忽略
    BOOL ignorable = reason != BJLLoadingSuspendReason_errorOccurred;
    // 提示错误信息并提供操作选择
    if (message) {
        UIAlertController *alert = [UIAlertController
                                    alertControllerWithTitle:ignorable ? @"提示" : @"错误"
                                                     message:message
                                              preferredStyle:UIAlertControllerStyleAlert];
        [alert addAction:[UIAlertAction
                          actionWithTitle:ignorable ? @"继续" : @"重试"
                                    style:UIAlertActionStyleDefault
                                  handler:^(UIAlertAction * _Nonnull action) {
                                      continueCallback(YES);
                                  }]];
        [alert addAction:[UIAlertAction
                          actionWithTitle:@"取消"
                                    style:UIAlertActionStyleCancel
                                  handler:^(UIAlertAction * _Nonnull action) {
                                      [self exitRoom];
                                      continueCallback(NO);
                                  }]];
        [self presentViewController:alert animated:YES completion:nil];
    }
};

3.5 进出教室

// 进入教室
[self.room enter];

// 退出教室
[self.room exit];

3.6 上下课

// 上课
BJLError *error = [self.room.roomVM sendLiveStarted:YES];

// 下课
BJLError *error = [self.room.roomVM sendLiveStarted:NO];

3.7 教室信息获取

教室信息可通过 BJLRoomroomInfo 属性获取,获取时机为进入教室成功(监听到 enterRoomSuccess)之后。

// 教室信息
@property (nonatomic, readonly, copy, nullable) NSObject<BJLRoomInfo> *roomInfo;
// BJLRoomInfo
@property (nonatomic, readonly) NSString *ID, *title; // 教室 ID、名称
@property (nonatomic, readonly) NSTimeInterval startTimeInterval, endTimeInterval; // 起止时间

@property (nonatomic, readonly) BJLRoomType roomType; // 教室类型

3.8 教室内在线用户信息获取(仅限 100 人以内)

当前登录用户信息可通过 BJLRoomloginUser 属性获取,获取时机为进入教室成功(监听到 enterRoomSuccess)之后。

在线用户信息列表采用分页加载,参考 BJLOnlineUsersVM

  • 获取当前登录用户信息。

    // 当前登录用户信息
    BJLUser *user = self.room.loginUser;
    
  • 监听在线用户变化。

    [self bjl_kvo:BJLMakeProperty(self.room.onlineUsersVM, onlineUsers)
      observer:^BOOL(id _Nullable value, id _Nullable oldValue, BJLPropertyChange * _Nullable change) {
         bjl_strongify(self);
         [self updateTitleWithOnlineUsersTotalCount];
         [self.tableView reloadData];
         return YES;
      }];
    
  • 监听用户进入教室。

 [self bjl_observe:BJLMakeMethod(self.room.onlineUsersVM, onlineUserDidEnter:)
          observer:^BOOL(BJLUser *user) {
              bjl_strongify(self);
              NSlog(@"onlineUser in: %@", user.name);
              return YES;
          }];
  • 监听用户退出教室。
 [self bjl_observe:BJLMakeMethod(self.room.onlineUsersVM, onlineUserDidExit:)
          observer:^BOOL(BJLUser *user) {
              bjl_strongify(self);
              NSLog(@"onlineUser out: %@", user.name);
              return YES;
          }];

3.9 分组教室信息

分组信息通过 BJLOnlineUsersVM 的属性及方法获取。

  • 教室内分组信息
@property (nonatomic, readonly, copy, nullable) NSArray<BJLUserGroup *> *groupList;
  • 获取各个分组的颜色
// 返回值为十六进制 RGB 色值字符串,如 `#FFFFFF`
- (nullable NSString *)getGroupColorWithID:(NSInteger)groupID;
  • 获取某一个分组是否有更多在线用户未加载
- (BOOL)hasMoreOnlineUsersofGroup:(NSInteger)groupID;
  • 加载对应分组的更多在线用户

加载成功时将更新 BJLOnlineUsersVMonlineUsers 属性,可指定一次请求加载的人数

- (nullable BJLError *)loadMoreOnlineUsersWithCount:(NSInteger)count
                                            groupID:(NSInteger)groupID;

// example:加载当前用户所在分组的 20 个用户
if ([self.room.onlineUsersVM hasMoreOnlineUsersofGroup:self.room.loginUser.groupID]) {
    [self.room.onlineUsersVM loadMoreOnlineUsersWithCount:20 groupID:self.room.loginUser.groupID];
}
  • 分组人数变化通知
- (BJLObservable)onlineUserGroupCountDidChange:(NSDictionary *)groupCountDic;

// example
bjl_weakify(self);
[self bjl_observe:BJLMakeMethod(self.room.onlineUsersVM, onlineUserGroupCountDidChange:)
         observer:^BOOL(NSDictionary *groupCountDic) {
    // bjl_strongify(self);
    [groupCountDic enumerateKeysAndObjectsUsingBlock:^(NSString *groupID, NSNumber *count, BOOL * _Nonnull stop) {
        NSLog(@"Group %@ has %@ users", groupID, count);
    }];
    return YES;
}];
  • 分组公告
    • BJLNoticegroupNoticeList 表示教室内各小组的公告,数组元素为 BJLNoticeModel 类型;
    • 教室内 BJLNotice 公告实例的获取参考文档的公告部分。

3.10 切换主讲人方法

切换主讲人,主讲人只能由教室内最大权限的老师来设置,之后老师本人或者助教才能被设置为主讲人, 参考BJLOnlineUsersVM。

/** 切换主讲
 #discussion 1. 主讲人只能由老师设置,2. 之后老师本人或者助教才能被设置为主讲人
 #param userID  老师或助教的 userID
 #return BJLError:
 BJLErrorCode_invalidCalling    不支持切换主讲,参考 `room.featureConfig.canChangePresenter`
 BJLErrorCode_invalidArguments  错误参数
 BJLErrorCode_invalidUserRole   错误权限,要求老师权限
 */
BJLError *error = [self.room.onlineUsersVM requestChangePresenterWithUserID:userID];

3.11 定制信令

基于客户自身需求,可能需要自己的业务逻辑,sdk 提供发送定制广播信令通道,客户可以实现自己的信令发送,参考BJLRoomVM。

/**
 发送定制广播信令
 #discussion 只有老师和助教才能发送定制广播信令
 #param key     信令类型
 #param value   信令内容,合法的 JSON 数据类型 - #see `[NSJSONSerialization isValidJSONObject:]`,序列化成字符串后不能过长,一般不超过 1024 个字符
 #param cache   是否缓存,缓存的信令可以通过 `requestCustomizedBroadcastCache:` 方法重新请求
 #return BJLError:
 BJLErrorCode_invalidUserRole   当前用户不是老师或者助教
 BJLErrorCode_invalidArguments  不支持的 key,内容为空或者内容过长
 BJLErrorCode_areYouRobot       发送频率过快,要求每秒不超过 5 条、并且每分钟不超过 60 条
 */
BJLError *error = [self.context.room.roomVM sendCustomizedBroadcast:customizedKey value:value cache:isNeedCache];

4. 音视频管理

音视频管理分为采集播放两部分。采集是指使用自己设备的麦克风和摄像头获取自己的音、视频数据,推送到服务端供教室内的其他用户播放,老师可以远程开关对象用户的麦克风、摄像头,即关闭对象的音视频采集;播放则是指播放其他正在发言的用户的音、视频,用户可以选择是否播放发言用户的视频,而音频是默认播放的,不可控制。

为了更方便的对教室内的音视频进行管理,需要对音视频的状态进行监听。音视频状态监听主要包含对当前采集/播放状态的监听,其中音视频用户列表表示当前教室所有正在发言的其他用户(不包含用户自身),监听它的变化是准确播放对象用户视频的前提

4.1 音视频采集

音视频采集功能由 BJLRecordingVM 管理。

4.1.1 采集状态监听

通过监听 self.room.recordingVM 的属性变化及方法调用来实现。

  • 关键属性监听。
@property (nonatomic, readonly) BOOL recordingAudio; // 音频采集开关状态
@property (nonatomic, readonly) BOOL recordingVideo; // 视频采集开关状态
@property (nonatomic, readonly) CGFloat inputVolumeLevel; // 音频输入级别 [0.0 - 1.0]
@property (nonatomic, readonly) CGFloat inputVideoAspectRatio; // 视频采集宽高比
@property (nonatomic, readonly) BOOL forbidRecordingAudio; // 是否禁止当前用户打开音频
@property (nonatomic, readonly) BOOL forbidAllRecordingAudio; // 是否禁止所有人打开音频
// example:监听视频采集(摄像头)开关状态
[self bjl_kvo:BJLMakeProperty(self.room.recordingVM, recordingVideo)
       filter:^BOOL(NSNumber * _Nullable now, NSNumber * _Nullable old, BJLPropertyChange * _Nullable change) {
           return now.boolValue != old.boolValue;
       }
     observer:^BOOL(NSNumber * _Nullable now, NSNumber * _Nullable old, BJLPropertyChange * _Nullable change) {
         // bjl_strongify(self);
         NSLog(@"摄像头已%@", now.boolValue ? @"打开" : @"关闭");
         return YES;
     }];
// example:监听麦克风音频输入级别 inputVolumeLevel
[self bjl_kvo:BJLMakeProperty(self.room.recordingVM, inputVolumeLevel)
      options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
       filter:^BOOL(NSNumber * _Nullable value, NSNumber * _Nullable oldValue, BJLPropertyChange * _Nullable change) {
           // 音量变化超过一定程度才触发
           return ABS(round(oldValue.doubleValue * 10) - round(value.doubleValue * 10)) >= 1.0;
       }
     observer:^BOOL(NSNumber * _Nullable value, NSNumber * _Nullable oldValue, BJLPropertyChange * _Nullable change) {
         NSLog(@"current input volume level:%f", value.doubleValue);
         return YES;
     }];
  • 关键方法监听。
// example:监听 摄像头/麦克风 被老师远程开关
[self bjl_observe:BJLMakeMethod(self.room.recordingVM, recordingDidRemoteChangedRecordingAudio:recordingVideo:recordingAudioChanged:recordingVideoChanged:)
         observer:(BJLMethodObserver)^BOOL(BOOL recordingAudio, BOOL recordingVideo, BOOL recordingAudioChanged, BOOL recordingVideoChanged) {
             // bjl_strongify(self);
             NSString *message = @"";
             if (recordingAudioChanged) {
                 message = recordingAudio ? @"老师开启了你的麦克风" : @"老师关闭了你的麦克风";
             }
             else if (recordingVideoChanged) {
                 message = recordingVideo ? @"老师开启了你的摄像头" : @"老师关闭了你的摄像头";
             }
             return YES;
         }];

// example:监听一键开关麦克风
    [self bjl_observe:BJLMakeMethod(self.room.recordingVM, didUpadateAllRecordingAudioMute:)
             observer:(BJLMethodObserver)^BOOL(BOOL mute) {
                 // bjl_strongify(self);
                 if (mute) {
                         NSLog(@"老师已关闭全体学生的麦克风");
                 }
                 else {
                         NSLog(@"老师已开启全体学生的麦克风");
                 }
                 return YES;
             }];
4.1.2 音视频采集控制

对于老师,开启采集有两个前提条件:进入教室成功和处于上课状态。进入教室成功通过监听到 BJLRoomenterRoomSuccess 方法得知,上课状态则通过监听 BJLRoomVMliveStarted 方法获取。对于学生,还需要处于发言状态才可以开启音视频采集,参考举手发言部分的内容。

  • 添加采集视图。
// example:将 BJLRoom 的 recordingView 添加到当前 viewController 的对应视图
[self.recordingView addSubview:self.room.recordingView];
  • 采集音视频开关。
 /**
 开关音视频 (需监听到 进入教室成功 和 处于上课状态,身份为学生则还需要处于发言状态)

 #param recordingAudio YES:打开音频采集,NO:关闭音频采集
 #param recordingVideo YES:打开视频采集,NO:关闭视频采集
 #discussion 上层自行检查麦克风、摄像头开关权限
 #discussion 上层可通过 `BJLSpeakingRequestVM` 实现学生发言需要举手的逻辑
 #return BJLError:
 BJLErrorCode_invalidCalling    错误调用,以下情况下开启音视频、在音频教室开启摄像头均会返回此错误
 登录用户分组 ID 不为 0,参考 `room.loginUser.groupID`
 非上课状态,参考 `room.roomVM.liveStarted`
 教室禁止打开音频,参考 `self.forbidRecordingAudio`
 音频禁止打开视频,参考 `featureConfig.mediaLimit`
 */
- (nullable BJLError *)setRecordingAudio:(BOOL)recordingAudio
                          recordingVideo:(BOOL)recordingVideo;
// example: 打开音视频采集
BJLError *error = [self.room.recordingVM setRecordingAudio:YES recordingVideo:YES];
if (error) {
    NSString *errorMessage = error.localizedFailureReason ?: error.localizedDescription;
    NSLog(@"%td-%@", error.code, errorMessage);
}
  • 音视频采集请求被服务端拒绝。

调用 setRecordingAudio:recordingVideo: 方法开启音视频采集时,可能因为教室内发言用户人数达到上限而被服务器拒绝,可通过监听 recordingDidDeny 方法给出错误提示,发言用户人数上限可联系百家云后台进行配置调整。

// example:开启音视频采集失败的提示
[self bjl_observe:BJLMakeMethod(self.room.recordingVM, recordingDidDeny)
         observer:^BOOL {
             // bjl_strongify(self);
             NSLog(@"服务器拒绝发布音视频,音视频并发已达上限");
             return YES;
         }];
  • 老师:远程开关学生音、视频。
/**
 老师: 远程开关学生音、视频

 #param user 对象用户,不能是老师
 #param audioOn YES:打开音频采集,NO:关闭音频采集
 #param videoOn YES:打开视频采集,NO:关闭视频采集
 #discussion 打开音频、视频会导致对方发言状态开启
 #discussion 同时关闭音频、视频会导致对方发言状态终止
 @see `speakingRequestVM.speakingEnabled`
 #return BJLError:
 BJLErrorCode_invalidArguments  错误参数;
 BJLErrorCode_invalidUserRole   错误权限,要求老师或助教权限。
 */
- (nullable BJLError *)remoteChangeRecordingWithUser:(BJLUser *)user
                                             audioOn:(BOOL)audioOn
                                             videoOn:(BOOL)videoOn;
/** 
 老师: 远程开启学生音、视频被自动拒绝,因为上麦路数达到上限

 #param user    开启音视频失败的学生
 */
- (BJLObservable)remoteChangeRecordingDidDenyForUser:(BJLUser *)user;

// example:远程开启学生音、视频被自动拒绝
[self bjl_observe:BJLMakeMethod(self.room.recordingVM, remoteChangeRecordingDidDenyForUser:)
             observer:^BOOL(BJLUser *user) {
                 // bjl_strongify(self);
                 NSLog(@"服务器拒绝强制 %@ 发言,音视频并发已达上限", user.name);
                 return YES;
             }];

  • 老师:开启/关闭 全体禁音。
/**
 老师: 设置全体禁音状态

 #param forbidAll YES:全体禁音,NO:取消禁音
 #discussion 设置成功后修改 `forbidAllRecordingAudio`、`forbidRecordingAudio`
 #return BJLError:
 BJLErrorCode_invalidUserRole   错误权限,要求老师或助教权限
 */
- (nullable BJLError *)sendForbidAllRecordingAudio:(BOOL)forbidAll;
// example:老师开启全体禁音
[self.room.recordingVM sendForbidAllRecordingAudio:YES];
  • 老师:开关全体学生麦克风。
/**
 开关全体学生麦克风

 #param mute YES:关闭,NO:打开
 #return BJLError
 */
- (nullable BJLError *)updateAllRecordingAudioMute:(BOOL)mute;
// example:老师关闭全体学生的麦克风
[self.room.recordingVM updateAllRecordingAudioMute:YES];
4.1.3 音视频采集设置
  • 禁止采集声音。
/** 学生: 是否禁止当前用户打开音频 - 个人实际状态
 #discussion 用于判断当前用户是否能打开音频
 #discussion 参考 `forbidAllRecordingAudio`
 */
@property (nonatomic, readonly) BOOL forbidRecordingAudio;
/** 是否禁止所有人打开音频 - 全局开关状态
 #discussion 用于判断教室内开关状态
 #discussion 如果学生正在采集音频,收到此事件时会被自动关闭
 #discussion 课程类型为小班课、新版小班课、双师课时可用,参考 `room.roomInfo.roomType`、`BJLRoomType`
 #discussion 1. 当老师禁止所有人打开音频时,`forbidAllRecordingAudio` 和 `forbidRecordingAudio` 同时被设置为 YES,
 #discussion 2. 当老师取消禁止所有人打开音频时,`forbidAllRecordingAudio` 和 `forbidRecordingAudio` 同时被设置为 NO,
 #discussion 3. 当老师邀请/强制当前用户发言时,`forbidAllRecordingAudio` 被设置成 NO,`forbidRecordingAudio` 依然是 YES,
 #discussion 4. 当老师取消邀请/强制结束当前用户发言时,`forbidAllRecordingAudio` 会被设置为与 `forbidRecordingAudio` 一样的取值
 */
@property (nonatomic, readonly) BOOL forbidAllRecordingAudio;
  • 切换摄像头。
// 是否正在使用后置摄像头,默认使用前置摄像头
@property (nonatomic, readonly) BOOL usingRearCamera; // NO: Front, YES Rear(iSight)

/**
 是否切换到后置摄像头

 #param usingRearCamera 使用后置摄像头
 #return BJLError:
 BJLErrorCode_invalidCalling  错误调用
 */
- (BJLError *)updateUsingRearCamera:(BOOL)usingRearCamera;
// example:切换前、后置摄像头
[self.room.recordingVM updateUsingRearCamera:!self.room.recordingVM.usingRearCamera];
  • 视频方向设置。
// 视频方向,默认自动,参考 BJLVideoOrientation 
@property (nonatomic, readonly) BJLVideoOrientation videoOrientation;

/**
 改变视频方向

 #param videoOrientation 方向
 #return BJLError:
 BJLErrorCode_invalidCalling  错误调用
 */
- (nullable BJLError *)updateVideoOrientation:(BJLVideoOrientation)videoOrientation;
  • 清晰度设置。
// 视频采集清晰度,默认标清
@property (nonatomic, readonly) BJLVideoDefinition videoDefinition;

/**
 改变视频清晰度

 #param videoDefinition 清晰度
 #return BJLError:
 BJLErrorCode_invalidCalling  错误调用
 */
- (BJLError *)updateVideoDefinition:(BJLVideoDefinition)videoDefinition;
/** 
 example:设置清晰度为高清

 BJLVideoDefinition_low     流畅
 BJLVideoDefinition_high    高清
 BJLVideoDefinition_default 默认
*/
[self.room.recordingVM updateVideoDefinition:BJLVideoDefinition_high];
  • 美颜设置。
// 当前美颜等级,参考 BJLVideoBeautifyLevel
@property (nonatomic, readonly) BJLVideoBeautifyLevel videoBeautifyLevel;

// 设置美颜等级
- (BJLError *)updateVideoBeautifyLevel:(BJLVideoBeautifyLevel)videoBeautifyLevel;
// example:设置美颜等级
[self.room.recordingVM updateVideoBeautifyLevel:BJLVideoBeautifyLevel_on];

4.2 音视频播放

音视频播放功能由 BJLPlayingVM 管理。

4.2.1 音视频播放要点说明
  • 直播教室中,一个用户可能同时在推送多路音视频流,也就是说,BJLOnlineUserVMonlineUsers 数组中的一个 BJLUser 实例,在 BJLPlayingVM 中可能对应到多个 BJLMediaUser 实例,他们的 IDnumber 等身份信息相同,区别在于 BJLUser 标识一个用户身份,而 BJLMediaUser 标识用户的一路音视频流、包含这路流的各项信息。

  • BJLMediaUser 标识用户的一路音视频流,它的 mediaSource 属性表示该音视频流的类型,参考 BJLMediaSoucecameraType 属性主要用于普通大班课,BJLCameraType_main 表示在大班课里需要占据主摄像头位置的音视频流,这种流的 mediaSourceBJLMediaSource_mainCameraBJLMediaSource_screenShareBJLMediaSource_mediaFileBJLCameraType_extra 表示在大班课里需要占据扩展摄像头位置的音视频流,这种流的 mediaSourceBJLMediaSource_extraCameraBJLMediaSource_extraScreenShare

  • 多路流模板。

2.0 及以上版本的 BJLiveCore SDK 支持同时播放一个用户的多路视频流,由 BJLPlayingVM 管理,对比旧版本 SDK,API 改动较大,集成方如果是从 1.x 版本或2.0.0-alpha2.0.0-beta 升级上来,且没有多视频流的需求,可以使用 BJLPlayingAdapterVM 管理音视频播放,此适配层支持旧版的单路流及主辅摄像头双路流模板,API 相对改动较小,具体使用方式可参考 旧版本 SDK 音视频模板适配 部分。

多路流模板中用户音视频流对应关系如下:

用户音视频流实例 视频源类型 含义
BJLMediaUser BJLMediaSource_mainCamera 主摄像头采集
BJLMediaSource_screenShare 屏幕共享
BJLMediaSource_mediaFile 媒体文件播放
BJLMediaSource_camera 辅助摄像头采集
BJLMediaSource_screenShare 辅助摄像头屏幕共享

对于一个 BJLUser 实例,在 BJLPlayingVM 中可能对应多个 BJLMediaUser 实例,每一个实例对应该用户的一路音视频流,它们之间 IDnumber 等用户身份信息相同,通过 mediaIDmediaSource 区分。

webRTC 教室(通过 self.room.featureConfig.isWebRTC 判断)中,mediaSourceBJLMediaSource_mainCameraBJLMediaUser 实例存储于 BJLPlayingVMplayingUsers 中,每个发言用户在数组中最多存在一个实例;其它视频源类型的实例存储于 BJLPlayingVMextraPlayingUsers 中,每个发言用户在数组中可能存在多个实例。

在非 webRTC 教室中,mediaSourceBJLMediaSource_mainCameraBJLMediaSource_screenShareBJLMediaSource_mediaFileBJLMediaUser 实例存储于 BJLPlayingVMplayingUsers 中,每个发言用户在数组中最多只存在一个实例;mediaSourceBJLMediaSource_extraCameraBJLMediaSource_extraScreenShareBJLMediaSource 实例存储于 BJLPlayingVMextraPlayingUsers 中,每个发言用户在数组可能存在多个实例。

4.2.2 监听播放信息

音视频信息通过监听 self.room.playingVM 的属性变化获取。

  • 监听音视频用户列表。

BJLPlayingVMplayingUsers 属性表示当前正在推送音频、视频的用户列表(不包含用户自身),它是随时会发生变化的,因此不要采用直接取值的方法获取列表,而应该监听列表的变化,即时获取最新列表,便于播放对应用户视频。监听音视频用户列表的变化可以通过监听 BJLPlayingVM 的属性 playingUsers 的变化来实现:

/**
 音视频用户列表

 #discussion 包含教室内推送主音视频流的用户,数组内 BJLMediaUser 实例的音视频信息为主音视频流的信息,每个用户在 playingUsers 中只有一个 BJLMediaUser 实例
 #discussion 在 webRTC 教室中,数组内的 BJLMediaUser 实例的 mediaSource 为 BJLMediaSource_mainCamera
 #discussion 在非 webRTC 教室中,数组内的 BJLMediaUser 实例的 mediaSource 为 BJLMediaSource_mainCamera、BJLMediaSource_screenShare 或 BJLMediaSource_mediaFile
 #discussion 所有用户的音频会自动播放,视频需要调用 `updatePlayingUserWithID:videoOn:mediaSource:` 打开或者通过 `autoPlayVideoBlock` 控制打开
 #discussion SDK 会处理音视频打断、恢复、前后台切换等情况
 */
@property (nonatomic, readonly, copy, nullable) NSArray<BJLMediaUser *> *playingUsers;
// example:监听 playingUsers 变化,获取实时信息
[self bjl_kvo:BJLMakeProperty(self.room.playingVM, playingUsers)
     observer:^BOOL(id _Nullable value, id _Nullable oldValue, BJLPropertyChange * _Nullable change) {
         // bjl_strongify(self);
         NSLog(@"playing users changed");
         return YES;
     }];
  • 监听扩展音视频流用户列表。

BJLPlayingVMextraPlayingUsers 属性表示正在推送主摄像头之外的扩展音视频流的用户列表(不包含用户自身),与 playingUsers 类似,是随时变化的,也需要通过监听来即时获取信息。

/**
 扩展音视频流用户列表

 #discussion 包含教室内推送扩展音视频流的用户,音视频信息为扩展音视频流的信息,每个用户在 extraPlayingUsers 中可以有多个 BJLMediaUser 实例
 #discussion 在 webRTC 教室中,数组内的 BJLMediaUser 实例的 mediaSource 为除 BJLMediaSource_mainCamera 之外的音视频流类型
 #discussion 在非 webRTC 教室中,数组内 BJLMediaUser 实例的 mediaSource 为 BJLMediaSource_extraCamera 或 BJLMediaSource_extraScreenShare
 #discussion 所有用户的音频会自动播放,视频需要调用 `updatePlayingUserWithID:videoOn:mediaSource:` 打开或者通过 `autoPlayVideoBlock` 控制打开
 #discussion 打开了扩展音视频流的用户将同时包含在 playingUsers 和 extraPlayingUsers 中,但两个列表中的 BJLMediaUser 实例的音视频信息不同
 */
@property (nonatomic, readonly, copy, nullable) NSArray<BJLMediaUser *> *extraPlayingUsers;
// example:监听 extraPlayingUsers 变化,获取实时信息
[self bjl_kvo:BJLMakeProperty(self.room.playingVM, extraPlayingUsers)
     observer:^BOOL(id _Nullable value, id _Nullable oldValue, BJLPropertyChange * _Nullable change) {
         // bjl_strongify(self);
         NSLog(@"extra playing users changed");
         return YES;
     }];
  • 监听当前正在播放的视频对应的用户列表。

BJLPlayingVMvideoPlayingUsers 属性表示当前正在播放的视频用户列表,如果当前正在播放对方用户的视频,则该视频流对应的 BJLMediaUser 实例会被包含在 videoPlayingUsers 中。

/**
 正在播放的视频用户

 #discussion 数组内元素包含在 `playingUsers`、`extraPlayingUsers` 之中,在当前打开了音视频的用户列表中,本地在播放的用户列表。
 #discussion 断开重连、暂停恢复等操作不自动重置 `videoPlayingUsers`,除非对方用户掉线、离线等
 */
@property (nonatomic, readonly, copy, nullable) NSArray<BJLMediaUser *> *videoPlayingUsers;
// example:监听 videoPlayingUsers ,videoPlayingUsers 表示当前正在播放的视频的对象,监听该属性的变化可以即时获取播放对象的最新信息
[self bjl_kvo:BJLMakeProperty(self.room.playingVM, videoPlayingUsers)
     observer:^BOOL(NSArray<BJLUser *> *value, NSArray<BJLUser *> *oldValue, BJLPropertyChange * _Nullable change) {
         NSLog(@"当前正在播放%td人的视频", value.count);
         return YES;
     }];

  • 监听老师播放媒体文件、共享桌面。

通过监听 BJLPlayingVMteacherSharingDesktopteacherPlayingMedia 变化实现。

在 2.x 版本 SDK 中,可以通过监听 playingUserDidUpdate:old: 方法调用来代替,与监听这两个属性相比,能通过方法回调的 BJLMediaUser 实例直接获取到老师最新的音视频信息,参考监听用户开关音视频 的示例代码。

// example:监听老师开关媒体文件播放
[self bjl_kvo:BJLMakeProperty(self.room.playingVM, teacherPlayingMedia)
       filter:^BOOL(NSNumber * _Nullable value, NSNumber * _Nullable oldValue, BJLPropertyChange * _Nullable change) {
           return value.boolValue != oldValue.boolValue;
       }
     observer:^BOOL(NSNumber * _Nullable value, NSNumber * _Nullable oldValue, BJLPropertyChange * _Nullable change) {
         // bjl_strongify(self);
         NSLog(@"老师%@了媒体文件播放", value.boolValue ? @"开启" : @"关闭");
         return YES;
     }];

// example:监听老师开关屏幕共享
[self bjl_kvo:BJLMakeProperty(self.room.playingVM, teacherSharingDesktop)
       filter:^BOOL(NSNumber * _Nullable value, NSNumber * _Nullable oldValue, BJLPropertyChange * _Nullable change) {
           return value.boolValue != oldValue.boolValue;
       }
     observer:^BOOL(NSNumber * _Nullable value, NSNumber * _Nullable oldValue, BJLPropertyChange * _Nullable change) {
         // bjl_strongify(self);
         NSLog(@"老师%@了屏幕共享", value.boolValue ? @"开启" : @"关闭");
         return YES;
     }];
4.2.3 监听播放回调

播放回调信息通过监听 self.room.playingVM 的方法调用获取。

  • 音视频用户列表覆盖更新。

列表覆盖更新一般在 进入教室 或 断网重连 时回调。

/**
 `playingUsers`、`extraPlayingUsers` 被覆盖更新

 #discussion 进教室后批量更新才调用,增量更新不调用
 #param playingUsers 音视频用户列表, 使用主摄像头采集音视频推流的用户列表
 #param extraPlayingUsers 扩展音视频流用户列表,不使用主摄像头采集音视频推流的用户列表
 TODO: 改进此方法,使之与监听 playingUsers 区别更小
 */
- (BJLObservable)playingUsersDidOverwrite:(nullable NSArray<BJLMediaUser *> *)playingUsers
                        extraPlayingUsers:(nullable NSArray<BJLMediaUser *> *)extraPlayingUsers;
  • 用户开关音视频。
/** 
 用户开关音、视频

 #discussion - 某个用户主动开关自己的音视频、切换清晰度时发送此通知,但不包含意外掉线等情况
 #discussion - 正在播放的视频用户 关闭视频时 `videoPlayingUser` 将被设置为 nil、同时发送此通知
 #discussion - 进教室后批量更新 `playingUsers` 时『不』发送此通知
 #discussion - 音视频开关状态通过 `BJLMediaUser` 的 `audioOn`、`videoOn` 获得
 #discussion - definitionIndex 可能会发生变化,调用 `definitionIndexForUserWithID:` 可获取最新的取值
 #discussion - 用户退出教室时,new 为 nil,old 的 `mediaSource` 属性为 `BJLMediaSource_all`
 #param now 新用户信息
 #param old 旧用户信息
 #param mediaSource 视频源类型
 */
- (BJLObservable)playingUserDidUpdate:(nullable BJLMediaUser *)now
                                  old:(nullable BJLMediaUser *)old;
// example:监听老师开关音视频的回调,给出相应提示
[self bjl_observe:BJLMakeMethod(self.room.playingVM, playingUserDidUpdate:old:)
         observer:(BJLMethodFilter)^BOOL(BJLMediaUser * _Nullable now, BJLMediaUser * _Nullable old) {
             bjl_strongify(self);
             if (now.isTeacher) {
                 BOOL audioChanged = (now.audioOn != old.audioOn
                                      && now.mediaSource == BJLMediaSource_mainCamera);
                 BOOL videoChanged = (now.videoOn != old.videoOn);

                 NSString *videoTitle = BJLVideoTitleWithMediaSource(now.mediaSource);
                 if (audioChanged && videoChanged) {
                     if (now.audioOn && now.videoOn) {
                         [self showProgressHUDWithText:[NSString stringWithFormat:@"老师开启了麦克风和%@", videoTitle]];
                     }
                     else if (now.audioOn) {
                         [self showProgressHUDWithText:@"老师开启了麦克风"];
                     }
                     else if (now.videoOn) {
                         [self showProgressHUDWithText:[NSString stringWithFormat:@"老师开启了%@", videoTitle]];
                     }
                     else {
                         [self showProgressHUDWithText:[NSString stringWithFormat:@"老师关闭了麦克风和%@", videoTitle]];
                     }
                 }
                 else if (audioChanged) {
                     if (now.audioOn) {
                         [self showProgressHUDWithText:@"老师开启了麦克风"];
                     }
                     else {
                         [self showProgressHUDWithText:@"老师关闭了麦克风"];
                     }
                 }
                 else { // videoChanged
                     if (now.videoOn) {
                         [self showProgressHUDWithText:[NSString stringWithFormat:@"老师开启了%@", videoTitle]];
                     }
                     else {
                         [self showProgressHUDWithText:[NSString stringWithFormat:@"老师关闭了%@", videoTitle]];
                     }
                 }
             }
             return YES;
         }];
  • 用户视频清晰度变化。
/**
 用户改变视频清晰度

 #param now 新用户信息
 #param old 旧用户信息

 #discussion 清晰度的变化可对比 `now` 与 `old` 的 `definitions` 属性得知
 */
- (BJLObservable)playingUserDidUpdateVideoDefinitions:(nullable BJLMediaUser *)now
                                                  old:(nullable BJLMediaUser *)old;
  • 用户视频宽高比变化。
/**
 用户视频宽高比发生变化的通知

 #param videoAspectRatio 视频宽高比
 #param BJLMediaUser     用户音视频流信息
 */
- (BJLObservable)playingViewAspectRatioChanged:(CGFloat)videoAspectRatio
                                       forUser:(BJLMediaUser *)user;
// example:视频宽高比变化监听
[self bjl_observe:BJLMakeMethod(self.room.playingVM, playingViewAspectRatioChanged:forUser:)
         observer:(BJLMethodObserver)^BOOL(CGFloat ratio, BJLMediaUser *user) {
             bjl_strongify(self);
             // 更新视频布局
             [self updateVideoViewConstranintsWithAspectRatio:ratio forUser:user];
             return YES;
         }];
  • 用户视频流加载状态。

可用于显示视频 loading 状态

/**
 视频开始加载,将要播放

 #param playingUser 将要播放的视频用户
 #discussion 播放视频的方法被成功调用时回调
 */
- (BJLObservable)playingUserDidStartLoadingVideo:(nullable BJLMediaUser *)playingUser;
/**
 视频播放成功

 #param playingUser 播放的视频对应的用户
 #discussion 用户视频播放成功
 */
- (BJLObservable)playingUserDidFinishLoadingVideo:(nullable BJLMediaUser *)playingUser;
bjl_weakify(self);
// 对象用户视频开始加载
[self bjl_observe:BJLMakeMethod(self.room.playingVM, playingUserDidStartLoadingVideo:)
         observer:^BOOL(BJLUser *user) {
             bjl_strongify(self);
             [self tryToShowLoadingViewWithUser:user];
             return YES;
         }];

// 对象用户视频加载完成
[self bjl_observe:BJLMakeMethod(self.room.playingVM, playingUserDidFinishLoadingVideo:)
         observer:^BOOL(BJLUser *user) {
             bjl_strongify(self);
             [self tryToCloseLoadingViewWithUser:user];
             return YES;
         }];
  • 播放出现卡顿。
/**
 播放出现卡顿

 #param user 出现卡顿的正在播放的视频用户实例
 */
- (BJLObservable)playLagWithPlayingUser:(BJLMediaUser *)user;
[self bjl_observe:BJLMakeMethod(self.context.mediaPlayer, playLagWithPlayingUser:)
         observer:(BJLMethodObserver)^BOOL(BJLMediaUser *user){
             // bjl_strongify(self);
             NSLog(@"%@的音视频出现卡顿", user.name);
             return YES;
         }];
4.2.4 播放控制
  • 获取播放视图。
/**
 获取播放用户的视频视图

 #param userID 用户 ID
 #param mediaSource 视频源类型
 */
- (nullable UIView *)playingViewForUserWithID:(NSString *)userID
                                  mediaSource:(BJLMediaSource)mediaSource;
// 获取老师主摄像头采集视频的视图
UIView *mainCameraView = [self.room.playingVM playingViewForUserWithID:self.room.onlineUsersVM.onlineTeacher.ID
                                                           mediaSource:BJLMediaSource_mainCamera];
  • 打开/关闭 对象用户的视频。
/**
 打开/关闭 对象用户的视频

 #param userID 用户 ID
 #param videoOn YES:打开视频,NO:关闭视频
 #param definitionIndex `BJLMediaUser` 的 `definitions` 属性的 index,参考 `BJLLiveDefinitionKey`、`BJLLiveDefinitionNameForKey()`
 #param mediaSource 视频源类型
 #return BJLError:
 BJLErrorCode_invalidArguments  错误参数,如 `playingUsers` 中不存在此用户;
 BJLErrorCode_invalidCalling    错误调用,如用户视频已经在播放、或用户没有开启摄像头。
 */
- (nullable BJLError *)updatePlayingUserWithID:(NSString *)userID
                                       videoOn:(BOOL)videoOn
                                   mediaSource:(BJLMediaSource)mediaSource;
- (nullable BJLError *)updatePlayingUserWithID:(NSString *)userID
                                       videoOn:(BOOL)videoOn
                                   mediaSource:(BJLMediaSource)mediaSource
                               definitionIndex:(NSInteger)definitionIndex;
// example:关闭 user 对应的视频流,user 为 `BJLMediaUser` 实例
[self.room.playingVM updatePlayingUserWithID:user.ID
                                     videoOn:NO
                                 mediaSource:user.mediaSource];
// example:播放老师主摄像头采集的视频
BJLUser *onlineTeacher = self.room.onlineUsersVM.onlineTeacher;
if (onlineTeacher) {
    // 指定目标视频源类型为主摄像头采集
    BJLMediaSource targetMediaSource = BJLMediaSource_mainCamera;
    // 获取老师主摄像头的的视频视图
    UIView *mainCameraView = [self.room.playingVM playingViewForUserWithID:onlineTeacher.ID mediaSource:targetMediaSource];
    // 将播放视图添加到当前 viewController 的对应视图(布局自定)
    [self.playingView addSubview:mainCameraView];
    // 播放视频
    [self.room.playingVM updatePlayingUserWithID:onlineTeacher.ID
                                         videoOn:YES
                                     mediaSource:targetMediaSource];
}
  • 自动播放视频并指定清晰度回调。

集成方可以通过 BJLPlayingVMautoPlayVideoBlock 控制视频的自动播放,并指定视频的清晰度。集成方可以自定义一个视频黑名单,将用户主动关闭过的视频流加入黑名单中,当 autoPlayVideoBlock 回调时,根据回调的 BJLMediaUser 对应的视频流是否在黑名单内,决定是否自动播放。

/** 
自动播放视频并指定清晰度回调

 #discussion 传入参数 user 和 cachedDefinitionIndex 分别为 用户 和 上次播放该用户视频时使用的清晰度
 #discussion 返回结果 autoPlay 和 definitionIndex 分别为 是否自动播放视频 和 播放视频使用的视频清晰度
 */
@property (nonatomic, copy, nullable) BJLAutoPlayVideo (^autoPlayVideoBlock)(BJLMediaUser *user, NSInteger cachedDefinitionIndex);
// example:主动关闭用户的视频流之后,将其加入黑名单,不再自动播放
[self.room.playingVM updatePlayingUserWithID:playingUser.ID videoOn:NO mediaSource:playingUser.mediaSource];

// videoKeyForUser:方法为用户的每一个视频源类型创建唯一的标识符,用于黑名单的存取
// 标识符建议使用 user.number-user.mediaSource 的字符串拼接形式,因为 ID、mediaID 会因为断网重连等情况发生改变
[self.autoPlayVideoBlacklist addObject:[self videoKeyForUser:playingUser] ?: @""];
// example:自动播放不在黑名单中的视频流
self.room.playingVM.autoPlayVideoBlock = ^BJLAutoPlayVideo(BOOL autoPlay, NSInteger definitionIndex)(BJLMediaUser *user, NSInteger cachedDefinitionIndex) {
    bjl_strongify(self);
    NSString *videoKey = [self videoKeyForUser:user];
    // 不在黑名单中的视频流,自动播放
    BOOL autoPlay = videoKey && ![self.autoPlayVideoBlacklist containsObject:videoKey];
    // 指定清晰度序号
    NSInteger definitionIndex = cachedDefinitionIndex;
    if (autoPlay) {
        NSInteger maxDefinitionIndex = MAX(0, (NSInteger)user.definitions.count - 1);
        definitionIndex = (cachedDefinitionIndex <= maxDefinitionIndex
                           ? cachedDefinitionIndex : maxDefinitionIndex);
    }
    return BJLAutoPlayVideoMake(autoPlay, definitionIndex);
};
  • 获取正在播放的视频流的清晰度。
/**
 获取播放用户的清晰度

 #param userID 用户 ID
 #param mediaSource 视频源类型
 #return 播放时传入的 `definitionIndex`
 */
- (NSInteger)definitionIndexForUserWithID:(NSString *)userID
                              mediaSource:(BJLMediaSource)mediaSource;
// example:获取老师主摄像头视频的清晰度序号,该序号为清晰度在 `BJLMediaUser` 的 `definitions` 数组中的序号
NSInteger definitionIndex = [self.room.playingVM definitionIndexForUserWithID:self.room.onlineUsersVM.onlineTeacher.ID
                                                                      mediaSource:BJLMediaSource_mainCamera];
  • 停止播放。
// 停止播放
[self.room.playingVM updatePlayingUserWithID:user.ID videoOn:NO mediaSource:user.mediaSource];

// 移除该 user 的 playingView (playingView 获取方法参考播放视频部分)
[playingView removeFromSuperView];

4.3 音视频流管理

音视频流由 BJLMediaVM 管理。

4.3.1 音视频链路设置。

音视频链路分为 上行链路 和 下行链路 两种,上行链路表示发言用户将自己的音视频数据流推送到服务端所采用的链路,而下行链路则是拉取发言用户的音视频数据流、进行播放时所采用的链路。

上、下行链路按类型划分为 UDP 和 TCP 两种。基于 UDP 的 RTP/RTCP 等协议,延迟小于300ms,延迟可控,一般都是自己搭建服务器来实现;基于 TCP 的 RTMP,延迟比较大(3-5秒),延迟不可控,一般都是用 CDN 来实现。不作配置的情况下,默认使用 UDP

音视频链路设置仅 非 webRTC 教室 可用

BJLMediaVM 类中定义了 upLinkType 表示上行链路类型,downLinkType 表示下行链路类型,均为 BJLLinkType 枚举类型。

// 是否允许设置上、下行链路类型
@property (nonatomic, readonly) BOOL upLinkTypeReadOnly, downLinkTypeReadOnly;

/**
 设置上、下行链路 (SDK 内部将监听属性值的变化,进行相应的切换处理)
 BJLLinkType_TCP:TCP
 BJLLinkType_UDP:UDP
 */
// 设置用于采集音视频的上行链路为 UDP
[self.room.mediaVM updateUpLinkType:BJLLinkType_UDP];
// 设置用于播放音视频的下行链路为 TCP
[self.room.mediaVM updateDownLinkType:BJLLinkType_TCP];
  • TCP 上下行链路控制。
// TCP 上行 CDN 切换,下行类似,参考 `BJLMediaVM`

/** TCP 上行线路可用 CDN 数 */
@property (nonatomic, readonly) NSUInteger availableUpLinkCDNCount;

/** TCP 上行线路优先使用的 CDN index
 #discussion 调用用 updatePrefferedCDNWithIndex: 设置该值
 #discussion 指定线路推流时可设置该值为范围 [0, availableCDN] 内的整数,每一个数对应一个 CDN 线路。指定 CDN 不可用时,服务器将自动分配。
 #discussion 设置该值为 [0, availableCDN] 外的任意值代表不指定 CDN,由服务器自动分配。
 #discussion 默认值为 NSNotFound, 即服务器自动分配。
 #discussion 改变该值将导致重新推流
 */
@property (nonatomic, readonly) NSInteger upLinkCDNIndex;

/** 设置 upLinkCDNIndex 并重新推流
 #discussion 调用该方法会将上行链路切换到 TCP
 */
BJLError *error = [self.room.mediaVM updateTCPUpLinkCDNWithIndex:selectIndex];
4.3.2 音视频采集,播放控制,SDK对前后台切换进行了处理,只要修改这些配置项即可。
// 当前应用是否控制音频
@property (nonatomic, readonly) BOOL isAudioSessionActive;

// 是否支持后台音频, 默认支持
@property (nonatomic, readonly) BOOL supportBackgroundAudio;
- (BJLError *)updateSupportBackgroundAudio:(BOOL)supportBackgroundAudio;

// 是否支持后台采集声音, 默认不支持
@property (nonatomic, readonly) BOOL supportBackgroundRecordingAudio;
- (BJLError *)updateSupportBackgroundRecordingAudio:(BOOL)supportBackgroundRecordingAudio;

// 是否播放视频静音, 默认不静音
@property (nonatomic, readonly) BOOL needMutePlayingAudio;
- (BJLError *)updateNeedMutePlayingAudio:(BOOL)needMutePlayingAudio;
4.3.3 webRTC 教室回调。

对于 webRTC 教室(通过 self.room.featureConfig.isWebRTC 判断),BJLMediaVMinLiveChannel 属性表示是否进入直播频道,这是使用音视频功能的前提。

// webRTC:是否已进入直播频道
@property (nonatomic, readonly) BOOL inLiveChannel;

// webRTC 进入直播频道失败
[self bjl_observe:BJLMakeMethod(self.room.mediaVM, enterLiveChannelFailed)
         observer:^BOOL{
             // bjl_strongify(self);
             NSLog(@"进入直播频道失败,将无法使用音视频");
             return YES;
         }];

// webRTC 直播频道断开提示
[self bjl_observe:BJLMakeMethod(self.room.mediaVM, didLiveChannelDisconnectWithError:)
         observer:^BOOL(NSError *error){
             // bjl_strongify(self);
             NSLog(@"直播频道已断开,请退出重试");
             return YES;
         }];

// 音视频网络状态更新回调
[self bjl_observe:BJLMakeMethod(self.room.mediaVM, mediaNetworkStatusDidUpdateWithUser:status:)         
         observer:(BJLMethodObserver)^BOOL(BJLMediaUser *user, BJLMediaNetworkStatus status) {         
             bjl_strongify(self);
             // 处理网络状态变化
             [self updateMediaNetworkStatus:status forUser:user];            
             return YES;         
         }];

// 音视频丢包率更新回调
[self bjl_observe:BJLMakeMethod(self.room.mediaVM, mediaLossRateDidUpdateWithUser:videoLossRate:audioLossRate:)
                  observer:(BJLMethodObserver)^BOOL(BJLMediaUser *user, CGFloat videoLossRate, CGFloat audioLossRate){
                      bjl_strongify(self);
                      // 处理丢包率变化
                      [self updateVideoLossRate:videoLossRate 
                                  audioLossRate:audioLossRate 
                                        forUser:user];
                      return YES;
          }];

// 音量更新回调
[self bjl_observe:BJLMakeMethod(self.room.mediaVM, volumeDidUpdateWithUser:volume:)
          observer:(BJLMethodObserver)^BOOL(BJLMediaUser *user, CGFloat volume){         
              bjl_strongify(self);
              // 处理音量变化
              [self updateVolume:volume forUser:user];           
              return YES;         
          }];

4.4 专业小班课 API

  • 视频窗口位置更新。
/**
 专业版小班课 - 更新视频窗口

 #param mediaID 视频流标识
 #param action 更新类型,参考 BJLWindowsUpdateModel 的 BJLWindowsUpdateAction
 #param displayInfos 教室内所有视频窗口显示信息
 #return 调用错误, 参考 BJLErrorCode
 */
- (nullable BJLError *)updateVideoWindowWithMediaID:(NSString *)mediaID
                                             action:(NSString *)action
                                       displayInfos:(NSArray<BJLWindowDisplayInfo *> *)displayInfos;
/**
 专业版小班课 - 视频窗口更新通知

 #param updateModel 更新信息
 #param shouldRest 是否重置所有窗口
 */
 - (BJLObservable)didUpdateVideoWindowWithModel:(BJLWindowUpdateModel *)updateModel
                                    shouldReset:(BOOL)shouldReset;

// example:根据通知更新窗口布局
[self bjl_observe:BJLMakeMethod(self.room.playingVM, didUpdateVideoWindowWithModel:shouldReset:)
         observer:(BJLMethodObserver)^BOOL(BJLWindowUpdateModel *updateModel, BOOL shouldReset) {
             bjl_strongify(self);
             if (shouldReset) {
                 [self resetVideoWindowsWithModel:updateModel];
             }
             else {
                 [self updateVideoWindowWithModel:updateModel];
             }
             return YES;
         }];
  • 老师/助教 将用户上下台。
// 用户上台
[self.room.playingVM requestAddActiveUser:user];
// 用户下台
[self.room.playingVM requestRemoveActiveUser:user];
// 用户上台成功回调
[self bjl_observe:BJLMakeMethod(self.room.playingVM, didAddActiveUser:)
         observer:^BOOL(BJLUser *user) {
             // bjl_strongify(self);
             NSLog(@"%@已上台", user.name);
             return YES;
         }];

// 用户上台请求被服务端拒绝
[self bjl_observe:BJLMakeMethod(self.room.playingVM, didAddActiveUserDeny:responseCode:)
         observer:^BOOL(BJLUser *user, NSInteger responseCode) {
             // bjl_strongify(self);
             NSLog(@"上台请求被拒绝:%@", (responseCode == 2)? @"该学生已离开教室" : @"上台人数已满");
             return YES;
         }];

// 用户下台成功回调
[self bjl_observe:BJLMakeMethod(self.room.playingVM, didRemoveActiveUser:)
         observer:^BOOL(BJLUser *user) {
             // bjl_strongify(self);
             NSLog(@"%@已下台", user.name);
             return YES;
         }];

5. 旧版 SDK 音视频模板适配 API

2.0 及以上版本的 BJLiveCore SDK 支持同时播放一个用户的多路视频流,由 BJLPlayingVM 管理,对比旧版本 SDK,API 改动较大,集成方如果是从 1.x 版本升级上来,且没有多视频流的需求,可以使用 BJLPlayingAdapterVM 管理音视频播放,此适配层在 2.x SDK BJLPlayingVM 的多路流模板 API 的基础上进行适配封装,支持旧版 SDK 的单路流及主辅摄像头双路流模板,API 相对改动较小。

5.1 音视频模板适配要点说明

  • 单路流模板。

1.x 版本 SDK 中,每个发言用户最多存在一路音视频流,2.x 版本针对这种使用场景进行了适配,通过 BJLPlayingAdapterVM 适配层提供近似于旧版 SDK 的 API,实现相关功能。单路流模板中,可通过 self.room.mainPlayingAdapterVM 管理音视频播放, 与 1.x 版本的 self.room.playingVM 功能相同。

单路流模板中用户音视频流对应关系如下:

用户音视频流实例 视频源类型 含义
BJLMediaUser BJLMediaSource_camera 主摄像头采集
BJLMediaSource_screenShare 屏幕共享
BJLMediaSource_mediaFile 媒体文件播放

对于一个 BJLUser 实例,在 self.room.mainPlayingAdapterVM.playingUsers 数组中最多存在一个 BJLMediaUser 实例,mediaSourceBJLMediaSource_mainCameraBJLMediaSource_screenShareBJLMediaSource_mediaFile

1.x 版本 SDK 升级到 2.x 时,代码改动示例如下:

// 1.x 版本播放对象用户视频
[self.playingVM updatePlayingUserWithID:user.ID videoOn:YES];

// 升级到 2.x 版本之后,使用 mainPlayingAdapterVM 代替 playingVM
[self.mainPlayingAdapterVM updatePlayingUserWithID:user.ID videoOn:YES];
  • 主辅摄像头双路流模板。

2.0.0-alpha2.0.0-beta 版本的 SDK 支持双路流模板,每个发言用户最多可同时存在主摄像头、辅主摄像头两路流,后续的 2.x 版本也同样通过 BJLPlayingAdapterVM 针对这种使用场景进行了适配。使用self.room.mainPlayingAdapterVM 管理主摄像头音视频,self.room.extraPlayingAdapterVM 管理辅助摄像头音视频,两者的 BJLMediaUser 实例通过 cameraType 属性区分, 参考 BJLCameraType

双路流模板中用户音视频流对应关系如下:

用户音视频流实例 摄像头位置 视频源类型 含义
BJLMediaUser BJLCameraType_main BJLMediaSource_mainCamera 主摄像头采集
BJLMediaSource_screenShare 屏幕共享
BJLMediaSource_mediaFile 媒体文件播放
BJLCameraType_extra BJLMediaSource_extraCamera 辅助摄像头采集
BJLMediaSource_extraScreenShare 辅助摄像头屏幕共享

双路流模板中,对于每个发言用户,他的主摄像头流对应的 BJLMediaUser 实例存在于 self.room.mainPlayingAdapterVM.playingUsers 中,实例的 cameraTypeBJLCameraType_mainmediaSourceBJLMediaSource_mainCameraBJLMediaSource_screenShareBJLMediaSource_mediaFile,每个发言用户在数组中有且仅存在一个实例;辅助摄像头流对应的 BJLMediaUser 实例存在于 self.room.mainPlayingAdapterVM.playingUsers 中,实例的 cameraTypeBJLCameraType_extramediaSourceBJLMediaSource_extraCameraBJLMediaSource_extraScreenShare,每个发言用户在数组中最多存在一个实例。

5.2 监听播放信息

适配 API 中,主摄像头的音视频信息通过监听 self.room.mainPlayingAdapterVM 的属性变化获取,辅助摄像头的音视频信息通过监听 self.room.extraPlayingAdapterVM 的属性变化获取。

  • 监听音视频用户列表。

self.room.mainPlayingAdapterVMplayingUsers 表示主摄像头音视频用户列表,self.room.extraPlayingAdapterVMplayingUsers 表示辅助摄像头音视频用户列表。

/** 音视频用户列表
 #discussion 包含 `videoPlayingUser`
 #discussion 所有用户的音频会自动播放,视频需要调用 `updatePlayingUserWithID:videoOn:` 打开或者通过 `autoPlayVideoBlock` 控制打开
 #discussion SDK 会处理音视频打断、恢复、前后台切换等情况
 */
@property (nonatomic, readonly, copy, nullable) NSArray<__kindof BJLMediaUser *> *playingUsers;
// example:监听 mainPlayingAdapterVM 的 playingUsers 变化,获取主摄像头音视频的实时信息。
[self bjl_kvo:BJLMakeProperty(self.room.mainPlayingAdapterVM, playingUsers)
     observer:^BOOL(id _Nullable value, id _Nullable oldValue, BJLPropertyChange * _Nullable change) {
         // bjl_strongify(self);
         NSLog(@"playing users changed");
         return YES;
     }];
  • 监听当前正在播放的视频对应的用户列表。

self.room.mainPlayingAdapterVMvideoPlayingUsers 表示当前正在播放的主摄像头视频用户对应的列表,self.room.extraPlayingAdapterVMvideoPlayingUsers 表示当前正在播放的辅助摄像头视频对应的用户列表。

/** 正在播放的视频用户
 #discussion `playingUsers` 的子集
 #discussion 断开重连、暂停恢复等操作不自动重置 `videoPlayingUsers`,除非对方用户掉线、离线等 */
@property (nonatomic, readonly, copy, nullable) NSArray<BJLMediaUser *> *videoPlayingUsers;
// example:监听 mainPlayingAdapterVM 的 videoPlayingUsers 变化
[self bjl_kvo:BJLMakeProperty(self.room.mainPlayingAdapterVM, videoPlayingUsers)
     observer:^BOOL(NSArray<BJLUser *> *value, NSArray<BJLUser *> *oldValue, BJLPropertyChange * _Nullable change) {
         NSLog(@"当前正在播放%td人的主摄像头视频", value.count);
         return YES;
     }];

5.3 监听播放回调

主摄像头的播放回调信息通过监听 self.room.mainPlayingAdapterVM 的方法调用获取;辅助摄像头的播放回调信息通过监听 self.room.extraPlayingAdapterVM 的方法调用获取。如果支持双路流,则需要分别监听。监听代码示例可参考BJLPlayingVM 的对应部分,只需要将监听的对象改为 mainPlayingAdapterVMextraPlayingAdapterVM 即可;

  • 音视频用户列表覆盖更新。

列表覆盖更新一般在 进入教室 或 断网重连 时回调。

/** `playingUsers` 被覆盖更新
 #discussion 进教室后批量更新才调用,增量更新不调用
 #param playingUsers 音视频用户列表
 */
- (BJLObservable)playingUsersDidOverwrite:(nullable NSArray<BJLMediaUser *> *)playingUsers;
  • 用户开关音视频。
/**
 用户开关音、视频

 #discussion - 某个用户主动开关自己的音视频、切换清晰度时发送此通知,但不包含意外掉线等情况
 #discussion - 正在播放的视频用户 关闭视频时 `videoPlayingUser` 将被设置为 nil、同时发送此通知
 #discussion - 进教室后批量更新 `playingUsers` 时『不』发送此通知
 #discussion - 音视频开关状态通过 `BJLMediaUser` 的 `audioOn`、`videoOn` 获得
 #discussion - definitionIndex 可能会发生变化,调用 `definitionIndexForUserWithID:` 可获取最新的取值
 #param now 新用户信息
 #param old 旧用户信息
 TODO: 增加方法支持同时监听初始音视频状态
 */
- (BJLObservable)playingUserDidUpdate:(nullable BJLMediaUser *)now
                                  old:(nullable BJLMediaUser *)old;
  • 用户视频清晰度变化。
/** 用户开改变视频清晰度
 #param now 新用户信息
 #param old 旧用户信息
 */
- (BJLObservable)playingUserDidUpdateVideoDefinitions:(nullable BJLMediaUser *)now
                                                  old:(nullable BJLMediaUser *)old;
  • 用户视频宽高比变化。
/** 用户视频宽高比发生变化的通知
 #param videoAspectRatio 视频宽高比
 #param user 用户实例
 */
- (BJLObservable)playingViewAspectRatioChanged:(CGFloat)videoAspectRatio
                                       forUser:(BJLMediaUser *)user;
  • 用户视频流加载状态。

可用于显示视频 loading 状态

/** 将要播放视频
 #discussion 播放或者关闭视频的方法被成功调用
 #param playingUser 将要播放视频用户
 */
- (BJLObservable)playingUserDidStartLoadingVideo:(nullable BJLMediaUser *)playingUser;

/** 播放成功
 #discussion 用户视频开启或者关闭成功
 #param playingUser 播放视频的用户
 */
- (BJLObservable)playingUserDidFinishLoadingVideo:(nullable BJLMediaUser *)playingUser;
  • 播放出现卡顿。
/** 播放出现卡顿
 #param user 出现卡顿的正在播放的视频用户实例
 */
- (BJLObservable)playLagWithPlayingUser:(BJLMediaUser *)user;

5.4 播放控制

主摄像头的播放通过 self.room.mainPlayingAdapterVM 控制;辅助摄像头的播放通过 self.room.extraPlayingAdapterVM 控制。

  • 获取播放视图。
/** 获取播放用户的视频视图
 #param userID 用户 ID
 */
- (nullable UIView *)playingViewForUserWithID:(NSString *)userID;
// example:获取老师主摄像头位置 (BJLCameraType_main)视频的视图
UIView *mainCameraView = [self.room.mainPlayingAdapterVM playingViewForUserWithID:self.room.onlineUsersVM.onlineTeacher.ID];

// example: 获取老师辅助摄像头位置(BJLCameraType_extra)视频的视图
UIView *extraCameraView = [self.room.extraPlayingAdapterVM playingViewForUserWithID:self.room.onlineUsersVM.onlineTeacher.ID];
  • 打开/关闭 对象用户的视频。
/** 设置播放用户的视频
 #param userID 用户 ID
 #param videoOn YES:打开视频,NO:关闭视频
 #param definitionIndex `BJLMediaUser` 的 `definitions` 属性的 index,参考 `BJLLiveDefinitionKey`、`BJLLiveDefinitionNameForKey()`
 #return BJLError:
 BJLErrorCode_invalidArguments  错误参数,如 `playingUsers` 中不存在此用户;
 BJLErrorCode_invalidCalling    错误调用,如用户视频已经在播放、或用户没有开启摄像头。
 */
- (nullable BJLError *)updatePlayingUserWithID:(NSString *)userID
                                       videoOn:(BOOL)videoOn;
- (nullable BJLError *)updatePlayingUserWithID:(NSString *)userID
                                       videoOn:(BOOL)videoOn
                               definitionIndex:(NSInteger)definitionIndex;
// example:关闭 user 对应的主摄像头视频流,user 为 `BJLMediaUser` 实例
[self.room.mainPlayingAdapterVM updatePlayingUserWithID:user.ID
                                                videoOn:NO];
  • 自动播放视频并指定清晰度回调。

主摄像头通过 self.room.mainPlayingAdapterVM.autoPlayVideoBlock 回调,辅助摄像头通过 self.room.mainPlayingAdapterVM.extraPlayVideoBlock。示例可参考 BJLPlayingVM 的对应示例代码

/** 自动播放视频并指定清晰度回调
 #discussion 传入参数 user 和 cachedDefinitionIndex 分别为 用户 和 上次播放该用户视频时使用的清晰度
 #discussion 返回结果 autoPlay 和 definitionIndex 分别为 是否自动播放视频 和 播放视频使用的视频清晰度,例如
 |  self.room.playingVM.autoPlayVideoBlock = ^BJLAutoPlayVideo(BJLMediaUser *user, NSInteger cachedDefinitionIndex) {
 |      BOOL autoPlay = user.number && ![self.autoPlayVideoBlacklist containsObject:user.number];
 |      NSInteger definitionIndex = cachedDefinitionIndex;
 |      if (autoPlay) {
 |          NSInteger maxDefinitionIndex = MAX(0, (NSInteger)user.definitions.count - 1);
 |          definitionIndex = (cachedDefinitionIndex <= maxDefinitionIndex
 |                             ? cachedDefinitionIndex : maxDefinitionIndex);
 |      }
 |      return BJLAutoPlayVideoMake(autoPlay, definitionIndex);
 |  };
 */
@property (nonatomic, copy, nullable) BJLAutoPlayVideo (^autoPlayVideoBlock)(BJLMediaUser *user, NSInteger cachedDefinitionIndex);
  • 获取正在播放的视频流的清晰度。
/** 获取播放用户的清晰度
 #param userID 用户 ID
 #return 播放时传入的 `definitionIndex`
 */
- (NSInteger)definitionIndexForUserWithID:(NSString *)userID;

6. 举手发言

6.1 学生举手发言

对于老师,只要进入教室成功并且处于上课状态,就会保持发言状态,可以随时向教室内的其他用户发布音、视频(进入教室成功通过监听到 BJLRoomenterRoomSuccess 方法得知,上课状态则通过监听 BJLRoomVMliveStarted 属性获取)。

对于学生,除了进入教室成功并且处于上课状态这两个条件之外,需要举手向老师发送申请,老师同意后才能进入发言状态。发送申请之前需要判断老师是否在教室以及当前是否处于上课状态,申请的处理结果可以通过监听获得,申请的超时时间固定为 30秒,SDK 提供了相应的倒计时监听方法。

  • 判断老师是否在教室。
BOOL hasTeacher = !!self.room.onlineUsersVM.onlineTeacher;
  • 判断当前是否禁止举手。
// 老师禁止学生举手状态
@property (nonatomic, readonly) BOOL forbidSpeakingRequest;
  • 判断当前是否是发言状态,小班课不处理 speakingEnabled,专业小班课仅在打开音频时认为是发言状态。
/** 学生: 发言状态
 #discussion 举手、邀请发言、远程开关音视频等事件会改变此状态
 #discussion 上层需要根据这个状态开启/关闭音视频,上层开关音视频前需要判断当前音视频状态
 #discussion 因为 `speakingDidRemoteControl:` 会直接开关音视频、然后再更新学生的 `speakingEnabled` */
@property (nonatomic, readonly) BOOL speakingEnabled;
  • 举手申请发言。
/** 学生: 发送发言申请
 #discussion 上课状态才能举手,参考 `roomVM.liveStarted`
 #discussion 发言申请被允许/拒绝时会收到通知 `speakingRequestDidReply:`
 #return BJLError:
 BJLErrorCode_invalidCalling    错误调用,如在非上课状态、或者禁止举手等情况下调用此方法;
 BJLErrorCode_invalidUserRole   错误权限,要求学生权限。
 */
BJLError *error = [self.room.speakingRequestVM sendSpeakingRequest];
  • 正在申请发言的学生,仅老师和助教可以取到。
// 正在申请发言的学生
@property (nonatomic, readonly, copy, nullable) NSArray<BJLUser *> *speakingRequestUsers;
  • 监听举手发言申请的处理结果。
bjl_weakify(self);
[self bjl_observe:BJLMakeMethod(self.room.speakingRequestVM, speakingRequestDidReplyEnabled:isUserCancelled:user:)
         observer:(BJLMethodObserver)^BOOL(BOOL speakingEnabled, BOOL isUserCancelled, BJLUser *user) {
             bjl_strongify(self);
             NSLog(@"发言申请已被%@", speakingEnabled ? @"允许" : @"拒绝");
             if (speakingEnabled) {
                //发言请求被批准,打开麦克风
                [self.room.recordingVM setRecordingAudio:YES
                                          recordingVideo:NO];
                NSLog(@"麦克风已打开");
             }
             return YES;
         }];
/** 学生: 举手发言申请被自动拒绝,因为上麦路数达到上限 */
[self bjl_observe:BJLMakeMethod(self.room.speakingRequestVM, speakingRequestDidDeny)
                 observer:^BOOL(void) {
                     bjl_strongify(self);
                     [self showProgressHUDWithText:@"服务器拒绝申请发言,音视频并发已达上限"];
                     return YES;
                 }];
  • 监听发言状态。
/** 音视频被远程开启、关闭,导致发言状态变化
 #discussion 音视频有一个打开就开启发言、全部关闭就结束发言
 #discussion SDK 内部先开关音视频、然后再更更新学生的 `speakingEnabled` 的状态
 #discussion 参考 `BJLRecordingVM` 的 `recordingDidRemoteChangedRecordingAudio:recordingVideo:recordingAudioChanged:recordingVideoChanged:`
 #param enabled YES:开启,NO:关闭
 */
[self bjl_observe:BJLMakeMethod(self.room.speakingRequestVM, speakingDidRemoteControl:)
         observer:(BJLMethodObserver)^BOOL(BOOL enabled) {
             NSlog(@"发言状态被%@", enabled ? @"开启" : @"关闭");
             return YES;
         }];
  • 举手发言申请的自动取消倒计时。
/** 超时时间及更新频率:定义在 SDK 内部,外部可访问。
调用 sendSpeakingRequest 方法举手时设置超时时间为  BJLSpeakingRequestTimeoutInterval (默认为30,可通过后台配置)秒,
每 BJLSpeakingRequestCountdownStep (固定为0.1)秒更新,
变为 0.0 时自动取消举手
*/
extern const NSTimeInterval BJLSpeakingRequestTimeoutInterval, BJLSpeakingRequestCountdownStep;
// 监听发言申请的剩余持续时间:剩余时间 speakingRequestTimeRemaining 的值由SDK内部计时器控制,可通过监听该值的变化进行自定义的响应操作
[self bjl_kvo:BJLMakeProperty(self.room.speakingRequestVM, speakingRequestTimeRemaining)
      options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
       filter:^BOOL(NSNumber * _Nullable timeRemaining, NSNumber * _Nullable oldValue, BJLPropertyChange * _Nullable change) {
           return timeRemaining.doubleValue != oldValue.doubleValue;
       }
     observer:^BOOL(NSNumber * _Nullable timeRemaining, NSNumber * _Nullable oldValue, BJLPropertyChange * _Nullable change) {
         NSLog(@"timeRemaining:%f/%f", timeRemaining, BJLSpeakingRequestTimeoutInterval);
         return YES;
     }];
  • 学生取消发言申请:取消申请不会自动关闭音视频采集,调用以下取消申请的方法之后 BJLRecordingVMspeakingEnabled 会变为 NO,可以事先监听该属性的变化,在监听的回调里调用 [self.room.recordingVM setRecordingAudio:NO recordingVideo:NO] 关闭音视频采集,完全结束发言。
[self.room.speakingRequestVM stopSpeakingRequest];
  • 停止发言:正在发言的用户,将音视频采集全部关闭则会自动关闭发言状态。
[self.room.recordingVM setRecordingAudio:NO recordingVideo:NO];

6.2 学生处理发言邀请

学生还可以收到老师的发言邀请(移动端目前不支持发送发言邀请),接受之后将进入发言状态。

  • 监听收到的发言邀请:监听 BJLSpeakingRequestVMdidReceiveSpeakingInvite: 方法,invite 参数为 YES 时表示收到邀请,为 NO 时表示邀请被取消。
[self bjl_observe:BJLMakeMethod(self.room.speakingRequestVM, didReceiveSpeakingInvite:)
         observer:(BJLMethodObserver)^BOOL(BOOL invite) {
             if (invite) {
                    NSLog(@"received speaking invitaion"); 
             }
             else {
                    NSLog(@"speaking invitation canceled");
             }
             return YES;
         }];
  • 接受或拒绝发言邀请。
[self.room.speakingRequestVM responseSpeakingInvite:YES]; //YES:接受,NO:拒绝

6.3 老师处理发言申请

  • 监听正在申请发言的学生列表:列表数组 speakingRequestUsers 在 SDK 内部即时更新,监听它的变化可以添加一些自定义的后续操作。
[self bjl_kvo:BJLMakeProperty(self.room.speakingRequestVM, speakingRequestUsers)
      options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
     observer:^BOOL(NSArray<BJLUser *> * _Nullable value, NSArray<BJLUser *> * _Nullable oldValue, BJLPropertyChange * _Nullable change) {
         NSLog(@"value: %lu elements, oldValue: %lu elements", (unsigned long)value.count, (unsigned long)oldValue.count);
         return YES;
     }];
  • 允许/拒绝发言。
[self.room.speakingRequestVM replySpeakingRequestToUserID:user.ID allowed:YES]; //YES:允许,NO:拒绝
  • 监听收到发言申请的通知:发送申请的 user 将被自动添加到 speakingRequestUsers 中,这里可添加自定义的后续操作。
bjl_weakify(self);
[self bjl_observe:BJLMakeMethod(self.room.speakingRequestVM, didReceiveSpeakingRequestFromUser:)
         observer:^BOOL(BJLUser *user) {
             bjl_strongify(self);
             // 自定义后续操作,以同意申请为例:
             [self.room.speakingRequestVM replySpeakingRequestToUserID:user.ID allowed:YES];
             NSLog(@"%@ 请求发言、已同意", user.name);
             return YES;
         }];
  • 远程开关学生音、视频。
/** 老师: 远程开关学生音、视频
 #discussion 打开音频、视频会导致对方发言状态开启
 #discussion 同时关闭音频、视频会导致对方发言状态终止
 @see `speakingRequestVM.speakingEnabled`
 #param user 对象用户,不能是老师
 #param audioOn YES:打开音频采集,NO:关闭音频采集
 #param videoOn YES:打开视频采集,NO:关闭视频采集
 #return BJLError:
 BJLErrorCode_invalidArguments  错误参数;
 BJLErrorCode_invalidUserRole   错误权限,要求老师或助教权限。
 */
BJLError *error = [self.room.recordingVM remoteChangeRecordingWithUser:user audioOn:NO videoOn:NO];

7. 课件

课件包括白板、PPT和画笔,SDK 支持 pdf、word、动效 PPT 等文档的显示,教室里面默认至少有一个白板课件,上传课件支持图片,PDF文件,Word文件,PPT文件。2.x的 SDK 支持多文档实例,BJLSlideVMBJLSlideshowVM 移除,替换为BJLDocumentVM

  • 设置课件类型(需要在进入教室之前设置):SDK 提供 native 和 H5 两种类型的课件。native 课件加载快、支持缩放手势,但不支持 PPT 动画;H5 课件加载略慢,不支持缩放手势,支持 PPT 动画。默认使用 H5 课件。目前 SDK 支持课件的动态切换,在使用 H5 课件的情况下,教室里存在动态课件,将会使用 H5 课件,教室里只有静态课件的时候,将会使用 native 课件。BJLiveCore 2.1.0 版本支持动态开关 PPT 动效,设置 BJLRoomdisablePPTAnimation 可即时切换课件类型。
// 设置课件类型,不设置则默认使用 H5 课件
self.room.disablePPTAnimation = NO; // YES:native, NO:H5
  • 单实例课件控制器显示。
// 设置静态课件尺寸 默认720,范围 (1 ~ 4096)
self.room.slideshowViewController.imageSize = 720;
// 显示课件视图, 将 BJLRoom 的课件视图添加到当前 viewController 的对应视图

[self addChildViewController:self.room.slideshowViewController];
[self.slideshowView addSubview:self.room.slideshowViewController.view];
[self.room.slideshowViewController didMoveToParentViewController:self];
/** 设置静态显示模式
 BJLContentMode_scaleAspectFit  完整
 BJLContentMode_scaleAspectFill 铺满
 BJLContentMode_scaleToFill     拉伸
*/
self.room.slideshowViewController.contentMode = BJLContentMode_scaleAspectFit;
  • 多实例课件控制器显示。
1、显示控制

 // 黑板
@property (nonatomic, readonly) UIViewController<BJLBlackboardUI> *blackboardViewController;
// 黑板页数
@property (nonatomic, readonly) NSInteger blackboardContentPages;
// 设置黑板的背景图
@property (nonatomic) UIImage *blackboardImage;
// 当前黑板页码
CGFloat localPageIndex = self.room.documentVM.blackboardViewController.localPageIndex;

/**
 指定文档 ID 创建对应的视图控制器

 #param documentID 文档 ID,通过 BJLDocument 的 documentID 获得
 #return 对应文档的视图控制器
 */
 self.documentViewController = [room.documentVM documentViewControllerWithID:documentID];                               
2、同步以及更新文档显示状态

//  更新文档显示信息
[self.context.room.documentVM updateDocumentWithID:BJLBlackboardID displayInfo:displayInfo];

// 更新文档显示信息的通知
[self bjl_observe:BJLMakeMethod(self.context.room.documentVM, didUpdateDocument:)
           filter:^BOOL(BJLDocument *document){
               // bjl_strongify(self);
               return [document.documentID isEqualToString:BJLBlackboardID];
           }
         observer:^BOOL(BJLDocument *document){
             bjl_strongify(self);
             // 黑板滑动
             [self scrollToTopOffset:document.displayInfo.topOffset];
             return YES;
         }];

/**
 更新文档窗口

 #param documentID 文档 ID
 #param action 更新类型,参考 BJLWindowsUpdateModel 的 BJLWindowsUpdateAction
 #param displayInfos 教室内所有文档窗口显示信息
 #return 调用错误, 参考 BJLErrorCode
 */
[self.room.documentVM updateDocumentWindowWithID:documentID
                                          action:action
                                    displayInfos:self.documentWindowDisplayInfos];

// 文档窗口更新通知               
[self bjl_observe:BJLMakeMethod(self.room.documentVM, didUpdateDocumentWindowWithModel:shouldReset:)
             observer:(BJLMethodObserver)^BOOL(BJLWindowUpdateModel *updateModel, BOOL shouldReset) {
                 bjl_strongify(self);
                 if (shouldReset) {
                     [self resetDocumentWindowsWithModel:updateModel];
                 }
                 else {
                    [self updateDocumentWindowWithModel:updateModel];
                 }
                 return YES;
             }]; 
  • 课件控制器管理。

课件控制器实现了 BJLSlideshowUI 协议,可以设置 静态课件 显示模式,加载图片尺寸,翻页,缩放控制。

// 静态课件显示模式
@property (nonatomic) BJLContentMode contentMode;
// 静态课件尺寸
@property (nonatomic) NSInteger imageSize;
// 静态课件占位图
@property (nonatomic) UIImage *placeholderImage;
// 本地当前页、可能与教室内的页数不同
@property (nonatomic) NSInteger localPageIndex;
// 设置是否可以翻页
- (void)updateScrollEnabled:(BOOL)scrollEnabled;
// 设置是否可以缩放
- (void)updateScaleEnabled:(BOOL)scaleEnabled;
// 能否向前翻页
@property (nonatomic, readonly) BOOL canStepForward;
// 能否向后翻页
@property (nonatomic, readonly) BOOL canStepBackward;
// 向前翻页
- (void)pageStepForward;
// 向后翻页
- (void)pageStepBackward;
  • 上传、添加课件。
1、图片格式
bjl_weakify(self);
[self.room.documentVM uploadImageFile:fileURL
                              progress:^(CGFloat progress){
                                   bjl_strongify(self);
                                  // 显示进度
                                  self.progressView.progress = progress;
                              }
                                finish:^(BJLDocument * _Nullable document, BJLError * _Nullable error) {
                                    bjl_strongify(self)
                                    if(document){
                                       [self.room.documentVM addDocument:document];
                                    }
                                    else{
                                        NSLog(@"error:%@", error);
                                    }
                                }];

2、更多文件格式
// 上传
[self.room.documentVM uploadFile:url
                        mimeType:mimeType
                        fileName:name
                      isAnimated:isAnimated
                        progress:^(CGFloat progress) {
                            //bjl_strongify(self);
                            NSLog(@"progress", progress);
                        }
                          finish:^(BJLDocument * _Nullable document, BJLError * _Nullable error) {
                              bjl_strongify(self);
                              if (!error) {
                                  if (image) {
                                      // 图片不需要转码,直接添加到教室
                                        [self.room.documentVM addDocument:document];
                                  }
                                  else {
                                      // 开始轮询转码进度
                                      [self startPollTimer];
                                  }
                              }
                              else {
                                  errorMessage = @"文件上传失败\n无法使用";
                              }
                          }];

// 转码
[self.room.documentVM requestTranscodingProgressWithFileIDList:array
                                                    completion:^(NSArray<BJLDocumentTranscodeModel *> * _Nullable transcodeModelArray, BJLError * _Nullable error) {
                                                        bjl_strongify(self);
                                                        if (error) {
                                                            return;
                                                        }
                                                        for (BJLDocumentTranscodeModel *model in transcodeModelArray) {
                                                            if (model.progress >= 100) {
                                                                // 转码完成,请求转码完成之后的新文档
                                                                [self requestDocumentList];
                                                            }
                                                            else {
                                                                // 更新转码进度
                                                                NSLog(@"progress", progress);
                                                            }
                                                        }
                                                    }];
// 添加文档
[self.room.documentVM requestDocumentListWithFileIDList:@[fileID]
                                             completion:^(NSArray<BJLDocument *> * _Nullable documentArray, BJLError * _Nullable error) {
                                                 if (error) {
                                                     return;
                                                 }
                                                 for (BJLDocument *document in documentArray) {
                                                     for (NSString *fileID in self.finishDocumentFileIDList) {
                                                         // 如果文档已经添加到了教室, 不处理
                                                         if ([document.fileID isEqualToString:fileID]) {
                                                             return;
                                                         }
                                                     }
                                                     // 请求到文档信息之后添加文档,这时认为已经添加到了教室里
                                                     [self.finishDocumentFileIDList bjl_addObject:document.fileID];
                                                     // 转码成功后可以获得文档的页码信息,更新本地document
                                                     // 添加文档
                                                     [self.room.documentVM addDocument:document];
                                                 }
                                             }];
  • 删除课件。
// 根据 ID 删除课件
[self.room.documentVM deleteDocunmentWithID:documentID]; 
  • 监听课件变化:通过监听 self.room.documentVM 的属性变化及方法调用来实现。
// 以监听所有课件 allDocuments 的变化为例
bjl_weakify(self);
[self bjl_kvo:BJLMakeProperty(self.room.documentVM, allDocuments)
     observer:^BOOL(NSArray<BJLDocument *> * _Nullable value, id _Nullable oldValue, BJLPropertyChange * _Nullable change) {
         bjl_strongify(self);
         // 更新数据源及相关界面控件
         self.allDocuments = [value mutableCopy];
         [self.tableView reloadData];
         [self updateViewsForDataCount];
         return YES;
     }];
// 监听添加课件的通知
 [self bjl_observe:BJLMakeMethod(self.room.documentVM, didAddDocument:)
          observer:^(BJLDocument *document) {
              // tableView的数据源及相关界面已经通过监听allDocuments的变化进行更新
              if(document){
                  NSLog(@"document: %@ added", document);
              }
              return YES;
          }];
 // 删除课件
 [self bjl_observe:BJLMakeMethod(self.room.documentVM, didDeleteDocument:)
             observer:^BOOL(BJLDocument *document) {
                 // bjl_strongify(self);
                 if(document){
                  NSLog(@"document: %@ delete", document);
                 }
                 return YES;
             }];
  • 监听本地课件页码:学生翻页不会影响到远程课件翻页,如果要禁止学生本地翻页,需要在上层限制。对于单实例的文档,BJLDocumentVMcurrentSlidePage 表示 整个教室的当前页,随 老师/助教 翻动课件而改变。因为学生可以回顾之前的课件,所以它不一定是本地的当前页,不能用于显示本地课件页码,SDK 限制了学生不能翻页超过远程的课件的当前页。本地课件页码通过监听 BJLRoomslideshowViewControllerlocalPageIndex 获得。对于多实例的文档,只能获得本地文档的当前页self.documentViewController.localPageIndex,学生翻页能够超过远程课件的当前页。
[self bjl_kvo:BJLMakeProperty(self.room.slideshowViewController, localPageIndex)
         observer:^BOOL(id  _Nullable value, id  _Nullable oldValue, BJLPropertyChange * _Nullable change) {
             NSLog(@"localPage:%td", [value integerValue]);
             return YES;
         }];
  • 课件授权:通过监听 self.room.documentVMauthorizedPPTUserNumbers 获取课件授权用户。
// 监听授权用户列表
[self bjl_kvo:BJLMakeProperty(self.room.documentVM, authorizedPPTUserNumbers)
         observer:^BOOL(id  _Nullable value, id  _Nullable oldValue, BJLPropertyChange * _Nullable change) {
             bjl_strongify(self);
                NSLog(@"authorizedPPTUserNumbers %@", authorizedPPTUserNumbers);
             return YES;
         }];
// 给某个用户授权
BJLError *error = [self.room.documentVM updateStudentPPTAuthorized:authorized userNumber:user.number];
  • 是否禁止学生翻页

通过设置 BJLOnlineUserVMforbidStudentChangePPT 属性决定是否允许教室内学生翻页,仅限大班课类型,BJLiveCore 2.1.0 版本开始支持。

  • 多白板
    • BJLiveCore 2.1.0 版本开始支持多白板;
    • 大班课支持多白板功能,老师或助教可动态添加、删除白板;
    • 单页白板实例为 BJLSlidePage 类型,documentID 均为 0slidePageIndex 表示序号。
    • 通过 BJLDocumentVMaddWhiteboardPage 添加一页白板,deleteWhiteboardPageWithIndex: 方法删除对应 slidePageIndex 的白板页;
    • 通过监听 BJLDocumentdidAddWhiteboardPage: 方法获取白板页添加通知,监听 didDeleteWhiteboardPageWithIndex: 方法获取白板页删除通知。
// 添加一页白板
- (nullable BJLError *)addWhiteboardPage;

/**
 成功添加白板的通知

 #discussion 同时更新 `allDocuments`
 #param pageIndex 白板页码
 */
- (BJLObservable)didAddWhiteboardPage:(NSInteger)pageIndex;
/**
 删除对应页码的白板

 @param pageIndex 白板页码,使用 BJLSlidePage 的 `slidePageIndex`
 @return BJLError
 */
- (nullable BJLError *)deleteWhiteboardPageWithIndex:(NSInteger)pageIndex;

/**
 成功删除白板的通知

 #discussion 同时更新 `allDocuments`
 #param pageIndex 白板页码
 */
- (BJLObservable)didDeleteWhiteboardPageWithIndex:(NSInteger)pageIndex;

8. 画笔

老师和处于发言状态的学生可以在白板和 PPT 上添加、清除画笔,对于单文档实例,操作画笔时用户的当前课件页面必须与老师保持一致,对于多文档实例,操作画笔的当前页面可以和远程页面不一致。画笔管理使用 BJLDrawingVM。2.x的画笔支持多种画笔形状,具体可以参考 BJLDrawingShapeType

  • 显示画笔视图: 目前画笔与课件共用同一个视图 单文档:self.room.slideshowViewController.view,多文档:通过 BJLDocumentVM 以及 documentID 获取 - (UIViewController<BJLSlideshowUI> *)documentViewControllerWithID:(NSString *)documentID;

  • 开启、关闭画笔。

/**
 开启、关闭画笔
 #param drawingEnabled YES:开启,NO:关闭
 #return BJLError:
 #discussion BJLErrorCode_invalidCalling    错误调用,当前用户是学生、`drawingGranted` 是 NO
 #discussion 开启画笔时,单文档实例情况下如果本地页数与服务端页数不同步则无法绘制
 #discussion `drawingGranted` 是 YES 时才可以开启,`drawingGranted` 是 NO 时会被自动关闭
 */
 BJLError *error = [self.room.drawingVM updateDrawingEnabled:YES];
  • 画笔授权。
/** 老师、助教: 给学生授权/取消画笔
 #param granted     是否授权
 #param user        授权操作的对象用户
 #return BJLError:
 BJLErrorCode_invalidUserRole   当前用户不是老师或者助教
 BJLErrorCode_invalidArguments  参数错误
 */
 BJLError *error = [self.room.drawingVM updateDrawingGranted:grant userNumber:user.number];
// 所有被授权使用画笔的学生编号
@property (nonatomic, readonly, copy) NSArray<NSString *> *drawingGrantedUserNumbers;
  • 画笔设置。
/**
 更新画笔操作模式

 #param operateMode 操作模式,参考 `BJLBrushOperateMode`
 #return BJLError:
 #discussion BJLErrorCode_invalidCalling drawingEnabled 是 NO
 */
 BJLError *error = [self.room.drawingVM updateBrushOperateMode:operateMode];
// 切换画笔图形, 参考 `BJLDrawingShapeType`
self.room.drawingVM.drawingShapeType = BJLDrawingShapeType_rectangle; // 画矩形
// 边框颜色
@property (nonatomic) NSString *strokeColor;
// 填充颜色
@property (nonatomic, nullable) NSString *fillColor;
// 涂鸦画笔线宽
@property (nonatomic) CGFloat doodleStrokeWidth;
// 涂鸦画笔是否使用虚线 
@property (nonatomic) BOOL isDottedLine;
// 图形画笔边框线宽
@property (nonatomic) CGFloat shapeStrokeWidth;
// 文字画笔字号
@property (nonatomic) CGFloat textFontSize;
// 文字画笔是否加粗
@property (nonatomic) BOOL textBold;
// 文字画笔是否使用斜体
@property (nonatomic) BOOL textItalic;
  • 添加图片画笔
/**
 添加图片画笔

 #param imageURL 图片 url
 #param relativeFrame 图片相对于画布的 frame,各项取值范围 0~1
 #param documentID 目标文档 ID
 #param pageIndex 目标页
 #return BJLErrorCode_invalidCalling drawingEnabled 是 NO
 */
- (nullable BJLError *)addImageShapeWithURL:(NSString *)imageURL
                              relativeFrame:(CGRect)relativeFrame
                               toDocumentID:(NSString *)documentID
                                  pageIndex:(NSUInteger)pageIndex;
  • 清空画板。
[self.room.slideshowViewController clearDrawing];
  • 激光笔:仅限专业版小班课
/**
 专业版小班课 - 激光笔位置移动请求

 #param location    激光笔目标位置
 #param documentID  激光笔所在文档的 ID
 #param pageIndex   激光笔所在文档页码
 */
- (nullable BJLError *)moveLaserPointToLocation:(CGPoint)location
                                     documentID:(nonnull NSString *)documentID
                                      pageIndex:(NSUInteger)pageIndex;
/**
 专业版小班课 - 激光笔位置移动监听

 #param location    激光笔位置
 #param documentID  激光笔所在文档的 ID
 #param pageIndex   激光笔所在文档页码
 */
- (BJLObservable)didLaserPointMoveToLocation:(CGPoint)location
                                  documentID:(NSString *)documentID
                                   pageIndex:(NSUInteger)pageIndex;

9. 聊天

SDK 提供教室内的群聊功能(不包含显示视图),可以发送文字、图片、表情三种类型的消息,提供禁言机制。

  • 聊天视图:需自行创建,SDK 提供聊天管理类型 BJLChatVM

  • 显示聊天的UI界面是需要重点优化的,优化方式可以使用高度缓存,手动计算高度,不使用自动布局等方式来优化,聊天界面一直占用主线程会导致音视频和课件不同步,界面一直卡顿等问题。如果成功优化之后依旧存在卡顿,可以使用调试工具针对性能消耗较大的功能进行针对性的优化。

  • 获取所有消息,2.x 之后 SDK 【不】保存聊天消息,需要上层自行维护。

/** `receivedMessages` 被覆盖更新
 #discussion 覆盖更新才调用,增量更新不调用
 #discussion 首次连接 server 或断开重连会导致覆盖更新
 #param receivedMessages 收到的所有消息
 */
[self bjl_observe:BJLMakeMethod(self.room.chatVM, receivedMessagesDidOverwrite:)
         observer:^BOOL(NSArray<BJLMessage *> * _Nullable messages) {
             bjl_strongify(self);
             if (messages.count) {
                 [self.messages removeAllObjects];
                 if (messages.count > 0) {
                     [self.messages addObjectsFromArray:messages];
                 }
                 [self.tableView bjl_clearHeightCaches];
                 [self.tableView reloadData];
                 [self scrollToTheEndTableView];
             }
             return YES;
         }];          
  • 监听消息增量更新。
[self bjl_observe:BJLMakeMethod(self.room.chatVM, didReceiveMessages:)
             observer:^BOOL(NSArray<BJLMessage *> *messages) {
                 bjl_strongify(self);
                 if (!messages.count) {
                     return YES;
                 }
                 [self.messages addObjectsFromArray:messages];
                 [self.tableView reloadData];
                 return YES;
             }
  • 禁言,可以通过 KVO 的方式监听状态的变化。
// 老师设置全体禁言状态
[self.room.chatVM sendForbidAll:YES]; // YES:全体禁言 NO:取消全体禁言

// 判断是否处于全体禁言状态
BOOL forbidAll = self.room.chatVM.forbidAll;
// 老师禁言单个用户,可设置禁言时长
[self.room.chatVM sendForbidUser:user duration:60.0];

// 判断用户是否被禁言
BOOL forbidMe = self.room.chatVM.forbidMe;
// 监听用户被禁言通知
[self bjl_observe:BJLMakeMethod(self.room.chatVM, didReceiveForbidUser:fromUser:duration:)
         observer:^BOOL(BJLUser *forbidUser, BJLUser *fromUser, NSTimeInterval duration){
             NSLog(@"%@被%@禁言%f秒", forbidUser.name,fromUser.name,duration);
             return YES;
         }];
  • 发送消息:发送前需判断用户是否被禁言。
// 发送文字消息
[self.room.chatVM sendMessage:self.textField.text];
// 发送图片消息:image 为需要发送的图片,fileURL 为它的文件路径
bjl_weakify(self);
// 上传图片
[self.room.chatVM uploadImageFile:fileURL
                         progress:^(CGFloat progress){
                             bjl_strongify(self);
                             // 显示进度
                             self.imageUploadingView.progress = progress;
                         }
                           finish:^(NSString * _Nullable imageURLString, BJLError * _Nullable error) {
                               bjl_strongify(self)
                               if(imageURLString){
                                   NSDictionary *imageData = [BJLMessage messageDataWithImageURLString:imageURLString imageSize:image.size];
                                   [self.room.chatVM sendMessageData:imageData];
                               }
                               else{
                                   NSLog(@"error:%@", error);
                               }
                           }];
// 发送表情
// 需要在教室内才可以获取到表情,获取 emotion 数组
NSArray<BJLEmoticon *> *emoticons = [BJLEmoticon allEmoticons];
if (emoticons.count > 0) {
    // 模拟表情选择,这里直接选择第一个表情
    BJLEmoticon *emoticon = [emoticons objectAtIndex:0];
    if (emoticon) {
        //发送表情
        [self.room.chatVM sendMessageData:[BJLMessage messageDataWithEmoticonKey:emoticon.key]];
    }
}

10. 录课

录课是将当前教室的情景、信息以及互动记录录制到云端生成回放,通过回放功能可以再现教室的情景。本 SDK 不包含回放功能,如需集成请参考 iOS 回放 Core SDK

录课管理类型为 BJLServerRecordingVM,实例 serverRecordingVM 在创建教室时被初始化。

  • 获取云端录课状态。
// 云端录课状态,反映当前云端录课是否开启
BOOL serverRecording = self.room.serverRecordingVM.serverRecording;

/* 云端录制详细状态
    BJLServerRecordingState_ready,                      // 未开启云端录制
    BJLServerRecordingState_recording,                  // 开启过云端录制并且未转码或者正在云端录制中,可继续录制。如果现在在录制中,长期课可以停止录制,请求转码后开启新的录制
    BJLServerRecordingState_transcoding,                // 云端录制转码中,只能开启新的云端录制,不能继续录制。短期课不会有这个状态
    BJLServerRecordingState_disable,                    // 云端录制不可用,是短期课已经录制过。长期课不会有这个状态
*/
@property (nonatomic, readonly) BJLServerRecordingState state;
  • 老师开启/停止云端录课:上课状态才能开启录课,参考 roomVM.liveStarted,此方法需要发起网络请求、检查云端录课是否可用。
[self.room.serverRecordingVM requestServerRecording:YES]; // YES:开启, NO:关闭
/**
 请求当前云端录制详细状态

 @param completion 状态更新完成,可以根据状态来进行云端录制
 */
 - (void)requestServerRecordingState:(void (^ __nullable)(void))completion;
  • 请求立即转码回放。
/**
 请求立刻转码回放

 #return BJLError:
 BJLErrorCode_invalidUserRole   错误权限,要求老师或助教权限。
 */
 BJLError *error = [self.room.serverRecordingVM requestServerRecordingTranscode];

 // 转码回放请求被接受
[self bjl_observe:BJLMakeMethod(self.room.serverRecordingVM, requestServerRecordingTranscodeAccept)
             observer:^BOOL{
                 // bjl_strongify(self);
                 return NO;
             }];
  • 通知清晰度改变。
/**
 录制过程中如果改变了清晰度,需要通知服务端

 #param size 分辨率
 #return BJLError:
 BJLErrorCode_invalidCalling    错误调用,如在非上课状态下调用此方法;
 BJLErrorCode_invalidUserRole   错误权限,要求老师或助教权限。
 */
- (nullable BJLError *)requestServerRecordingChangeResolution:(CGSize)size;

// 通知清晰度改变被接收
[self bjl_observe:BJLMakeMethod(self.room.serverRecordingVM, requestServerRecordingChangeResolutionAccept)
             observer:^BOOL{
                 // bjl_strongify(self);
                 return NO;
             }];
  • 监听云端录课不可用的通知。
[self bjl_observe:BJLMakeMethod(self.room.serverRecordingVM, requestServerRecordingDidFailed:)
         observer:^BOOL(NSString *message) {
             NSLog(@"request server recording failed:%@", message);
             return YES;
         }];

11. 公告

用户可以查看教室内由老师发布的公告,公告可包含跳转链接。BJLRoomVM 提供公告的获取、发布方法,也可以监听公告变化,即时更新。

  • 获取教室公告。
// 获取教室公告,连接教室后、掉线重新连接后自动调用 loadNotice,获取成功后修改 notice
[self.room.roomVM loadNotice];
BJLNotice *notice = self.room.roomVM.notice;
  • 监听公告变化,即时显示。
bjl_weakify(self); 
[self bjl_kvo:BJLMakeProperty(self.room.roomVM, notice)
     observer:^BOOL(BJLNotice * _Nullable notice, id _Nullable oldValue, BJLPropertyChange * _Nullable change) {
         bjl_strongify(self);
         self.noticeTextView.text = notice.noticeText.length ? notice.noticeText : nil;
         return YES;
     }];
  • 发布公告。
// noticeText:公告内容 linkURL:公告跳转链接
[self.room.roomVM sendNoticeWithText:noticeText linkURL:noticeURL];

12. 测验

测验由老师发布,学生接收并答题。题目类型为 BJLSurvey, 每个题目有序号和多个 BJLSurveyOption 类型的选项,BJLSurveyOptionkey 标识一个选项,value 则代表选项的具体内容。SDK 不支持发布测验,相关的 API 在 BJLRoomVM 中。低于2.1.0 版本的SDK支持的测验是非 H5 页面的测验,即旧版测验,新版测验需要参考 UI SDK 的 BJLQuizWebViewController实现。2.1.0 版本开始支持新版测验的 native 集成方式。

12.1 新版测验 native 集成方式

参考 BJLRoomVM测验 V2 native 方式 部分。

12.1.1 测验内容管理
  • 测验状态列表
// key 为测验 ID,object 为测验状态(参考 `BJLQuizState`),测验的先后根据测验 ID 从小到大对应
@property (nonatomic, readonly, nullable) NSDictionary<NSString *, NSNumber *> *quizStateList;
  • 当前正在进行的测验 ID
// 至多只有一个测验正在进行,如果没有正在进行的测验,值为空
@property (nonatomic, readonly, nullable) NSString *currentQuizID;
  • 加载测验列表请求
/**
[self.room.roomVM loadQuizListWithCompletion:^(NSArray<BJLQuiz *> * _Nullable quizList, BJLError * _Nullable error) {
    // bjl_strongify(self);
    // your code
 }];
  • 老师、助教:新增或更新测验
/**
[self.room.roomVM updateQuiz:myQuiz
                  completion:^(NSString * _Nullable quizID, BJLError * _Nullable error) {
                      // bjl_strongify(self);
                      // your code
                  }];
  • 老师、助教:删除测验
[self.room.roomVM deleteQuizWithID:targetQuizID
                        completion:^(NSString * _Nullable quizID, BJLError * _Nullable error) {
                            // bjl_strongify(self);
                            // your code
                        }];
  • 加载测验的详细内容
/**
 加载测验详细内容
 #discussion 所有角色,对于老师或助教,如果测验结束了,有答题情况了,会返回测验的答题情况,对于学生,不会返回答题情况
 #param quizID 测验 ID
 #param completion BJLQuiz,测验详细信息
 #return task
 */
- (nullable NSURLSessionDataTask *)loadQuizDetailWithID:(NSString *)quizID completion:(nullable void (^)(BJLQuiz * _Nullable quiz, BJLError * _Nullable error))completion;
12.1.2 测验流程控制
  • 开始测验
/**
 老师、助教:开始测验
 #param quizID quizID
 #param force 是否强制参加
 #return BJLError
 */
- (nullable BJLError *)startQuizWithID:(NSString *)quizID force:(BOOL)force;
// 监听测验开始
[self bjl_observe:BJLMakeMethod(self.room.roomVM, didStartQuizWithID:force:)
         observer:(BJLMethodObserver)^BOOL(NSString *quizID, BOOL force) {
             // bjl_strongify(self);
             NSLog(@"测验%@开始,强制参加:%@", quizID, force? @"YES" : @"NO");
             return YES;
         }];
  • 结束测验
/**
 老师、助教:结束测验
 #param quizID 测验 ID
 #return BJLError
 */
- (nullable BJLError *)endQuizWithID:(NSString *)quizID;
// 监听测验结束
[self bjl_observe:BJLMakeMethod(self.room.roomVM, didEndQuizWithID:)
         observer:^BOOL(NSString *quizID) {
             // bjl_strongify(self);
             NSLog(@"测验%@结束", quizID);
             return YES;
         }];
  • 发布测验答案
/**
 发布测验答案,老师或助教身份
 #param quiz 需要发布答案的测验 ID
 #return BJLError
 */
- (nullable BJLError *)publishQuizSolutionWithID:(NSString *)quizID;
// 发布测验答案通知,可通过 KVO 监听
- (BJLObservable)didReceiveQuizWithID:(NSString *)quizID solution:(NSDictionary<NSString *, id> *)solutions;
  • 加载当前测验
// 测验加载请求
- (nullable BJLError *)loadCurrentQuiz;
// 测验加载回调,学生用户 如果回答过,将返回回答的结果
- (BJLObservable)didLoadCurrentQuiz:(BJLQuiz *)quiz;
  • 提交测验
/**
 学生:提交测验,此方法无提交完成的回调
 @param quizID 测验 ID
 @param solutions 测验回答 key -> 问题 ID,value -> 问题回答,对于 value,Radio 类型的问题的值为选项 ID,Checkbox 类型的问题的值为选项 ID 数组,ShortAnswer 类型的问题值为简答的关键内容
 @return BJLError
 */
- (nullable BJLError *)submitQuizWithID:(NSString *)quizID solution:(NSDictionary<NSString *, id> *)solutions;
/**
 老师、助教:收到学生提交测验
 #param quizID 测验ID
 @param solutions 学生答题情况
 */
- (BJLObservable)didSubmitQuizWithID:(NSString *)quizID solution:(NSDictionary<NSString *, id> *)solutions;
  • 大小班场景测验
/**
 位于小班,加载大班的已经结束的测验列表
 @return BJLError
 */
- (nullable BJLError *)loadParentRoomFinishedQuizList;
/**
 位于小班,收到大班结束的测验信息
 @param quizList 测验列表
 */
- (BJLObservable)didLoadParentRoomFinishedQuizList:(nullable NSArray<BJLQuiz *> *)quizList;

12.2 旧版测验

不推荐使用,如有需要可联系百家云后台开启。

  • 请求历史题目。
[self.room.roomVM loadSurveyHistory];
  • 监听收到历史题目以及当前用户的答题情况。
[self bjl_observe:BJLMakeMethod(self.room.roomVM, didReceiveSurveyHistory:rightCount:wrongCount:)
         observer:^BOOL(NSArray<BJLSurvey *> *surveyHistory, NSInteger rightCount, NSInteger wrongCount) {
             NSLog(@"receive %td history surveys, %td are right, %td are wrong", surveyHistory.count, rightCount, wrongCount);
             return YES;
         }];
  • 学生:收到新题目。
[self bjl_observe:BJLMakeMethod(self.room.roomVM, didReceiveSurvey:)
         observer:^BOOL(BJLSurvey *survey) {
             NSLog(@"did receive survey: %@", survey.question);
             return YES;
         }];
  • 学生答题:支持多选。
/**
 @param answers  学生选择的 BJLSurveyOption 的 key 的数组,
 @param result   与每个 BJLSurveyOption 的 isAnswer 比对得出,如果一个题目下所有 BJLSurveyOption 的 isAnswer 都是 NO 表示此题目没有标准答案
 @param order    序号,BJLSurvey 的 order
 */
[self.room.roomVM sendSurveyAnswers:answers result:result order:order];
// 例:假设当前题目为 survey,学生选择的 BJLSurveyOption 的 key 的数组为 selectAnswers
NSMutableArray *answers;
BJLSurveyResult result;
// 筛选出当前题目的所有正确答案
for (BJLSurveyOption *option in survey.options) {
    if (option.isAnswer) {
        [answers addObject:option.key];
    }
}

if (answers.count <= 0) {
    // 无标准答案
    result = BJLSurveyResultNA;
}
else if ([selectAnswers isEqualToArray:answers]) {
    // 所选答案与正确答案匹配
    result = BJLSurveyResultRight;
}
else {
    result = BJLSurveyResultWrong;
}

 // 发送答案
[self.room.roomVM sendSurveyAnswers:selectAnswers result:result order:survey.order];
  • 监听收到答题统计。
/**
 @param results 统计结果,这个 NSDictionary 的 key-value 分别是 BJLSurveyOption 的 key 和选择该选项的人数
 @param order   序号,BJLSurvey 的 order
*/
[self bjl_observe:BJLMakeMethod(self.room.roomVM, didReceiveSurveyResults:order:)
         observer:^BOOL(NSDictionary<NSString *, NSNumber *> *results, NSInteger order) {
             NSLog(@"did receive results  of survey: %td", order);
             return YES;
         }];
  • 学生:收到答题结束。
[self bjl_observe:BJLMakeMethod(self.room.roomVM, didFinishSurvey:)
         observer:^BOOL {
             return YES;
         }];

13. 答题器

答题由老师发布,学生接收并答题。SDK 不支持发布答题,相关的 API 在 BJLRoomVM 中。

  • 收到答题
// 答题开始
    [self bjl_observe:BJLMakeMethod(self.room.roomVM, didReceiveQuestionAnswerSheet:)
             observer:^BOOL(BJLAnswerSheet *answerSheet) {
             bjl_strongify(self);
             [self showProgressHUDWithText:@"答题开始"];
             [self showAnswerSheet];
             return YES;
         }];
  • 答题结束
// 答题结束
    // 答题结束
    [self bjl_observe:BJLMakeMethod(self.room.roomVM, didReceiveEndQuestionAnswerWithEndTime:)
             observer:(BJLMethodObserver)^BOOL(NSTimeInterval endTimeInterval) {
             bjl_strongify(self);
             [self showProgressHUDWithText:@"答题已结束"];
             [self clearAnswerSheet];
             return YES;
         }];
  • 提交答案
 /**
 提交答案
 @param answerSheet 答题表:options 数组中的 BJLAnswerSheetOption 实例对应各个选项, 它的 seletced 属性表示该选项是否被选中
 */          
 BJLError *error = [self.room.roomVM submitQuestionAnswer:answerSheet];

14. 点赞

  • 助教和老师可以给学生点赞,学生无法点赞,助教和老师不能被点赞,下课清空点赞记录。
/** 点赞字典
 #discussion key --> userNumber
 #discussion value --> 点赞数
 */
@property (nonatomic, readonly, nullable) NSDictionary<NSString *, NSNumber *> *likeList;
  • 点赞用户。
/**
 点赞
 #discussion 点赞时需要把传历史所有点赞记录, 包括本次点赞
 #param userNumber userNumber
 #return error:
 BJLErrorCode_invalidCalling    错误调用,如用户不在线;
 BJLErrorCode_invalidUserRole   错误权限,如点赞用户不能是学生, 被点赞用户不能是老师,助教,。
 */
BJLError *error = [self.room.roomVM sendLikeForUserNumber:userNumber];
  • 收到点赞。
/**
 收到点赞
 #discussion 收到的所有点赞都在点赞记录中, 包括本次收到的点赞
 #param userNumber userNumber
 #param records 点赞记录 key --> userNumber, value --> 点赞数
 */
[self bjl_observe:BJLMakeMethod(self.room.roomVM, didReceiveLikeForUserNumber:records:)
         observer:^BOOL(NSString *userNumber, NSDictionary<NSString *, NSNumber *> *records) {
                 return YES;
         }];

15. 点名

点名由老师发布,学生接收并答到。SDK 不支持发起点名,相关的 API 在 BJLRoomVM 中。

  • 收到点名。
/** 学生: 收到点名
 #discussion 学生需要在规定时间内 `timeout` 答到 - 调用 `answerToRollcall`
 #discussion 参考 `rollcallTimeRemaining`
 #param timeout 超时时间
 */
- (BJLObservable)didReceiveRollcallWithTimeout:(NSTimeInterval)timeout;
  • 点名倒计时。
// 点名倒计时,每秒更新
@property (nonatomic, readonly) NSTimeInterval rollcallTimeRemaining;
  • 收到点名取消。
/** 学生: 收到点名取消
 #discussion 可能是老师取消、或者倒计时结束
 #discussion 点名倒计时参考 `rollcallTimeRemaining`
 */
- (BJLObservable)rollcallDidFinish;
  • 答到。
/** 学生: 答到
 #return BJLError:
 BJLErrorCode_invalidCalling    错误调用,如老师没有点名或者点名已过期;
 BJLErrorCode_invalidUserRole   错误权限,要求学生权限。
 */
- (nullable BJLError *)answerToRollcall;

16. 移出用户

/** 踢出学生
 #param userID 学生ID
 #return BJLError:
 BJLErrorCode_invalidArguments  错误参数;
 BJLErrorCode_invalidCalling    错误调用,如要踢出的用户是老师或助教;
 BJLErrorCode_invalidUserRole   错误权限,要求老师或助教权限。
 */
BJLError *error = [self.room.onlineUsersVM blockUserWithID:user.ID];

/** 学生被踢出
 #discussion 被踢出的用户非当前用户
 #param blockedUser 被踢出的学生
 */
[self bjl_observe:BJLMakeMethod(self.room.onlineUsersVM, didBlockUser:)
         observer:^BOOL(BJLUser *blockedUser) {
             // bjl_strongify(self);
             return YES;
         }];
// 加载黑名单列表
- (void)loadBlockedUserList;
/**
 返回黑名单列表
 #param userList 用户列表
 */
- (BJLObservable)didReceiveBlockedUserList:(NSArray<BJLUser *> *)userList;
/**
 解除用户黑名单
 #param userNumber userNumber
 #return BJLError:
 BJLErrorCode_invalidArguments  错误参数;
 BJLErrorCode_invalidCalling    错误调用,如要踢出的用户是老师或助教;
 BJLErrorCode_invalidUserRole   错误权限,要求老师或助教权限。
 */
BJLError *error = [self.room.onlineUsersVM freeBlockedUserWithNumber:user.number];

/**
 用户黑名单被解除
 #param userNumber userNumber
 */
- (BJLObservable) didFreeBlockedUserWithNumber:(NSString *)userNumber;

/**
 解除全部用户黑名单
 #return BJLError:
 BJLErrorCode_invalidArguments  错误参数;
 BJLErrorCode_invalidCalling    错误调用,如要踢出的用户是老师或助教;
 BJLErrorCode_invalidUserRole   错误权限,要求老师或助教权限。
 */
BJLError *error = [self.room.onlineUsersVM freeAllBlockedUsers];

// 所有黑名单被解除
- (BJLObservable) didFreeAllBlockedUsers;

17. 问答

  • 获取问答数据。
/**
 加载某一页的问答
 #param page 页码,从0开始计数
 #param count 每一页的数据量
 #return error
 */
BJLError *error = [self.room.roomVM loadQuestionHistoryWithPage:self.currentQuestionPage countPerPage:perPageQuestionCount];
// 收到问答数据
[self bjl_observe:BJLMakeMethod(self.room.roomVM, didLoadQuestionHistory:currentPage:totalPage:)
             observer:^BOOL(NSArray<BJLQuestion *> *history, NSInteger currentPage, NSInteger totalPage) {
                 bjl_strongify(self);
                 [self.tableView reloadData];
                 return YES;
             }];
  • 创建、发布、取消发布、回复问答。
/**
 创建问答
 #param question 问题内容
 */
- (BJLError *)sendQuestion:(NSString *)question;
/**
 创建问答成功
 #param question 问答,包括问答 ID
 */
- (BJLObservable)didSendQuestion:(BJLQuestion *)question;

/**
 发布问答,需要先成功创建问答
 #param questionID 问答 ID
 */
- (BJLError *)publishQuestionWithQuestionID:(NSString *)questionID;

/**
 取消发布问答
 #param questionID 问答 ID
 */
- (BJLError *)unpublishQuestionWithQuestionID:(NSString *)questionID;

/**
 回复问答
 #param questionID 问答 ID
 #param reply 回复内容
 */
- (BJLError *)replyQuestionWithQuestionID:(NSString *)questionID reply:(NSString *)reply;
  • 改变用户问答状态
/** 禁止提出问答的 userNumber 列表
 #discussion 列表仅包含禁止提出问答的 userNumber
 #discussion key --> userNumber
 #discussion value --> forbid
 */
@property (nonatomic, readonly, nullable) NSDictionary<NSString *, NSNumber *> *fobidQuestionUserNumberList;

/**
 改变问答状态

 #param user user
 #param forbid 是否禁止问答
 */
- (BJLError *)switchQuestionForbidForUser:(BJLUser *)user forbid:(BOOL)forbid;

18. 红包雨

18.1 红包雨信息管理

  • 创建红包雨
/**
 创建红包雨

 #param amount 红包总数
 #param score 学分总数
 #param duration 红包雨时长
 #param completion 红包雨活动ID
 #return task
 */
- (nullable NSURLSessionDataTask *)createEnvelopeRainWithAmount:(NSInteger)amount score:(NSInteger)score duration:(NSInteger)duration completion:(nullable void (^)(NSInteger envelopeID, BJLError * _Nullable error))completion;
  • 抢红包
/**
 抢红包
 #discussion 内置了每次调用间隔,completion 以抢到学分为 0 的方式返回
 #discussion 活动结束时,completion 也返回 0,并且没有 task
 #param envelopeID 红包雨活动ID
 #param completion 抢到的学分
 #return task
 */
- (nullable NSURLSessionDataTask *)grapEnvelopeWithID:(NSInteger)envelopeID completion:(nullable void (^)(NSInteger score, BJLError * _Nullable error))completion;
  • 获取学生抢到的学分总数
/**
 获取学生抢到的学分总数

 #param userNumber user number
 #param completion 学分总数
 #return task
 */
- (nullable NSURLSessionDataTask *)requestTotalScoreWithUserNumber:(NSString *)userNumber completion:(nullable void (^)(NSInteger totalScore, BJLError * _Nullable error))completion;
  • 获取指定红包雨活动的最终结果
/**
 获取指定红包雨活动的最终结果

 #param envelopeID 红包雨活动ID
 #param completion completion 红包雨活动结果
 #return task
 */
- (nullable NSURLSessionDataTask *)requestResultWithEnvelopeID:(NSInteger)envelopeID completion:(nullable void (^)(BJLEnvelopeResult * _Nullable result, BJLError * _Nullable error))completion;
  • 获取指定红包雨活动的排行榜
/**
 获取指定红包雨活动的排行榜

 #param envelopeID envelopeID
 #param completion completion 排行榜数据
 #return task
 */
- (nullable NSURLSessionDataTask *)requestRankListWithEnvelopeID:(NSInteger)envelopeID completion:(nullable void (^)(NSArray<BJLEnvelopeRank *> * _Nullable, BJLError * _Nullable))completion;

18.2 红包雨流程

  • 开始红包雨
/**
 开始红包雨

 #param envelopeID 红包雨活动ID
 #param duration 红包雨时长
 #return error
 */
- (nullable BJLError *)startEnvelopRainWithID:(NSInteger)envelopeID duration:(NSInteger)duration;
  • 收到红包雨
/**
 收到红包雨

 #param envelopeID 红包雨活动ID
 #param duration 红包雨时长
 */
- (BJLObservable)didStartEnvelopRainWithID:(NSInteger)envelopeID duration:(NSInteger)duration;

集成常见问题

1. Block 监听相关问题

1.1 监听不到对象的 属性变化/方法调用

解决方法:

  • 检查添加监听时监听对象是否为空:SDK 中。
  • 检查 filter 中过滤条件是否正确。
  • 检查 observer 中是否 return NO 导致监听取消。

2. 音视频相关问题

2.1 音视频用户列表为空

准备播放视频时,获取的 self.room.playingVM.playingUsers 为空。

解决方法:

  • playingUsers 是随时变化的,不能用直接取值的方法来获取音视频列表,应该监听 self.room.playingVMplayingUsers 属性的变化,即时获取最新列表,参考监听音视频用户列表。使用监听方式出现此问题则请参考后续部分。

  • 检查教室内是否有用户在发言(打开了音频或视频)。

3. Swift 项目集成 SDK 相关问题

3.1 使用 Block 方式监听方法调用时无回调

解决方法:

picture

  • Bridging Header 中导入适配文件。
#import "NSObject+SwiftObserver.h"
  • 使用适配文件提供的监听方法进行监听,以监听 BJLRoomenterRoomSuccess 为例:
self.bjl_observeEnterRoomSuccess(forTarget: self.room,
                                 filter: { () -> Bool in
                                    return true },
                                 observer: { () -> Bool in
                                    // your code
                                    return true 
                                 })

3.2 属性监听

Swift 使用 Block 的方式监听属性时必须指定 NSKeyValueObservingOptions,以监听 BJLRoomliveStarted 属性为例。

let observingOptions : NSKeyValueObservingOptions = [.new, .old, .initial]
self.bjl_kvo(BJLPropertyMeta.instance(withTarget: self.room, name: "liveStarted"),
                     options: observingOptions,
                     observer: {(new:Any?, old:Any?, change:Any?) -> Bool in
                        if let liveStarted = new as? Bool {
                            // your code
                        }
                        return true
                     })

3.3 protocol

BJLRoomslideshowViewController 属性为例,它在 OC 中定义如下:

/** 课件、画笔视图
 尺寸、位置随意设定 */
@property (nonatomic, readonly, nullable) UIViewController<BJLSlideshowUI> *slideshowViewController;

在 Swift 项目中编译之后变为:

/** 课件、画笔视图
     尺寸、位置随意设定 */
 open var slideshowViewController: UIViewController? { get }

可以发现,slideshowViewController 作为 BJLRoom 的属性时所遵循的 BJLSlideshowUI 这个 protocol 被 Swift 忽略了,这将导致它在 Swift 中无法调用 BJLSlideshowUI 中声明的属性和方法。

解决方法:

以上述 slideshowViewController 为例,使用如下方式调用 BJLSlideshowUI 中定义的属性和方法:

if let slideShowUI:BJLSlideshowUI = room.slideshowViewController as? BJLSlideshowUI {
    slideShowUI.contentMode = BJLContentMode.scaleAspectFill
}

4. 主要移除的 1.x API 一览

移除文件: 
BJLSlideshowVM,BJLSlideVM,使用 BJLDocumentVM。

新增文件:
BJLDocumentVM ,BJLDrawingVM。

废弃方法:
BJLRoom:

+ (void)setExclusiveSubdomain:(nullable NSString *)exclusiveSubdomain DEPRECATED_MSG_ATTRIBUTE("use `setPrivateDomainPrefix:` instead");
@property (nonatomic) NSString *lampContent DEPRECATED_MSG_ATTRIBUTE("use BJLRoomVM.`lampContent` instead");

BJLChatVM:

@property (nonatomic, readonly, copy, nullable) NSArray<BJLMessage *> *receivedMessages DEPRECATED_ATTRIBUTE;

BJLMediaVM:

- (void)setUpLinkType:(BJLLinkType)upLinkType DEPRECATED_MSG_ATTRIBUTE("use `updateUpLinkType:`");
- (void)setDownLinkType:(BJLLinkType)downLinkType DEPRECATED_MSG_ATTRIBUTE("use `updateDownLinkType:`");

BJLPlayingVM:

- (BJLObservable)playingUserWantsShowInFullScreen:(BJLMediaUser *)user DEPRECATED_MSG_ATTRIBUTE("use `teacherSharingDesktop` and `teacherPlayingMedia` instead");
- (BJLObservable)playingUser:(BJLMediaUser *)user didUpdateMediaPlaying:(BOOL)mediaPlaying DEPRECATED_MSG_ATTRIBUTE("use `teacherPlayingMedia` instead");
- (BJLObservable)playingUser:(BJLMediaUser *)user didUpdateDesktopSharing:(BOOL)desktopSharing DEPRECATED_MSG_ATTRIBUTE("use `teacherSharingDesktop` instead");
- (void)mutePlayingAudio:(BOOL)mute DEPRECATED_MSG_ATTRIBUTE("use `BJLMediaVM needMutePlayingAudio` instead");
- (nullable __kindof BJLMediaUser *)userWithID:(nullable NSString *)userID
                                        number:(nullable NSString *)userNumber DEPRECATED_MSG_ATTRIBUTE("use `playingUserWithID:number:` instead");

BJLRecordingVM:

- (void)restartRecording DEPRECATED_ATTRIBUTE;

BJLRoomVM:

- (BJLObservable)didReceiveCustomizedSignal:(NSString *)key value:(nullable id)value isCache:(BOOL)isCache DEPRECATED_MSG_ATTRIBUTE("use `didReceiveCustomizedBroadcast:value:isCache:` instead");
- (nullable BJLError *)requestCustomizedSignalCache:(NSString *)key DEPRECATED_MSG_ATTRIBUTE("use `requestCustomizedBroadcastCache:` instead");

BJLSpeakingRequestVM:

- (BJLObservable)receivedSpeakingRequestFromUser:(BJLUser *)user DEPRECATED_MSG_ATTRIBUTE("use `didReceiveSpeakingRequestFromUser:` instead");

附录

1. 变更记录

2. API 文档