基于Core Animation的KTV歌词视图的平滑实现

KTV歌词视图,只要去过KTV的的朋友一定不会陌生。我们先来看一下最终的效果,再一步步说明唱吧歌词视图的演进。想把事件事情说得清清楚楚的确很难,有很多tricky的地方;另外毕竟不是open source的,只能给大家挑重点分享一下实现的过程和思路。

唱吧6.0歌词视图

歌词视图剖析

一个体验良好的歌词视图,由以下方面组成,这也是我们的设计目标:

  • 有倒计时功能,歌者可以提前作演唱的准备
  • 根据场景的不同,支持多行或者双行显示,为歌者提供演唱的上下文
  • 歌者清晰的了解当前在唱哪一句歌词,我称之为焦点行
  • 焦点行需要染色,并需要精准地作逐字渲染
  • 两句之前使用适当的动画换行过渡
  • 歌词动画平滑不突兀,适应不同节奏的歌曲
  • 根据产品和设计师的要求,灵活地对歌词视图进行字体、颜色调整(1/3/5是绿色,2/4/6是红色,阴历节日是黄色,I’m serious and it’s safe to forget Sunday! Cheers!)

此外我们还需要了解一下歌词信息的结构,大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
@interface Line : NSObject
@property (nonatomic, strong) NSArray *words;
@property (nonatomic, strong) NSString *text;
@property CGFloat start;
@property CGFloat length;
@end
@interface Word : NSObject
@property (nonatomic, strong) NSString *text;
@property CGFloat start;
@property CGFloat length;
@end
  • 一首歌的歌词我们称之为Lyrics
  • Lyrics包含多行,每行我们称之为Line; Line有它的start及length,分别代表时间戳以及长度
  • Line包含多个字,每个字我们称之为Word; Word也有它的start以及length,分别代表时间戳以及长度

了解完这些我们看看如何来渲染焦点行歌词,先看简单直接的方式。

基于Core Graphics的实现

我们知道歌曲的开始时间,也有歌词数据提供时间支持,那么就可以计算出当前歌词视图的状态。对于歌词的焦点行,有两部分状态:

  • 歌者已经演唱的部分,渲染成绿色
  • 歌者待演唱的部分,渲染成白色

我们省略计算的过程,假设已经得出绿色、白色歌词的rect及point,就可以直接渲染了:

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
@implementation LyricsView
- (void)drawRect:(CGRect)rect {
// Assume we calcuated them before
CGRect greenRect;
CGRect whiteRect
CGPoint greenPoint;
CGPoint whitePoint;
// We have the focus line and font
Line *line;
UIFont *font;
// Render focus line text
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSaveGState(context);
CGContextClipToRect(context, greenRect);
[[UIColor greenColor] set];
[line.text drawAtPoint:greenPoint withFont:font];
CGContextRestoreGState(context);
CGContextSaveGState(context);
CGContextClipToRect(context, whiteRect);
[[UIColor whiteColor] set];
[line.text drawAtPoint:whitePoint withFont:font];
CGContextRestoreGState(context);
}
@end

本质上我们使用了NSString的UIStringDrawing Category搞定了这个事情。既然我们解决了任一时间点的状态,那么把它动起来也很容易:

  • 将这段code snippet放到LyricsView的drawRect中
  • 以60 FPS的频率调用[lyricsView setNeedsDisplay]

一切看起来很直观,但问题来了,这个歌词视图根本跑不到60 FPS(我保证这个效果看起来像癫痫一样儿,v4.9之前就是一直这么癫过来的),即使在目前性能最强的iPhone 5S上。我们来分析一下原因:

  • Core Graphics使用CPU作渲染
  • 这个界面是CPU intensive,需要播放伴奏,还需要录制歌者的声音,甚至需要给声音加“滤镜”
  • 还有对歌者进行实时打分的task及动画
  • 回望过去5年iPhone的硬件发展,GPU的提升也远高于CPU,不能指望短期设备升级解决这个问题

5S上毕竟还可以跑到50FPS,但低端设备的FPS对我来讲是实在是没法接受的。唱吧是线上KTV的应用的用户体验标准,不解决这个问题是说不过去的。既然CPU不给力,那么我们让GPU来做这件事情。

基于Core Animation的实现

14年初的时候,Facebook open source了惊艳的Shimmer。由于跟我设想的实现机制是相同的,直接拖了几百个shimmer view作了一下profile,在4S上都可以达到完美的60FPS。

让我们先理一下思路,看看基于Core Animation的焦点行的视图结构:

1
2
- GreenLineLabel: UILabel
- WhiteLineLabel: UILabel

没错,就是简单的把绿色的UILabel置于白色的之上,剩下的问题就是如何控制绿色的UILabel按我们的时间控制进行部分渲染。

部分渲染就是加一个mask,我们来看一下CALayer的mask property:

1
2
3
4
5
6
7
8
9
10
11
@interface CALayer : NSObject <NSCoding, CAMediaTiming>
/* A layer whose alpha channel is used as a mask to select between the
* layer's background and the result of compositing the layer's
* contents with its filtered background. Defaults to nil. When used as
* a mask the layer's `compositingFilter' and `backgroundFilters'
* properties are ignored. When setting the mask to a new layer, the
* new layer must have a nil superlayer, otherwise the behavior is
* undefined. Nested masks (mask layers with their own masks) are
* unsupported. */
@property(strong) CALayer *mask;
@end

我们可以知道,mask layer的alpha用来与CALayer的content进行alpha blending,如果alpha为1则content显示,反之不显示。受Shimmer的启发,我们可以对mask作动画,让它从左到右移动到绿色歌词的layer上,并最终与之重合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@interface GreenLineLabel: UILabel
@end
@implementation GreenLineLabel {
CALayer *_maskLayer;
}
- (instance)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
_maskLayer = [CALayer layer];
_maskLayer.backgroundColor = [[UIColor whiteColor] CGColor]; // Any color, only alpha channel matters
_maskLayer.anchorPoint = CGPointZero;
_maskLayer.frame = CGRectOffset(self.frame, -CGRectGetWidth(self.frame), 0);
self.layer.mask = _maskLayer;
self.backgroundColor = [UIColor clearColor];
}
return self;
}

上面这段代码我们将_maskLayer的anchorPoint设置为CGPointZero,便于后面的动画计算坐标。

下面我们对_maskLayer的position作CAKeyframeAnimation动画,根据歌词数据我们可以算出每个字渲染的时间(keyTimes)和动画总时长(duration)。假设每个字是等宽的,我们可以算出_maskLayer在每一个keyTime的position,也就是values。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (void)startAnimation {
// Assume we calculated keyTimes and values
NSMutableArray *keyTimes;
NSMutableArray *values;
CGFloat duration;
CAKeyframeAnimation *animation = [CAKeyframeAnimation animationWithKeyPath:@"position"];
animation.keyTimes = keyTimes;
animation.values = values;
animation.duration = duration;
animation.calculationMode = kCAAnimationLinear;
animation.fillMode = kCAFillModeForwards;
animation.removedOnCompletion = NO;
[_maskLayer addAnimation:animation forKey:@"MaskAnimation"];
}

至此我们完成了基于Core Animation的歌词焦点行染色动画。

写在后面

很抱歉我提供的code snippet不是production ready,歌词动画是一个非常复杂的系统,很难单独抽离出来介绍给大家,所以只能管窥一豹地介绍下。

附小广告一则:唱吧iOS团队诚招iOS工程师,推荐成功即奖励6000元现金或iPhone 6一部,详见这篇blog