Skip to content
Permalink
master
Switch branches/tags

Name already in use

A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
Go to file
 
 
Cannot retrieve contributors at this time

前言

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

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

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.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)都自带后台解码。

XIB

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

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

于是,第一个优化点就是

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

AutoLayout

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

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

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

reload

接着我发现,每次滚动的时候时候都调用collectionView.reload()是一件很蠢的事情,因为数据没改变,那么UI也不会改变,reload()只会带来额外的性能开销,于是给Row增加了一个isDirty属性,只有isDirty为true的时候,才reload。

if _row.isDirty{
    _row.isDirty = false
    reloadData()
}

经过这个处理,在第一次滚动到底部的时候会掉帧,之后再也不会掉帧了。原因也很简单,我们的所有Cell都是异构的,第一次滚动到底部后,都已经加载到内存里,所以不再有额外的创建对象和Layout的开销。

轻量级的View

CollectionView是一个昂贵的视图,它有各种的代理方法,并且需要根据UICollectionViewLayout动态的计算出每一个Cell的大小和位置。考虑到我们的布局比较简单,于是全部改写成简单的UIView,布局代码在视图的LayoutSubViews中写。

预计算Layout

我们继续用TimeProfile进行分析:

可以看到LayoutSubviews竟然占用了11ms,双击这一行,看看是什么代码占用了这么多时间:

可以看到,sizeToFit应该就是罪魁祸首了,因为让Label去自适应大小是需要额外的CPU计算。

文本的大小计算是很损耗性能的,如果你的App中用到了大量的富文本(类似微博),那么基于CoreText打造一个异步的绘制引擎是一个不错的优化点,当然直接用YYText也可以。

预加载 & Runloop

到这里,继续用Time Profiler分析发现主线程的CPU占用几乎都在main函数里了。我们来分析,在Cell显示到屏幕上CPU都干了什么:

  • 创建Cell的实例
  • 对Cell的实例和Model进行绑定,换句话说就是调整UI控件的属性
  • 根据内容进行重新布局

这些任务有一个特点:必须在主线程上进行

任务不能调度到后台,那么只能从两个方面入手了:

  • 预加载。对模版Cell进行提前创建,并且放入内存缓存。这样显示到屏幕的时候,就不再需要重新创建Cell的对象了。
  • 任务拆分。将大的任务拆分成小的任务,一次执行一个,这样瞬时CPU占用就不会很高。

先来分析预加载,什么时候预加载呢?一次预加载几个Cell呢?

这里采用监听主线程Runloop的方式进行加载,监听beforeWaitingexit两个事件,这时候去执行一些任务。

核心代码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工程跑跑看