前言
Clubhouse是一个新的社交网络应用程序,提供了实时音频聊天互动方式,给用户创造了打破由社会圈层壁垒所导致的信息传播和人际链接壁垒的可能性。Clubhouse通常被昵称为“硅谷最热门的初创企业”,将自己定位为一个“独家”和“另类”社交网络,吸引了各种名人和只想互相交谈的人。
开发环境
- 开发工具:Xcode12 真机运行
- 开发语言:Swift
- SDK:ARtcKit_iOS
效果展示
核心框架
platform :ios, '9.0'
use_frameworks!
target 'anyHouse-iOS' do
#anyRTC 音视频库
pod 'ARtcKit_iOS', '~> 4.1.4.1'
#anyRTC 实时消息库
pod 'ARtmKit_iOS', '~> 1.0.1.4'
end
项目文件目录结构
功能目录:
Main:
①ARMainViewController:主页面,房间列表;
②ARMineViewController:我的,包含修改昵称、隐私协议、版本信息等等;
③ARCreateRoomViewController:创建房间,包含创建公开/私密房间、添加话题。
Audio:
①ARAudioViewController:语音房间,包含语音聊天、上下麦等功能;
②ARMicViewController:请求连麦列表;
③ARReportViewController:举报功能。
项目部分功能模块详解
登录、我的、首页
- 首页
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
let avatar = Int(UserDefaults.string(forKey: .avatar) ?? "1")! - 1
avatarButton.setImage(UIImage(named: headListArr![avatar] as! String), for: .normal)
let arr = UserDefaults.standard.array(forKey: blacklistIdentifier)
arr?.count ?? 0 > 0 ? (blackList.addObjects(from: arr!)) : nil
tableView.tableFooterView = UIView()
tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 120
tableView.mj_header = MJRefreshNormalHeader(refreshingBlock: {
[weak self] () -> Void in
guard let weakself = self else {return}
weakself.index = 1
weakself.requestRoomList()
})
}
@objc func createPlaceholder() {
placeholderView.showPlaceholderView(self.tableView, placeholderImageName: "icon_add", placeholderTitle: "可以尝试下拉刷新或者创建房间") {
self.tableView.mj_header?.beginRefreshing()
}
placeholderView.backgroundColor = UIColor.clear
}
创建房间、添加话题
- 创建房间逻辑:
@IBAction func didClickTopicButton(_ sender: Any) {
passwordTextField.resignFirstResponder()
let alertVc = ARAlertTextViewController(title: "添加话题 \n ", message: "比如发生在身边的趣事", preferredStyle: .alert)
alertVc.updateTextView(text: topic)
let cancelAction = UIAlertAction (title: "取消" , style: .cancel , handler: nil )
let okAction = UIAlertAction (title: "设置话题" , style: . default , handler: {
action in
if !self.stringAllIsEmpty(string: alertVc.textView.text) {
self.topic = alertVc.textView.text ?? ""
self.updateTopic()
}
})
alertVc.addAction(cancelAction)
alertVc.addAction(okAction)
present(alertVc, animated: true, completion: nil)
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) {
alertVc.textView.becomeFirstResponder()
}
}
func updateTopic() {
if isPrivate == 0 {
(topic.count == 0) ? (topicLabel.text = publicText) : (topicLabel.text = String(format: "%@:“%@”", publicText,topic))
} else {
(topic.count == 0) ? (topicLabel.text = passwordText) : (topicLabel.text = String(format: "%@:“%@”", passwordText,topic))
}
}
@IBAction func didClickButton(_ sender: UIButton) {
if sender.tag != isPrivate {
isPrivate = sender.tag
passwordTextField.resignFirstResponder()
updateTopic()
if isPrivate == 0 {
//公开
passwordView.isHidden = true
padding.constant = 0
publicButton.backgroundColor = UIColor(hexString: "#DFE2EE")
passwordButton.backgroundColor = UIColor.white
} else {
//私密
passwordView.isHidden = false
padding.constant = 47
passwordButton.backgroundColor = UIColor(hexString: "#DFE2EE")
publicButton.backgroundColor = UIColor.white
}
}
}
- 重写UIAlertController实现添加话题:
class ARAlertTextViewController : UIAlertController, UITextViewDelegate {
public var textView : UITextView!
private var tipLabel: UILabel!
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
let contentView = UIView()
let controller = UIViewController()
controller.view = contentView
textView = UITextView()
textView.delegate = self
textView.layer.masksToBounds = true
textView.layer.cornerRadius = 5
contentView.addSubview(textView)
textView.snp.makeConstraints({ (make) in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 15, bottom: 16, right: 15))
})
tipLabel = UILabel.init()
tipLabel.text = "还剩输入60个字符"
tipLabel.textColor = UIColor(hexString: "#999999")
tipLabel.font = UIFont.init(name: "PingFang SC", size: 12)
tipLabel.textAlignment = .center
textView.addSubview(tipLabel)
tipLabel.snp.makeConstraints({ (make) in
make.bottom.equalTo(textView.snp_bottom).offset(80)
make.centerX.equalToSuperview()
make.width.equalTo(100)
make.height.equalTo(15)
})
//super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
self.setValue(controller, forKey: "contentViewController")
}
func updateTextView(text: String!) {
textView.text = text
tipLabel.text = String(format: "还剩输入%d个字符", 60 - text.count)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func textViewDidChange(_ textView: UITextView) {
if textView.text?.count ?? 0 > 60 {
textView.text = String(textView.text.prefix(60))
}
tipLabel.text = String(format: "还剩输入%d个字符", 60 - textView.text.count)
}
}
语音房间、互动连麦
- 核心代码:
func initializeEngine() {
// init ARtcEngineKit
rtcKit = ARtcEngineKit.sharedEngine(withAppId: UserDefaults.string(forKey: .appId)!, delegate: self)
rtcKit.setAudioProfile(.musicHighQuality, scenario: .gameStreaming)
//开启音频AI降噪
let dic1: NSDictionary = ["Cmd": "SetAudioAiNoise", "Enable": 1]
rtcKit.setParameters(getJSONStringFromDictionary(dictionary: dic1))
rtcKit.setChannelProfile(.liveBroadcasting)
if infoModel!.isBroadcaster {
rtcKit.setClientRole(.broadcaster)
}
rtcKit.enableAudioVolumeIndication(500, smooth: 3, report_vad: true)
//init ARtmKit
rtmEngine = ARtmKit.init(appId: UserDefaults.string(forKey: .appId)!, delegate: self)
rtmEngine.login(byToken: infoModel?.rtmToken, user: UserDefaults.string(forKey: .uid) ?? "0") { [weak self](errorCode) in
self?.rtmChannel = self?.rtmEngine.createChannel(withId: (self?.infoModel?.roomId)!, delegate: self)
self?.rtmChannel?.join(completion: { (errorCode) in
})
}
}
- 音频检测
//提示频道内谁正在说话、说话者音量及本地用户是否在说话的回调
func rtcEngine(_ engine: ARtcEngineKit, reportAudioVolumeIndicationOfSpeakers speakers: [ARtcAudioVolumeInfo], totalVolume: Int) {
for speakInfo in speakers {
if speakInfo.volume > 3 {
for index in 0..<modelArr[0].count {
let micModel = modelArr[0][index]
if speakInfo.uid == micModel.uid || (speakInfo.uid == "0" && micModel.uid == UserDefaults.string(forKey: .uid)){
let indexPath: NSIndexPath = NSIndexPath(row: index, section: 0)
let cell: ARAudioViewCell? = collectionView.cellForItem(at: indexPath as IndexPath) as? ARAudioViewCell
cell?.startAnimation()
break
}
}
}
}
}
- 上下麦
private func becomBroadcaster(role: ARClientRole) {
//切换角色
rtcKit.setClientRole(role)
if role == .audience {
//下麦
audioButton.isHidden = true
audioButton.isSelected = false
micButton.isHidden = false
micButton.isSelected = false
rtcKit.enableLocalAudio(true)
for index in 0..<modelArr[0].count {
let micModel = modelArr[0][index]
if micModel.uid == UserDefaults.string(forKey: .uid) {
modelArr[0].remove(at: index)
modelArr[1].append(micModel)
collectionView.reloadData()
break
}
}
Drop.down("您已成为听众", state: .color(UIColor(hexString: "#4BAB63")), duration: 1)
} else {
//上麦
audioButton.isHidden = false
micButton.isHidden = true
}
}
协议、屏蔽、举报功能
- 为应对苹果审核机制,故而添加协议、屏蔽、举报等功能模块。
RTM相关信令
json:key =action value Int
例如: {“action”:1} toID:发送对象
Key | Value | 说明 | http |
---|---|---|---|
action userName avatar |
1 userName(String) 1(Int) |
举手 (toID为主持人) | updateUserStatus status =1 |
action | 2 | 邀请听众上台 (toID为该听众) | updateUserStatus status =-1 |
action | 3 userName(String) |
听众拒绝邀请(toID为主持人) | updateUserStatus status = 0 |
action userName |
4 | 同意邀请(toID为主持人) | updateUserStatus status =2 |
action | 5 | 主持人关闭该发言者的麦克风(toID为该听众) | |
action | 6 | 主持人将该发言者设置为听众(*)(toID为该听众) | updateUserStatus status =0 |
action | 7 | 取消举手(toID为主持人) | updateUserStatus status = 0 |
action | 8 | 主持人正常离开(发送频道消息) | leaveRoom |
action userName avatar |
9 userName(String) 1(Int) |
加入rtm频道发送个人信息(发送频道消息) |
加入频道发送频道消息,用于其他人显示
{“avatar”:1,userName:“lili”}
结束语
本项目并没有完全复原ClubHouse,项目中还存在一些bug和待完善的功能点。仅供参考,欢迎大家fork。有不足之处欢迎大家指出issues。
最后再贴一下 Github开源下载地址 。如果觉得不错,希望点个star~