iOS 自訂螢幕佈局¶
arctos links iOS SDK(後續簡稱 SDK)支援自訂螢幕佈局。
本文件並不會講解一般的 User Interface 介面要怎麼實作,而是著重在提供 SDK 對外所開放的元件與相關流程的運作方式,目的在幫助開發者盡可能地理解該如何透過 SDK 來實現自己想要的呈現的模板介面。
物件介紹與名詞解釋¶
SDK 除了開放原本的 ArctosLinks 系列的 API 之外,提供額外四個物件作為處理自動連線機制,同時開放這些元件的部分存取權讓開發者可以建立客製化模板,分別為
-
MeetingRoomInfoManager -
MeetingMemberInfo -
ConnectInfo -
LiveStreamView
以下為它們的關係圖:
主持端¶

參與端¶

其中,有虛線的部分,代表該物件不一定存在,會隨著使用情境而產生、或消失,例如:
-
Participants 內的所有
MeetingMemberInfo是根據參與者加入會議室的行為而產生,離開而消失。 -
MeetingMemberInfo內的ConnectInfo是在各成員進入會議室之後,開始互相連線後,才會產生。
紅色箭頭部分則代表著,從 MeetingMemberInfo 中取得其內部的 ConnectInfo,並綁定在 LiveStreamView 上的過程。
MeetingRoomInfoManager¶
用於管理會議室相關資訊用物件,設計模式屬於 Singleton Pattern,透過調用 SDK 會議室相關 API,會自動更新會議室內的相關細節,無需開發者自行管理,僅需在特定的流程場合下取用本物件即可。
MeetingRoomInfoManager 包含了以下屬性:
class MeetingRoomInfoManager {
public static let shared = MeetingRoomInfoManager()
/// 會議室 ID
let roomID: String
let roomID_Subject: BehaviorSubject<String>
/// 會議室名稱
let name: String
let name_Subject: BehaviorSubject<String>
/// 會議室描述
let description: String
let description_Subject: BehaviorSubject<String>
/// 主持人成員
let hostInfo: MeetingMemberInfo?
/// 參與者成員(們)
let participants: [MeetingMemberInfo]
let participants_Subject: BehaviorSubject<[MeetingMemberInfo]>
}
_Subject,其用途可透過 RxSwift 語法協助開發者監聽該屬性的變化。
例如:我可能需要對會議室參與人數進行監聽,就可以使用以下方式:
class SampleVC: UIVIewController {
@IBOutlet weak var tableView: UITableView!
private var disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
/// Observe something you want
_ = MeetingRoomInfoManager.shared.participants_Subject
.observe(on: MainScheduler.instance)
.subscribe(onNext: { participants in
// This closure triggered when number of participants changed, handle them than refresh tableView
tableView.reloadData()
}).disposed(by: disposeBag)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
/// Stop observe
disposeBag = DisposeBag()
}
}
詳情參閱 iOS SDK API 說明文件
MeetingMemberInfo¶
代表會議中成員的物件,依附在 MeetingRoomInfoManager 內,可在特定場合內直接取用,(參閱 連線流程與物件取用時機點),根據成員型態可分為兩類:
- hostInfo 主持端
- participant 參與端
MeetingMemberInfo 包含了以下屬性:
class MeetingMemberInfo: NSObject {
/// cms 專屬 UserId
let cmsUID: Int
/// 是否為自己,會議室成員人數眾多,可透過此屬性判別是否為自己
let isSelf: Bool
/// 該成員為什麼樣的角色,host or participant
let role: ArctosLinks.Conference.UserIdentity
let role_Subject: BehaviorSubject<ArctosLinks.Conference.UserIdentity>
/// 成員名稱
let displayName: String
let displayName_Subject: BehaviorSubject<String>
/// 連線狀態
let isOnline: Bool
let isOnline_Subject: BehaviorSubject<Bool>
/// 連線中可額外取的詳細內容
let connectInfo: ConnectInfo?
let connectInfo_Subject: BehaviorSubject<ConnectInfo?>
}
!!! warning "注意事項" connectInfo 在取得 MeetingMemberInfo 的當下不一定會存在,connectInfo 是通過 SDK 內部的自動連線機制所管理,因此建議透過監聽 connectInfo_Subject 的方式處理,以確保連線完成後外部可以收到產生 connectInfo 的通知。
ConnectInfo¶
ConnectInfo 存在於 MeetingMemberInfo 屬性內,表示該會議成員在連線時的各種狀態、同時也提供部分設定提供給開發者調整。
ConnectInfo 包含了以下屬性與方法:
class ConnectInfo {
// MARK: == Video ==
/// 影像源每秒接收量 FPS
let videoReceiveFrameRate: UInt
let videoReceiveFrameRate_Subject: BehaviorSubject<UInt>
/// 影像源接收解析度
let videoReceiveResolution: CGSize
let videoReceiveResolution_Subject: BehaviorSubject<CGSize>
/// 取得繪圖模式
/// 寬高比 (aspectFill):透過裁切溢位部分來填滿視圖邊界。
/// 寬高比 (aspectFit):適合邊界,保持寬高比,多餘留黑。
let videoRenderMode: VideoRenderMode
let videoRenderMode_Subject: BehaviorSubject<ConnectInfo.VideoRenderMode>
/// 設定繪圖模式,可在連線過程中調整,將在下一張影像來源送達後生效
func videoRenderMode(VideoRenderMode)
/// 取得是否開啟繪圖,預設為 true = 開啟,false = 關閉(呈現黑畫面)
let enableVideoRender: Bool
let enableVideoRender_Subject: BehaviorSubject<Bool>
/// 是否開啟繪圖
func enableVideoRender(Bool)
// MARK: == Audio ==
/// 聲音源每秒接收量 FPS
let audioReceiveFrameRate: UInt
let audioReceiveFrameRate_Subject: BehaviorSubject<UInt>
/// 聲音源每秒發送量 FPS
let audioSendFrameRate: UInt
let audioSendFrameRate_Subject: BehaviorSubject<UInt>
/// 成員發言音量偵測,可透過監聽得知該成員是否正在說話,true 為偵測到說話、反之為 false
let audioSpotlight_Subject: BehaviorSubject<Bool>
/// 取得目前是否阻斷接收聲音源
let enableAudioPlay: Bool
let enableAudioPlay_Subject: BehaviorSubject<Bool>
/// 是否開啟阻斷接收聲音源,阻斷聲音源,表示不處理該成員傳來的聲音資料,並不代表關閉揚聲器功能
func enableAudioPlay(Bool)
// MARK: == Bandwidth ==
/// 本地端接資料接收量,單位 Bytes
let localRecvBytes: UInt
let localRecvBytes_Subject: BehaviorSubject<UInt>
/// 本地端接資料發送量,單位 Bytes
let localSentBytes: UInt
let localSentBytes_Subject: BehaviorSubject<UInt>
/// 本地端接資料遺失量,單位 Bytes
let localLostBytes: UInt
let localLostBytes_Subject: BehaviorSubject<UInt>
/// 本地端,單位統計起始時間,單位 ms
let localStartTimestamp_ms: UInt
let localStartTimestamp_ms_Subject: BehaviorSubject<UInt>
/// 遠端接資料接收量,單位 Bytes
let remoteRecvBytes: UInt
let remoteRecvBytes_Subject: BehaviorSubject<UInt>
/// 遠端接資料發送量,單位 Bytes
let remoteSentBytes: UInt
let remoteSentBytes_Subject: BehaviorSubject<UInt>
/// 遠端接資料遺失量,單位 Bytes
let remoteLostBytes: UInt
let remoteLostBytes_Subject: BehaviorSubject<UInt>
/// 遠端,單位統計起始時間,單位 ms
let remoteStartTimestamp_ms: UInt
let remoteStartTimestamp_ms_Subject: BehaviorSubject<UInt>
}
詳情可參閱我司提供的另一份 API(ArctosLinks.doccarchive)說明文件
另外,您可能會發現 ConnectInfo 並沒有提供 video 的 fps 發送統計(videoSendFrameRate), 但卻有接受統計(videoReceiveFrameRate),這是因為對主持端來說,它作為所有成員的整合端,將整合後影像源一次性地發送給其他連線中的成員,因此在統計上是無法將此參數分散至各成員的 ConnectInfo 內;而對參與端來說,它僅針對主持端連線,對其他成員來說並不會傳輸任何資料,因此也沒有必要統計 videoSendFrameRate 並設計在 ConnectInfo 內。
但如果您還是想得知主持端能夠發送的 FPS 數量,SDK 還是有提供 API 能夠取用,您可以從以下 Sample Code 來得知 video 的 fps 發送量:
_ = ArctosLinks.ConferenceEventCallback.videoSendFrameRate
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] fps in
guard let self else { return }
// Set fps value to your UI object.
videoSendFpsLabel.text = "\(fps)fps"
}).disposed(by: disposeBag)
LiveStreamView¶
LiveStreamView 是一個需要綁定 connectInfo 的專用繪圖元件,也是客製化會議室介面最重要的元件。
當取得指定成員的 connectInfo,並注入在 LiveStreamView,LiveStreamView 將根據開發者的設定,自動執行影像繪圖;相對地,若 connectInfo 為 nil,相當於解除 LiveStreamView、connectInfo 兩者之間的關係,LiveStreamView 就沒有影像來源可以繪圖,而舊有的殘留畫面將會自動清除,留下黑畫面。
LiveStreamView 包含了以下屬性:
class LiveStreamView: MTKView {
/// 基本宣告
init(frame: CGRect)
/// 可注入綁定的 connectInfo 內容,設置 nil 將解除綁定
var connectInfo: ConnectInfo?
}
LiveStreamView 使用方法¶
SDK 所提供的 LiveStreamView 採用 UIKit 的原生做法,分別支持 CodeView 與 Storyboard、Xib,分別詳細介紹。
CodeView¶
以下為 LiveStreamView 與 connectInfo 綁定 Sample Code:
主持端¶
主持端需要處理不同參與者的連線,因此會需要用到許多 LiveStreamView,同時您可能需要通過一些方式去辨別各自的 LiveStreamView 分別對應於哪一個參與者,以 Sample Code 的案例來說,則是根據 LiveStreamView 的 tag 屬性對應參與者的 cmsUID。
import UIKit
import RxSwift
import ArctosLinks
class RoomVC: UIViewController {
/// 宣告各會議室人員所需的 LiveStreamView
private lazy var hostLiveStreamView = LiveStreamView(frame: CGRect(origin: .zero, size: CGSize(width: 160, height: 90)))
private lazy var liveStreamView2 = LiveStreamView(frame: CGRect(origin: .zero, size: CGSize(width: 160, height: 90)))
private lazy var liveStreamView3 = LiveStreamView(frame: CGRect(origin: .zero, size: CGSize(width: 160, height: 90)))
private lazy var liveStreamView4 = LiveStreamView(frame: CGRect(origin: .zero, size: CGSize(width: 160, height: 90)))
private var disposeBag = DisposeBag()
private var connectDisposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
// !!!: Important, don't forget to set your merge view to SDK at the first.
ArctosLinks.Media.setHostSourceView(self.view) { result in
if case .failure(let error) = result {
print("Failed to set host view: \(error.localizedDescription)")
}
}
// Set frame of hostLiveStreamView to self.view
hostLiveStreamView.frame = CGRect(x: 0, y: 0, width: 160, height: 90)
self.view.addSubview(hostLiveStreamView)
// Or you can also using constraint to self.view
hostLiveStreamView.translatesAutoresizingMaskIntoConstraints = true
hostLiveStreamView.autoresizingMask = []
self.view.addSubview(hostLiveStreamView)
self.view.topAnchor.constraint(equalTo: hostLiveStreamView.topAnchor).isActive = true
self.view.rightAnchor.constraint(equalTo: hostLiveStreamView.rightAnchor).isActive = true
self.view.leftAnchor.constraint(equalTo: hostLiveStreamView.leftAnchor).isActive = true
self.view.bottomAnchor.constraint(equalTo: hostLiveStreamView.bottomAnchor).isActive = true
// Get connectInfo from host.
let hostInfo = MeetingRoomInfoManager.shared.hostInfo
_ = hostInfo?.connectInfo_Subject
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] connectInfo in
guard let self else { return }
// Callback object type of connectInfo is `ConnectInfo?`.
// Set connectInfo to hostLiveStreamView.
hostLiveStreamView.connectInfo = connectInfo
}).disposed(by: disposeBag)
// Get MeetingMemberInfo from connecting participants.
_ = MeetingRoomInfoManager.shared.participants_Subject
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] participants in
guard let self else { return }
// Callback object type of participants is [MeetingMemberInfo].
// Stop observe all participants
connectDisposeBag = DisposeBag()
let streamViewArray = [liveStreamView2, liveStreamView3, liveStreamView4]
// Number of participant
participants.forEach { memberInfo in
// Search view tag which is same as cmsUID
guard let streamView = streamViewArray.first(where: { $0.tag == memberInfo.cmsUID }) else {
// Set cmsUID at first time.
if let first = streamViewArray.first(where: { $0.tag != memberInfo.cmsUID }) {
first.tag = memberInfo.cmsUID
self.view.addSubview(first)
// Observe connectInfo
_ = memberInfo.connectInfo_Subject
.observe(on: MainScheduler.instance)
.subscribe(onNext: { connectInfo in
first.connectInfo = connectInfo
}).disposed(by: self.connectDisposeBag)
}
return
}
self.view.addSubview(streamView)
// Observe connectInfo
_ = memberInfo.connectInfo_Subject
.observe(on: MainScheduler.instance)
.subscribe(onNext: { connectInfo in
streamView.connectInfo = connectInfo
}).disposed(by: self.connectDisposeBag)
}
}).disposed(by: disposeBag)
}
}
注意事項
主持端除了需要顯示各自成員的串流內容外,也必須有一個畫面整合用的 View 元件(簡稱 SourceView)作為基底,提供給 SDK 做整合輸出,參考 API 設定串流視圖位置,主持端的 SDK 會自動將 SourceView 下的所有元件(subView)做截頻整合發送,也就是說,如果你想要將所有的成員內容都呈現出來,開發者必須將所有的 LiveStreamView 都作為 SourceView 下的 subView,這樣截頻下來的內容才會包含所有的成員內容。除此之外,還必須留意一個限制,若您想將 AVPlayer、WKWebView 相關的元件作為 SourceView 的 subView,他們的影片播放功能是無法取得畫面的,此為官方 API 限制,這點請留意。
參與端¶
每個參與端都只需處理主持端的連線,因此只會用到一個 LiveStreamView,而 HostInfo 內所取得的 connectInfo,即為來自主持端整合後的畫面,Sample Code 如下:
import UIKit
import RxSwift
import ArctosLinks
class RoomVC: UIViewController {
/// 只會有 Host StreamView
private lazy var hostLiveStreamView = LiveStreamView(frame: CGRect(origin: .zero, size: CGSize(width: 160, height: 90)))
private var disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
// Set frame of hostLiveStreamView to self.view
hostLiveStreamView.frame = CGRect(x: 0, y: 0, width: 160, height: 90)
self.view.addSubview(hostLiveStreamView)
// Get connectInfo from host.
let hostInfo = MeetingRoomInfoManager.shared.hostInfo
_ = hostInfo?.connectInfo_Subject
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] connectInfo in
guard let self else { return }
// Callback object type of connectInfo is `ConnectInfo?`.
// Set connectInfo to hostLiveStreamView.
hostLiveStreamView.connectInfo = connectInfo
}).disposed(by: disposeBag)
}
}
Storyboard or Xib¶
以下為使用 Xib 構建 CustomLiveStreamView.xib 的案例,而 Storyboard 使用方式雷同就不多提,其中 Subview 元件包含一個命名為 Live Stream View 的 UIView(左側選定綠色反白),只要將 Custom Class 的部分 -> Class 欄位指定為 LiveStreamView、Module 欄位指定為 ArctosLinks 即可,如圖(右上角橢圓紅框部分):

