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 之类的.这个周末,我会尝试封装一下(鸽鸽鸽~~)
网友评论