ViewChaos: iOS UI调试黑科技之实现原理

作者: 黑暗中的孤影 | 来源:发表于2016-05-20 11:11 被阅读1431次

    上一篇文章我给大家展示了ViewChaos强大的UI调试能力,相信有部分读者会对它的实现机制有兴趣,这一篇我给大家讲一下开发这个工具碰到的坑和一些功能实现的原理。如果你还没有看上一篇ViewChaos iOS UI 调试黑科技,请先看这篇文章。另外Github地址为
    ViewChaos,�如果你感觉这个项目对你的iOS开发有帮助,请Star一下表示支持

    怎么才能在不写一行代码的情况下启动ViewChaos

    这个问题其实并不难,相信各位读者知道在Objective-C里,有一个方法叫load,利用它,在里面加上自己想要的代码,很容易便能在APP启动的时侯加入自己想要的东西。

    +(void)load{
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken,^{
            //在这里面加入自己想要的功能,APP启动时会自动调用这个方法
        });
    }
    

    关于load方法的原理这里就不探究了,但问题是Swift已经没有这个方法了,所以只好用另一个办法,就是initialize方法,这个方法可以放在extension里面,和load方法不一样,当需要initialize的类每实例化一次,这个方法一次就会被调用一次。所以我们还要加入单次分派,来保证整个APP的生命周期只调用一次。

    extension UIWindow {
        #if DEBUG  //这里用了宏
        public override  class func initialize(){  //initialize方法
        struct UIWindow_SwizzleToken {
             static var onceToken:dispatch_once_t = 0
            }
              //在这里面加入自己想要的功能,APP启动时会自动调用这个方法
        }
        #endif
    }
    

    这样ViewChaos就能随系统启动而不用写一行代码,但这里存在的问题是这样如何后来APP开发者也想写这种功能,如果他想用扩展UIWindow来实现自己的功能,会导致冲突。

    更新,在最新的swift3.1里,苹果已经在代码里将 initialize方法警告未来会禁用,那么怎么办呢,对此我用了一篇文章在Swift3.1中 initialize被警告未来会禁用(disallow),那么来什么来代替它呢 给出了解决方案,里面还详细地给出了原理,各位读者可以参考这篇文章。

    怎么才能在Debug模式下启用功能,而Release模式下自动关闭

    这个很简单,上一段代码里我用了宏,这个宏说明只有在DEBUG模式下才会编译里面启动调试功能的的代码。所以Release自然就没有该功能了,但目前是Swift其实并不支持宏,而是通过Swift Compiler-Custom Flags的方式来实现的,在里面的Other Swift Flags里面加入-DDEBUG标记就行了,

    将Other Swift Flags的Debug加上—DEBUG

    怎么添加那个小圆球

    启动会有小圆球

    我们在UIWindowinitialize方法中使用了Method Swizzle 已经更新在ViewChaosStart类的awake方法里使用了Method Swizzle,这里就不解释什么是Method Swizzle了,我在这里替换了四个方法,其中makeKeyAndVisible方法是APP启动时UIWindow必定会调用的一个方法。我替换了这个方法,在里面加入了这个小球

    //这个方法就不用我解释了吧。
    func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
        window = UIWindow(frame: UIScreen.mainScreen().bounds)
        let mainViewController = ViewController()
        print(mainViewController.chaosName)
        let rootNavigationController = UINavigationController(rootViewController: mainViewController)
        window?.rootViewController = rootNavigationController
        window?.makeKeyAndVisible()//这个方法被我替换,加入了小球
        return true
    }
    
    //替换系统的makeKeyAndVisible方法
     Chaos.hookMethod(UIWindow.self, originalSelector: #selector(UIWindow.makeKeyAndVisible), swizzleSelector: #selector(UIWindow.vcMakeKeyAndVisible))
     
    //自定义的makeKeyAndVisible方法
     public  func vcMakeKeyAndVisible(){
        self.vcMakeKeyAndVisible()//看起来是死循环,其实不是,因为已经交换过了
        if self.frame.size.height > 20  
            let viewChaos = ViewChaos()
            self.addSubview(viewChaos)  /加入小球
            UIApplication.sharedApplication().applicationSupportsShakeToEdit = true //启用摇一摇功能
        }
    }
    

    这里要解释一下if self.frame.size.height > 20这行代码,这里是判断该UIWindow对象是不是状态栏,因是iOS最上面的信号条也是个UIWindow对象,所以过滤掉。

    如果启动摇一摇功能

    见上面代码,添加UIApplication.sharedApplication().applicationSupportsShakeToEdit = true就能启动摇一摇了,当然,关闭也可以用这个属性。然后再在
    public override func motionBegan(motion: UIEventSubtype, withEvent event: UIEvent?)方法里处理事件就OK了,当然苹果还提供了

    public override func motionEnded(motion: UIEventSubtype, withEvent event: UIEvent?)  //摇一摇结束
    public override func motionCancelled(motion: UIEventSubtype, withEvent event: UIEvent?)   //摇一摇取消,我不知道这个事件是会怎么触发的
    

    这两个方法。

    如何放大View并获取该点的颜色


    这个功能比较有意思,首先在放大镜模式下App里面的点击和触摸事件都要让它失效,不然会起冲突。我定义了一个叫ZoomViewBrace的View。它的作用是起承担override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent?)事件的,这样就可以屏蔽掉原页面里的点击和触摸事件,就可以对该View做放大操作了。

    放大的View名叫ZoomView,它是一个UIWindow对象,它有个viewToZoom的属性,当我们用手触摸时,截图的View传给该属性,然后再将坐标点也传进去,再调用setNeedsDisplay方法,
    ZoomView就会自动调用下面的方法,将放大自己1.5倍后再绘制出来。

     override func drawLayer(layer: CALayer, inContext ctx: CGContext) { 
            CGContextTranslateCTM(ctx, self.frame.size.width / 2, self.frame.size.height / 2)
            CGContextScaleCTM(ctx, 1.5, 1.5)    //放大1.5倍
            CGContextTranslateCTM(ctx, -1 * self.pointToZoom!.x, -1 * self.pointToZoom!.y)
            self.viewToZoom?.layer.renderInContext(ctx)
        }
    

    这样就有放大效果了

    然后就是该点颜色显示功能,实现它的步骤是这样的,首先获取viewToZoom的那个View,生成一张截图,再转化成UnsafeMutablePointer<CUnsignedChar>对象,这里面包含了该截图的颜色信息。接下来就是根据坐标点提取RBG值了。这样就能获取该点颜色了。
    这里的代码稍微有点长,就不写出来了,建议有兴趣的读者看源码。

    如何显示所有View的边框和透明值

    边框模式
    透明模式
    这个其实非常简单,就用一个递归加上循环不停在获取UIWindow下里面所有的View的位置,再生成一个和其位置一样的View,显示这个View的边框,再插入这些VIewUIWindow就OK啦,透明度也一样。这里设置了该Viewtag值,是为了在移除时更方便地判断该View是不是插入的边框View
        private func showBorderView(view:UIView){
          for v in view.subviews{
               let fm = v.convertRect(v.bounds, toView: self) //坐标位置转换。
               let vBorder = UIView(frame: fm)
               vBorder.layer.borderWidth = 0.5
               vBorder.tag = -5000
               vBorder.layer.borderColor = UIColor.redColor().CGColor
               self.insertSubview(vBorder, atIndex: 500)  //插入到最上面
               showBorderView(v)
           }
        }
    

    如何实现点击显示该View的标记线

    穿透黄色View的线

    从上面的图可以看出,Moonlight这个UILable从左边射出了两条线,并且这两条线是重合的。一条连接左边的黄色View,长度是16px,另一条连接最左边的边框,长度是105px。通常这两条线如果没有重合,那么可以不处理它,但如果重合在一起,在View比较复杂的情况下界面会比较乱,下面Category这个UILable也是一样的。这还只是垂直线,再加上水平线,就更乱了,所以需要移除这些重复的线。

    下面是移除重复的线的效果

    移除重复的线的效果

    可见移除这些重复的线后界面清爽不少呢,只显示了距离自己最近的View的距离。
    那么怎么删除重复的线呢,见下面的代码

            let minValue:CGFloat = 5 //设置一个最小的距离值,如果两条线的距离小于这个数,那么就要移除一条
            for  obj in arrViewFrameObjs{
                obj.leftInjectedObjs =  obj.leftInjectedObjs.sorted{$0.point1.point.y > $1.point1.point.y}   // 排序:Y值:从大到小
                var i = 0
                var baseLine:Line?   //基准线
                var compareLine:Line?  //比较线
                if obj.leftInjectedObjs.count > 0{
                    baseLine = obj.leftInjectedObjs[i]   //基准线为第一条
                }
                while i < obj.leftInjectedObjs.count{
                    if i + 1 < obj.leftInjectedObjs.count{
                        compareLine = obj.leftInjectedObjs[i+1]   //比较线为基准线的下一条
                        if abs(baseLine!.point1.point.y - compareLine!.point1.point.y) < minValue{  //比较两条线的y轴的距离。如果小于最小值,那么需要移除其中的一条
                            if baseLine!.lineWidth > compareLine!.lineWidth{  //比较两条线长度
                                arrLines.removeWith(condition: { (l) -> Bool in  
                                    l == baseLine!    //如果基准线比比较线长,移除基准线
                                })
                                baseLine = compareLine  //同时将比较线赋值给基准线
                            }
                            else{
                                arrLines.removeWith(condition: { (l) -> Bool in
                                    l == compareLine!  //如果比较线比基准线长,移除比较线
                                })
                            }
                        }
                        else{
                            baseLine = compareLine  //如果距离比较OK,将比较线赋值给基准线继续循环
                        }
                    }
                    i = i + 1
                }
    }
    

    上面的代码加上注释后不难理解,移除这些线后就做最后一步,打标记了。

    • 5 将保存在集合里的线统一放在一个View里,再绘制出来
            var taggintView:TaggingView?
            for s in supView.subviews{
                if s is TaggingView{ //先判断标记的View存不存在TaggingView,如果存在,就直接赋值给它。
                    taggintView  = s as? TaggingView 
                    break
                }
            }
            if taggintView == nil {
                 taggintView = TaggingView(frame: supView.bounds, lines: arrLines) //如果不存在就新建TaggingView
                taggintView?.attachedView = supView
            }
            else{
                taggintView?.addLines(arrLines)   //如果存在就加上这些线并绘制上
            }
            
            supView.addSubview(taggintView!) //�添加到需要绘制的View上
            view.isMarked = true
    
    

    最后一步就是使用TaggingView把所有的线绘制出来了

     override func draw(_ rect: CGRect) {
            super.draw(rect)
            guard let context = UIGraphicsGetCurrentContext() else{
                return
            }
            guard let mLimes = lines else {
                return
            }
            
            for line in mLimes {
                context.setLineWidth(2.0 / UIScreen.main.scale)    //设置宽度
                context.setAllowsAntialiasing(true)      //设置抗锯齿
                context.setStrokeColor(red: 1, green: 0, blue: 70.0/255.0, alpha: 1)//设置线条颜色
                context.beginPath()
                context.move(to: line.point1.point)
                context.addLine(to: line.point2.point)
                context.strokePath()    //绘制这条线
                let str = String.init(format: "%.0f px", line.lineWidth)
                let position = CGRect(x: line.centerPoint.x - 15 < 0 ? 3 :  line.centerPoint.x - 15 , y: line.centerPoint.y - 6 < 3 ? 0 : line.centerPoint.y - 6 , width: 30, height: 16)
                 (str as NSString).draw(in: position, withAttributes: [NSFontAttributeName:UIFont.systemFont(ofSize: 7),NSForegroundColorAttributeName:UIColor.red,NSBackgroundColorAttributeName:UIColor(red: 1, green: 1, blue: 0, alpha: 0.5)]) //再绘制出这条线的长度
            }
        }
    
    

    绘制的代码比较简单,就这样,整个标记过程全部完成了。我相信大部分读者看了上面的代码可能还是不知所解,最好结合Demo调试和代码一起看才能深入理解这些功能是怎么实现的。下面继续。

    如果获取绿色小球下的View

    这个ViewChaos最为核心的功能;首先,我定义了一个 arrViewHit的数组,它是一个[UIView]对象,它的作用是用来保存位于该小球下的所有的View,当小球上touchesBegain事件触发或者touchesMove事件触发时,不停地调用topView方法。而topView方法就是获取该点(你手指触摸的那个点)下面最上层的View

        override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent?) {
            if !isTouch
            {
                return
            }
            
            let touch = touches.first
            let point = touch?.locationInView(self.window)    //获取触摸点
            self.frame = CGRect(x: point!.x - CGFloat(left), y: point!.y - CGFloat(top), width: self.frame.size.width, height: self.frame.size.height)//这是为了精准定位.,要处理当前点到top和left的位移
            if  let view = topView(self.window!, point: point!) //如果下面有View
            {
                let fm = self.window?.convertRect(view.bounds, fromView: view)   //获取转换后的坐标
                viewTouch = view
                viewBound.frame = fm!
                lblInfo.text = "\(view.dynamicType) l:\(view.frame.origin.x.format(".1f"))t:\(view.frame.origin.y.format(".1f"))w:\(view.frame.size.width.format(".1f"))h:\(view.frame.size.height.format(".1f"))"
                windowInfo.alpha = 1
                windowInfo.hidden = false  //获取该View的Frame信息并显示在最上面。
            }
        }
    
        func topView(view:UIView,point:CGPoint)->UIView?{ //从arrViewHit里面取出最外面有View
            arrViewHit .removeAll()   //清空arrViewHit里面所有View
            hitTest(view, point: point)  //抓取触摸点下所有View并保存到arrViewHit的立法 
            let viewTop = arrViewHit.last //取出最上面的那个
            arrViewHit.removeAll()
            return viewTop
        }
    

    topView就是取arrViewHit里面的最后一个View,最后一个View就是位于小球下的整个View层级最上面的ViewhitTest这个方法会将所有位置小球下的View放进arrViewHit里面。
    下面看看hitTest这个方法

     func hitTest(view:UIView, point:CGPoint){
            var pt = point
            if view is UIScrollView{
                pt.x += (view as! UIScrollView).contentOffset.x    //设置偏移量
                pt.y += (view as! UIScrollView).contentOffset.y
            }
            if view.pointInside(point, withEvent: nil) && !view.hidden && view.alpha > 0.01 && view != viewBound && !view.isDescendantOfView(self){//这里的判断很重要.
                arrViewHit.append(view) //如果该点在这个View中,那么把这个View添加到arrViewHit
                for subView in view.subviews{
                    let subPoint = CGPoint(x: point.x - subView.frame.origin.x , y: point.y - subView.frame.origin.y)
                    hitTest(subView, point: subPoint) //遍历该View下所有子View,然后递归调用hitTest方法获取所有符合条件的View
                }
            }//四个条件,当前触摸的点一定要在要抓的View里面,View不能是隐藏的或者透明的,View不是我们用于定位的边界View,同时也不是我们用于定位的View.也就是说isDescendantOfView
        }
    

    首先如果该View是UIScrollView的话,需要把contentOffset加上去。然后这里有四个条件需要判断:当前触摸的点一定要在要抓的View里面,View不能是隐藏的或者透明的,View不是我们用于定位的边界View,同时也不是我们用于定位的View.也就是说isDescendantOfView。然后如果这些条件都满足,那么添加这个ViewarrViewHit里面。然后再对这个View的所有subviews递归调用这个方法,注意坐标需要转换一下。所有方法递归完成之后,arrViewHit里面会保存所有满足条件的View,也就是所有位于小球下面的View,然后取最后一个出来就行了。

    实现显示View所有信息的表格

    表格显示View的基本信息表格显示View的基本信息

    获取到了想要的View,那么获取到View的一些基本信息并将这些信息显示到表格里就比较简单了,主要是业务和逻辑比较多,需要写很多代码处理。操控View也是一样,写好这些逻辑代码就OK了,并没有多少难点,有兴趣有读者可以去看源代码。比较长,但是也比较简单。

    这篇文章主要给读者详解了ViewChaos的一些实现原理和难点,主要面向有兴趣看源码和和想了解实现机制的读者。其实深入研究这个库对自己的iOS能力提升还是比较大的。有什么问题可以即时联系。如果大家觉得这个库对你的项目的帮助的话。或者也可以学到一些新技术的话,可以给个Star, 谢谢。再次放出地址ViewChaos

    相关文章

      网友评论

        本文标题:ViewChaos: iOS UI调试黑科技之实现原理

        本文链接:https://www.haomeiwen.com/subject/wncnrttx.html