UIPageViewController缺陷

写在前面

本文整理了UIPageViewController在使用中的一些缺陷和bug,结合网上的一些资料提供一个相对全面的总结。文章及代码中如有任何形式的错误、疑问欢迎在留言区提出。

文中提到的Crash类问题使用(iOS7)设备进行测试,在iOS8、9上UIPageViewController的crash类问题已经得到了改进。但仍然存在不同程度的缺陷。

UIPageViewController

UIPageViewController是App中常用的控制器。它提供了一种分页效果来显示其childController的View。用户可以通过手势像翻书一样切换页面。
切换页面时看起来是连续的,但静止状态下UIPageViewController同时只有一个childViewController。它的导航效果是通过替换childController实现的。
这是一种非常有效的设计:无论是三两个还上千个页面,用户的翻页与导航处理都是无差别的。因为这些页面都是即时创建的,每个页面只有当你浏览它的时候才会存在。这种设计显然苹果更多地考虑了内存的问题和通用性。
UIPageViewController替我们解决了页面导航、childController生命周期管理平滑过渡动画(index不相邻页面切换时)等问题。但是存在一些不足和bug。

为什么弃用UIPageViewController?

问题1:

设置UIPageViewController为UIPageViewControllerTransitionStyleScroll且调用setViewControllers:direction:animated:completion:传递参数animated YES时,会引发一系列症状,例如:

  1. 缓存页面导航设置不正确,Page View Controller会导航到错误的页面
  2. 删除上一页view controller(已经切换完成后)失败,仍然可以scroll回到一个空白页

这儿这些症状的根源被描述地很详细:

This is actually a bug in UIPageViewController. It occurs only with the scroll style (.Scroll) and only after calling setViewControllers:direction:animated:completion: with animated:YES. Thus there are two workarounds:

Don't use UIPageViewControllerTransitionStyleScroll.

Or, if you call setViewControllers:direction:animated:completion:, use animated:NO.

To see the bug clearly, call setViewControllers:direction:animated:completion: and then, in the interface (as user), navigate left (back) to the preceding page manually. You will navigate back to the wrong page: not the preceding page at all, but the page you were on when setViewControllers:direction:animated:completion: was called.

The reason for the bug appears to be that, when using the scroll style, UIPageViewController does some sort of internal caching. Thus, after the call to setViewControllers:direction:animated:completion:, it fails to clear its internal cache. It thinks it knows what the preceding page is. Thus, when the user navigates leftward to the preceding page, UIPageViewController fails to call the dataSource method pageViewController:viewControllerBeforeViewController:, or calls it with the wrong current view controller.

大意是这是UIPageViewController的bug,当且仅当UIPageViewController为.Scroll时,调用setViewControllers:direction:animated:completion: 且方法参数animated YES才可能出现。
错误的原因是,当使用.Scroll风格时,UIPageViewController做了一些内部的缓存排序,当调用setViewControllers:direction:animated:completion:时,它没有清空其内部缓存。它认为它已经知道前一个页面的存在,当它调用前一个页面的时候,就不会去调用dataSource方法或者调用错误。
根据这个描述,其实可以对之前的错误代码做相应的处理,通过再次调用setViewControllers方法强制重新调用dataSource方法以更新缓存。例如:

@weakify(self)
 [self.pageViewController setViewControllers:@[targetViewController]
                                   direction:direction
                                    animated:animated
                                  completion:^(BOOL finished)
 {
     @strongify(self)
     if (finished) {
         [self.pageViewController setViewControllers:@[self.pages[index]] direction:direction animated:NO completion:NULL];               
         // bug fix for uipageview controller
     }
 }];

很不幸这种做法引发了崩溃(iOS7/8/9):

Assertion failure in
[_UIQueuingScrollView_replaceViews:updatingContents:adjustContentInsets:animated:], /SourceCache/UIKit_Sim/UIKit-3318.16.14/_UIQueuingScrollView.m:383**

分析后发现我们忽略的一个重点,setViewControllers这个方法是更新页面的操作应该在主线程中调用。修改代码如下:

 @weakify(self)
 [self.pageViewController setViewControllers:@[targetViewController]
                                   direction:direction
                                    animated:animated
                                  completion:^(BOOL finished)
 {
     @strongify(self)
     if (finished) {
         dispatch_async(dispatch_get_main_queue(), ^
                        {
                            if (self) {
                                [self.pageViewController setViewControllers:@[self.pages[index]] direction:direction animated:NO completion:NULL];               
                                // bug fix for uipageview controller
                            }
                        });
     }
 }];

代码看起来虽然不怎么优雅,但好在这样可以继续使用UIPageViewController。直到我们发现了Fabric上的崩溃和上段解决方案代码引发的新问题:

Fabric崩溃:
UIPageViewController_Fabric.jpg

这个崩溃在iOS7/8/9上都有出现。显然这和Apple Developer Forums中提到的问题现象是一样的:No view controller managing visible view:

但Apple Developer Forums中的问题,是因为没有在主线程中执行第二次setViewControllers方法导致的。我们的代码里已经做了相应处理,但仍然有小规模数量的崩溃。很不幸,暂时还没找到Fabric崩溃的具体复现方式。

上图说明,上段代码并没有完全解决page view controller页面缓存不正确的bug。只是不会那么频繁地出现。

引发新问题:

此外还发现:当通过上段代码导航切换index的时候,如果第一次导航(setViewControllers)动画没有结束时,马上开始第二次导航(setViewControllers)。很容易复现问题:页面没有停在最终的那个index上。这是因为第二次主动调用的带动画的setViewControllers执行在第一次调用的回调的无动画的setViewControllers之前导致的。

问题2:

切换childViewController引起的卡顿问题很严重。在iPhone4、4s、5、5c、甚至5s上都会有不同程度的卡顿问题:
一般情况下,page view controller切换页面的资源消耗至少相当于调用一次transitionFromViewController:toViewController:duration:options:animations:completion:。在硬件配置较低的iPhone4、4s等手机上就会有明显卡顿,如果child view controller的生命周期方法中再做一些消耗资源的操作,App甚至会因为切换导致资源占用过多、内存警告,最终引起Crash。这种情况给低配手机性能优化带来了很大障碍。

其性能问题主要体现在,切换childController时候的CPU占用升高、以及切换时的内存频繁波动。
静止状态下,其CPU占用率位置在很低的水品甚至不到1%,当child congroller切换时,其CPU占用率如图所示(iPhone6 Plus/iOS9.3):

UIPageViewController快速非交互切换_CPU.png
UIPageViewController快速交互切换_CPU.png

文章开头介绍UIPageViewController时提到过,UIPageViewController的设计更多的考虑了少占用内存,从下图中内存的波动曲线也可以看到。当频繁切换child controller时,UIPageViewController尽可能快的清理内存。快速切换时,其内存波动如下图所示(iPhone6 Plus/iOS9.3):


UIPageViewController快速切换_Memory.png

总结:

经过多个设备、系统版本的测试和网上资料的整理。问题1的处理方案是现在最主流的一种解决方案,但这样的方案仍然引发了Fabric崩溃和新的缺陷问题,从保证App质量的角度出发,这个解决方案是不可取的。问题2的性能问题虽然在高配手机上并不明显,但考虑的所有用户的体验这个缺陷也很值得去优化。
为了彻底解决这些缺陷问题,作者计划重新开发一个Page view controller控件来彻底解决UIPageViewController带来的缺陷问题。替代方案将会尽快整理并发出,待续...

传送门:UIPageViewController替换方案

推荐阅读更多精彩内容