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不能执行时丢弃该操作

swift github源码地址:UISkipControl
objective-c 源码地址 https://github.com/AKZHW/UISkipControl
coocoapods 引用 UISkipControl 版本 0.0.1

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,723评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,080评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,604评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,440评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,431评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,499评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,893评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,541评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,751评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,547评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,619评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,320评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,890评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,896评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,137评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,796评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,335评论 2 342

推荐阅读更多精彩内容

  • 1.自定义控件 a.继承某个控件 b.重写initWithFrame方法可以设置一些它的属性 c.在layouts...
    圍繞的城阅读 3,338评论 2 4
  • 博主是一个很内敛的人。 大概从2年前开始接触编程,1年前接触前端,于是便有了这个网站,下面是我的一些心路历程。 2...
    深沉的简单阅读 125评论 0 0
  • 和往年一样,到了这个点的梅雨季都是一天接着一天的雨。 01 我掐点最后一节课的铃声,趿拉着拖鞋湿湿嗒嗒,啪嗒啪嗒踩...
    少女陈敲阅读 419评论 4 2
  • 在一个关注的微信公众号上看到一篇名为“好姑娘上天堂,女流氓走四方”的文章,鉴于我对于自己为什么要上天堂的疑问读完了...
    咸鱼zzz阅读 377评论 0 0