美文网首页解决方案
iOS(swift) 音视频通讯大小窗策略

iOS(swift) 音视频通讯大小窗策略

作者: 简单coder | 来源:发表于2021-07-20 22:11 被阅读0次

    iOS面试不需要框架😭😭😭, 他们只关心直播,音视频,编解码.所以我目前不得不暂时先放弃项目框架的抽离,先把音视频搞出来,等后面有时间的时候再慢慢把之前的框架写写完吧.说来也有点惭愧,音视频项目做了一年多,连一篇总结型的文章都没写过.

    这篇文章讲解的是音视频通话的大小窗策略,顺便也夹杂了一些基础知识点.先看看我实现的 demo 效果吧.


    window层级及 window 配置

    想要实现音视频小窗的可拖拽属性,我们必须将其视图脱离于 viewController 栈.这里,你需要有自定义 window 层的知识.
    我们必须要自定义管理一个 window,并为其设定 windowLevel

    • window 层级,普通的 UIApplication.shared.delegate.window.windowLevel 为0,所以我们设置的任何大于0的 window 都会在其之上展示.
    • window的展示不需要任何的添加或者删除操作,仅由 isHidden 属性控制(注意不要对 自定义window设置为 keywindow)
    log(UIWindow.Level.statusBar.rawValue) // 1000
    

    这里给出的建议是围绕statusBar状态栏设定,常用的直播,音视频,我们可以设置其 window<1000,便于更加容易得看到状态栏,通常的习惯是,直播,音视频的 window 会在 statusbar 层级下,而礼物选择,礼物播放,toast,自定义效果弹窗都会在 statusBar 之上
    这里贴一份我司的windowLevel 层级展示(有删减).

    public extension UIWindow.Level {
        private static var statusBarRawValue: CGFloat { return UIWindow.Level.statusBar.rawValue }
        static var live: UIWindow.Level {
            .init(Self.statusBarRawValue - 12)
        }
        static var chat: UIWindow.Level {
            .init(Self.statusBarRawValue - 9)
        }
        static var logo: UIWindow.Level {
            .init(Self.statusBarRawValue + 3)
        }
        static var present: UIWindow.Level {
            .init(Self.statusBarRawValue + 9)
        }
        static var overlay: UIWindow.Level {
            .init(Self.statusBarRawValue + 10)
        }
        static var giftDisplay: UIWindow.Level {
            .init(Self.statusBarRawValue + 11)
        }
        static var inAppNotify: UIWindow.Level {
            .init(Self.statusBarRawValue + 13)
        }
        static var guide: UIWindow.Level {
            .init(Self.statusBarRawValue + 15)
        }
    }
    

    注意其中的点,Call(音视频), Live(直播)必须是最底层的界面,因为任何的礼物队列播放(giftDisplay), 自定义弹窗(Overlay),我们都希望他能正常地展示在音视频或直播层级之上.接下里讲下

    自定义 CallWindow

    我给一个范例,用于特殊的 Call 之类弹窗效果

    class CallWindow: UIWindow {
        
        var isValid: Bool {
            CallManager.shared.isMiniWindow == false
        }
        
        class Presenter: UIViewController {
            
            override func viewDidLoad() {
                super.viewDidLoad()
                view.backgroundColor = .clear
                view.tag = 999
            }
            
            override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
                super.dismiss(animated: flag) { [weak self] in
                    completion?()
                    self?.view.removeFromSuperview()
                }
            }
        }
        
        static var shared = CallWindow(frame: Screen.bounds)
        
        lazy var presenter = Presenter().then({ self.rootViewController = $0 })
        
        var rootView: UIView {
            presenter.view
        }
        
        private override init(frame: CGRect) {
            super.init(frame: UIWindow.customBounds)
            windowLevel = .chat
            isVisible = false
        }
        
        required init?(coder: NSCoder) {
            super.init(coder: coder)
        }
    }
    

    主要做的工作,就是维护一个 CallWindow.shared,持有一个 presenter(用于做弹窗动画),设定window层级,接下里设定其出现时机.

    override func didAddSubview(_ subview: UIView) {
            super.didAddSubview(subview)
            isVisible = true
            UIApplication.shared.isIdleTimerDisabled = true
        }
        
        override func willRemoveSubview(_ subview: UIView) {
            super.willRemoveSubview(subview)
            if subview == rootView {// present 的 view
                isVisible = false
                UIApplication.shared.isIdleTimerDisabled = false
                return
            } else {
                isVisible = true
            }
        }
    func present(viewController toPresent: UIViewController, animated: Bool, completion: (() -> Void)? = nil) {
            if self.presenter.view.superview == nil {
                self.addSubview(self.presenter.view)
            }
            toPresent.modalPresentationStyle = .fullScreen
            self.presenter.present(toPresent, animated: animated, completion: completion)
        }
        
        func dismiss(viewController animated: Bool, completion: (() -> Void)? = nil) {
            self.presenter.dismiss(animated: animated, completion: {
                completion?()
            })
        }
    

    维护其生命周期方法didAddSubview,willRemoveSubview
    didAdd 好理解,当添加子视图时设定 isHidden=false,不过要注意的是willRemoveSubview调用时,subview 是还未被移除的,这时候 subviews.count>0,你们如何判断隐藏 window就见仁见智了,我这里的策略是当移除的是 presenter.view 时,设置不可见(我会在 CallController 移除时机顺便移除掉 presenter).

    window 点击策略

    自定义 window 做音视频通话界面,另一个要注意的点就是,点击的策略,因为即使是小窗状态下,window 也是会响应空白区域的点击的,我们希望的是,让 window 穿透空白区域,去可响应 keywindow 的普通页面点击.所以这里要做一些工作.
    这里引入一个新的自定义基类

    class PointInsideOnDemandView: UIView {
        override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
            for view in subviews.reversed() {
                guard !view.isHidden, view.alpha > 0, view.isUserInteractionEnabled,
                    view.frame.contains(point) else {
                    continue
                }
                if view is PointInsideOnDemandView {
                    let innerPoint = convert(point, to: view)
                    return view.point(inside: innerPoint, with: event)
                }
                return true
            }
            return false
        }
    }
    

    对于所有继承我这个类的子类,pointInside方法,我只判断subviews 能否包含点击的点,self 会穿透过去.

    在 CallWindow 中,我们手动管理 hittest 类,这里有个逻辑,当 userInterative可见并且能响应点击的时候,我们默认这时候是大窗状态(interativeView 会在小窗时设置 alpha=0),然后就走默认的 vcView 的点击逻辑层,当不能响应时,这时候为小窗状态,我们只响应 floatwindow 的点击,floatwindow 即为包含 localview 和 remoteview 的聊天视图层

    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
            for view in subviews.reversed() {
                guard view.isVisible, view.alpha > 0, view.isUserInteractionEnabled, view.frame.contains(point) else {
                    continue
                }
                guard let vcView = view.viewWithTag(CallControllerViewTag) else {
                    continue
                }
                if let interativeView = vcView.viewWithTag(CallControllerInterativeViewTag), interativeView.isVisible, interativeView.alpha > 0, interativeView.isUserInteractionEnabled, interativeView.frame.contains(point) {
    //                let converPoint = convert(point, to: interativeView)
                    return vcView.hitTest(point, with: event)
                } else if let floatWindowView = vcView.viewWithTag(CallControllerFloatViewTag), floatWindowView.isVisible, floatWindowView.alpha > 0, floatWindowView.isUserInteractionEnabled, floatWindowView.frame.contains(point) {
                    let converPoint = convert(point, to: floatWindowView)
                    return floatWindowView.hitTest(converPoint, with: event)
                } else {
                    continue
                }
            }
            return nil
        }
    

    这里需要会的知识点,就是 hittest 响应链和 pointInside 是否包含,这样才能做到小窗不影响普通界面使用

    大小窗交互,拖拽手势交互

    这里要准备2个手势

    • restoreWindowTapGesture恢复大窗手势.
    • changeBigTapGesture更改远近窗手势.
      简要的核心逻辑,在大小窗,远近窗变化后,要做响应的视图层级,手势,拖拽等判断处理
    if isSmall {
        self.localView.removeGestureRecognizer(self.changeBigTapGesture)
        self.remoteView.removeGestureRecognizer(self.changeBigTapGesture)
        self.localView.isDragEnable?.value = false
        self.remoteView.isDragEnable?.value = false
        self.floatWindowView.isDragEnable?.value = true
    } else {
        self.floatWindowView.isDragEnable?.value = false
        if isLocalBig {
            self.floatWindowView.bringSubviewToFront(self.remoteView)
            self.remoteView.isDragEnable?.value = true
            self.remoteView.addGestureRecognizer(self.changeBigTapGesture)
        } else {
            self.floatWindowView.bringSubviewToFront(self.localView)
            self.localView.isDragEnable?.value = true
            self.localView.addGestureRecognizer(self.changeBigTapGesture)
        }
    }
    
    • 当小窗时,localView,remoteView均被禁用拖拽,frame 更改为 floatwindow 的 bounds,floatwindow 做 view 动画形变,小窗添加changeBigTapGesture
    • 当大窗时,判断小窗是 local 还是 remote,并且要将其 brintToFront(重要!),并且添加changeBigTapGesture手势,floatwindow 形变.
    • 当触发更改远近窗手势时,小窗需要添加相应changeBigTapGesture手势,大窗可以添加底部交互界面手势(按需),拖拽的手势也是在各个形变过程中做响应的触发
      还有一点,音视频大小窗逻辑,我没提及摄像头关闭,这也是要加入判断的,影响大小窗展示的逻辑是有很多重的因素的,我们在做的时候,最好写点 xmind 逻辑,分析分析.

    这篇文章不算框架,只是我临时实现的大小窗逻辑,也是比较粗糙的,手势拖拽啥的,大小窗手势,我都没细讲.因为最近准备面试,临时更改到音视频知识巩固,所以也没时间细讲(平时加班比较多,能写 blog 时间本来也不多).

    本篇不算音视频的知识,只能算是一个音视频大小窗模块策略,不过下一篇我已经有思路,我准备封装一个简易可拖展的音视频采集模块.发散一下大家的思路吧,在正常做业务模块的时候,我们可能会设计到一些音视频功能,比如,扫码授权,录音,录视频,拍照,美颜预配置什么的,然后,可能大家会从网上 copy 一下相应的视频采集代码,输入输出,然后一个个模块各自为战.是不是可以这样呢,我们不关心音视频会话,输入,输出捕获,我希望有个管理者,帮我做好这些东西,我传进去相应的配置,你负责回调,或代理,返回我需要的结果,还有 previewLayer 之类的.这个周末,我会尝试封装一下(鸽鸽鸽~~)

    相关文章

      网友评论

        本文标题:iOS(swift) 音视频通讯大小窗策略

        本文链接:https://www.haomeiwen.com/subject/ivwlmltx.html