踩坑趟雷,抉择实践,开源项目YourView诞生录

627 阅读11分钟

一、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~