iOS能力探索-自动生成视图唯一标识

What

我们知道苹果提供了手动给控件添加可访问性(Accessibility)的能力,然而这样做的工作量大且不一定能满足需求,只能另辟蹊径从技术自动化方向去尝试解决这个问题。

Why

由于组内有不少技术性项目(自动化测试、自动化埋点等…)都需要有唯一标识的能力,于是有了此次的探索。

How

在组内小范围讨论了一番并结合网络检索后,总结出以下2种方案:

  1. 视图栈方案: 运行时获得视图栈,动态生成唯一标识。
  2. 脚本(配置)方案: 提前生成唯一标识的配置数据,然后在运行时进行绑定。

为了方便理解,接下来会用同一个案例来讲述两个方案的差别
如下图所示,有一个继承自UIViewController的类V1,其树形结构是用@property关键字声明的递归属性树(为了方便理解,已经由黑白名单策略过滤了私有变量,无效变量等节点)
矩形代表ViewController,圆形代表View,菱形代表Object
eg-w480

视图栈方案

先说视图栈方案,目前业内开源库里接受度较高的解决方案是TBUIAutoTest,其原理可以简述为:

  • 如果是类的属性变量,则用属性的变量名作为唯一标识。
  • 如果是局部变量,则用其来源的内容作为唯一标识。

图解如下:

view_stack-w1080

该方案也暴露出以下问题:

被复用的视图D,在被复用的父视图A和父视图B中的变量名都是d1,用该方案则会出现同一个unique_id对应多个视图的情况。
虽然可以通过运行时的视图栈index来进行区分,但是会出现另一个问题,就是视图的层级可能发生变化,导致unique_id不稳定。

为了解决以上问题,也引出了接下来要重点说明的配置(脚本)方案

配置(脚本)方案

要优化标识可能一致的问题,可以收敛要处理的类的范围,即只处理ViewController继承类的属性树,这也是该方案的关键点。
一般来说ViewController被复用的可能性不高,即使真的复用程度高,也可以配合后端下发唯一标识来解决相对定位的问题。

具体实现步骤如下:
1.生成配置数据

  • 获得所有VC // objc_getClassList()
  • 递归遍历VC持有的属性树 // class_copyPropertyList()
  • 保存属性树上每个结点的keypath并生成其唯一标识 // md5(keypath)

2.保存格式化后的数据(json)

  • 格式化保存VC的属性树数据(可以静态写入本地文件或保存在远端等)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 格式参考
{
PRKHomeViewController : {
"dce359c476c4ae262e6f958a3e647c25":"guideView",
"f1a282bdbf688604b1dc1f5c94df1ada":"myView",
"f958a3e6860f688601dcf1ad62e6f958":"myView.loadingView",
...
},
PRKDetailViewController : {
"b1dc1f5c94df1ad62e6f958aa3e68606":"navbar",
"c4ae262e6f958a3e6860e266f958a3e6":"navbar.titleLbl",
...
},
...
}

3.关联配置数据
关联数据有两种情况

  • 已知unique_id获取视图对象

因为之前已经生成好配置文件,那我们只需要在运行时索引配置表,通过unique_id找到对应的keypath,再通过KVC机制获取到对应的视图对象。

关键代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
@brief 传入unique_id和rootVC对象,获得视图
@param uniqueId 唯一标识
@param rootRef 当前的ViewController
@return 对应的视图
*/
- (id)refForUniqueId:(NSString *)uniqueId inRootRef:(id)rootRef {
if (uniqueId.length <= 0) {
return nil;
}
id aObj = [[PRKUniqueIdSession sharedSession].uniqueIdRefCache objectForKey:uniqueId];
if (nil != rootRef && [aObj isKindOfClass:[NSDictionary class]]) {
return [(NSDictionary *)aObj objectForKey:[NSString stringWithFormat:@"%p",rootRef]];
}
if (nil == aObj && rootRef && uniqueId) {
// NOTE: try to find object by KVC
NSString *keyPath = [[PRKUniqueIdSession sharedSession] keyPathForUniqueId:uniqueId inRootClass:[rootRef class]];
id ref = nil;
@try {
ref = [rootRef valueForKeyPath:keyPath];
} @catch (NSException *exception) {
} @finally {
if (ref) {
NSString *uniqueId = [[PRKUniqueIdSession sharedSession] uniqueIdForKeyPath:keyPath inRootClass:[rootRef class]];
[(NSObject *)ref setPrk_sfRootRef:rootRef];
[(NSObject *)ref setPrk_sfUniqueId:uniqueId];
aObj = ref;
}
}
}
return aObj;
}
  • 已知视图对象获取unique_id

用runtime的提供的相关API找到对象的varName以及对象的ViewController,再通过配置表定位到他的unique_id。

关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// @category: NSObject+uniqueId
- (NSString *)prk_sfUniqueId {
NSString *result = (NSString *)objc_getAssociatedObject(self, _kNSObject_prk_sfUniqueId);
// NOTE: 初始化赋值
if (nil == result && [self isKindOfClass:[UIResponder class]]) {
// NOTE: 兼容性逻辑,在此初始化一番
NSString *varName = [(UIResponder *)self prk_findNameWithInstance:(UIResponder *)self];
UIViewController *rootRef = [(UIResponder *)self prk_findViewController];
if (varName.length > 0 && rootRef) {
// NOTE:遍历对应rootRef的所有keypath的lastObject对应的ref匹配,匹配则返回对应值
NSDictionary *map = [[PRKUniqueIdSession sharedSession] uniqueIdKeyPathMapInRootClass:[rootRef class]];
if (map.count <= 0) {
return result;
}
NSMutableArray *maybelist = [NSMutableArray arrayWithCapacity:map.allValues.count];
for (NSString *aKeyPath in map.allValues) {
if ([aKeyPath hasSuffix:varName]) {
[maybelist addObject:[map allKeysForObject:aKeyPath].lastObject];
}
}
for (NSString *uniqueId in maybelist) {
// 该方法已给self赋值unique_id
id ref = [PRKUinqueIdKit prk_fetchRefForUniqueId:uniqueId inRootRef:rootRef];
if (ref == self) {
return uniqueId;
}
}
}
}
return result;
}

同样我们再图解一番该方案:

vc_tree-w1080

用视图的keypath作为唯一标识(为了让unique_id长度一致,对其进行了md5编码)能保证在同一个VC下,被复用的视图的唯一标识不同,解决了视图栈方案的唯一标识重复问题。
被复用的视图D,在其父视图A下的唯一标识分别是md5(o1.a1.d1)和md5(o1.a2.d1),在父视图B中的唯一标识是md5(b1.d1)

模块设计图

介于尚未获得公司的开源许可,暂放一张模块设计图供大家参考。

模块设计图-w640

TODO

最后再说明下目前脚本(配置)方案的局限性:只覆盖了通过@property方式或者iVar方式声明的属性。
以下是需要完善的地方及其解决方案的思考:

  • 完善手动赋值情况: 提供生成唯一标识的规则
  • VC被复用的场景: 可以通过服务器下发配合客户端赋值来解决
  • 列表元素类型(UITableViewCell、UICollectionViewCell): 可以通过服务器下发配合客户端赋值来解决
  • 局部变量: 可以复用TBUIAutoTest的方案临时解决(待完善)

Reference