iOS实现高性能弹幕框架

前言

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

目前DanmakuKit已经具备一些基础的能力,我还列了一些TODO,未来我会利用周末的空余时间持续完善它的功能。

简介

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就好了,代码如下:

    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的并行队列任务试试看。

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

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,或为它提供建议。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345