UIPageViewController替换方案

前文回顾

文接上一篇UIPageViewController缺陷。上篇中总结了UIPageViewController的几个不可接受缺陷:1.在Scroll style下UIPageViewController的setViewControllers方法调用导致缓存设置不正确的缺陷以及针对这个缺陷改进方案引发的另一个快速连续切换问题;2.在低配设备上的性能缺陷。
针对这些问题本文通过自定义GYPageViewController的方式来解决。并记录分享在模拟替换过程中遇到的麻烦及解决方案。文中及代码中如有任何形式的错误、疑问欢迎在留言区提出。

名词解释

交互切换:用户通过屏幕的手势操作切换,例如滑动。
非交互切换:通过方法调用来导航切换,例如选择segment选项来触发切换方法。
交互动画:用户通过屏幕手势参与的切换动画,动画无固定时长随用户交互变化。
非交互动画:通过方法调用的切换,所带有的固定时长动画。

GitHub上的替代方案

Git上流行的替代方案:TYPagerController、WMPageController。这两个都是基于scroll view做的定制。适配了很多需求使用场景。但是它们有两个关键点没有考虑:生命周期管理、** 非交互动画**。
这两个解决方案在child controllers生命周期管理时,不能以交互切换、非交互切换的起止点来区分willAppear/didAppear,willDisappear/didDisppear等方法调用顺序与时间间隔;动画方面以禁止非交互切换动画的方式来规避非相邻index切换时动画突兀的问题(非相邻index切换,会快速scroll过中间间隔的page,且有些page可能并未添加到scrollView上)。
本文中涉及的业务模块基于UIPageViewController构建,只有保证生命周期管理顺序上完全一致才能使项目的修改最小;另外,scroll view原生offset动画在非相邻index切换时会很突兀;也为了满足需求的前提下最大限度模拟UIPageViewController的功能,最终采取自定义控件的方式来解决问题。

Step1:问题分解

功能:

  1. 管理多个controller和view。
  2. 支持页面导航切换,包括交互切换,非交互切换(例segment bar)
  3. 保证与UIPageViewController导航时child controller的生命周期方法调用顺序完全相同(包括交互、非交互切换)
  4. 模拟UIPageViewController的平滑切换,在非相邻index切换时仍与相邻页面切换效果相同。

性能:

  1. 在低配设备(iPhone4、4s等)child controller初始化之后能在大部分情况下保证切换速度、避免页面卡顿。

标黑 部分问题是替换方案的关键,将在Step 3 趟坑时中细述

Step2:初步方案

  1. UIScrollView提供了分页效果、手势处理以及交互操作中多个时机的代理回调方法。从这些代理方法入手可以获得交互切换、非交互切换的时机与起止点。管理child controller方面,UIViewController即可胜任。
  2. child controller生命周期的切换与模拟需要花一些功夫。下文中会详细描述生命周期模拟过程。
  3. 动画问题,scroll view的setContentOffset:animated:方法效果上在不相邻index切换回跨过中间页面,很突兀不可接受。本文将通过自定义动画模拟来解决。
  4. 为了保证切换速度避免卡顿。编码时尽量避免频繁地add/remove/transitionFrom child view controller。只在第一次用到child view controller的时候对其进行addChildViewController操作。这样在下次切换的时候只需要关注child controllers的生命周期调用即可。

Step3:开始趟坑

1号坑:生命周期管理方式选用

一般情况下,child controller的生命周期调用是通过parent controller传递的。而parent controller生命周期方法首次传递是通过对child controller的添加/删除/切换操作来实现的,在此之后child controller的生命周期则随parent controller的生命周期一起调用。
显然这种中规中矩的方式遇到了瓶颈,因为child controller的添加/删除/切换操作具有事务性。所以最终改成了一种直接控制child controller生命周期顺序的方式。下文瓶颈 中详述。

Note:这里可以复习一下控制器的管理的相关知识

瓶颈:

