UIScrollView调优——节省超过50%内存

1,822 阅读6分钟

自己做了一个模仿简书的小项目练手,主要布局是上面的scrollview有一排label,下面的scrollview有多个UITableView。点击上面的label,下面就可以显示不同的页面。具体效果可以打开简书官方的APP查看,很多新闻软件也是这种效果。

一开始的思路就是加载所有ViewController,因为是TableView,所以每个TableView还有自己的DataSource,真机运行了一下,发现占用内存大概是36M左右。于是我开始着手对这种原始的实现方案进行逐步优化,主要是内存占用相关的,以及一些其他的小技巧。

项目在Github开源,本文涉及到的相关代码都可以自行查看。项目地址:MJianshu

优化前内存

优化一:分离DataSource

为了轻量化UIViewController,同时也为了后期的解耦,我首先把DataSourceUIViewController中分离出来。思路是在UIViewController中引用一个DataSource对象,然后把table的dataSource属性设置成这个变量而不是自己,用代码描述就是:

// UIViewController.swift
var dataSource = ContentTableDatasource()

tableView.dataSource = dataSource

把DataSource相关的代理方法都放到ContentTableDatasource中去:

extension ContentTableDatasource {
    func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        //行数
    }

    func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        //返回cell
    }
}

这样做的好处在于,UIViewController对具体的数据获取一无所知,它只负责给table委派数据源的任务。只要改变数据源,table的内容就可以改变。这也符合MVC模式中M和C的解耦。更详细的介绍在objc.io的Lighter View Controllers一文中。

优化二:重用ViewController

如果不考虑点击顶部标签的情况,也就是只能滑动BottomScrollview,我们可以注意到一个事实。比如当前我在第五页,不管我要滑到其他的任何一页,都必须经过第四页或第六页。也就是说在这种情况下,除了4、5、6这三页的UIViewController,其他的都是无用的。一旦我向左滑到第四页,那么第六页的UIViewController也是无用的,它可以被重复利用,装载第三页所显示的UIView

所以,思路就是模仿UITableView的重用机制维护一个队列,实现UIViewController的重用。每当一个UIViewController变成无用的,就放入重用队列。需要UIViewController时先从重用队列中找,如果找不到就新建。这样一来内存中最多只会保存三个UIViewController的实例,所以占用内存大幅度降低。核心代码如下:

func scrollViewDidScroll(scrollView: UIScrollView) {
    // 加载即将出现的页面
    loadPage(page)
}

func loadPage(page: Int) {
    guard currentPage != page else { return }  //还在当前页面就不用加载了
    currentPage = page

    var pagesToLoad = [page - 1, page, page + 1]  // 筛选出需要加载的页面,一般只有一个
    var vcsToEnqueue: Array<ContentTableController> = []  // 把用不到的ViewController入队
}

func addViewControllerForPage(page: Int) {
    let vc = dequeueReusableViewController()  // 从队列中获取VC
    vc.pageID = page
    // 添加视图
}

func dequeueReusableViewController() -> ContentTableController {
    if reusableViewControllers.count > 0 {
        return reusableViewControllers.removeFirst() // 如果有可以重用的VC就直接返回
    }
    else { //否则就创建。程序刚开始运行的时候一般需要执行这一步
        let vc = ContentTableController()
        return vc
    }
}

关于重用队列,可以参考这个项目:Reuse

优化三:点击Label后的过渡

如果从第一页滑动到第三页,那么第二页也会快速闪过。这样会导致用户体验比较差。我的思路是首先在第二页的位置上覆盖一个和第一页一模一样的UIView,然后不加动画的切换到第二页。这一瞬间用户感觉不到任何变化。然后再有动画的滑动到第三页。滑动完成之后需要移除这个临时添加的UIView,关键步骤如下所示

var maskView = UIView()
maskView = bottomScrollViewController.currentDisplayViewController()?.view // 获取用于遮盖的view

bottomScrollView.addBottomViewAtIndex(targetPage - 1, view: maskView) // 把view添加到目标页的前一页
buttomScrollView.bottomScroll.setContentOffset(CGPointMake(previousOffSetX, 0), animated: false)  //无动画滑动
buttomScrollView.bottomScroll.setContentOffset(CGPointMake(offSetX, 0), animated: true) //有动画滑动

func scrollViewDidEndScrollingAnimation(scrollView: UIScrollView) {
    maskView.removeFromSuperview()  // 滑动结束后移除临时视图
}

实际操作远比这个复杂。因为要实现UIViewController的重用,所以在scrollViewDidScroll这个代理方法中需要时刻监听滑动状态并加载下一页。在点击Label的时候需要禁掉这个特性。

总的来说,点击Label的切换和滑动切换页面并不是同一个原理,所以要保证他们之间的逻辑互不干扰

优化四:缓存DataSource

最初的逻辑是每个UIViewController自己处理自己的dataSource,现在因为在BottomScrollview中处理UIViewController的重用逻辑,所以dataSource的缓存和获取也就一并放在这里处理了。每个UIViewController重用时都会根据自己的页数去缓存中查找dataSource是否已经存在,如果已经存在的话就直接获取了。关键代码如下所示:

var dataSources: [Int: ContentTableDatasource] = [:]  // 键是页数,值是datasource对象

func bindDataSourceWithViewController(viewController: ContentTableController, page: Int) {
    if dataSources[page] == nil {  // 如果不存在,就去新建datasource
        dataSources[page] = ContentTableDatasource(page: page)
    }
    viewController.dataSource = dataSources[page]
}

实际上dataSource也可以重用,但是这样做并不能节省太多内存,反而会导致dataSource中内容的反复切换,有点得不偿失

防掉坑指南

最后再谈一谈UIScrollView中的一些坑,之前也写过一篇文章——史上最简单的UIScrollView+Autolayout出坑指南,主要是关于UIScrollView在Autolayout下的布局问题。在后续的开发过程中,还是遇到了一些值得注意的地方。

因为UIScrollView是可以滑动的,所以对它的布局约束要格外小心。举个例子,一个子视图的left已经确定,这时候不管设置它的right约束还是width约束都可以固定它的位置。但是在UIScrollView,千万不要设置right约束。否则你可以想象一下,有一个橡皮筋,一端被固定,另一端被拉伸的感觉:

make.right.equalTo(view) // 滑动时视图会被拉伸
make.width.equalTo(viewWidth) // 正确

这样的bug非常难找到,所以我个人的经验是,在对UIScrollView的子视图布局时,尽量不要用两端的位置来确定视图自己的长度,而是应该通过自己长度确定另一端的位置。或者,干脆不要依赖于外部视图布局,而是用一个Container容器。这也是我在之前的文章中强烈推荐的方法。

成果:

内存占用显著减少,只有大约原来的一半。考虑到程序还有其他地方占用内存,可以认为重用机制降低了Scrollview超过50%的内存占用:

优化后内存

不过这么做还是稍有不足,如果数据量比较大,频繁的重用UIViewController会导致多次reloadData()。切换页面的时候会稍有卡顿的感觉。也许是我哪里考虑欠周,欢迎指正。目前来看,重用机智更适合于呈现静态内容的UIViewController

项目地址戳这里,欢迎star。