实现 60fps 的网易云音乐首页

2022-12-13,,,

网易音乐是一款很优秀的音乐软件,我也是它的忠实用户。最近在研究如何更好的开发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:https://github.com/alibaba/LazyScrollView

用UITableView作为容器,其他的每一行内容都是一个Cell。

本文选择了后者,原因也很简单:我是为了评估MDTable,而MDTable是一个基于TableView的框架。

Banner

网易云音乐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

Menu的目标效果如下:

中间的“每日歌曲推荐”这个选项有点意思,因为中间的文字是会随着日期变的。实现起来也很简单,图片留白,中间放一个Lable即可。

这是最后我选择的布局方式:

侧面看起来:

也就是说,SubView是这样子的:

UIImageView - 红色背景圆圈
UILabel - 标题(每日歌曲推荐)
UIImageView - 图标(日历图标)
UILabel - 日期时间(25)

Note: 这里先不管按下态,按下态在下文统一讲解。

Cells

我们先从UI效果入手,一共有六种异构的Cell。

第一个冒出来的想法是Cell中放置CollectionView,CollectiionViewLayout也很简单,采用系统提供的FlowLayout即可。

图个省事,每一个CollectionViewCell我都采用Xib的方式,用AutoLayout布局的。

然后,每一个TableViewCell的子类如下:

//初始化
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
let flowLayout = UICollectionViewFlowLayout(www.zenmebanw.com)
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: www.lieqibiji.com/ UIPanGestureRecognizer.self){
return false
}
if gestureRecognizer == longPresssGesture {
return false
}
return true
}

Controller

开发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. www.wmyl15.com/ 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: www.wmyl11.com [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 = "www.wmyl11.com 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} /www.caibayule88.com/排序的标题
var sequence: Int {get set} /www.qinLinyuLe.cn/ 顺序
var defaultSequeue:Int {get} /www.quyingyulecs.com
/默认顺序
var identifier: String {get} /www.sLthyLvip.cn/唯一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(www.haoyyuLe699.cn)
DispatchQueue.main.async {
self.image = decodeImage
}
}
}

解码的原理也很简单,提前把图片绘制到一个CGContext中,再从Context获取图片,这样能够强制图片解码。通常三方库(KingFisher,SDWebImage)都自带后台解码。

XIB

这是我用CollectionView实现第一个版本时候的截图:

可以清楚的看到,初始化xib占用了11ms。原理也很简单,直接从代码创建和读文件创建,肯定读文件要慢很多,

于是,第一个优化点就是

删除xib文件,用代码手动写。

AutoLayout

AutoLayout是一种很方便的技术,通过添加约束我们可以实现各种复杂的布局。但是同样,它也是很昂贵的,CPU在布局的时候需要进行不少计算。眼神阅读:Auto Layout Performance on iOS。

所以,这个优化点很容易想到:

用手动Layout代替AutoLayout。其实优化AutoLayout对本文的场景带来的性能提升并不大,因为我们的视图较少,并且层级简单。但是写Demo的时候,我不想再引入一套DSL进行AutoLayout,手动Layout代码还清楚一些。

实现 60fps 的网易云音乐首页的相关教程结束。

《实现 60fps 的网易云音乐首页.doc》

下载本文的Word格式文档,以方便收藏与打印。