子控制器生命周期方法的首次被调用依赖于添加/删除/切换操作。而这些操作都必须将对view的操作包夹在操作过程中(例:添加/删除/切换源码),即对child controller的添加/删除/切换这三种操作均具有事务性(其中包括controller操作add/remove、view操作)。曾经尝试过将view的操作分离出来单独add/remove,但这将直接导致生命周期调用顺序错误或者不调用,因为直到view显示在页面上整个添加操作才算完成。

  • 如果通过频繁地添加(willAppear、didAppear)/删除(willDisappear、did Disappear)/切换(前两个操作综合)child controllers的方式来模拟生命周期调用。即当非交互切换页面时(点击segment通过代码showPageAtIndex:),效果毫无问题。但是,频繁的添加删除controller这样带来的卡顿问题是这种方案的缺陷1
  • 当用户通过滑动scroll view来切换页面时,在交互切换未完成时添加新child controller。将会造成一种现象:用户取消滑动操作或者来回多次滑向相邻的index时,这种设计将会更加频繁地调用child controller的添加删除。缺陷2
  • 缺陷2其实可以通过scroll view的代理方法在scroll view彻底切换到新的index时再调用child controller操作,就可以避免无谓的调用而引起卡顿。但是,因为view的操作和child controller的操作是绑定的事务性的。因为直到scroll view滑动到新的index才会添加新view。这期间是看不到新view的缺陷3
  • 考虑过,可以预加载的方式来解决相邻页面交互切换滑动的问题,但是无论预加载的是view还是controller,要么会引起卡顿问题、要么会引起生命周期错误调用问题。
解决方法:

这三个方法可以解决这个瓶颈:
-(BOOL) shouldAutomaticallyForwardAppearanceMethods
在UIViewController的子类中可以重写这个方法,return YES将会把生命周期自动传递给childControllers,NO将不会自动传递生命周期。
-(void) beginAppearanceTransition:animated:
当实现一个container controller时,使用这些方法来通知child合适调用appear、disappear方法。而不是直接调用。第一个参数YES表示要显示页面调用willAppear方法,NO表示要让页面小时调用willDisappear方法。
-(void) endAppearanceTransition
这个方法要与上一个方法成对出现。上一个方法的效果会调用willXXX操作,这个方法会调用didXXX操作。不成对会导致生命周期调用错误。

就是说可以重写shouldAutomaticallyForwardAppearanceMethods方法以return NO的方式规避添加/删除/切换的不合适的生命周期调用。并通过beginAppearanceTransition和endAppearanceTransition方法在合适时机管理生命周期。这样既解决了缺陷1缺陷2缺陷3又给模拟UIPageViewController的生命周期调用顺序提供了一种新方式。

