网易云音乐是一款很优秀的音乐软件,我也是它的忠实用户。最近在研究如何更好的开发TableView,接着我写了一个Model驱动的小框架 - MDTable。为了去验证框架的可用性,我选择了网易云音乐的首页来作为Demo,语言是Swift 3。
本文的内容包括:
- 实现网易云音乐首页的思路
- 建立一个轻量级的
UITableViewController
(不到100行) - 性能瓶颈原因以分析及如何优化到接近60fps
Note:本文并没有用Reveal去分析网易云音乐iOS客户端的原始UI布局,所以实现方式肯定和原始App有出入。另外,本文仅代表个人观点,与雇主没有任何关系。
最后效果如下
整体上分析来看,网易云音乐的首页是一个异构的滚动视图。由上至下依次是:
- Banner - 轮播
- Menu - 三个入口
- 6个分类,推荐歌单,独家放送,推荐MV,精选专栏,主播电台,最新音乐。每一个分类的UI布局都不一样。
并且这些布局都不是动态的,所谓动态的就是向淘宝京东首页那种,做不同的活动,首页可以按照不同的方式去显示内容,而不需要从App Store下载新的版本。
基于这些,有两种实现方式:
用单纯的UIScrollView作为容器,其他的内容作为SubView添加到ScrollView中,但要手动控制每一个视图进入屏幕和消失的事件,来进行图片的懒加载。采用这种方式可以选择天猫开源的LazyScrollView
用UITableView作为容器,其他的每一行内容都是一个Cell。
本文选择了后者,原因也很简单:我是为了评估MDTable,而MDTable是一个基于TableView的框架。
网易云音乐Banner的最上面是一个轮播图,效果如下
可以看到视图大致分为两部分:
- ScrollView - 容器
- ItemView - 轮播的具体内容
- ImageView - 背景图
- Label - 标签,就是图中的广告部分。
轮播图有很多种实现方式,这里我选择了之前写的ParallexBanner。
这是一个支持视差效果的Banner,所谓视差效果,就是类似这种:
ParallexBanner原理我在这篇博客里有详细介绍,这里就不浪费篇幅了。
另外,那个标签Label也很容易实现,只要用一个左边是圆角的UILabel即可,这里写了个方便的扩展
extension UIView {
func roundCorners(_ corners: UIRectCorner, radius: CGFloat) {
let path = UIBezierPath(roundedRect: self.bounds, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
let mask = CAShapeLayer()
mask.path = path.cgPath
self.layer.mask = mask
}
}
Menu的目标效果如下:
中间的“每日歌曲推荐”这个选项有点意思,因为中间的文字是会随着日期变的。实现起来也很简单,图片留白,中间放一个Lable即可。
这是最后我选择的布局方式:
侧面看起来:
也就是说,SubView是这样子的:
- UIImageView - 红色背景圆圈
- UILabel - 标题(每日歌曲推荐)
- UIImageView - 图标(日历图标)
- UILabel - 日期时间(25)
Note: 这里先不管按下态,按下态在下文统一讲解。
我们先从UI效果入手,一共有六种异构的Cell。
第一个冒出来的想法是Cell中放置CollectionView,CollectiionViewLayout也很简单,采用系统提供的FlowLayout即可。
图个省事,每一个CollectionViewCell我都采用Xib的方式,用AutoLayout布局的。
然后,每一个TableViewCell的子类如下:
//初始化
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
let flowLayout = UICollectionViewFlowLayout()
flowLayout.itemSize = CGSize(width: xxx, height: xxx)
collectionView = UICollectionView(frame: contentView.bounds, collectionViewLayout: flowLayout)
contentView.addSubview(collectionView)
let nib = UINib(nibName: "xxx", bundle: Bundle.main)
collectionView.register(nib, forCellWithReuseIdentifier: "cell")
}
用CollectionView写的第一个版本在这里。 感兴趣的同学可以下载下来看看,进入界面后滚动,能够明显的感到掉帧,具体的优化过程在后文。
像这样的一个视图,需要在图像上展示白色的图标和文字,这就引入了一个问题:
如果展示文字的区域的背景图也是白色的怎么办?
答案是在图片上面盖一层半透明的渐变蒙版:
所谓按下态就是当你的手指放到一个视图上,UI会有一些变化告诉用户。比如网易云音乐的按下态是图片上加上一个半透明的遮罩:
实践的过程中发现,视图有如下特点:
- 支持点击手势
- 支持长按手势
- 手指接触后一小段时间(0.1秒)左右才会显示按下态,直接点击并不会出现一瞬间的半透明遮罩
- 按下态触发后,上下移动并不会造成TableView滚动
看来想要实现这种效果,不是简单的重写touchBegan
之类的方法就能实现了。
最后,我选择了三种手势,分别用来处理点击,长按和按下态,源代码AvatarItemView。点击和长按没什么好说的,主要讲解下按态:
按下态采用一个Lazy的CoverView:
lazy var highLightCoverView: UIView = {
let view = UIView().added(to: self)
view.backgroundColor = UIColor.black.withAlphaComponent(0.5)
return view
}()
按下由一个长按手势触发:
highLightGesture = UILongPressGestureRecognizer(...)
highLightGesture.delegate = self
highLightGesture.minimumPressDuration = 0.1
//手势
func handleHight(_ sender: UILongPressGestureRecognizer){
switch sender.state {
case .began,.changed:
let location = sender.location(in: self)
let touchInside = self.bounds.contains(location)
avatarImageView.highLightCoverView.isHidden = !touchInside
default:
avatarImageView.highLightCoverView.isHidden = true
}
}
同时,为了防止两个长按手势冲突,实现手势代理方法,和保证按下态的时候TableView不滚动:
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if otherGestureRecognizer.isKind(of: UIPanGestureRecognizer.self){
return false
}
if gestureRecognizer == longPresssGesture {
return false
}
return true
}
开发MDTable的目的就是获得一个更轻量级的TableView。事实上,在Controller里,我只用15行代码就实现了这个样一个复杂的TableView。
DispatchQueue.global(qos: .userInteractive).async {//准备MDTable的数据
let menuSection = MenuSection.mockSection
let recommendSection = RecommendSection.mockSection
let exclusiveSection = ExclusiveSection.mockSection
let mvSection = NMMVSection.mockSection
let columnistSection = NeteaseColumnlistSection.mockSection
let channelSection = ChannelSection.mockSection
let latestMusicSection = LatestMusicSection.mockSection
self.sections = [menuSection,recommendSection,exclusiveSection,mvSection,columnistSection,channelSection,latestMusicSection]
DispatchQueue.main.async {//绑定数据
self.tableView.manager = TableManager(sections: self.sections)
self.tableView.tableFooterView = footer
}
}
以主播电台为例,对应Controller中的这一行
let channelSection = ChannelSection.mockSection
ChannelSection是MDTable提供的基础类型Section的子类:表示主播电台这个TableView Section,对应MVVM设计模式中的ViewModel角色
class ChannelSection: Section, SortableSection{
static var mockSection:ChannelSection{
get{
let channelTitleRow = NMColumnTitleRow(title: "主播电台")
let channelRow = NMChannelRow(channels: channels)
let channelSection = ChannelSection(rows: [channelTitleRow,channelRow])
return channelSection
}
}
}
其中,NMChannelRow是ReactiveRow的子类,对应MVVM设计模式中的ViewModel角色
class NMChannelRow:ReactiveRow {
var channels:[NMChannel] //Models
var isDirty = true
init(channels:[NMChannel]){
self.channels = channels
super.init()
//行高相关信息
self.rowHeight = NMChannelConst.itemHeight * 2.0
self.reuseIdentifier = "NMChannelRow"
self.shouldHighlight = false
self.initalType = .code(className: NMChannelCell.self)
}
}
接着,我们再来看看NMChannelCell,也就是View的角色
class NMChannelCell: MDTableViewCell {
weak var row:NMChannelRow?
override func render(with row: RowConvertable) {
//重写render方法,把ViewModel绑定到View
guard let _row = row as? NMChannelRow else {
return;
}
self.row = _row
if _row.isDirty{
_row.isDirty = false
reloadData()
}
}
}
因为是模型驱动的TableView,只要修改模型的顺序即可。这里定义了一个协议,表示一个Section支持可排序:
protocol SortableSection {
var sortTitle: String {get set} //排序的标题
var sequence: Int {get set} // 顺序
var defaultSequeue:Int {get} //默认顺序
var identifier: String {get} //唯一id
}
接着,我们只需要在点击排序的时候,对Section进行过滤即可
let sortableSections = sections.filter { $0 is SortableSection }.map{$0 as! SortableSection}
let sortController = NeteaseCloudMusicSortController(sections: sortableSections)
let navController = BaseNavigationController(rootViewController: sortController)
present(navController, animated: true, completion: nil)
到这里,我用MDTable很容易的就实现了网易云音乐的首页。但是卡顿的首页不是我想要的(事实上网易云音乐在5s上上下滚动能够感受到明显的卡顿),于是就开始了漫长的性能优化之路。如果你对卡顿分析好无头绪,建议先读读ibireme的这篇文章:《iOS 保持界面流畅的技巧》。
分析卡顿一般会从CPU和GPU两个方面入手,相信我除非你的UI层次特别复杂,比如大量的阴影遮罩之类的,一般来说GPU都不是卡顿的瓶颈。
卡顿的原因一般有三个:
- UI对象的创建,属性修改
- 布局
- 渲染
iOS设备是每秒60帧,也就是说一帧从"CPU计算->GPU渲染->显示"只有16.7ms。 一般来说,当你在滚动的时候,发现CPU持续占用超过50ms,肉眼就能明显的感觉到掉帧,肉眼很难分别出60fps和59fps。
首先分析CPU,使用工具Time Profiler:
图片解码是一个常见的优化点,原因是
当你创建一个UIImage的时候,默认发生实际的解码,只有当图片要被显示到屏幕上的时候,才会发生实际的解码,解码是在CPU上进行了。
由于Demo是采用UIImage(named:"")
,并不会后台解码,于是写了个异步设置的方法
func asyncSetImage(_ image:UIImage){
DispatchQueue.global(qos: .userInteractive).async {
let decodeImage = image.decodedImage()
DispatchQueue.main.async {
self.image = decodeImage
}
}
}
解码的原理也很简单,提前把图片绘制到一个CGContext中,再从Context获取图片,这样能够强制图片解码。通常三方库(KingFisher,SDWebImage)都自带后台解码。
这是我用CollectionView实现第一个版本时候的截图:
可以清楚的看到,初始化xib占用了11ms。原理也很简单,直接从代码创建和读文件创建,肯定读文件要慢很多,
于是,第一个优化点就是
删除xib文件,用代码手动写。
AutoLayout是一种很方便的技术,通过添加约束我们可以实现各种复杂的布局。但是同样,它也是很昂贵的,CPU在布局的时候需要进行不少计算。眼神阅读:Auto Layout Performance on iOS。
所以,这个优化点很容易想到:
用手动Layout代替AutoLayout。其实优化AutoLayout对本文的场景带来的性能提升并不大,因为我们的视图较少,并且层级简单。但是写Demo的时候,我不想再引入一套DSL进行AutoLayout,手动Layout代码还清楚一些。
接着我发现,每次滚动的时候时候都调用collectionView.reload()是一件很蠢的事情,因为数据没改变,那么UI也不会改变,reload()只会带来额外的性能开销,于是给Row增加了一个isDirty属性,只有isDirty为true的时候,才reload。
if _row.isDirty{
_row.isDirty = false
reloadData()
}
经过这个处理,在第一次滚动到底部的时候会掉帧,之后再也不会掉帧了。原因也很简单,我们的所有Cell都是异构的,第一次滚动到底部后,都已经加载到内存里,所以不再有额外的创建对象和Layout的开销。
CollectionView是一个昂贵的视图,它有各种的代理方法,并且需要根据UICollectionViewLayout
动态的计算出每一个Cell的大小和位置。考虑到我们的布局比较简单,于是全部改写成简单的UIView,布局代码在视图的LayoutSubViews中写。
我们继续用TimeProfile进行分析:
可以看到LayoutSubviews竟然占用了11ms,双击这一行,看看是什么代码占用了这么多时间:
可以看到,sizeToFit
应该就是罪魁祸首了,因为让Label去自适应大小是需要额外的CPU计算。
文本的大小计算是很损耗性能的,如果你的App中用到了大量的富文本(类似微博),那么基于CoreText打造一个异步的绘制引擎是一个不错的优化点,当然直接用YYText也可以。
到这里,继续用Time Profiler分析发现主线程的CPU占用几乎都在main函数里了。我们来分析,在Cell显示到屏幕上CPU都干了什么:
- 创建Cell的实例
- 对Cell的实例和Model进行绑定,换句话说就是调整UI控件的属性
- 根据内容进行重新布局
这些任务有一个特点:必须在主线程上进行。
任务不能调度到后台,那么只能从两个方面入手了:
- 预加载。对模版Cell进行提前创建,并且放入内存缓存。这样显示到屏幕的时候,就不再需要重新创建Cell的对象了。
- 任务拆分。将大的任务拆分成小的任务,一次执行一个,这样瞬时CPU占用就不会很高。
先来分析预加载,什么时候预加载呢?一次预加载几个Cell呢?
这里采用监听主线程Runloop的方式进行加载,监听
beforeWaiting
和exit
两个事件,这时候去执行一些任务。
核心代码TaskDispatcher
类:每一个Runloop中,TaskDispatcher
只执行一个任务
let mainRunloop = RunLoop.main.getCFRunLoop()
let activities = CFRunLoopActivity.beforeWaiting.rawValue | CFRunLoopActivity.exit.rawValue
observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault,
activities,
true,
Int.max) { (observer, activity) in
self.executeTask()
}
let runLoopMode = (mode == .common) ? CFRunLoopMode.commonModes : CFRunLoopMode.defaultMode
CFRunLoopAddObserver(mainRunloop, observer, runLoopMode)
TaskDispatcher有两个模式
enum TaskExecuteMode {
case `default` //用来预加载cell,滚动的时候停止预加载
case common //用来对大任务进行拆分
}
于是,预加载变成了这样子:
TaskDispatcher.default.add(row.reuseIdentifier, {
//预加载Cell
})
在CellForRow中,首先检查预加载缓存,如果有,直接返回缓存数据。
在对每一行reloadData的时候,我们需要调整6个subView的属性,对其进行Layout。我们可以利用之前写的TaskDispatcher对六个任务进行拆分:
for i in 0..<row.musics.count{
TaskDispatcher.common.add("Music\(i)") {
let style:MusicCollectionCellStyle = i == 0 ? .slogen : .normal;
let music = row.musics[i]
let itemView = self.itemViews[i]
itemView.config(music, style: style)
}
}
性能分析就是一句话:能放到后台的就放到后台,不能放到后台的要么预加载,要么拆分。另外还有一个Facebook出品的三方库非常推荐:Texture,这个是一个异步显示框架,会省去很多事。
感兴趣的同学可以下载Demo工程跑跑看