彈幕誕生於日本的視頻平臺,後來被B站這種短視頻平臺引入到國內,並在國內發展壯大。後來逐漸被長視頻平臺所接受,現在視頻相關的應用基本上都會有彈幕。
但是長視頻彈幕和B站這類的短視頻彈幕還不太一樣,短視頻平臺有自己特有的彈幕文化,所以彈幕更注重和用戶的互動。長視頻平臺還是以看劇為主,彈幕類似於評論的功能,所以不能影響用戶看劇,彈幕不能太密集,而且相互之間最好不要有遮蓋,否則會對視頻內容會有比較明顯的影響。
本篇文章主要從長視頻平臺的角度來講彈幕的實現原理,但其實短視頻平臺的彈幕也是同樣的原理,區別在於短視頻可能彈幕種類會多一些。
畫布以我公司應用為例,有iPhone和iPad兩個平臺,在iPhone平臺上有橫豎屏的概念,都需要展示彈幕。在iPad上有大小屏的概念,也需要都展示彈幕。彈幕的技術方案肯定是兩個平臺用一套,但需要考慮跨不同設備和屏幕的情況。
所以,對於這個問題,我通過畫布的概念來解決通用性的問題。畫布並不區分屏幕大小和比例的概念,只是單純的用來展示彈幕,並不處理其他業務邏輯,通過一個Render類來控制畫布的渲染。對於不同設備上的差異,例如iPad字體大一些,iPhone字體小一些這種情況,通過config類來進行控制,畫布內部不做判斷。
小屏上畫布會根據比例少展示一些,大屏上則多展示一些。字體變大畫布也會根據比例和左右間距進行控制,保證展示比例是對的,並且在屏幕寬高發生改變後,自動適應新的尺寸,不會出現彈幕銜接斷開的問題,例如iPad上大小屏切換。外部在使用時,只需要傳入一個frame即可,不需要關注畫布內部的調整。
彈幕軌道從屏幕上來看,可以看到彈幕一般都是一行一行的。為了方便對彈幕視圖進行管理,以及後續的擴展工作,我對彈幕設計了「軌道」的概念。每一行都是一個軌道,對彈幕進行橫向的管理,這一行包括速度、末端彈幕、高度等參數,這些參數適用於這一行的所有彈幕。軌道是一個虛擬的概念,並沒有對應的視圖。
軌道有對應的類來實現,類中會包含一個數組,數組中有這一行所有的彈幕。這個思路有點像玩過的一款遊戲-節奏大師,裡面也有音樂軌道的概念,每個軌道上對應不同速度和顏色的音符,音符數量也是不固定的,根據節奏來決定。
軌道還有一個好處在於,對於不同速度的彈幕比較好控制。例如騰訊視頻的彈幕其實是不同速度的,但是你仔細觀察的話,可以發現他們的彈幕是「奇偶行不同速」,也就是奇數行一個速度,偶數行一個速度,讓人從感官上來覺得所有彈幕的速度都不一樣。如果通過軌道的方式就很好實現,不同的軌道根據當前所在行數,對發出的彈幕設置不同的速度即可。
有時候看視頻過程中會從右側出現一條活動彈幕,可能是視頻中的梗,也可能是類似於廣告的互動。但是活動彈幕出現時一般是單行清屏的,也就是和普通彈幕是互斥的,展示活動彈幕的時候前後沒有普通彈幕。這種通過軌道的方式也比較好實現,每條彈幕都對應一個時間段,根據活動彈幕的時間和速度,將活動彈幕展示的前後時間,將這段時間軌道暫時關閉,只保留活動彈幕即可。
輪詢每條彈幕都對應著一個展示時間,所以需要每隔一段時間就找一下有沒有需要展示的彈幕。我設計的方案是通過輪詢,來驅動彈幕展示。
通過CADisplayLink來進行輪訓,將frameInterval設置為60,即每秒輪詢一次。在輪詢的回調中查找有沒有要展示的彈幕,有的話就從上到下查找每條軌道,某條軌道有位置可以展示的話就交給這條軌道展示,如果所有軌道都有正在展示的彈幕,則將此條彈幕丟棄。是否有位置是根據屏幕最右側,最後一條彈幕是否已經展示完全,並且後面有空餘位置來決定的。
對於取數據的部分,數據和視圖的邏輯是分離的,相互之間並沒有耦合關係。取數據時只是從一個很小的字典中,根據時間取出所用的彈幕數據,並轉化為model。字典的數據很少,最多十秒的數據,而且這裡並不會接觸到讀資料庫的操作,也沒有網絡請求的邏輯,這些都是獨立的邏輯,後面會講到。
彈幕視圖經常看視頻的同學應該會知道,彈幕的展示形式有很多,有帶明星頭像的、有帶點讚數的、帶矩形背景色的,很多種展示形態。為了更好的對視圖進行組織,所以我採用的就是很普通的UIView的展示形式,並沒有為了性能去做很複雜的渲染操作。
用UIView的好處主要就是方便做布局和子視圖管理,但在屏幕上做動畫時,是對CALayer進行渲染的。也就是說UIView就是用來做視圖組織,並不會直接參與渲染,這也符合蘋果的設計理念。
復用池彈幕是一個高頻使用的控制項,所以不能一直頻繁創建,以及添加和移除視圖,會對性能有影響。所以就像很多同學設計的模塊一樣,我也引入了緩存池的概念,我這裡叫復用池。
彈幕復用池和UITableView的復用池類似,離開屏幕的彈幕會被放在復用池中等待覆用,下次直接從復用池中取而不重新創建。彈幕視圖做的工作就是接收新的model對象,並根據彈幕類型進行不同的視圖布局。
並且彈幕只會在創建時被addSubview一次,當彈幕離開屏幕不會被從父視圖移除,這樣彈幕從復用池中取出時也不需要被addSubview。當動畫執行完成後,彈幕就直接留在動畫結束的位置,下次做動畫時彈幕會自動回到fromValue的位置。實際上視圖結構就如上圖所示,灰色區域就是可視區域。
系統彈幕在視頻剛開始時會有引導信息,比如引導用戶發彈幕,或者提示彈幕有多少條,這個我們叫做系統彈幕。系統彈幕一般是展示到屏幕中間時,才開始展示後續彈幕。但是要精確的計算到彈幕到達屏幕中間,然後再展示後續彈幕,這種的採用清除前後特定時間段的彈幕就不太精確,所以我們採用的是另一套實現方案。
系統彈幕的實現是通過一個更高精度的CADisplayLink進行輪詢檢測,也就是把frameInterval設置的更小,我這裡設置的是10,也就是每秒檢測六次。但是進行檢測時不能直接用CALayer進行判斷,需要使用presentationLayer也就是屏幕上正在展示的layer進行檢測,通過這個layer獲取到的frame和屏幕上顯示的才是一致的。
這裡簡單介紹一下CALayer的結構,我們都知道UIView是對CALayer的一層封裝,實際上屏幕上的顯示都是通過layer來實現的,而layer本身也分為以下三層,並有不同的功能。
presentationLayer,其本身是當前幀的一個拷貝,每次獲取都是一個新的對象,和動畫過程中屏幕上顯示的位置是一樣的。modelLayer,表示layer動畫完成後的真實值,如果列印一下modelLayer和layer的話,發現二者其實是一個對象。renderLayer,渲染幀,應用程式會根據視圖層級,構成由layer組成的渲染樹,renderLayer就代表layer在渲染樹中的對象。炫彩彈幕在播放彈幕的過程中,我們可以看到有漸變顏色的彈幕,我們叫做「炫彩彈幕」。這種彈幕有一個很明顯的特徵,就是其顏色是漸變的。這時候要考慮性能的問題,因為播放高清視頻時本身性能消耗就很大,在彈幕量比較大的情況下,會造成更多的性能消耗,所以減少性能消耗就是很重要的,漸變彈幕可能會使性能消耗加劇。
對於漸變文字,一般都是通過mask的方式實現,下面放一個CAGradientLayer做漸變,上面蓋一個文字的layer。但是這種會觸發離屏渲染,會導致性能下降,並不能用這種方案。經過我們的嘗試,決定用設置漸變文字顏色的方式解決。
CGFloat scale = [UIScreen mainScreen].scale;
UIGraphicsBeginImageContextWithOptions(imageSize, NO, scale);
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSaveGState(context);
CGColorSpaceRef colorSpace = CGColorGetColorSpace([[colors lastObject] CGColor]);
CGGradientRef gradient = CGGradientCreateWithColors(colorSpace, (CFArrayRef)ar, NULL);
CGPoint start = CGPointMake(0.0, 0.0);
CGPoint end = CGPointMake(imageSize.width, 0.0);
CGContextDrawLinearGradient(context, gradient, start, end, kCGGradientDrawsBeforeStartLocation | kCGGradientDrawsAfterEndLocation);
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
CGGradientRelease(gradient);
CGContextRestoreGState(context);
UIGraphicsEndImageContext();
實現方式就是先開闢一個上下文,用來進行圖片繪製,隨後對上下文進行一個漸變的繪製,最後獲取到一個UIImage,並將圖片賦值給UILabel的textColor即可。
從離屏檢測來看,並未發生離屏渲染,fps也始終保持在一個很高的水平。
彈幕是隨視頻播放和暫停的,所以需要對彈幕提供暫停和繼續的支持,對於這塊我採用的CAMediaTiming協議來處理,可以通過此協議對動畫的過程進行控制。
代碼中加0.05是為了避免彈幕在暫停時導致的回跳,所以加上一個時間差。具體原因是因為通過convertTime:fromLayer:方法計算得到的時間,和屏幕上彈幕的位置依然存在一個微弱的時間差,而導致渲染時視圖位置發生回跳,這個0.05是一個實踐得來的經驗值。
- (void)pauseAnimation {
// 增加判斷條件,避免重複調用
if (self.layer.speed == 0.f) {
return;
}
CFTimeInterval pausedTime = [self.layer convertTime:CACurrentMediaTime() fromLayer:nil];
self.layer.speed = 0.f;
self.layer.timeOffset = pausedTime + 0.05f;
}
- (void)resumeAnimation {
// 增加判斷條件,避免重複調用
if (self.layer.speed == 1.f) {
return;
}
CFTimeInterval pausedTime = self.layer.timeOffset;
self.layer.speed = 1.0;
self.layer.timeOffset = 0.0;
self.layer.beginTime = 0.0;
CFTimeInterval timeSincePause = [self.layer convertTime:CACurrentMediaTime() fromLayer:nil] - pausedTime;
self.layer.beginTime = timeSincePause;
}
CAMediaTiming協議是用來對動畫過程控制的一個協議,例如通過CoreAnimation創建的動畫,CALayer遵守了這個協議。這樣如果需要對動畫進行控制的話,不需要引用一個CABasicAnimation對象,然後再修改動畫屬性這種方式對動畫流程進行控制,只需要直接對layer的屬性進行修改即可。
下面是CAMediaTiming協議中一些關鍵的屬性,在上文中也用到了其中的部分屬性。
beginTime,動畫開始時間,可以控制動畫延遲展示。一般是一個絕對時間,為了保證準確性,最好先對當前layer進行一個轉換,延遲展示在後面加對應的時間即可。speed,動畫執行速度,默認是1。動畫最終執行時間=duration/speed,也就是duration是3秒,speed是2,最終動畫執行時間是1.5秒。timeOffset,控制動畫進程,主要用來結合speed來對動畫進行暫停和開始。repeatCount,重複執行次數,和repeatDuration互斥。repeatDuration,重複執行時間,如果時間不是duration的倍數,最後一次的動畫會執行不完整。autoreverses,動畫反轉,在動畫執行完成後,是否按照原先的過程反向執行一次。此屬性會對duration有一個疊加效果,如果duration是1s,autoreverses設置為YES後時間就是2s。fillMode,如果想要動畫在開始時,就停留在fromValue的位置,就可以設置為kCAFillModeBackwards。如果想要動畫結束時停留在toValue的位置,就設置為kCAFillModeForwards,如果兩種都要就設置為kCAFillModeBoth,默認是kCAFillModeRemoved,即動畫結束後移除。插入彈幕現在彈幕一般都會結合劇中主角,以及各種文字顏色讓你去選擇,通過這些功能也可以帶來一部分付費用戶。當發送一條彈幕時,會從上到下查找軌道,查找軌道時是通過presentationLayer來進行frame的判斷,如果layer的最右邊不在屏幕外,並且距離右側屏幕還有一定空隙,項目中寫的是10pt,則表示有空位可以插入下一條彈幕,這條彈幕會被放在這條軌道上。
如果當前軌道沒有空位置,則從上到下逐條查找軌道,直到找到有空位的軌道。如果當前屏幕上彈幕較多,所有軌道都沒有空位,則這一條彈幕會被拋棄。
如果是自己發的彈幕,這個是必須要展示出來的,因為用戶發的彈幕要在界面上給用戶一個反饋。對於自己發的彈幕,會有一個插隊操作,優先級比其他彈幕都要高。自己發的彈幕並不入本地資料庫,只是進行一個網絡請求傳給伺服器,以及在界面上進行展示。
選擇角色在上面的圖片中可以看到,文本之前會有角色和角色名,這些都是獨立於輸入文字之外的。用戶如果刪除完輸入的文字之後,再點擊刪除要把角色也一起刪除掉。輸入框頁面構成是一個UITextField,左邊的角色頭像和角色名是一個自定義View,被當做textField的leftView來展示。如果刪除的話就是將leftView置nil即可。
問題在於,如果使用UIControlEventEditingChanged的事件,只能獲取到文本發生變化時的內容,如果輸入框的文字已經被刪完,而角色是一個leftView,但由於文本已經為空,則無法再獲取到刪除事件,也就不能把角色刪除掉。
對於這個問題,我們找到了下面的協議來實現。UITextField遵守UITextInput協議,但UITextInput協議繼承自UIKeyInput協議,所以也就擁有下面兩個方法。下面兩個方法分別在插入文字,以及點擊刪除按鈕時調用,即使文本已經為空,依然可以收到deleteBackward的回調。在這個回調裡就可以判斷文本是否為空,如果為空則刪除角色即可。
@protocol UIKeyInput <UITextInputTraits>
@property(nonatomic, readonly) BOOL hasText;
- (void)insertText:(NSString *)text;
- (void)deleteBackward;
@end
彈幕一般都不是一種形態,很多參數都是可以調整的,對於iPhone和iPad兩個平臺參數還不一樣,調整範圍也不一樣。這些參數肯定是不能放在業務代碼裡進行判斷的,這樣各種判斷條件散落在項目中,會導致代碼耦合嚴重。
對於這個問題,我們的實現方式是通過BarrageConfig來區分不同平臺,將兩個平臺的數值差異都放在這個類中。業務部分直接讀取屬性即可,不需要做任何判斷,包括退出進程的持久化也在內部完成,這樣就可以讓業務部分使用無感知,也保證了各個類中的數值統一。
當有任何參數的改動,都可以對BarrageConfig進行修改,然後調用Render的layoutBarrageSubviews進行渲染即可。因為調整參數之後,屏幕上已經顯示的彈幕也需要跟著變,而且變得過程中還是在動畫執行過程中,動畫執行不能斷掉,所以對動畫的處理就很重要。這部分處理起來比較複雜,就不詳細講了。
點讚彈幕還會有點讚和長按的功能,點讚一般是點擊屏幕然後出現一個選擇視圖,點擊點讚後有一個動畫效果。長按就是選中一個彈幕,識別到手勢長按之後,右側出現一個舉報頁面。
這兩個手勢我用tap和longPress兩個手勢來處理,並給longPress設置了一個0.2s的識別時間,將這兩種手勢的識別交給系統去做,這樣也比較省事。
這兩個手勢都加到Render上,而不是每個彈幕視圖對應一個手勢,這樣管理起來也比較簡單。這樣在手勢識別時,就需要先找到手勢觸摸點,再根據觸摸點查找對應的彈幕視圖,查找的時候依然通過presentationLayer來查找區域,而不能用視圖做查找。
- (void)singleTapHandle:(UITapGestureRecognizer *)tapGestureRecognizer {
CGPoint touchLocation = [tapGestureRecognizer locationInView:self.barrageRender];
__block BOOL barrageLiked = NO;
weakifyself;
[self enumerateObjectsUsingBlock:^(SVBarrageItemLabelView *itemLabel, NSUInteger index, BOOL *stop) {
strongifyself;
if ([itemLabel.layer.presentationLayer hitTest:touchLocation] && barrageLiked == NO) {
barrageLiked = YES;
[self likeAction:itemLabel withTouchLocation:touchLocation];
*stop = YES;
}
}];
}
對於這麼好的一個展示位置,廣告部門必然不會放過。在視頻播放過程中,會根據金主爸爸投放要求,在指定的時間展示一個廣告彈幕,並且這個彈幕的形態還是不固定的。也就是說大小、動畫形式都不能確定,而且這條彈幕還要在最上層展示。
對於這個問題,我們採用的方案是,給廣告專門留了一個視圖,視圖層級高於Render,在初始化廣告SDK的時候傳給SDK,這樣就把廣告彈幕的控制交給SDK,我們不做處理。
圖層管理播放器上存在很多圖層,播控、彈幕Render、廣告之類的,看得到的和看不到的有很多。對於這個問題,播放器創建了一個繼承自NSObject的視圖管理器,這個視圖管理器可以對視圖進行分層管理。
播放器上的視圖,都需要調用指定的方法,將自己加到對應的圖層上,移除也需要調用對應的方法。當需要調整前後順序時,修改定義的枚舉即可。
前面一直說的都是視圖的部分,沒有涉及數據的部分,這是因為UI和數據其實是解耦和的,二者並沒有強耦合,所以可以單獨拿出來講。數據部分的設計,類似於播放器的local server方案,將請求數據到本地,和從本地讀取數據做了一個拆分。
請求數據彈幕數據量比較大,肯定是不能一次都請求下來的,這樣很容易造成請求失敗的情況。所以這塊採取的是五分鐘一個分片數據,在當前的五分鐘彈幕快播完的前十秒,開始請求下一個時間段的彈幕。如果拖動進度條,則拖動完成後開始請求新位置的彈幕。在每次請求前都會查一下庫,數據是否已存在。
請求數據由業務部分驅動,請求數據後並不會直接拿來使用,而是存入本地資料庫,這部分比較像伺服器往本地寫ts分片的操作。資料庫存儲的部分,推薦使用WCDB,彈幕這塊主要都是批量數據處理,而WCDB對於批量數據的處理,性能高於FMDB。
取數據取數據同樣由業務層驅動,為了減少頻繁進行資料庫讀寫,每隔十秒鐘進行一次資料庫批量讀取,並轉換為model返回給上層。彈幕模塊在內存中維護了一個字典,字典以時間為key,數組為value,因為同一時間可能會有多條彈幕。
從資料庫批量獲取的數據會被保存到字典中,上層業務層在使用數據時,都是通過字典來獲取數據,這樣也實現了數據層和業務層的一個解耦和。上層業務層每隔一秒從字典中讀取一次數據,並通過數據找到合適的軌道,將數據傳給合適的軌道來處理。
現在很多視頻網站都上線了彈幕防遮擋方案,對於視頻中的人物,彈幕會在其下方展示,而不會遮擋住人物。還有的應用針對彈幕遮擋進行了新的探索,即成為付費會員後,可以選擇只有自己喜歡的愛豆不被遮擋,其他人依然被遮擋。
語義分割根據業務場景我們分析,首先需要把人像部分分割出來,獲取到人像的位置之後才能做後續的操作。所以人像分割的部分採取語義分割的方式實現,提前對視頻關鍵幀進行標註,這個工作量是很龐大的,所以需要一個專門的標註團隊去完成。根據標註後的模型,通過機器學習的方式,讓計算機可以準確的識別出人的位置,並導出多邊形路徑。
這裡面還涉及一個問題,就是近景識別和遠景識別的問題,機器進行識別時只需要識別近景人物,遠景人物並不需要進行識別,否則彈幕展示效果會受到很大影響。語義分割可以通過Google的Mask_RCNN來實現。
客戶端實現方案客戶端的實現方案是通過人像的多邊形路徑,對原視頻摳出人像並導出一個新的視頻。在播放的時候實際上是前後兩個播放器在播放,彈幕夾在兩個播放器中間來實現的。並且前面的人像層需要做邊緣虛化,讓彈幕的過渡顯得自然些,否則會太突兀。
這種方案的過渡效果會好一些。因為對每一幀視頻進行切割的時候,每一幀並不能保證相鄰幀切割的邊緣相差都不大,也就是相鄰近的幀邊緣不能保證很好的銜接,這樣就容易出現視頻連續性的問題。前後兩個播放器疊加的方案,兩個層的視頻內容實際上是銜接很緊密的,把彈幕層去掉你根本看不出來這是兩層播放器,所以連續性的問題就不明顯了。
前端實現方案前端的實現方案是服務端將多邊形路徑放在一個svg文件中,並將文件下發給前端,前端通過css的mask‑image遮罩實現的。通過遮罩把人像部分摳出來,人像之外依然是黑色區域,黑色是可顯示區域,和iOS的mask屬性類似。
B站是最開始做彈幕防擋的,現在B站已經不局限於真人彈幕防擋了,現在很多番劇中的動漫人物也支持彈幕防擋。可以看下面的視頻感受一下。
B站番劇彈幕防擋視頻連結:https://www.bilibili.com/video/BV1Db411C7hJ