Xib 對應 CustomLiveStreamView.swift 代碼:
import UIKit
import ArctosLinks
import RxSwift
class CustomLiveStreamView: UIView {
@IBOutlet weak var liveStreamView: LiveStreamView!
private var disposeBag = DisposeBag()
deinit {
disposeBag = DisposeBag()
}
class func instantiateFromNib() -> CustomLiveStreamView {
guard let view = UINib(nibName: String(describing: Self.self), bundle: nil).instantiate(withOwner: nil).first as? CustomLiveStreamView else { return CustomLiveStreamView() }
return view
}
/// 注意用 weak 處理 meetingMemberInfo,避免 retain cycle
weak var meetingMemberInfo: MeetingMemberInfo? {
didSet {
synchronized(self) {
disposeBag = DisposeBag()
guard let meetingMemberInfo else { return }
// 訂閱連線資訊
_ = meetingMemberInfo.connectInfo_Subject
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] connectInfo in
guard let self else { return }
liveStreamView.connectInfo = connectInfo
}).disposed(by: disposeBag)
}
}
}
}
在 VC 內使用 CustomLiveStreamView:
class RoomVC: UIViewController {
private lazy var streamView = CustomLiveStreamView.instantiateFromNib()
override func viewDidLoad() {
super.viewDidLoad()
// Set frame of streamView to self.view
streamView.frame = CGRect(x: 0, y: 0, width: 160, height: 90)
self.view.addSubview(streamView)
// Get meetingMemberInfo of host.
let hostInfo = MeetingRoomInfoManager.shared.hostInfo
streamView.meetingMemberInfo = hostInfo
}
}
連線流程與物件取用時機點¶
本章節重點在介紹整體的連線流程與後續會發生的行為,以便讓開發者能理解什麼時候該從 SDK 取用對應的相關物件,並做出相應的操作。
連線流程¶
MeetingRoomInfoManager 物件負責管理會議室內的所有內容,所以了解該物件在什麼時候最做出相應的更新,並在什麼時機點取得很重要。
關於 SDK 進入會議室的相關流程,根據角色不同,會有不同對應 API、EventCallback,以下為 主持端 Host、參與端 Participant 的基本流程分析,開發者們可以參照下方所提供的流程說明、調用 API、UI 轉場切換、監聽 EventCallback 切入點排序,深入理解會議室操作的行為。
主持端 Host 流程¶
- 從列表選擇會議室並加入 - ArctosLinks.Conference.joinRoom ✅
- 進入會議室(UI轉場)- viewDidLoad ✅
- 取得會議室資訊 - ArctosLinks.Conference.getRoomInfo ✅
- 同意/取消申請端加入 - ArctosLinks.Conference.hostJoinHandle ✅
- 請離參與者 - ArctosLinks.Conference.hostKickParticipant
- 離開會議室 - ArctosLinks.Conference.leaveRoom
- 會議室頁面關閉(UI轉場)- viewWillDisappear
- 監聽事件
- 會議室內容更新 - ArctosLinks.ConferenceEventCallback.roomInfoUpdated ✅
- 申請端申請加入會議室 - ArctosLinks.ConferenceEventCallback.requestJoinRoom
- 申請端取消加入會議室 - ArctosLinks.ConferenceEventCallback.cancelJoinRequest
- 有參與端加入會議室 - ArctosLinks.ConferenceEventCallback.participantJoinRoom
- 與參與端的連線狀態 - ArctosLinks.ConferenceEventCallback.updateParticipant
- 有參與端離開會議室 - ArctosLinks.ConferenceEventCallback.participantLeftRoom
其中,在流程中會看見綠色勾勾✅,即為主要 MeetingRoomInfoManager 更新資料內容的地方,開發者可以從這些切入點著手,取得 MeetingRoomInfoManager 下所提供的相關物件,建立屬於自己的模板介面。
至於其他的切入點,在通常的情況下,您不需要進行額外的處理,SDK 會協助您自動管理,只要您在取用物件後並使用得當,確保您的資源在會議室流程結束後能有效釋放,就不會有其他問題產生。(參閱注意事項)
參與端 Participant 流程¶
- 從列表選擇會議室並申請 - ArctosLinks.Conference.joinRoom
- 監聽專用事件
- 主持端同意 - ArctosLinks.ConferenceEventCallback.responseJoinRoom ✅
- 進入會議室(UI轉場)- viewDidLoad ✅
- 取得會議室資訊 - ArctosLinks.Conference.getRoomInfo ✅
- 離開會議室 - ArctosLinks.Conference.leaveRoom
- 會議室頁面關閉(UI轉場)- viewWillDisappear
- 監聽事件
- 會議室內容更新 - ArctosLinks.ConferenceEventCallback.roomInfoUpdated ✅
- 與主持端連線狀態 - ArctosLinks.ConferenceEventCallback.updateHost
- 被主持端請離會議室 - ArctosLinks.ConferenceEventCallback.kickOutByHost
- 會議室頁面關閉(UI轉場)- viewWillDisappear
- 主持端已關閉會議室 - ArctosLinks.ConferenceEventCallback.hostCloseRoom
- 其他參與端離開會議室 - ArctosLinks.ConferenceEventCallback.participantKickedOutRoom
- 參與端加入會議室 - ArctosLinks.ConferenceEventCallback.participantJoinRoom
- 有參與端離開會議室 - ArctosLinks.ConferenceEventCallback.participantLeftRoom
參與端 Participant 流程 下,要額外注意的是,在 MeetingRoomInfoManager 的取得過程中,雖然可取得所有的成員物件 MeetingMemberInfo,但實際上只有 hostInfo 存在 connectInfo 可供 LiveStreamView 綁定,而 participants 內將不存在任何的 connectInfo。
class MeetingRoomInfoManager {
... 以上略 ...
/// 主持人成員
let hostInfo: MeetingMemberInfo? <= 內部存在 connectInfo,且為畫面整合後的內容
/// 參與者成員(們)
let participants: [MeetingMemberInfo] <= 不存在 connectInfo 供
}
注意事項¶
GPU 繪圖最大限制¶
SDK 所提供的 LiveStreamView 物件,雖採用 iOS 原生、且繪圖效率最高的 Metal GPU 繪圖,但在管理上還是有相關條件限制,以下為該注意的條件:
-
iOS 設備的 Metal GPU 繪圖,其最大限制為每秒鐘可繪圖 120 次,也就是說,單個
LiveStreamView繪圖理論上可以支持到 120 fps。 -
當會議室內同時有多人進行視訊,一般就會多個
LiveStreamView在同時進行繪圖,而這些元件實際上是同時共享 120 fps 的 GPU 資源。例如:會議室內有四個成員,每道影像來源都有 30 fps 張數正在繪圖,那麼 GPU 資源將會被佔滿(4 個LiveStreamView* 30 fps = 120 fps)。 -
當 GPU 資源 120 fps 被佔滿時,還需要額外的
LiveStreamView繪製其他的影像源,開發者就必須做出對應的取捨,否則將影響到畫面呈現的流暢度,有幾個建議供開發者進行處理:-
降低各別影像源的繪圖頻率、影像傳輸率,例如:在一個頁面上可能要同時呈現 8 個
LiveStreamView的繪圖量,平均每一個LiveStreamView的繪圖量就必須控制在 15 fps 左右(120 / 8 = 15) -
ConnectInfo有額外提供接口public func enableVideoRender(_ enable: Bool),讓開發者自行開關控制,預設為 true 可繪圖狀態,設定 false 為自動屏蔽繪圖功能以節省 GPU 繪圖資源。
-
Retain Cycle、Memory Leak 的產生¶
MeetingMemberInfo、ConnectInfo 兩者皆為繼承 Class 物件,同時也由 SDK 內部自動進行管理,當 SDK 外部開發者需取用相關資源暫存使用時,建議使用 weak 修飾符作為變數儲存,因為 SDK 內部自動管理 MeetingMemberInfo、ConnectInfo 元件時,較不易收到干擾,導致預期外的行為,例如 Retain Cycle 所造成的 Memory Leak。