上一篇文章我给大家展示了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
标记就行了,
怎么添加那个小圆球
我们在 已经更新在ViewChaosStart类的awake方法里使用了Method Swizzle,这里就不解释什么是Method Swizzle了,我在这里替换了四个方法,其中UIWindow
的initialize
方法中使用了Method SwizzlemakeKeyAndVisible
方法是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
的边框,再插入这些VIew
到UIWindow
就OK啦,透明度也一样。这里设置了该View
的tag
值,是为了在移除时更方便地判断该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
,这里我定义一个HolderMarkView
来实现,它屏蔽该页面本身的一些View
的点击事件和其他一些触摸事件。然后就是在UIWindow
里面插入这个View
let v = HolderMarkView(frame: self.bounds)
v.tag = -2000 //设置标签为-2000,是为了删除方便
self.insertSubview(v, at: 1000) //插入到UIWindow里,并且设置到最上面
在HolderMarkView
里需要重写override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
方法,touches可以获取到你用手指在屏幕上触摸坐标点,这个坐标点就是全局的屏幕坐标点,然后再获取该坐标点下面的View
。基体实现原理请参考下一小节,
下一步就是标记获取到View,这里面稍微有些复杂,
- 1: 获取该
View
的父View
,再在父View
注入测试边框,因为是显示该View
到父View的标记
guard let supView = view.superview else {
return
}
registerBorderTestView(view: supView) // 在父View的边框注入测试View
static func registerBorderTestView(view:UIView){
let minWh = 1.0 / UIScreen.main.scale //获取最小宽度
let leftBorderView = BorderAttachView(frame: CGRect(x: 0, y: 0, width: minWh, height: view.bounds.size.height)) //左边框
let rightBorderView = BorderAttachView(frame: CGRect(x: view.bounds.size.width - minWh, y: 0, width: minWh, height: view.bounds.size.height))//右边框
let topBorderView = BorderAttachView(frame: CGRect(x: 0, y: 0, width: view.bounds.size.width, height: minWh))//上边框
let bottomBorderView = BorderAttachView(frame: CGRect(x: 0, y: view.bounds.size.height - minWh, width: view.bounds.size.height, height: minWh))//下边框
view.addSubview(leftBorderView)
view.addSubview(rightBorderView)
view.addSubview(topBorderView)
view.addSubview(bottomBorderView) //然后添加上下左右四条测试边框View
}
registerBorderTestView
方法就是给这个View
加上四个宽度不超过1像素的边框,然后就能用来计算其各个子View之间的距离啦,这个测试View
继承于AbstractView
,这是为了和普通View
区分出来。
var arrViewFrameObjs = [FrameObject]() //声明一个保存所有需要测量和标记的数组
for sub in supView.subviews{ //从这个父View下的所有的子View做一个循环
if !(sub is AbstractView ){ // 如果不是AbstractView需要详细判断
if sub.alpha < 0.01 { //过滤透明的View
continue
}
if sub.frame.size.width < 2 { //过滤太小的View
continue
}
if sub != view { //如果不是自身,就过滤掉,因为标记模式只显示自己的标记
continue
}
}
let frameObject = FrameObject(frame: sub.frame, attachedView: sub)
arrViewFrameObjs.append(frameObject) //实例化FrameObject再添加到数组
}
//这样就添加了父View注入的测试View和该View到arrViewFrameObjs数组里
- 3: 获取了所有需要测量的
View
后,就是获取距离线了,用一个双循环搞定
for sourceFrameObj in arrViewFrameObjs{
for var targetFrameObj in arrViewFrameObjs{
if sourceFrameObj.attachedView is AbstractView && targetFrameObj.attachedView is AbstractView {
continue //过滤测试的AbstractView
}
// 寻找两个View有没有水平连线
var hLine = horizontalLine(frameObj1: sourceFrameObj, frameObj2: targetFrameObj)
if hLine != nil {
hLine?.belongToFrame = sourceFrameObj.frame.size.width < 1 ? targetFrameObj.frame : sourceFrameObj.frame
arrLines.append(hLine!) //添加水平线
targetFrameObj.leftInjectedObjs.append(hLine!)
}
// 寻找两个View有没有垂直连线
var vLine = verticalLine(frameObj1: sourceFrameObj, frameObj2: targetFrameObj)
if vLine != nil{
vLine?.belongToFrame = sourceFrameObj.frame.size.height < 1 ? targetFrameObj.frame : sourceFrameObj.frame
arrLines.append(vLine!) //添加垂直线
targetFrameObj.topInjectedObjs.append(vLine!)
}
}
}
上面代码里面的关键方法horizontalLine(frameObj1: sourceFrameObj, frameObj2: targetFrameObj)
就是测量获取两个View之前有没有连线,下面以horizontalLine为例子
static func horizontalLine(frameObj1:FrameObject,frameObj2:FrameObject)->Line?{
if abs(frameObj1.frame.origin.x - frameObj2.frame.origin.x) < 3 {
return nil
}
// 如果frameObj1在frameObj2右边,就返回nil
if frameObj1.frame.origin.x + frameObj1.frame.size.width > frameObj2.frame.origin.x {
return nil
}
let obj1RightX = frameObj1.frame.origin.x + frameObj1.frame.size.width //第一个View的右上点
let obj1Height = frameObj1.frame.size.height //第一个View的高度
let obj2LeftX = frameObj2.frame.origin.x //第二个View的左上点
let obj2Height = frameObj2.frame.size.height //第二个View的高度
var handle:CGFloat = 0
let pointY = approperiatePoint(interval1: Interval(start:frameObj1.frame.origin.y,length:obj1Height), interval2: Interval(start:frameObj2.frame.origin.y,length:obj2Height), handle: &handle)
//获取这两个View左边连接线最合适的连接坐标点,是一个Y坐标点
let line = Line(point1: ShortPoint(x:obj1RightX,y:pointY,handle:handle), point2: ShortPoint(x:obj2LeftX,y:pointY,handle:handle))
//根据两个点生成Line对象,也就是线了
return line
}
static func approperiatePoint(interval1,interval2,handle:inout)
方法是从两个View的左边找到一个合适的点来做这条连线,这里代码比较长就不放出来了
- 4 找出重复的线并删除,为什么会有这一步呢让我们看看下面的情况
从上面的图可以看出,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
层级最上面的View
。hitTest
这个方法会将所有位置小球下的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
。然后如果这些条件都满足,那么添加这个View
到arrViewHit
里面。然后再对这个View
的所有subviews
递归调用这个方法,注意坐标需要转换一下。所有方法递归完成之后,arrViewHit
里面会保存所有满足条件的View
,也就是所有位于小球下面的View
,然后取最后一个出来就行了。
实现显示View
所有信息的表格
获取到了想要的View,那么获取到View的一些基本信息并将这些信息显示到表格里就比较简单了,主要是业务和逻辑比较多,需要写很多代码处理。操控View也是一样,写好这些逻辑代码就OK了,并没有多少难点,有兴趣有读者可以去看源代码。比较长,但是也比较简单。
这篇文章主要给读者详解了ViewChaos的一些实现原理和难点,主要面向有兴趣看源码和和想了解实现机制的读者。其实深入研究这个库对自己的iOS能力提升还是比较大的。有什么问题可以即时联系。如果大家觉得这个库对你的项目的帮助的话。或者也可以学到一些新技术的话,可以给个Star, 谢谢。再次放出地址ViewChaos