译者注:原文使用的是xcode6.3.2,我翻译的时候,使用的是xcode7.2.1,经过验证,文章中说说的依然是有效的。在文中你可以学习到一系列的技能,非常值得一看。
苹果的"一个足以应付所有"策略使得它的产品越来越像一个难以下咽的药丸。尽管苹果已经将一些工作流带给了iOS/OS X的开发者,我们仍然希望通过插件来使得Xcode更加顺手!
虽然苹果并没有提供任何的官方文档来指导我们如何创建一个xcode插件,但是开发者社区做了大量的工作开发了一些非常有用的工具,通过这些工具,可以用来帮助开发者。
从 自动完成图片名的插件,到 清除缓存的插件 再到 使Xcode变成一个vim编辑器的插件,Xcode的插件社区已经拓展了我们的思维,我们可以让Xcode变得更加智能。
在 这个不算短的三部分教程中,你将创建一个Xcode的插件来娱乐你的同事,其特色莫过于显示的内容并不是他所期望看到的!尽管这个插件是很轻量级的,你仍 然可以学习到很多知识,例如,通过调试Xcode,怎样找出你感兴趣的元素并且修改它,怎样将系统的功能函数替换为你自己的函数(通过swizzle技 术)!
你将会使用 x86汇编知识,代码定技能以及LLDB调试技能来查阅未公开的私有framework,并且要探索这些私有framework中的私有API,还要使用 method swizzleing来进行代码的注入。正因为有这么多内容,所以本教程的讲解速度会很快。在继续之前,请务必确定你已经掌握了相关的 iOS/OS X的开发。
使用Swift来开发插件,还是一个比较复杂的主题,并且Swift的调试工具依然比Objective-C要弱很多。就目前而言,这意味着插件开发的最佳选择(本教程!)是Objective-C.
开始
为了庆祝 恶作剧你的同事日,你的Xcode插件将会Rayroll你的受害者。等等… 什么是Rayrolling?它是一个免费和无版权的Rayrolling瑞克摇摆-就是你看到的内容并不是你期望的内容,有点挂羊头卖狗肉的意思。当你完成了这个系列,你的插件将会更改Xcode显示的内容:
用Ray‘s的头像来替换Xcode的某些警示框(例如, Build成功或者失败的Xcode提示框)
替换Xcode的标题内容为Ray的热门歌曲的一句歌词,Never Gonna Live You Up
替换所有的Xcode中的搜索文档内容为一个视频 Rayroll‘d video
在教程的第一部分,我们将聚焦于寻找到负责展示"Build成功"警示框的那个类,并且将其图片改为Ray的头像这张图片
安装插件管理插件 Alcatraz
在开始之前,需要先安装Alcatraz,它是Xcode插件管理工具。
典型的安装Alcatraz的方式是通过命令行
1 |
|
当这条命令结束后,重启Xcode。你可能会看到一个提示 Alcatraz bundle的警示框;点击 Load Bundle 继续,以便xcode能够加载Alcatraz插件,这样这个Alcatraz插件才能起作用
注意:如果你一不小心点击了"skip bundle",你可以通过命令行输入以下命令来重新显示它!
1 |
|
以上的Xcode 7.2.1是你机子上的Xcode版本号,如果你的不是7.2.1,改为你的对应的版本号就可以了。
你 将会在Xcode的Window菜单下看到一个新的菜单项:Package Manager。创建一个xcode插件,需要你通过设置Build Settings来运行另一个新的Xcode的实例来加载才可以,这是一个枯燥和乏味的过程(如果想知道这个枯燥的过程,可以参考我的文章Xcode7 插件制作入门),幸好,已经有人替你完成了这件事情了,有人开发了一个Xcode的工程模板,可以让你很方便的创建一个插件工程。
打开Alcatraz(Window->Package Manager)。在Alcatraz的搜索框中输入Xcode Plugin。务必确保你选中了搜索框中的All和Templates两个属性。一旦你搜索到,单击其左边的Install来安装它!!
如果你搜索不到,也没关系,你可以前往 https://github.com/kattrali/Xcode-Plugin-Template自己下载下来的方式来加载它,具体安装方式可以见工程的说明。
一 旦Alcatraz下载完了Xcode Plugin插件,你就可以创建一个插件工程了(File->New -> Project…),选择这个新的OS X ->Xcode Plugin ->Xcode Plugin模板,然后点击下一步。
给工程取名字Rayrolling,组织的标识符为com。raywenderlich(这一步非常重要),选择Objective-C作为代码语言。。保存工程到任何一个你想放置的目录中。
Hello World插件模板
编译,然后运行这个Rayroll工程,你将会看到一个新的Xcode实例出现。这个Xcode实例在Edit菜单栏下多了一个菜单项Do Action:
选择这个菜单项,将会出现一个模态的弹出框:
从 Xcode5开始,插件都只能运行在特定版本的Xcode中。这也就意味着当新的Xcode更新安装后,所有的第三方插件都将失效,除非你添加了该版本 Xcode的UUID。如果部分模板没有起作用,你也没看到一个新的菜单项,可能的原因之一就是因为没有对应版本的UUID,你需要添加对应该版本 Xcode的支持。
为了添加UUID,首先是在命令行中运行以下命令
1 |
|
这条命令会输出当前版本xcode的UUID。打开Rayroll工程的Info.plist文件。导航到DVTPlugInCompatibilityUUID,添加它
注意:通过本教程,你会运行和修改已经安装了的插件。这将会改变Xcode的默认行为,当然,这也可能会导致Xcode crash!!如果你想禁止某个插件,你可以手动的通过终端去删除它.
1 2 |
|
然后重启一下Xcode
找到我们要修改的xcode的某项特性
最直接最有效的得到幕布后发生什么的方式是 通过注册一个NSNotification observer 来监听所有的Xcode事件。通过Xcode和监听这些消息通知,你将会深入到一些内部类的内部。
打开Rayrollling.m,在类中添加如下的属性
1 |
|
这个NSMutableSet用来存储所有的Xcode的控制台打印出来的NSNotification的名字
下一步,在initWithBundle:中,if (self = [super init]) {之后,添加如下代码
1 2 3 |
|
给name参数传递nil指示,偶们需要监听xcode的所有NSNotification。
现在,实现handleNotification:方法:
1 2 3 4 5 6 |
|
handleNotification:检查获取到的通知名称是不是在notificationSet中,如果不是,则在控制台打印出它的通知名和通知多对应的类。然后添加到notificationSet中。通过这种方式,你只会在控制台看到每一种类型的通知一次
下一步,找到添加menu item的声明,将其替换成下面的代码
1 2 |
|
这段代码只是简单的更改了NSMenuItem的标题,以便让你知道,当你点击它的时候,它将会重置存放NSNotification 的set对象。
最后,替换doMenuAction的实现代码为下面的
1 2 3 |
|
这个菜单项将会重置所有存放在notificationSet属性中的通知。这样做的目的是让你在控制台中很容易的观察到你感兴趣的通知,而不至于被控制台的重复消息所刷屏。让你更加专注。
再 一次编译运行,请确认你分清了哪个是你工程的Xcode(也就是父Xcode),哪个是你debug出来的一个Xcode实例(子Xcode),为什么要 分清楚呢?因为我们的每一次改变,当重新debug的时候,debug出来的Xcode中已经起作用了,而父Xcode,只有等到你重启后,才能看到效果 的。
在子Xcode中,随便点击点击按钮,打开一些窗口,浏览浏览程序,你会在父xcode的控制台中看到消息的触发。
查找和监测编译的提示框
现在,你已经学会了基本的查看由Xcode本身引起的通知(NSNotification),现在你需要明确的找出来显示编译状态的提示框所关联的类是哪一个。
运行Xcode插件,在子Xcode中,打开任何一个工程,确保打开了Xcode设置中的bezel notifications,Succeeds和Fails中的bezel notifications都要打开。当然了,请再次确定你操作的是子Xcode实例!
通过你在Xcode的edit菜单中创建的 菜单项Reset Logger来重置notificationSet,然后运行你的子code(上面让你在子Xcode中打开了任何一个工程,现在你在子Xcode中运行这个打开的工程)
当 子Xcode的工程编译结果出来后(或者成功,或者失败都没关系),关注父Xcode中控制台输出的信息。粗略的浏览一遍,看是否有能引起你关注的通知。 你能够发现一些值得你进一步关注的notifications么?下面的这些可能能给你一些帮助(原文中,以下的列表是隐藏的,你可以点击展示它,作者鼓 励大家自己先找找看,如果找不到,再打开下面的这个提示,由于我使用的markdown编辑器的限制,做不到这点,所以此处直接放出来了)
下面的一些项值得你进一步关注:
- NSWindowWillOrderOffScreenNotification, DVTBezelAlertPanel
- NSWindowDidOrderOffScreenNotification, DVTBezelAlertPanel
- NSWindowDidOrderOffScreenAndFinishAnimatingNotification, DVTBezelAlertPanel
你 应该挑其中一个,并进一步探索它,看看是不是可以从中得到一些重要的信息。例如 NSWindowWillOrderOffScreenNotification是干什么的? 很好,你选择了进一步探索NSWindowWillOrderOffScreenNotification。
回到父Xcode中的Rayrolled.m文件,定位到handleNotification:方法,添加一个断点到方法中的第一行,并且按照如下来设置这个断点:
- 鼠标停在这个断点,右击这个断点,选择 Edit Breakpoint
- 在弹出的断点编辑框中的condition输入框中,添加[notification.name isEqualToString:@"NSWindowWillOrderOffScreenNotification"]
- 在Action部分,添加 po notification.object
- 如 果父xcode已经处在运行状态了,重新让它运行debug,然后在生成的子xcode中,再编译运行一个工程。父xcode中的断点应该会停在 NSWindowWillOrderOffScreenNotification通知。观察控制台输出的-[notification object]的值DVTBezelAlertPanel,这也是第一个值得你深入关注的诸多私有类中的一员
你现在知道了有一个类的名字叫DVTBezelAlertPanel,更重要的是,你知道内存中有一个这个类的实例。不过不幸的是,你找到不到任何关于这个类的头文件能够告诉你,这个类是否就是展示Xcode的警示框的。
实际上,还是可以获取到这些信息的。尽管我们没有关于这个类的头文件,可是你有一个调试器连接到子Xcode,内存中的信息照样可以告诉你关于这个类的相关信息,就如同你阅读其头文件一样。
注 意:在这个系列的教程中,LLDB的输出通常是伴随在标准的控制台输出中的。任何以(lldb)打头的行,可以认为是输入行,在此处你可以输入一些命令。 三个点…输出在控制台,表示控制台来打印不及,忽略了其中某些。如果在控制台显示了太多的打印日志,可以直接按 ? + K来清楚当前的输出,并且重新接受输出
确保你父Xcode是调试状态的,程序停在断点处,输入以下的lldb命令道父Xcode的lldb控制台:
1 2 3 |
|
这 句命令搜索任何的加载到xcode进程中的frameworks,libraries,plugins,查找名为DVTBezelAlertPanel的 类的相关信息,然后输出查找到的信息。观察搜索结果列出的方法。你是否已经能够找到一些方法可以用来关联DVTBezelAlertPanel类和子 Xocde中出现的编译成功/失败的警示框?下面我提供了一些方法的列表,这些可以帮助到你。(原文中,以下的列表是隐藏的,你可以点击展示它,作者鼓励 大家自己先找找看,如果找不到,再打开下面的这个提示,由于我使用的Markdown编辑器的限制,做不到这点,所以此处直接放出来了).
有帮助的方法
以下列出的DVTBezelAlertPanel类的方法,值得你进一步探索:
- initWithIcon:message:parentWindow:duration
- initWithIcon:message:controlView:duration:
- controlView
以上的两个初始话方法中的任何一个,基本上就可以帮助你验证是否关联了类DVTBezelAlertPanel和出现的提示框中的内容了
注意:LLDB的image lookup命令只列出在内存中实现了的方法。当你使用这个查找某些类时候,它并不包含那些继承于父类,但是子类并没有重载的方法,也就是说它只列出自己实现了的方法。
确保依然停留在父Xcode的断点出,在父类的LLDB控制台中输入以下命令来 检测contentView
1 2 |
|
控 制台输出的是nil.(⊙o⊙)…,可能是因为这个contentView在这个时候还没有被初始化吧。没关系,我们尝试下一 个:initWithIcon:message:parentWindow:duration和 initWithIcon:message:controlView:duration: ,因为你已经了解,内存中已经存在DVTBezelAlertPanel类的实例了,这意味着这两个初始化方法,已经被调用过了。你需要给这两个方法添加 调试断点,因为我们没有它的实现文件,所以这里我们用LLDB控制台来添加断点。然后再次触发这个类的初始化。
父Xcode依然停留在断点出,输入以下命令
1 2 3 4 |
|
yohunl备注:在xcode7.2.1中,显示的是
1 2 3 4 |
|
这个正则表达式形式的断点将会给上面的两个初始化方法都添加一个断点,这是因为两个方法都有一个相同的起始字符,正则表达式将匹配它们两个。别忘记上面正则表达式中空格前的\符号,还有就是用单引号‘来包含整个表达式,这样LLDB才知道怎么解析它
切换到子Xcode,重新编译子工程(ctrl+B)。父Xcode将会命中initWithIcon:message:parentWindow:duration断点
如果没有命中断点,检查一下,是不是将断点设在父Xcode中(假如你设在子xcode中,当然不起作用呀),是不是在子Xcode中编译了一个工程。,因为找不到相应的源码文件,Xcode将会断点在方法的汇编代码中。
现在,你在没有源代码的情况下,断点进入了一个方法。你需要一个方式来打印出传递给该方法的参数。是时候让我们谈一谈。。汇编了…:]
汇编之旅
当你面对私有API的时候,你要做的往往是分析寄存器(registers ),而不是像在拥有源码情况下的调试一样使用调试符号(debug symbols )。了解寄存器(registers)在x86-64架构下的行为,将会给你提供很多的帮助.
尽管不是一篇必读文章,这篇文章是一篇非常好的关于x86 Mach-0 汇编的文章。在本教程的第三部分,你将会通过方法的部分反汇编代码去了解方法到底是做什么的。不过现在,你需要的只是简单的了解。
以下的寄存器和其是怎样工作的,值得你关注:
- $rdi:这个寄存器代表传递给方法的参数self,这也是第一个传递的参数。
- $rsi:表示Selector,这是传递给的第二个参数
- $rdx:传递给函数的第三个参数,也是我们看到的Objective-C的第一个参数(因为self和Selector是隐含的参数)
- $rcx:传递给函数的第四个参数,也是我们看到的Objective-C的第2个参数(因为self和Selector是隐含的参数)
- $r8:传递给函数的第五个参数。如果需要传递更多的参数,$r9将会作为跟随之后的第6个参数的栈帧
- $rax:函数的返回值存放在此寄存器中。例如,当我们执行完方法-[aClass description],$rax将会存放aClass对象的描述NSString。
注意:以上描述的不是绝对的。在某些二进制中,会使用不同的寄存器来存放不同类型的参数,例如:doubles使用$xmm寄存器组。上面的只是作为一个快速的参考!
下面我们采用以下的方法,来将上述的理论运用到实践中来
1 2 3 4 5 6 7 8 9 10 11 |
|
使用如下的代码来执行它:
1 2 |
|
编译后,对方法aMethodWithMessage的调用将会由Runtime层准换为对objc_msgSend的调用,基本上类似于如下:
1 |
|
aClass的方法aMethodWithMessage调用,会使得一些寄存机的内容被改变:
调用方法aMethodWithMessage前
- $rdi: 存放aClass类型的一个实例变量
- $rsi:存放SEL类型的aMethodWithMessage:,实际上它是一个chra * 类型的字符串(可以通过在lldb中 输入 po (SEL)$rsi来验证)
- $rdx:包含传递给方法的message,此处,是一个字符串 @"Hello World"
当调用方法结束后
- $rax:存放方法执行后的返回值,在此处是一个NSString。在这个特定的例子中,存放的是字符串@"Hey the message is: Hello World"
x86寄存器
通 过以上的内容,你已经有了一份寄存器指南了,是时候重新审视DVTBezelAlertPanel的初始化方法 initWithIcon:message:parentWindow:duration:了。希望你的父Xcode的断点还停留在此方法处。当然,如果 不是,也没关系,重新运行子Xcode,再次停留在父类的断点initWithIcon:message:parentWindow:duration: 处。记住,你是在寻找将类DVTBezelAlertPanel和显示Xcode的编译成功/失败提示框之间的线索。
当程序断点在initWithIcon:message:parentWindow:duration处,在LLDB控制台输入以下内容
1 |
|
这条命令是register read的缩写,它是用来输出当前你机器上可见的重要寄存器内容的命令。
运 用你所学到的关于x86寄存器的知识,去查看哪个寄存器是用来存放message参数的和objc_msgSend方法的第四个参数。是否这个内容就是我 们所希望得到的警示框的提示内容呢?(原文中,以下的列表是隐藏的,你可以点击展示它,作者鼓励大家自己先找找看,如果找不到,再打开下面的这个提示,由 于我使用的markdown编辑器的限制,做不到这点,所以此处直接放出来了)
是的,你应该查看寄存器$rcx,你将会看到,它的内容就是message参数的内容,也就是显示在xcode的编译提示框中的提示信息
输入以下命令来进一步深入了解:
1 2 |
|
注 意:Xcode输出寄存器内容是采用默认的AT&T汇编格式的,在这种格式中,源操作符和目标操作符的位置是交换过的,意思是AT&T语 法第一个为源操作数,第二个为目的操作数,方向从左到右,这个同Intel的汇编格式是相反的。(译者注:关于AT&T汇编,可以参考http://blog.csdn.net/bigloomy/article/details/6581754)
看起来这个就是我们要找的寄存器呀!
试着更改$rcx的内容为一个新的字符串,看看是不是警示框的内容改变了:
1 2 3 4 5 6 7 8 |
|
应用程序将恢复运行。注意观察显示的编译成功/失败的提示框的内容是不是变成了我们修改的字符串。你将会看到,它的确是变成了我们设置的新字符串,这也验证了我们的假定- DVTBezelAlertPanel就是用来显示这个提示信息的。
代码注入(Injection)
你已经找到了你所需要的类,是时候,我们通过代码注入来扩展DVTBezelAlertPanel的行为,在编译提示框中展示lovely Rayrolling(人名)的头像。
我们采用的是 metthod swizzling技术。
你可能要swizzle来自很多不同的类的大量的方法,所以最佳建议是创建一个NSObject的category,在其中提供一个便捷的方法,来建立所有的swizzle逻辑。
在Xocde中,选择File\New\File…,然后选择 OS X\Source\Objective-C File,建立名称为MethodSwizzler的文件,确保它的形式是NSObject的category。
打开NSObject+MethodSwizzler.m,将其替换成如下的代码:
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 |
|
其中的关键代码都加了序号,下面一一解释:
- 这是使用method swizzle所需要的头文件
- isClassMethod指示,这个方法是实例方法还是类方法
- 如果不借助于编译器的方法提示,那很容易拼错上述的方法。这段代码是用来检查的,确保你的拼写是正确的
- 这个是关键函数,用来交换方法的实现的
在头文件NSObject+MethodSwizzler。h中添加方法swizzleWithOriginalSelector:swizzledSelector:isClassMethod的声明,如下所示:
1 2 3 4 5 |
|
接下来,就可以完成实际的swizzle了。创建一个新的名为Rayrolling_DVTBezelAlertPanel的category,这个category同样是NSObject的category。
替换创建的NSObject+Rayrolling_DVTBezelAlertPanel。m的代码为如下代码
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 33 34 35 |
|
上面的代码比较简单,我们来分析:
- 引入用来swizzling的头文件
- 前向声明所有你打算使用到的方法。虽然这不是必须的,但是这个使得编译器能够智能感知完成你的代码,另外,这也消除了编译器提示的找不到方法声明的警告
- 这是你实际上需要swizzle的方法
- 因为我们不想重新声明一个私有类,替代的方式是声明一个category。
- 这个方法是触发代码注入的地方。你应该将代码注入都放到load中。这个load是唯一的一个"一对多关系"的方法,也就是说,多个category都有load方法,那么所有的category的load方法都能够得到执行
- 因为load可能会被多次执行,所以,使用dispatch_once确保只执行一次
- swizzle前面声明的方法为你自己的实现。当然了,其中使用了NSClassFromString来动态的获取内存中的类!
- 这是你写的用来取代原来的方法的方法,建议是它采用独特的命令方式,这样从名字立马知道它是做什么的
- 输出一下,确保swizzle成功了
- 因 为你已经swizzle了原始的方法,那么你调用swizzled后的方法(此处是[self Rayrolling_initWithIcon:icon message:message parentWindow:window duration:duration];),它将会调用的是原来的方法。这意味着你在原来的方法执行之前或者之后,添加任何你想要的代码,甚至是更改传递 给原始方法的参数…当然了,这里你已经在这么做了
祝贺你,你已经成功的注入代码到一个私有类的私有方法中了!编译父xcode,然后在子xcode中编译运行一个工程,查看父xcode的控制台输出,看是不是成功的wswizzled了。
1 |
|
接下来,你就可以替换编译成功/失败的提示框上的图标为Rayrolling头像啦。从这里下载头像资源Crispy from here,然后添加到工程中来,确保选择了 Copy Items if Needed。
现在,导航到方法Rayrolling_initWithIcon:message:parentWindow:duration,将其代码改为:
1 2 3 4 5 6 7 8 9 |
|
这 个方法首先检查是否一个图片参数被传递给了原方法,然后将其替换成我们自定义的图片。注意:此处你是使用[NSBundle bundleWithIdentifier:@"com。raywenderlich。Rayrolling"];来加载图片的,这是因为xcode的 MainBundle并不包含我们的资源。
重新编译父Xcode,然后在子Xcode中编译一个工程,你会看到
添加一个开关和持久化
设计这个plugin是用来娱乐用的,所以你肯定需要一个开关,让它起作用或者不起作用。我们通过NSUserDefaults来持久化存放使它起作用或者不起作用的变量.
导航到Rayrolling.h ,添加如下代码
1 |
|
在Rayrolling.m文件中添加
1 2 3 4 5 6 7 |
|
你已经有了持久化你的选择的逻辑,下面是将它关联到GUI上去
回到Rayrolling.m中,修改-(void)doMenuAction的代码为下面的:
1 2 3 4 |
|
这是个用来切换的bool值,启用或者禁用Rayrolling
最后,更改在didApplicationFinishLaunchingNotification:中的菜单项的初始化代码,改为如下:
1 2 3 4 5 6 7 8 |
|
这个菜单项将会保留你选择的是否启用的逻辑,即使Xcode重启后也没关系,因为你的选择已经持久化存储了。
导航到文件NSObject+Rayrolling_DVTBezelAlertPanel.m,添加一行头文件
1 |
|
最后,打开方法Rayrolling_initWithIcon:message:parentWindow:duration:,将
1 |
|
替换为
1 |
|
构建并运行程序,以便更改插件的行为。
现在,你创建了一个可以用来改变xcode编译成功/失败提示框图标和内容的插件,并且它还是能够被选择是否打开。这一天的工作成果相当不错,难道不是吗???
接下来做什么?
你可以从这里 下载完整的demo工程。
你已经取得了很大的进步,但是依然有很多事情要做!在本教程的第二部分,你将会学习到DTrace基本知识,并且深入到一些LLDB的高级特性,诸如查找正在运行的进程,比如正在运行的xcode进程。
如果你想更进一步,那么在你前往教程3之前,你还有一些工作要做。在教程3中,你将会看到大量的汇编代码。确保在这之前,你已经开始了解了相关的x86_64汇编知识,这里有2篇Mike Ash的介绍分析汇编的系列文章 文章1,文章2,可以给你提供相关的帮助。