作者 | 东海 ,携程移动开发专家,专注于移动端框架、移动端性能。
元帅,携程资深软件工程师,致力于平台基建开发。
一、背景
现在各大公司的APP都采用的是组件化架构,组件化架构带来了高内聚、低耦合、平台化等诸多有点,使工程结构更加清晰,工程管理更加轻松。iOS工程大多采用CocoaPod进行组件化管理,一些大型的项目需要打包平台来执行组件打bundle包和APP打测试包的任务,在开发方面会采用二进制与源码切换的方式来提高编译速度。
组件化虽然对APP项目的工程管理带来了巨大的好处,但是对开发人员来讲,存在着一些繁琐的问题:
在开发中,如果需要调试未解开源码的组件,就需要重新执行命令解开相应组件的源码才能进行调试。
每切换一次组件的源码,都需要在终端输入一串加了各种参数的命令来执行pod install,手动输入慢,而且容易出错。
组件化使得组件颗粒度变得越来越细,每个人所管理的组件数量就会多,每次组件更新都需要在打包平台上进行打包,等组件bundle包打完,再打测试包进行验证。
这些虽然能让工作正常进行,但是繁琐重复的操作却影响了开发人员的开发效率。
二、现状
携程火车票APP一直以来采用的也是组件化管理,在去年改用CocoaPod进行组件化管理,随着业务的迭代和基础建设不断的完善,pod组件也越来越精细化,目前pod组件数量已超60+。
pod组件数量越庞大,开发人员维护的成本也会越高,不仅要管理维护每个pod组件的更新,还要处理pod组件打bundle包问题、测试包打包时间长的问题。上述繁琐、重复、耗时的操作困扰着我们的iOS开发人员。如果能尽可能的对这些开发体验问题进行优化,那么必然会带来开发人员效率的提升。
三、优化方案
为了让开发人员调试代码更加方便,打包测试体验更好,开发过程更加专注,我们做了许多方面的操作优化和技术实践,主要有:
3.1 通过技术手段,实现二进制调试
在开发过程,难免会遇到自己想调试的组件没有解开源码,程序在运行中崩溃但是崩溃在了未解开源码的组件上,自己看到的只是一堆不明所以的汇编代码(图1),无法像源码调试那样看到足够丰富的调试信息。
图1
3.1.1 二进制文件分析
如何才能不解开源码也能调试二进制、崩溃在了二进制组件上也能定位到具体哪一行成了我们新的问题。我们找了各种资料,找到了美团有款zsource的CocoaPod插件可以进行二进制调试,虽未开源,但大致逻辑文章里已经罗列的很清晰,大致原理:
以libXXXX.a二进制文件为例,用 MachOView 来查看二进制文件,以获取到更友好的二进制信息。我们可以看到 “debug_str” Section 这些信息都存在了二进制的中。debug_str在编译的时候内部会记录源码地址:
图2
使用命令在终端输入:
dwarfdump ./libXXXX.a | grep 'XXXX'
注意到了 AT_name 这个字段名,去DWARF 1.1.0 Reference文档中查阅,我们可以得知:
- 一个DW_AT_name属性,其值是一个以空字符结尾的字符串,其中包含从其派生编译单元的主源文件的完整或相对路径名。
- 一个DW_AT_comp_dir属性,其值是一个以空值结尾的字符串,其中包含编译命令的当前工作目录,该编译命令以某种形式将Forelax视为主机系统,从而生成此编译单元。
XXXX.swift源文件存在这个地址下: /Users/marshal/Desktop/XXXX/XXXX/XXXX.swift
这个地址就是编译时源码所在地址,Debug调试的时候,编译器会先从这里拿对应映射地址去加载源码文件。如果存在对应地址存在源码文件时,就能进入源码调试。
3.1.2 脚本开发
了解基础原理后,那接下来的事情就是解决各种问题障碍:
1)要获取到静态库的源码。
2)获取静态库中存储的编译静态库时源码文件所在的路径。
3)在本地创建上面👆🏻获取的路径,让静态库的源码和该路径关联起来。
- 问题1:我们当时制作二进制包时为了方便切换源码调试,在pod install的时候源码+.a会同时下载到本地。
- 问题2:在美团的文章中可以了解到,使用dwarfdump 命令可以获取静态库中存储的编译静态库时源码文件所在的路径。
- 问题3:这个问题,我想大多数人第一个的想法是把静态库的源码copy到本地创建的静态库编译目录里面,但是我们采用更加轻巧的方式:通过软连接命令ln将两个目标关联起来。
最终我们通过开发脚本解决了上面的问题,通过Hook post_integrate 将脚本穿插到pod install的过程中,使整个过程顺畅自然。
主要脚本代码如下:
#链接,.a文件位置, 源码目录,工程名
def link(lib_file,target_path,basename)
#查询源码所在位置
dir = (`dwarfdump "#{lib_file}" | grep "AT_comp_dir" | head -1 | cut -d \\" -f2 `)
#创建目录
FileUtils.mkdir_p(dir)
#链接
FileUtils.rm_rf(File.join(dir,basename))
`ln -s #{target_path} #{dir}`
end
#通过pod post_integrate集成脚本
post_integrate do |installer|
openStaticLibDebug(installer,"project")
end
def openStaticLibDebug(installer,project)
if !ENV["DEBUGLIB"]
return
end
#脚本目录
path_root = "#{Pathname.new(File.dirname(__FILE__)).realpath}"
#当前项目的pod目录
pod_path = "#{path_root}/#{project}/Pods"
installer.pods_project.targets.each do |target|
bunlde_name = target.name
#这里可以根据环境变量选择性开启源码调试
enableDebug = ENV["#{bunlde_name}_DEBUGLIB"]
enableDebug = true
if enableDebug
DebugLibCode.new().link(lib_file,target_path,basename)
end
end
end
整个流程如下图3:
图3
3.1.3 方案调优
通过上面的脚本虽然实现了二进制静态库的调试,但是在推广和使用的时候又遇到了新的问题:
1)每个开发人员第一次执行二进制调试脚本的时候都会报错,因为权限问题,需要开发人员手动在 Users下面创建一个cbuilder的用户目录。
2)每次pod install的时间变长了很多,经过多次测量,在M1芯片的电脑上,从未接入二进制调试执行pod install到接入后增加超过了60%;在Inter芯片的电脑上,增加超过了 70%,如图4:
图4
对于问题1,开发人员手动cbuilder的用户目录是个不合理的操作,我们把这个操作集成到 ZTPodTool内(ZTPodTool是我们开发的一个podfile管理工具,下面会详细介绍),让ZTPodTool来创建cbuilder用户目录,开发人员就能无感知的开发。但是尝试了各种创建目录的api发现都不能创建这个目录,这个问题困扰了我们好久。
查找了大量资料,发现AppleScript是一个与macOS结合非常紧密的脚本语言,它显著的特点就是可以控制其他macOS上的应用程序,通过使用它可以完成一些繁琐重复的工作。代码如下:
NSString *script = @"do shell script \" /bin/mkdir -m 777 /Users/cbuilder\" with administrator privileges";
NSError *errorInfo = nil;
NSAppleScript *appleScript = [[NSAppleScript new] initWithSource:script];
NSAppleEventDescriptor * eventResult = [appleScript executeAndReturnError:&errorInfo];
对于问题2,我们分析发现,通过dwarfdump命令来解析二进制文件获取源码路径,会先加载整个二进制到内存中,再通过grep、head、cut等命令解析出取源码路径目录,这个过程非常耗时。工程里面的pod组件库越多,这个过程耗时就越大。
这些耗时的命令就为了获取个路径,如果能通过其他途径获取路径就可以把这些时间节省下来,可以省下一大笔时间开支。于是我们想到,既然是打包机上的路径,那就让打包机打包时把包相关信息用json保存在产物目录下,在install的时候,通过读取产物里面的json文件就可以获取打包源码路径。
优化脚本后,经过测量,和之前pod install的时间相差无几(图5)。就这样,我们的开发人员可以无差别的调试各个组件的代码了。
图5
3.2 另辟蹊径,解决M1电脑iOS模拟器剪切板问题
用M1系列电脑在iOS模拟器上开发的人员基本上都会遇到一个非常棘手的问题,那就是模拟器的剪切板无法和电脑的剪切板互通,开发人员也无法给剪切板赋值,一赋值就报错:
[CoreServices] _LSSchemaConfigureForStore failed with error Error Domain=NSOSStatusErrorDomain
Code=-10817 "(null)" UserInfo={_LSFunction=_LSSchemaConfigureForStore, ExpectedSimulatorHash=
{length = 32, bytes =0x4014b70c 8322afc9 dfb06ed8 13148b48 ... b6adae0d b2637192 }, _LSLine=
405, WrongSimulatorHash={length = 32, bytes = 0x073253e6 9a9b67cc 089d6640 ca4fdb3e ...
46b00d8b bca98999 }}
在苹果的官方论坛上反馈这个问题,得到的回复却是这样的:
//https://developer.apple.com/forums/thread/682395
So far I’ve been ignoring this thread because it started out with folks running Xcode and the simulator under Rosetta. This isn’t a supported configuration and I recommend that you switch to running these natively.
However, it’s now clear that multiple folks are hitting this while running Xcode and the simulator natively. Have any of you filed a bug about this? If so, please post your bug number?
如果剪切板不能用,在模拟器中输入地址或者长文本,对iOS、RN和H5的开发者都是非常耗时、非常痛苦的事情。为此我们想了一个轻巧的办法,绕过了这个系统bug,完美解决了这个问题,主要流程如下:
图6
我们给自己的APP自定了快捷键 Ctrl + V,用于触发用户进行粘贴操作 :
- (NSArray<UIKeyCommand *> *)keyCommands {
NSArray *a = [super keyCommands];
if (a) {
NSMutableArray *commands = [NSMutableArray arrayWithArray:a];
[commands addObject:[UIKeyCommand keyCommandWithInput:@"v"
modifierFlags:UIKeyModifierControl
action:@selector(posteboardCommand:)
discoverabilityTitle:@""]];
return commands;
}
return @[
[UIKeyCommand keyCommandWithInput:@"v"
modifierFlags:UIKeyModifierControl
action:@selector(posteboardCommand:)
discoverabilityTitle:@""]
];
}
本地服务我们是开发了一个mac客户端,主要功能是:在本地起一个Http服务,专门处理获取当前电脑剪切板内容的请求。它显示在系统状态栏上,方便控制服务的开启、停止和退出,支持修改端口号(图7)。点击这里即可下载使用。
图7
获取当前输入框的代码如下:
@interface UIResponder (FirstResponder)
+ (id)currentFirstResponder;
@end
static __weak id currentFirstResponder;
@implementation UIResponder (firstResponder)
+(id)currentFirstResponder {
currentFirstResponder = nil;
[[UIApplication sharedApplication] sendAction:@selector(findFirstResponder:) to:nil from:nil forEvent:nil];
return currentFirstResponder;
}
-(void)findFirstResponder:(id)sender {
currentFirstResponder = self;
}
@end
//触发获取剪切板的操作如下:
- (void)posteboardCommand:(UIKeyCommand *)command
{
#if TARGET_IPHONE_SIMULATOR
NSURLSession *session = [NSURLSession sharedSession];
NSURL *url = [NSURL URLWithString:@"http://127.0.0.1:8123/getPasteboardString"];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
request.HTTPMethod = @"GET";
NSURLSessionDataTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
dispatch_async(dispatch_get_main_queue(), ^{
if (error) {
NSLog(@"读取出错,请检查服务是否打开");
} else {
NSString *pasteString = [[NSString alloc]initWithData:data encoding:NSUTF8StringEncoding];
UIResponder* aFirstResponder = [UIResponder currentFirstResponder];
if ([aFirstResponder isKindOfClass:[UITextField class]]) {
[(UITextField *)aFirstResponder setText:pasteString];
} else if ([aFirstResponder isKindOfClass:[UITextView class]]) {
[(UITextView *)aFirstResponder setText:pasteString];
} else {
}
}
});
}];
[task resume];
#endif
}
加入这项优化后,开发人员只用使用Ctrl + V可以把mac电脑的剪切板内容粘贴到iOS模拟器的输入框中了,和正常的复制粘贴的功能体验完全一样。
3.3 开发可视化工具,集成各种功能
组件的日渐繁多,使得podfile文件操作变的复杂,打包组件bundle包变的频繁,打测试包时间变的冗长。为了简化终端输入命令、打组件包和APP测试包的繁琐操作,我们开发了一款可视化工具ZTPodTool,图8。这个工具不仅能直接展示出组件间的依赖层级关系,而且可以直接在工具上提交打组件包请求,不用再到浏览器的打包平台进行频繁切换页面的点击操作。
图8
在ZTPodTool上,不仅可以便捷地操作每个组件的源码与二进制切换、打组件包,而且支持打测试包(图9)。
图9
开发人员点击install按钮,ZTPodTool就会根据用户的源码设置拼装好命令,然后自动打开显示日志更友好的终端,让终端来执行该命令。虽然通过NSTask和NSPipe也可以执行pod install命令,但是获取到的StandardOutput日志无法高亮,看起来十分痛苦。要是能直接在终端执行,那样对开发者就更友好了,查阅苹果文档后,发现官方没有提供“终端”的SDK供开发者使用,在当时如何通过其他途径唤起终端执行命令成一件必须解决的事情。
最终还是靠上文提到AppleScript来解决了这个问题,下面是两种调用AppleScript的方式:
//方式一
NSTask* task = [[NSTask alloc] init];
task.launchPath = @"/usr/bin/osascript";
task.arguments = @"tell application \"Terminal\" to do script \" pod install --repo-update";
task.currentDirectoryPath = @"/Users/zhangsan/iosWorkSpace";
task.environment = @{
@"LANG":@"zh_CN.UTF-8",
@"PATH":@"/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/git/bin:/usr/local/"
};
[task launch];
//方式二
NSError *err = nil;
NSAppleScript *appleScript = [[NSAppleScript new] initWithSource:@"tell application \"Terminal\" to do script \" pod install --repo-update"];
NSAppleEventDescriptor * eventResult = [appleScript executeAndReturnError:&err];
我们加入的更加人性化的功能还有:
- 收到测试包打包完成消息后,开发人员通常发送安装包二位给测试人员进行验证。在ZTPodTool上,我们支持了打包后自动发送包的二维码给所选的测试人员,无需开发人员再通知。
- 列表中组件越来越多,开发人员寻找选择自己维护的组件也需要更多的时间。为此我们支持了关注列表功能,开发人员只看到自己关注的组件。
- 在ZTPodTool上输入版本号,就可以更新各个pod组件的版本。
- install完成后自动打开工程
3.4 优化打包流程,更快打出测试包
二进制打包最大的痛点就在于打ipa前先打独立组件二进制,多个组件依赖,需要串行打依赖bundle,整体的打包流程上耗时比较大。在测试阶段,如果测试包能快速的打出来,这无疑能显著的提升bug的验收效率。我们每次提交代码后打包都是这样的流程(图10):
图10
上面的流程无论那种方式打包都要等到组件包打完之后才能打测试包,出测试包的时间取决于打组件包的数量与组件间的依赖关系。有没有办法缩短这一流程呢?我们在本地开发的时候编译很快,到了打测试包的时候却要先打组件包才能打测试包,如果打包机也可以自定义部分源码编译,那么就不用等待组件先编译完成了。这样就直接省去了打组件包的时间,可以更快速的打包。
让打包机支持部分源码打包,首先得配置好podfile文件,但是开发者不可能提交podfile的修改,那样的话会造成git冲突。于是我们另辟蹊径,把需要变为源码依赖的组件名作为打包网络请求的部分参数,打包平台在打包的时候将这部分参数写入到环境变量里面,然后修改打包脚本,让其在开始执行pod install前去读取这些参数,如果有需要源码编译的组件,就按照参数去修改podfile,这样一通操作下来就让打包机完美支持了。
为了更完善这个功能,我们在开发人员点击打包后,可以选择是否同时打组件包,再结合上面提到打包后自动通知测试人员的功能,现在的流程是这样的(图11):
图11
从上面简化的流程可以看出,我们将原有的串行任务改为了可并行执行的任务。经过多次实验对比,排除打包排队情况的干扰,所有组件bundle平均打包时间为203秒,全bundle打测试包时间为367秒,部分源码打包时间为384秒,所以理想环境情况打包效率提升32.6%。
- 原来总打包时长为:203 + 367 = 570
- 打包效率提升为:(570-384)/570 = 0.326315
考虑到实际情况,打完组件bundle包,开发或者测试人员收到通知后才会在打包平台进行打测试包操作,还要勾选一些配置等信息。如果要打多个组件bundle包,组件之间还有依赖关系的话,那么就需要更多时间才能打出测试包,而源码打包基本不受组件依赖的影响。所以这项优化使得出包效率会远远超过32.6%。
四、总结
无论是架构演进、流程优化还是制作工具,工程师们总是希望用技术手段去减少重复工作,提高人效。篇幅原因,做这些优化的过程中遇到的很多问题及解决方案都没罗列出来。目前还有些已知的问题还没解决,这些已知问题是我们持续优化的动力,也相信我们能为开发者带来更优秀的开发体验。