iOS实现高性能弹幕框架

前言

我之前维护过公司的弹幕库,但由于它的历史包袱过重,改造成本过高,一直没有将它改造成我心中理想状态的一个库。另外在周末,我也需要做一些事情来消磨时间,所以我写了一个比较符合我心中理想状态的弹幕库并将它开源:https://github.com/qyz777/DanmakuKit

简介

DanmakuKit是一个高性能弹幕框架,它提供了基础的弹幕功能,能够让你通过异步队列的方式渲染弹幕。它提供三种弹幕类别,分别是浮动、置顶和置底弹幕。目前它支持的功能如下:

  • 速度调节
  • 轨道高度调节
  • 显示区域调节
  • 点击回调
  • 暂停单个弹幕
  • 重叠展示
  • 禁用不同类型的弹幕
  • 渲染不同进度的弹幕到屏幕上

原理

在说原理之前先把弹幕库的类图放上,DanmakuKit由DanmakuView作为承载弹幕的主体,其中包含不同的DanmakuTrack作为管理view中弹幕的对象。对于使用者而言,只需要给DanmakuView传入了实现DanmakuCellModel协议的对象,它就能根据协议自动创建或复用一个DanmakuCell来展示弹幕。

类图

性能问题

在实际写代码之前就必须考虑到弹幕的性能问题,因为如果不考虑这个问题的话一旦弹幕量很大那就会极大的影响app使用体验。那么在iOS中,想要获得最佳的性能体验,我们可以很快的想到一个流程,那就是异步队列渲染出一张弹幕图片,把它放在layer.content中,再用Core Animation播放出来。另外,反复的创建和销毁管理弹幕的对象也有一些开销,我们要用合适的方法来管理这些对象。

因此我们总结一下,如果想要实现一个高性能的弹幕,那我们肯定会用到以下3点:

  • 复用
  • 异步队列绘制
  • Core Animation

复用

复用是一个很容易想到的想法,在DanmakuKit中,弹幕的绘制是由DanmakuCell实现的,而它是一个view的子类,所以复用也是以view为维度的。复用view是为了减少反复addSubview以及removeFromSuperView的开销,当然,在实际测试来看这块性能开销并不会特别大。

异步队列绘制

在DanmakuKit中,绘制使用的是CGContext,将内容绘制成一张图片放在layer.content中。如果这块的逻辑是在主线程同步的话那么必然会是个不小的开销,此时选择异步队列绘制就是一个很好的选择。当然,异步队列绘制也有它的劣势,那就是在写代码的过程中必须注意线程安全问题。

异步队列渲染的原理可以参考https://github.com/ibireme/YYAsyncLayer ,网上也有不少的播客解析过原理。

使用Core Animation

如果动画使用Pop,那就不用操心手势响应事件了,但由于Pop是基于CADisplayLink实现的动画,它的执行是在主线程中,所以主线程一旦卡顿,那么动画也必然卡。而Core Animation的动画是由系统用一个专用的进程来进行渲染,使用它的好处不用多说了。

点击事件

由于DanmakuKit使用了Core Animation,因此弹幕在动画过程中的展示的其实是layer而不是view。layer是不支持手势响应的,因此点击事件必然也需要特别的实现一下。

实现弹幕在动画中的点击并不是一件很难的事情,我们可以充分利用手势响应链的知识来实现。众所周知,系统是先找到最上层最适合响应事件的view,再往下找能够响应的view,其中找view的方法就是hitTest。

在hitTest中,系统先会判断当前的point是否在view的范围内,如果在的话会从后往前遍历当前view的subViews数组,将传入的point转化为子view的point继续传入调用子view的hitTest方法,直到找到为止。那么我们只要将其中找子view的部分替换为找当前在播放动画的layer就好了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard self.point(inside: point, with: event) else { return nil }

for i in (0..<subviews.count).reversed() {
let subView = subviews[i]
//如果当前的layer正在播放动画
if subView.layer.animationKeys() != nil, let presentationLayer = subView.layer.presentation() {
//用动画的layer判断一下是否在点击范围内
let newPoint = layer.convert(point, to: presentationLayer)
if presentationLayer.contains(newPoint) {
//是的话就找到这个view了
return subView
}
} else {
let newPoint = convert(point, to: subView)
if let findView = subView.hitTest(newPoint, with: event) {
return findView
}
}
}
return nil
}

需要注意的是,Core Animation动画中获取实时坐标的layer是layer.presentation()。另外,在开发过程中我发现presentationLayer的实际size与layer并不是完全相同的,因此在计算中最好只使用presentationLayer的坐标,否则总会出一些奇奇怪怪的问题。

队列池

之前说到渲染弹幕要使用异步队列,那我们能不能直接使用GCD的并行队列呢?答案是不行的,因为随意使用GCD的并行队列很容易造成线程数量爆炸,引发内存问题或者使主线程卡死,大家可以用for循环遍历1000次来执行GCD的并行队列任务试试看。

为了解决这类的问题,我们必须实现一个队列池来解决在可控数量的队列内满足我们的并行需求。实现原理很简单,就是创建一定数量的串行队列存在数组中,每次获取队列时通过计数来获取到不同的队列,下方是一个简单的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import Foundation

class DanmakuQueuePool {

public let name: String

private var queues: [DispatchQueue] = []

public let queueCount: Int

private var counter: Int = 0

public init(name: String, queueCount: Int, qos: DispatchQoS) {
self.name = name
self.queueCount = queueCount
for _ in 0..<queueCount {
let queue = DispatchQueue(label: name, qos: qos, attributes: [], autoreleaseFrequency: .inherit, target: nil)
queues.append(queue)
}
}

public var queue: DispatchQueue {
return getQueue()
}

private func getQueue() -> DispatchQueue {
if counter == Int.max {
counter = 0
}
let queue = queues[counter % queueCount]
counter += 1
return queue
}

}

写在最后

欢迎大家使用DanmakuKit,或为它提供建议。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!