anyHouse-iOS 高仿ClubHouse

653 阅读3分钟

前言

Clubhouse是一个新的社交网络应用程序,提供了实时音频聊天互动方式,给用户创造了打破由社会圈层壁垒所导致的信息传播和人际链接壁垒的可能性。Clubhouse通常被昵称为“硅谷最热门的初创企业”,将自己定位为一个“独家”和“另类”社交网络,吸引了各种名人和只想互相交谈的人。

App Store 下载地址

Github开源下载地址

开发环境
  • 开发工具: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:发送对象

KeyValue说明http
action

userName

avatar
1

userName(String)

1(Int)
举手 (toID为主持人)updateUserStatus

status =1
action2邀请听众上台 (toID为该听众)updateUserStatus

status =-1
action3

userName(String)
听众拒绝邀请(toID为主持人)updateUserStatus

status = 0
action

userName
4同意邀请(toID为主持人)updateUserStatus

status =2
action5主持人关闭该发言者的麦克风(toID为该听众)
action6主持人将该发言者设置为听众(下台)(toID为该听众)updateUserStatus

status =0
action7取消举手(toID为主持人)updateUserStatus

status = 0
action8主持人正常离开(发送频道消息leaveRoom
action

userName

avatar
9

userName(String)

1(Int)
加入rtm频道发送个人信息(发送频道消息

加入频道发送频道消息,用于其他人显示

{"avatar":1,userName:"lili"}

结束语

本项目并没有完全复原ClubHouse,项目中还存在一些bug和待完善的功能点。仅供参考,欢迎大家fork。有不足之处欢迎大家指出issues

最后再贴一下 Github开源下载地址 。如果觉得不错,希望点个star~