代码:
  func scrollViewDidScroll(scrollView: UIScrollView) {
         ......
  
              if lastGuessIndex != guessToIndex &&
                  guessToIndex != self.currentPageIndex &&
                  self.guessToIndex >= 0 &&
                  self.guessToIndex < maxCount
              {
                  self.gy_pageViewControllerWillShow(self.guessToIndex, toIndex: self.currentPageIndex, animated: true)
                  self.delegate?.gy_pageViewController?(self, willTransitonFrom: self.pageControllers[self.guessToIndex],
                                                        toViewController: self.pageControllers[self.currentPageIndex])
                  
                  self.addVisibleViewContorllerWith(self.guessToIndex)
                  self.pageControllers[self.guessToIndex].beginAppearanceTransition(true, animated: true)
                  /**
                   *  Solve problem: When scroll with interaction, scroll page from one direction to the other for more than one time, the beginAppearanceTransition() method will invoke more than once but only one time endAppearanceTransition() invoked, so that the life cycle methods not correct.
                   *  When lastGuessIndex = self.currentPageIndex is the first time which need to invoke beginAppearanceTransition().
                   */
                  if lastGuessIndex == self.currentPageIndex {
                      self.pageControllers[self.currentPageIndex].beginAppearanceTransition(false, animated: true)
                  }
                  
                  if lastGuessIndex != self.currentPageIndex &&
                      lastGuessIndex >= 0 &&
                      lastGuessIndex < maxCount{
                      self.pageControllers[lastGuessIndex].beginAppearanceTransition(false, animated: true)
                      self.pageControllers[lastGuessIndex].endAppearanceTransition()
                  }
              }
          }
   }

   func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
          let newIndex = self.calcIndexWithOffset(Float(scrollView.contentOffset.x),
                                                  width: Float(scrollView.frame.size.width))
          let oldIndex = self.currentPageIndex
          self.currentPageIndex = newIndex
          
          if newIndex == oldIndex {//最终确定的位置与其实位置相同时,需要重新显示其实位置的视图,以及消失最近一次猜测的位置的视图。
              if self.guessToIndex >= 0 && self.guessToIndex < self.pageControllers.count {
                  self.pageControllers[oldIndex].beginAppearanceTransition(true, animated: true)
                  self.pageControllers[oldIndex].endAppearanceTransition()
                  self.pageControllers[self.guessToIndex].beginAppearanceTransition(false, animated: true)
                  self.pageControllers[self.guessToIndex].endAppearanceTransition()
              }
          } else {
              self.pageControllers[newIndex].endAppearanceTransition()
              self.pageControllers[oldIndex].endAppearanceTransition()
          }
          
          //Reset for calculation in next interaction
          self.originOffset = Double(scrollView.contentOffset.x)
          self.guessToIndex = self.currentPageIndex
          
          self.gy_pageViewControllerDidShow(self.guessToIndex, toIndex: self.currentPageIndex, finished:true)
          self.delegate?.gy_pageViewController?(self, didTransitonFrom: self.pageControllers[self.guessToIndex],
                                                toViewController: self.pageControllers[self.currentPageIndex])
   }

2号坑:UIPageViewController生命周期顺序模拟

UIPageViewController生命周期规律

UIPageViewController生命周期方法调用场景,可以根据用户的交互方式加以区别:**交互切换 **、 非交互切换。先看看它的生命周期管理顺序。

  • 非交互切换:

这种导航交互比较简单,动画具有固定时间大概是0.3秒左右。而且,在非交互的导航切换中,index是否相邻顺序都是一样的。child controllers的生命周期调用顺序是:
** Will Appear 0
** Will Disappear 1
//这里大概会有0.3秒的动画时间
** Did Appear 0
** Did Disppear 1

  • 交互切换:

有交互切换情况会复杂很多,切换动画是根据用户的操作来决定的。而且涉及到用户的取消操作即来回滑动。不过一般只能切换到相邻的index,连续切换(第一次滑动松手Decelerate动画未结束时,马上交互开始下一次滑动)的情况也会进行特殊处理

1. 滑动切换到相邻index:
    //交互开始刚进入到新index立即执行
    ** Will Appear     0
    ** Will Disappear  1 
    //间隔时间:由交互时间+松手后Decelerate动画时间决定
    ** Did Appear      0
    ** Did Disppear    1
2. 从index1滑向index2紧接着反向滑动到index0(取消一次):
    //从index1滑向index2
    ** Will Appear          2
    ** Will Disappear       1
    //取消滑向index2并离开,反向滑向index0
    ** Will Appear          0
    ** Will Disppear        2
    ** Did Disppear         2
    //松手完成交互,Decelerate动画完成剩余offset的偏移。
    ** Did Appear           0
    ** Did Disppear         1
3. 从index1滑向index2紧接着反向滑动到index0再反向滑向index2(取消两次):
    //第一次滑向index2
    ** Will Appear      2
    ** Will Disappear   1
    //第一次取消并反向滑向index0
    ** Will Appear      0
    ** Will Disappear   2
    ** Did Disappear    2
    //第二次取消并反向滑向index2
    ** Will Appear      2
    ** Will Disappear   0
    ** Did Disppear     0
    //松手完成交互,Decelerate动画完成剩余offset的偏移。
    ** Did Appear       2
    ** Did Disppear     1
4. 连续切换(第一次滑动松手Decelerate动画未结束时,马上交互开始下一次滑动):
    ** Will Appear      2
    ** Will Disppear    3
    ** Will Appear      1
    ** Will Disppear    2
    ** Did Disppear     2
    ** Will Appear      0
    ** Will Disappear   1
    ** Did Disppear     1
    ** Did Appear       0

