一、YourView简介
YourView是一款桌面App,使用Objective-C语言开发,基于Apple SceneKit技术框架,支援将iOS App的View结构进行远端渲染并支援3D显示模式,还能够动态显示View树结构,方便开发者对App UI进行分析和除错。在上一篇文章《UI分析工具YourView开源—App开发者不可多得的利器!》中,我们列举了一些YourView的特性,以及专案的GitHub地址:https://github.com/TalkingData/YourView 欢迎检视,Star&Fork。
在开发YourView之前,我们也有试用过一些其他的UI分析除错工具,但是这些工具大多数是收费的,而现有开源工具在功能上又难以满足需要。因此我们干脆自行研发并开源了YourView。
二、macOS和iOS通讯
在开发之初,我们一直在调研相关的技术实现。也曾经一度跑偏,认为有什么黑科技可以把iOS内存里的UI资料直接dump到macOS中,然后macOS可以直接渲染绘制,所以一直在研究XPC和程序通讯。
但后来发现这条路行不通,第一,iOS和macOS分属于ARM架构和X86架构,指令集不一样,直接dump内存到不同架构的装置上是无法相容的;第二,macOS和iOS的开发框架不同,即使能够dump出内存,还需要做大量的框架桥接程式码。所以最后选择了另外一条更为直接的路,把UIView序列化成JSON字串结构,通过网络协议传输,接收方接受JSON资料后,再反序列化成内存中的物件,然后绘制展示。
01选择哪种网络协议?
网络协议可以使用WebSocket,也可以用HTTP协议。最后选择使用了HTTP协议。原因如下:
第一,WebSocket需要server端的支援,目前OC语言并没有十分好的WebSocket server实现。
第二,这次开发不需要特别高的实时性,所以HTTP协议是一个比较好的选择,而且OC语言已经有比较好的webServer实现——GCDWebServer和CocoaHttpServer。由于CocoaHttpServer最近的维护已经是几年前了,所以我选择了维护更频繁的GCDWebServer作为通讯的Server端。
02 Server应该放在哪里?
放在桌面端:由于IP无法固定,每次iOS装置启动的时候需要动态的去配置IP地址,并且如果在iOS端提供输入框或者每次动态配置程式码填入IP又太不友好,违背了易用性原则,于是放弃了这个选择。
放在iOS端:在iOS App内启动HTTP Server,有被劫持的风险,如果真的做成商用软件,这样做无疑是很危险的。但是作为一个开源软件来讲,这是OK的,因为在开放的源代码面前,一切都是透明的。如果开发者想开发自己桌面端,可以加上引数校验签名等机制,提升安全性。
03自动连线还是手动连线?
自动连线:iOS提供了BonjourService。Bonjour是法语你好的意思。这里跑题多讲几句。据研究表明(其实是瞎编的),全世界人民都比较喜欢异域文字带来的新奇感,就像会有人起名叫Tony一样,英语语系的人们也喜欢起一些奇怪的德系甚至拉丁语系的名字。所以这个BonjourService,翻译过来其实就是HelloService。字面意思很好理解,就是在局域网里广而告之,和局域网里的所有人SayHello。
这个服务最大的优势就是在局域网里可以自动的获取对方的IP地址,完成通讯。现在很多厂商已经内建了BonjourService,特别是打印机厂商。在macOS上则无需知道对方的IP地址,就可以自动完成连线操作,非常方便。
手动连线:所以也考虑过在iOS端开启BonjourService,而且GCDWebServer已经实现了相关的界面。但是实践证明,当在macOS上实现BonjourService Browser之后,虽然能够自动识别装置,但是面对中间的网络异常,比如防火墙导致的网络无法连线等,并没有很好地方法进行提示,在连线不上的时候很容易让人摸不着头脑、不能准确了解状况。这样对开发者其实是不友好的。由于网络只是通讯的必要手段,并不是UI检视的重点,所以我们选择把更多的精力放在UI绘制上,而将网络模组尽量做得轻量易于除错,于是放弃了自动扫描的方式。
衡量之下,我们选择在YourView桌面端启动的时候,给使用者提供一个IP输入框。使用者在输入IP之后,点选连线,如果网络有问题会直接弹框提示。虽然技术上的自动好过手动,但是自动带来的复杂性和不确定性,以及考虑到在实践中的实际表现,最后还是选择了手动连线的方式。想来,这可能和老司机喜欢开手动档车是一样的吧,我们都喜欢操作可控带来的感觉(其实也是因为懒)。
三、UIView的序列化
如果对树的操作不熟悉,那么可以移步LeetCode,先把关于树的操作的问题敲一遍。UIView其实就是一棵多叉树。每个节点具有资料域和指标域,资料域就是自身的属性,指标域就是关系,在UIView中就是subViews。所以理解了这一点,就很容易写出序列化程式码了。01序列化方案一: 非递回平铺树
借助栈或者伫列的帮助,对View树进行遍历把每个节点变成一JSONObject,然后把这些Object放到一个数组里。最后整棵树就像被拍平了一样,树变成了列表。把拍平的节点阵列作为资料来源,驱动TableView显示,然后根据每个节点自身的深度在对应的cell上绘制对应的缩排,用来表示树的层次结构。
这样的做法在UI中可以表现出树的层次结构,但是实际上已经丢失了树的两个重要特征,兄弟关系、父子关系。前驱后继关系丢失之后,对节点的收缩和展开操作就不太方便了。
02序列化方案二:递回关系树
贴一段简化过的递回程式码:
-(NSDictionary*)traversal{ NSMutableArray * subArr = [NSMutableArray array]; for (UIView * v in self.subviews) { [subArr addObject:[v traversal]]; } return @{@sub:subArr};}复制程式码
这段程式码执行之后,就在JSON结构里储存了UIView的父子和兄弟关系。
03序列化中对资料域的处理
UIView物件
iOS端需要储存UIView物件,为后续的macOS端的操作(比如编辑)做准备。但是随着界面的滚动,UIView可能被释放掉。所以这里选择了用NSMapTable用来做储存容器。
储存的Key就是UIView的内存地址:
-(NSString*)_address{ return [NSString stringWithFormat:@%p,self];}复制程式码
储存的是UIView物件本身,当UIView因为离开屏幕而被释放的时候,使用内存地址取值为空,不会产生野指标。
map引用传递
稍微改造一下我们的递回函式,在递回引数中增加用来记录的map。需要注意这个map是引用传递,递回中的每次呼叫都指向同一个map。
-(NSDictionary*)traversalWithRecorder:(NSMapTable*)map{ NSMutableArray * subArr = [NSMutableArray array]; [map setObject:self forKey:[NSString stringWithFormat:@%p,self]]; for (UIView * v in self.subviews) { [subArr addObject:[v traversalWithRecorder:map]]; } return @{@sub:subArr};}复制程式码
StepIn物件
1.UIViewController的获取
想获取一个UIView对应的ViewController,可以使用nextResponser属性,直到找到UIViewController停下。假如UIView的平均深度是10,有N个View,那么需要迭代的次数就是N*10。这样会使得时间复杂度提升,所以我们对此进行了一些优化。对于UIView,只找最近一级的nextResponder,如果这个Responder是UIViewController那么选择记录在自己的data域内,并且把这个UIViewController作为递回的引数传递到下一级,否则把递回中父节点的controller作为自己的ViewController。再次改造递回函式:
-(NSDictionary*)traversal:(UIViewController*)vc{ UIViewController * vcToNext = vc; if ([[self nextResponder]isKindOfClass:[UIViewController class]]) { vcToNext = [self nextResponder]; } NSMutableArray * subArr = [NSMutableArray array]; for (UIView * v in self.subviews) { [subArr addObject:[v traversal:vcToNext]]; } return @{@sub:subArr};}复制程式码
2.递回
对于UITableViewCell和UICollectionViewCell也是同样的操作,在获取IndexPath的时候,也只向上找一级,否则直接从递回的引数中获取,递回的初始值是预设的section=-1,row=-1。
3.Depth和level的处理
Depth表明的是当前View所在树的深度。Depth可以从当前的View直向上找superview,直到superView为空为止。但是这样的处理也会带来和UIViewController获取同样的问题。所以这个和UIViewController一样的处理策略,每次递回的时候,把当前的depth加1,向下传递。序列化后的view属性:
无论是Depth还是level,抑或ViewController和IndexPath,它们都是从上级递回而来并且在当次递回中拼接自己的引数,所以我们选择把这些属性封装在一个StepIn物件里,并抽象出一个stepin方法。每次递回开始,StepIn物件都会根据当前的view的状态,进行相应的stepin操作。具体的程式码可以参考libyourview/serializer/UIView+YVTraversel
截图的处理
由于截图需要在JSON里传输,所以需要把截图的imgData转换成base64编码的string。截图是针对layer的操作,在截图的时候,一定不能带上sublayer。所以在针对每个View进行截图的时候,需要把没有hidden的layer变成hidden状态,并储存在阵列中,在截图方法呼叫完毕之后,需要把阵列layer的hidden属性进行restore操作。
四、macOS端的渲染
桌面端一共有三个ViewController:Left、Middle和Right。其中Left负责展示树状结构,Middle负责3D展示,Right负责展示view属性。
Left
由于上文中的序列化操作已经把UIView变成了树状的JSONString,所以直接把序列化之后的string转化为NSDictionary并作为资料来源驱动NSOutlineView展示就OK了。
Middle
1.使用SceneKit渲染
用平面SCNPlane来展示UIView的截图。展示的同时需要把UIView的座标从UIKit座标系转换到SceneKit座标系。转换公式如下:
2. 射线检测
鼠标移动的时候需要将鼠标指向的View边框高亮,边框是当前Node的一个subNode,在被指向的时候,将前一个unhover,将当前指向的高亮。选中也是同样的道理,鼠标单击的时候,将射线击中Node的子Node的hidden属性置为No即可。SceneKit提供了类似射线检测的API,直接用point和plane呼叫hitTest方法,取返回结果的第0个元素即可。
3. Z轴控制
目前YourView共支援三种显示模式。现在大部分开源软件的做法是将所有View拍平之后按照深度优先的顺序每层排列一个,如果View特别多的话,会造成所有z轴特别大,在旋转的时候视觉效果很差。YourView则实现了智慧回溯算法,在深度优先的基础上,递回中记录当前被占据的level和frame,每次新的view进来都会从深度优先的基础上向前回溯,一直找到第一个不被遮挡的位置。
4. 相机的选择
可以在Xcode的场景编辑器里直观的感受一下。左边是,右边是正交相机。
正交相机:orthographicCamera 所见即所得,所有View的scale不随深度变化;:View近大远小。为了更好的视觉效果,YourView选择使用正交相机用来展示View。
五、后续的优化
目前YourView只实现了3D渲染,对UIView的动态编辑能力还比较弱,后续会继续完善编辑功能;在View树里增加UIViewController 手势,布局等元素;UI美化工作和使用者体验提升。六、参考资料
Apple SceneKit:https://developer.apple.com/scenekit/Bonjour:https://developer.apple.com/bonjour/接下来,我们会进一步完善与优化YourView,为大家提供更好的使用体验,同时,也欢迎大家使用YourView(专案地址:https://github.com/TalkingData/YourView),也欢迎大家为我们提供宝贵的建议和意见,让我们一起维护这个专案~作者:张自玉
点选扩充套件连结直达YourView~