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

歌词视图剖析
一个体验良好的歌词视图,由以下方面组成,这也是我们的设计目标:
- 有倒计时功能,歌者可以提前作演唱的准备
- 根据场景的不同,支持多行或者双行显示,为歌者提供演唱的上下文
- 歌者清晰的了解当前在唱哪一句歌词,我称之为焦点行
- 焦点行需要染色,并需要精准地作逐字渲染
- 两句之前使用适当的动画换行过渡
- 歌词动画平滑不突兀,适应不同节奏的歌曲
- 根据产品和设计师的要求,灵活地对歌词视图进行字体、颜色调整(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 { CGRect greenRect; CGRect whiteRect CGPoint greenPoint; CGPoint whitePoint; Line *line; UIFont *font; 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]; _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。