事实上,每次操作的调用顺序都是不稳定的,虽然结果取决于操作的速度和滑动时机衔接,但这已经毫无规律可言。可见在这点上UIPageViewController也并没有做处理。我们暂且忽略这种情况。

生命周期一般规律:

  1. 一个Will Appear可以没有Did Appear与之对应;
  2. 一个Will Disappear一定有一个Did Disappear与之对应;
  3. 只有最终确定导航到的index才会调用Did Appear,即整个交互过程中Did Appear只调用一次;
解决方法:
  • 非交互切换:

模拟scroll view的导航动画,并在动画之前调用beginAppearanceTransition:animated:,在动画结束回调中调用endAppearanceTransition即可。代码:

        // Aciton closure before simulated scroll animation
        let scrollBeginAnimation = { () -> Void in
            self.pageControllers[self.currentPageIndex].beginAppearanceTransition(true, animated: animated)
            if self.currentPageIndex != self.lastSelectedIndex {
                self.pageControllers[self.lastSelectedIndex].beginAppearanceTransition(false, animated: animated)
            }
        }
        
        /* Scroll closure invoke setContentOffset with animation false. Because the scroll animation is customed.
         *
         * Simulate scroll animation among oldSelectView, lastView and currentView.
         * After simulated animation the scrollAnimation closure is invoked
         */
        let scrollAnimation = { () -> Void in
            self.scrollView.setContentOffset(self.calcOffsetWithIndex(
                self.currentPageIndex,
                width:Float(self.scrollView.frame.size.width),
                maxWidth:Float(self.scrollView.contentSize.width)), animated: false)
        }
        
        // Action closure after simulated scroll animation
        let scrollEndAnimation = { () -> Void in
            self.pageControllers[self.currentPageIndex].endAppearanceTransition()
            if self.currentPageIndex != self.lastSelectedIndex {
                self.pageControllers[self.lastSelectedIndex].endAppearanceTransition()
            }
            
            self.gy_pageViewControllerDidShow(self.lastSelectedIndex, toIndex: self.currentPageIndex, finished: animated)
            self.delegate?.gy_pageViewController?(self, didLeaveViewController: self.pageControllers[self.lastSelectedIndex],
                                                  toViewController: self.pageControllers[self.currentPageIndex],
                                                  finished:animated)
        }
  • 交互切换:

