iOS 页面切换控制

iOS的页面基本由UIViewController, UINavigationController完成,切换方式也基本是Present, Push,Pop等等。这些切换过程会遇到以下两种crash

1. Can't add self as subview
2. Attempt to present xx  on yy  whose view is not in the window hierarchy!
3. 快速点击两次按钮,连续push两三次

第一个问题的原因有两种,一种是[self addSubview:self]; 第二种是连续两次push,或者pop,这个在iOS 7下概率极高。
第二个问题由于当前的页面根本不在window的最顶层,你无法使用当前VC做操作。

问题的根源在于系统在做UI切换的时候,由于动画没有执行完毕,页面层级和状态都不正确,这个时候再次发起切换动画,就会造成紊乱,严重时会引发crash

针对这两个问题

延时解决方案

self.navigationController?.pushViewController(UIViewController(), animated: true)
//延迟执行
DispatchQueue.main.asyncAfter(deadline: DispatchTime(uptimeNanoseconds: 0.3)) {
     self.navigationController?.pushViewController(UIViewController(), animated: true)
}

弊端
这种延时的方法,偶尔用上一两处,可能可以解决问题,但是如果满大街都是这种使用方法,那么问题依然存在,因为大家都用延时,在一个时间线上,肯定会出现两个切换间隔时间不足的问题。

viewDidAppear增加变量控制

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated);
        //增加切换控制
    self.isAnimated = NO;
}
public func pushViewController(_ viewController:UIViewController, animated:Bool) {
    if (self.topViewController.isAnimated == ture) {
         return
    }
//省略
}

弊端

1.每一个VC增加一个类似的变量,需要在基类中维护
2.viewDidload被调用了,也不代表能push或者pop完成,真正完成切换在navigationController的代理didShow函数里面
3.没有做VC是否在Window 检测,依然会导致Crash

终极解决方案,UIWindow控制

问题的本质在于,同一时间一个UIWindow只能有一个页面切换,那么我们索性给UIWindow上增加一个控制变量

extension UIWindow {

    //动画标志状态
    var isAnimated: Bool {
        get{
            if objc_getAssociatedObject(self, &isAnimatedKey) != nil {
                return (objc_getAssociatedObject(self, &isAnimatedKey) as? Bool)!
            }
            return false
        }
        set{
            objc_setAssociatedObject(self, &isAnimatedKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_ASSIGN)
        }
    }
}

在每次切换的 加锁 变量, 弄完之后 释放 变量
切换前的需要处理的方法如下
UINavigationController,push pop, popTo 等等,先用方法替换的方式,
UIViewController做present 和dismiss方法处
将UINavigationController的方法做替换,这样可以便于

//UINavigationController
UISkipControlHelper.exchangeMethod(className: self, originMethodName: "pushViewController:animated:", currentMethodName: "skipControlPushViewController:animated:")
UISkipControlHelper.exchangeMethod(className: self, originMethodName: "popViewControllerAnimated:", currentMethodName: "skipControlPopViewControllerAnimated:")
UISkipControlHelper.exchangeMethod(className: self, originMethodName: "popToViewController:animated:", currentMethodName: "skipControlPopToViewController:animated:")
UISkipControlHelper.exchangeMethod(className: self, originMethodName: "popToRootViewControllerAnimated:", currentMethodName: "skipControlPopToRootViewControllerAnimated:")
 //present push 
UISkipControlHelper.exchangeMethod(className: self, originMethodName: "presentViewController:animated:completion:", currentMethodName: "skipControlPresentViewController:animated:completion:")
UISkipControlHelper.exchangeMethod(className: self, originMethodName: "dismissViewControllerAnimated:completion:", currentMethodName: "skipControlDismissViewControllerAnimated:completion:")

然后我们在替换的方法中将具体的跳转转接到一个单例中去,让他去负责做 加锁 和释放window 操作,然后跳转,例如在pushViewController中做法如下

public func skipControlPushViewController(_ viewController:UIViewController, animated:Bool) {
// 由于UINavigationController initWithRootViewController 会调用该方法,并且当时没有显示在Window上,所以特殊处理,此处不加控制,并不会造成crash
   if  self.viewControllers.count == 0 && animated == false {
       self.skipControlPushViewController(viewController, animated: animated)
       return
   }

具体的 加锁 和释放Window的地方我们用了一个单例,而没有在push发发中执行 window. isAnimated = true? 先看看我们释放的window的地方在哪里,就明白为什么不这样做。
UINavigationController 切换完成时会给其代理函数发送一个一个方法

//切换前
optional public func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool)

//切换后 1 
optional public func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool)

//切换后2 发送

post  UINavigationControllerDidShowViewControllerNotification

切换之前会想代理调用willShow回调,切换后会调用didShow,切换后也会发送UINavigationControllerDidShowViewControllerNotification 这个通知。如果我们把释放和加锁放入UINavigationController扩展里面,势必要会将UINavigationController.delegate变更成UINavigationController本身,或者添加这个UINavigationControllerDidShowViewControllerNotification这个通知,那我们必然面临两个问题
- 1.由于delegate被这个扩展使用,而其他真正使用代理做事情的类,无法再次使用代理
- 2.我们添加了这个通知, 那么什么时候释放它(ios 9以上,不需要释放通知),没有合适的地方。