有交互切换较为复杂的地方就是要考虑到取消的情况,而这些情况在生命周期方法中并没有很好地给与区分。我们只能根据交互过程中scroll view的property以及缓存的一些参数来判断用户的行为。
首先,交互切换的结束代理方法是固定的:scrollViewDidEndDecelerating。
其次,我们要通过对方法scrollViewDidScroll的调用情况加以区分,区别用户的交互动作的开始和变化等行为。
func scrollViewDidScroll(scrollView: UIScrollView) {
//首先scrollView在dragging状态下确定其为交互切换。
if scrollView.dragging == true && scrollView == self.scrollView {
let offset = scrollView.contentOffset.x
let width = CGRectGetWidth(scrollView.frame)
//上一次操作猜测的用户将要滑向的lastGuessIndex
let lastGuessIndex = self.guessToIndex < 0 ? self.currentPageIndex : self.guessToIndex
//计算本次用户将要去往的index:并缓存到变量guessToIndex
if self.originOffset < Double(offset) {
self.guessToIndex = Int(ceil((offset)/width))
} else if (self.originOffset > Double(offset)) {
self.guessToIndex = Int(floor((offset)/width))
} else {}
let maxCount = self.pageControllers.count

        //如果上一次猜测的和本次猜测的有变化,即用户取消了上一次操作做了一次反向滑动。当然,所有猜测的index应该在安全范围内且所有猜测的页面不应该是当前显示的currentPageIndex。
        if lastGuessIndex != guessToIndex &&
            guessToIndex != self.currentPageIndex &&
            self.guessToIndex >= 0 &&
            self.guessToIndex < maxCount
        {
            self.gy_pageViewControllerWillShow(self.guessToIndex, toIndex: self.currentPageIndex, animated: true)
            self.delegate?.gy_pageViewController?(self, willTransitonFrom: self.pageControllers[self.guessToIndex],
                                                  toViewController: self.pageControllers[self.currentPageIndex])
            
            //如果guessToIndex对应child controller还未添加则添加。然后调用生IM那个周期will appear方法。
            self.addVisibleViewContorllerWith(self.guessToIndex)
            self.pageControllers[self.guessToIndex].beginAppearanceTransition(true, animated: true)
            /**
             *  Solve problem: When scroll with interaction, scroll page from one direction to the other for more than one time, the beginAppearanceTransition() method will invoke more than once but only one time endAppearanceTransition() invoked, so that the life cycle methods not correct.
             *  When lastGuessIndex = self.currentPageIndex is the first time which need to invoke beginAppearanceTransition().
             */
            //只有交互初始化的时候lastGuessIndex 才会等于 self.currentPageIndex也只有这一次才需要调用当前页面的will disappear方法。
            if lastGuessIndex == self.currentPageIndex {
                self.pageControllers[self.currentPageIndex].beginAppearanceTransition(false, animated: true)
            }
            
            //如果lastGuessIndex和当前的页面不是一个页面,就要调用其will disappear方法和did disappear方法。
            if lastGuessIndex != self.currentPageIndex &&
                lastGuessIndex >= 0 &&
                lastGuessIndex < maxCount{
                self.pageControllers[lastGuessIndex].beginAppearanceTransition(false, animated: true)
                self.pageControllers[lastGuessIndex].endAppearanceTransition()
            }
        }
    }
  }

至此,经过能够完全模拟UIPageViewController的child controller 生命周期方法调用。

UIPageViewController替换方案_第1张图片
生命周期调用顺序.gif

3号坑: 交互动画效果

前文提到:在非相邻index导航切换的动画会很突兀。有交互的导航,是不需要考虑这个问题的,因为只能够切换到相邻index。在无交互情况下,才需要去模拟切换动画。

这里,需要考虑的复杂情况主要是:在一次导航切换动画没有完成的时候,马上又开启下一次的导航切换。前文说过UIPageViewController在做这一操作的时候,偶尔会出现最终页面错乱的问题。而本文代码在导航结果上不会出现问题,但是动画上需要处理。

考虑在第一次动画未执行结束就开启第二次动画的时候,提前结束第一次动画:


UIPageViewController替换方案_第2张图片
交互动画打断.gif

Step4:性能对比

下面的性能测试对比都是基于iPhone6 Plus/iOS9.3:

在静止状态下,新老控件的CPU占用率很小很小甚至不到1% 。当交互、非交互快速切换时,我们可以看到(图4.1 - 4.4)GYPageViewController的CPU占用率明显优于UIPageViewController:

UIPageViewController替换方案_第3张图片
图4.1 UIPageViewController快速非交互切换_CPU.png
UIPageViewController替换方案_第4张图片
图4.2 UIPageViewController快速交互切换_CPU.png
UIPageViewController替换方案_第5张图片
图4.3 GYPageViewController快速非交互切换_CPU.png
UIPageViewController替换方案_第6张图片
图4.4 GYPageViewController快速交互切换_CPU.png

新老控件的内存(Memory)占用方面也存在很大差异:

从图4.5中内存占用图标的波动情况可以看出UIPageViewController在快速切换的时,会尽可能快地释放掉不用的controller及其view(主要是view)以保证内存占用较小,所以图标指标先才会频繁的波动。(这并不是引起低配设备卡顿的直接原因。)
这和UIPageViewController缺陷文章开头关于UIPageViewController设计的介绍是一致的。

UIPageViewController替换方案_第7张图片
图4.5 UIPageViewController快速切换_Memory.png

从图4.6 中可以看到起内存占用在所有页面都缓存之后是趋于稳定的。这样是以空间换时间,来换取切换child controller时的低CPU占用。在实际测试中(包括iPhone4s/iOS7、iPhone6 Plus/iOS9.3)这样的牺牲在页面卡顿方面有了明显改善。


UIPageViewController替换方案_第8张图片
图4.6 GYPageViewController快速切换_Memory.png

在两个测试例子当中,主要的内存差异主要来源于view的内存占用。

Step5:总结

新的解决方案在需求上能够很好地解决UIPageViewController功能方面的缺陷(缓存设置不正确等);在性能上也优化了在低配设备上由于切换child controller及其view而引发的卡顿;而且弥补了网上现行方案生命周期管理顺序不正确、非相邻index切换动画等的问题。
但新方案并非在所有方面都优于UIPageViewController,至少在内存占用上是有劣势的。(一个相当复杂的child controller及其view内存占用大概在(3~4M),简单页面大概在0.5M以内,就这点看维护十几个以内的页面还不成问题。)所有的取舍应该以自己的需求为准。

GYPageViewController解决的问题 2016-7-28 更新

1.从设计思路上避免了UIPageViewController切换页面时的缓存Bug
2.生命周期与UIPageViewController完全一致且解决了其连续快速切换生命周期调用错乱的问题。
3.用缓存控件换取切换child conroller的时间,避免瞬间CPU使用率飙升问题。
4.通过缓存限额节省内存,并且处理内存警告。使内存占用量可控且做了一场处理。
5.解决UIPageViewController连续快速切换的页面闪白问题(页面在使用时未及时添加或提前移除)

Step6:TO DO

在整个替代方案中,仍有一些地方值得继续优化:

  1. 维护缓存池,移除长期不使用的child controller及其页面以节省内存。 Step7:更新中已解决
  2. 非交互动画,在前一次动画未结束时开始新一次动画,不强制结束上次移动而是在当前时刻暂停并以这个状态为起点,开始新动画。
  3. 在快速连续交互切换时,生命周期规则重新整理并实现有规律调用。 Step7:更新中已解决

Step7:更新2016-7-28

缓存策略:

内存占用方面,主要是container controller的childViewControllers持有过多不必要的child controller所导致。之前的想法是用空间换取切换(add/remove)child controller的时间。但综合考虑内存等因素对早前方案做一个折衷:缓存一定数量的常用child controllers并且在适当时机清理多余的controllers。

UIPageViewController替换方案_第9张图片
缓存简图.jpg

Memory Cache的职责:
1.避免多次调用dataSource方法,即避免频繁通过delegate创建常用的child controller带来的性能损耗。
2.提供缓存child controllers限额
3.提供淘汰策略与时机
childViewControllers职责与限制:
1.存储具体child controllers指针。空间换时间的功臣。
2.需要淘汰时机,稳定状态下数量与Memory Cache一致。

因为存的是child controllers的指针,两份存储并不会导致内存double。难点在于两者的同步问题:

1.当element从Memory Cache淘汰的同时检查是否为当前操作页面的相关交互页面。(非交互切换:原始页面、目的页面为相关交互页面;交互切换:当前猜的的目的页面及其左右两个页面为相关交互页面)如果检查非相关交互页面则直接清理,否则加入到延迟清理集合并在最终的稳定态开始时清理(切换过程彻底结束时)。
2.过滤延迟切换队列:当频繁切换时,延迟队列里的element可能是最终切换停止时的page。如果不过滤,将根据交互情况出现最终页面被错误移除的情况。需要在延迟清理集合的时候,过滤掉最终的目标页面。
3.在连续交互切换时,很长时间不能达到一个稳定态。即很多根据稳定态计算的临时数据是有偏差的。需要在连续切换过程中随时纠偏以保证缓存的正确清理。

快速连续切换生命周期规律调用:

这个问题和缓存同步问题的第三点的问题原因是一样的。在连续切换交互中,数据计算有偏差会导致猜测的目标页面不准确,生命周期调用错乱。在达到稳定态之前的任何交互节点(例如手指离开屏幕)都需要对数据计算纠偏。纠偏主要是不断调试和优化计算的工作,这里有兴趣可以参看下Git上的代码。

你可能感兴趣的