所以我们形成了一个单例去在程序整个生命周期去管理这个事务,我们采用检测通知的方式。
单例中的跳转处理方法如下

 func skipViewController(_ skipingController: UIViewController, skippedController: UIViewController?, skipType:UISkipControlSkipType, isAllowQueued:Bool, isAnimated:Bool, completionBlock:(()->Void)?) -> [AnyObject]? {
        /// 合法性检测,VC对应的Window必须存在
       weak var weakWindow = UIWindow.windowForViewController(skipingController)
        if weakWindow == nil {
            return nil
        }
        /// 构造切换完成后的清理工作
        weak var weakSkippingController = skipingController
        weak var weakSkippedController = skippedController
        let freeCompetionBlock = {
            //打印log
            let strongSkippingController = weakSkippingController
            let strongSkippedController = weakSkippedController
            print("DID -- \(strongSkippingController) \(skipType.rawValue) \(strongSkippedController)")
            //1. 切换完成后释放VC对应的Window的动画属性
            let strongWindow = weakWindow
            if (strongWindow != nil) {
                strongWindow?.isAnimated = false
            }
            //将(1)和(2)加入到主线程队列中执行,主要目的在于让系统完成自己的清场任务后执行,否则有问题
           // DispatchQueue.main.async {
                //(1). 执行自定义的完成切换回调
                if (completionBlock != nil) {
                    completionBlock!()
                }
                //(2). 执行该VC Window对应的队列
                strongWindow?.performAnimationBlock()
            //}
        }
        2.判断当前Window是否可以执行VC切换
        if weakWindow != nil && weakWindow?.isAnimated == false {
            //可以执行切换,先锁定window
            weakWindow?.isAnimated = true;
            //log
            print("WILL -- \(skipingController) \(skipType.rawValue) \(skippedController)")
            //执行切换
            return self.performSkip(skipingController, skippedController: skippedController, skipType: skipType, isAnimated: isAnimated , completionBlock: freeCompetionBlock)
        } else if (isAllowQueued){ 
            //3.当前不能执行切换,但在允许加入队列的情况下,构造队列完成操作任务,加入到window队列
            weak var weakSelf = self
            weakWindow?.enqueueAnimationBlock {
                let strongSelf = weakSelf
                let strongSkippingController = weakSkippingController
                //let strongSkippedController = weakSkippedController 取消对 skippedController weak持有,否则push popTo present 无法执行
                if (strongSelf != nil && strongSkippingController != nil) {
                    //执行切换
                    strongSelf?.performSkip(strongSkippingController!, skippedController: skippedController, skipType: skipType, isAnimated: isAnimated, completionBlock: freeCompetionBlock)
                }
            }

            //log
            print("QUEUED -- \(skipingController) \(skipType.rawValue) \(skippedController)")
        }
        //log 当前无法进行切换
        print("FAILED -- \(skipingController) \(skipType.rawValue) \(skippedController)")
        return nil
    }

代码稍微发杂了一点,原因是上面代码还考虑了另外一个需求,有时候我们冷启动Push,这个时候需要跳转,由于根本没有准备充足,直接跳转可能被阻挡,强制跳转可能会引起crash,所以我们增加了一个队列,捆绑在window上

fileprivate var skipAnimationQueue:[BlockObject]{
     get {
            if objc_getAssociatedObject(self, &skipAnimationQueueKey) != nil {
                return objc_getAssociatedObject(self, &skipAnimationQueueKey) as! [BlockObject]
            } else {
                let queue:[BlockObject] = [BlockObject]()
                self.skipAnimationQueue = queue;
                return queue;
            }
        }
        set {
            objc_setAssociatedObject(self, &skipAnimationQueueKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    }

    //队列执行函数
    func performAnimationBlock()
    {
        if self.isAnimated == false {
            if self.skipAnimationQueue.count > 0 {
                let blockObject = self.skipAnimationQueue.first
                if blockObject != nil {
                    blockObject!.performBlock()
                    self.skipAnimationQueue.removeFirst()
                }
            }
        }
    }

    //加入队列
    public func enqueueAnimationBlock(_ block:@escaping ()->()){
        self.skipAnimationQueue.append(BlockObject(block: block))
    }

在widow上增加了一个block数组作为队列。可以将某一次跳转加入到队列中,这样就能保证每次跳转都是有次序的。
现在看最上面的代码就不难理解,我们做了以下的事情
1.构建一个切换完成的 freeBlock ,来处理完成后的window释放和原本用户添加的完成块,最后队列,查看队列是否有切换需要执行,这个freeblock会绑定到UINavigationController上
2.判断当前window是否可以执行动画,如果可以就直接执行具体切换,

 return self.performSkip(skipingController, skippedController: skippedController, skipType: skipType, isAnimated: isAnimated , completionBlock: freeCompetionBlock)

3.如果不能,查看调用是否有入队列的需求,有的话,加入UIWindwow的队列。
最后我们在接受通知的函数里面执freeblock

@objc public func handleNavigationControllerDidSkip(_ notification:NSNotification) {
    var navigationtroller:UINavigationController?
    if (notification.object != nil && notification.object is UINavigationController ) {
       navigationtroller = notification.object as! UINavigationController?
       if ((navigationtroller?.completionBlock) != nil) {
           navigationtroller?.completionBlock!()
       }
    }
}

最后我们使用起来如下

let vc = UIViewController() self.navigationController?.pushViewController(vc, animated: true);
let vc1 = UIViewController()
self.navigationController?.pushViewController(vc, animated: true, allowQueued: true, completionBlock: nil) //成功,因为加入到队列了

普通的跳转和原来的系统的api一样不会有任何变化,如果需要加入对垒可以使用体用的新函数。
全部结束,关于Present,和push类似,文章最后又源码地址。

总结

我们队切换控制增加了三点
- 给UIWindow增加一个变量保证同一时间只有一个切换
- 增加一个单例来控制切换,解放出了UINavigationController的delegate的真正用途
- 增加了跳转队列,避免了有些业务跳转一定要保证完成,而不是window不能执行时丢弃该操作

github源码地址:UISkipControl

你可能感兴趣的:(ios,开发)