Xcode7 插件开发:从开发到pull到Alcatraz
来自: http://ifujun.com/xcode7-cha-jian-kai-fa-cong-kai-fa-dao-pulldao-alcatraz/
开发
Xcode很强大,但是有些封闭,官方并没有提供Xcode插件开发的文档。 喵神的教程 比较全,也比较适合入门。本文的教程只是作为我在开发 FKConsole 的过程中的总结,并不会很全面。
FKConsole 是我开发的一个用于在Xcode控制台显示中文的插件,很小,很简单。这个插件开发的初衷是因为一个朋友有这种需求,而又没有找到相应的插件。如果不使用插件,就要在工程中嵌入文件,他并不乐意。所以 FKConsole 在设计上只会去修改Xcode控制台内的文字显示,绝不会去修改你的文件,这点大家可以放心。
模板
因为现在已经有很多人做Xcode插件开发了,所以插件模板这种东西也就应运而生了。
Xcode-Plugin-Template 是一个Xcode插件开发的基本模板,可以使用 Alcatraz 直接安装,支持Xcode 6+。
安装完成之后,在创建工程的时候,会出现一个Xcode Plugin的选项,这个就是Xcode的插件工程模板。
模板会生成 NSObject_Extension 和你的工程名称一样的两个文件(.m)。
NSObject_Extension.m 中的 + (void)pluginDidLoad:(NSBundle *)plugin 方法也是整个插件的入口。
一般来说,我们希望我们的插件是存活于整个Xcode的声明周期的,所以一般是一个单例,这个在另一个文件中会有体现。
添加按钮
这篇博文是记录 FKConsole 开发过程的,自然以此举例。
Xcode启动之后,会发出 NSApplicationDidFinishLaunchingNotification 的通知,模板上已经做了监听,我们在程序启动之后要在头部工具栏上加一个 FKConsole 的选项,以设置 FKConsole 插件的开关。
Mac软件开发和iOS开发有一些不同,它使用的是 AppKit 的UI库,而不是 UIKit ,所以可能会感觉有些别扭。
NSApp 中的 [NSApp mainMenu] 方法可以获取到头部的主按钮,里面会包含很有 NSMenuItem ,我们将在Xcode的 Window 选项之前插入一个 Plugins 选项(参考 破博客 的做法),然后在这个选项中添加一个 FKConsole 的选项。(之所以添加一个 Plugins 选项是因为有些插件会添加到 Edit 中,有些会添加到 View 、 Window 中,我找半天都没找到选项在哪,还不如直接建一个 Plugins 选项,用户一眼就能知道插件在哪。)
NSMenu *mainMenu = [NSApp mainMenu]; if (!mainMenu) { return; } NSMenuItem *pluginsMenuItem = [mainMenu itemWithTitle:@"Plugins"]; if (!pluginsMenuItem) { pluginsMenuItem = [[NSMenuItem alloc] init]; pluginsMenuItem.title = @"Plugins"; pluginsMenuItem.submenu = [[NSMenu alloc] initWithTitle:pluginsMenuItem.title]; NSInteger windowIndex = [mainMenu indexOfItemWithTitle:@"Window"]; [mainMenu insertItem:pluginsMenuItem atIndex:windowIndex]; } NSMenuItem *subMenuItem = [[NSMenuItem alloc] init]; subMenuItem.title = @"FKConsole"; subMenuItem.target = self; subMenuItem.action = @selector(toggleMenu:); subMenuItem.state = value.boolValue?NSOnState:NSOffState; [pluginsMenuItem.submenu addItem:subMenuItem];
我们需要一个状态来表示插件的开关,刚好 NSMenuItem 上有一个 state 可以表示状态,而刚好显示效果也不错,我们就用它了。
图层
按钮添加完之后,我们现在需要获取到控制台的实例。很遗憾,苹果并没有给出文档。
很抱歉,我没有找到Mac软件开发上类似于Reveal的那种图层查看工具。喵神推荐了一个 NSView 的 Dumping Category ,代码如下:
来自于 http://onevcat.com/2013/02/xcode-plugin/ 。
-(void)dumpWithIndent:(NSString *)indent { NSString *class = NSStringFromClass([self class]); NSString *info = @""; if ([self respondsToSelector:@selector(title)]) { NSString *title = [self performSelector:@selector(title)]; if (title != nil && [title length] > 0) { info = [info stringByAppendingFormat:@" title=%@", title]; } } if ([self respondsToSelector:@selector(stringValue)]) { NSString *string = [self performSelector:@selector(stringValue)]; if (string != nil && [string length] > 0) { info = [info stringByAppendingFormat:@" stringValue=%@", string]; } } NSString *tooltip = [self toolTip]; if (tooltip != nil && [tooltip length] > 0) { info = [info stringByAppendingFormat:@" tooltip=%@", tooltip]; } NSLog(@"%@%@%@", indent, class, info); if ([[self subviews] count] > 0) { NSString *subIndent = [NSString stringWithFormat:@"%@%@", indent, ([indent length]/2)%2==0 ? @"| " : @": "]; for (NSView *subview in [self subviews]) { [subview dumpWithIndent:subIndent]; } } }
效果类似于如下:
除了这种做法之外,我用的是 chisel ,这是非死book开源的一个LLDB的命令行辅助调试的工具。里面包含有一个 pviews 命令,可以直接递归打印整个 key window ,效果如下:
导入私有API
我们在里面找到了一个叫做 IDEConsoleTextView 的类,这是在上图中看到的所有View中唯一包含 Console 这个关键字的,我们查看一下它的frame,确定控制台就是它。
苹果并没有给将这个 IDEConsoleTextView 放到 AppKit 中,它是一个私有类,我们现在想要修改它,那么就需要拿到它的头文件。
Github上有很多dump出来的Xcode header,大家可以看一下: https://github.com/search?utf8=%E2%9C%93&q=xcode+header 。我们在header中找到了 IDEConsoleTextView.h ,处于 IDEKit 中。
在头文件中可以看到, IDEConsoleTextView 是继承自 DVTCompletingTextView -> DVTTextView -> NSTextView 。 NSTextView 中保存文字内容使用的是 NSTextStorage *textStorage ,所以我们要修改的是 IDEConsoleTextView 的 textStorage 。但是我们在 NSTextStorage 的头文件中并没有找到具体文字保存的属性,那我们这就去找。
功能开发
我们循环遍历所有的 NSView ,找到 IDEConsoleTextView ,我们看一下它的信息:
我们没有找到它的 textStorage 属性,我们尝试在控制台中打一下:
它是有这个属性的,只是在debug区没有看到。
textStorage 的delegate中有两个方法,分别是:
// Sent inside -processEditing right before fixing attributes. Delegates can change the characters or attributes. - (void)textStorage:(NSTextStorage *)textStorage willProcessEditing:(NSTextStorageEditActions)editedMask range:(NSRange)editedRange changeInLength:(NSInteger)delta NS_AVAILABLE(10_11, 7_0); // Sent inside -processEditing right before notifying layout managers. Delegates can change the attributes. - (void)textStorage:(NSTextStorage *)textStorage didProcessEditing:(NSTextStorageEditActions)editedMask range:(NSRange)editedRange changeInLength:(NSInteger)delta NS_AVAILABLE(10_11, 7_0);
textStorage 中字符或者描述被修改之后,会触发这个代理,那我们实现一下这个代理方法:
self.fkConsoleTextView.textStorage.delegate = self; - (void)textStorage:(NSTextStorage *)textStorage willProcessEditing:(NSTextStorageEditActions)editedMask range:(NSRange)editedRange changeInLength:(NSInteger)delta { }
OK,这次我们找到了, IDEConsoleTextView 中有一个 _contents 属性,这是一个继承自 NSMutableAttributedString 的类,这个里面的 mutableString 保存文字, mutableAttributes 保存对文字的描述。我们需要修改的就是这个 mutableString 属性。
我们在代理方法中使用 valueForKeyPath: 可以获取到 mutableString 属性,那么,现在我们将它进行转换。
FKConsole 是用来调整控制台中文显示的,目的是将类似于这种的Unicode编码( \U6d4b\U8bd5" )修改为( "测试啊" )这种的正常显示。
我在 stackoverflow 上找到一种解决办法。代码类似于这样:
来自于 http://stackoverflow.com/questions/13240620/uilabel-text-with-unicode-nsstring
- (NSString *)stringByReplaceUnicode:(NSString *)string { NSMutableString *convertedString = [string mutableCopy]; [convertedString replaceOccurrencesOfString:@"\\U" withString:@"\\u" options:0 range:NSMakeRange(0, convertedString.length)]; CFStringRef transform = CFSTR("Any-Hex/Java"); CFStringTransform((__bridge CFMutableStringRef)convertedString, NULL, transform, YES); return convertedString; }
我们使用 setValue:forKeyPath: 的方式去修改 mutableString 属性。
运行,确实可以,但是有一些问题。
- 如果使用findView的方式去查找 IDEConsoleTextView ,然后去设置代理的话,那么,在什么时候去findView呢,如果这时候又新打开几个页面呢,这是不确定的。
- 修改后的文字长度和原先的不一样,哪怕修改了 editedRange 也没有用。这样的话,如果在控制台上输入文字或者调试命令,可能会崩溃,崩溃的主要原因是 IDEConsoleTextView 用 _startLocationOfLastLine 和 _lastRemovableTextLocation 这两个属性去控制文字起始位置和删除位置,在设置 mutableString 之后,由于长度不一,可能会发生字符串取值越界的问题,而 NSTextStorage 的代理中又是获取不到持有它的 IDEConsoleTextView 的。
监听通知
针对第一个问题,我们可以使用通知的方式去解决。
参照喵神的博客,可以监听全部的通知,然后去查找哪个是你所需要的。
-(id)init { if (self = [super init]) { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(notificationListener:) name:nil object:nil]; } return self; } -(void)notificationListener:(NSNotification *)noti { NSLog(@" Notification: %@", [noti name]); }
我们这里只需要监听 NSTextDidChangeNotification 就行,然后在方法内去判断一下,之后再设置代理。
- (void)textStorageDidChange:(NSNotification *)noti { if ([noti.object isKindOfClass:NSClassFromString(@"IDEConsoleTextView")] && ((IDEConsoleTextView *)noti.object).textStorage.delegate != self) { ((IDEConsoleTextView *)noti.object).textStorage.delegate = self; } }
这样就解决了第一个问题。
Add Method and Method Swizzling
这里有兴趣的话,可以参考我另外一篇博客: Objective-C runtime常见用法 ,里面以举例的方式讲解了常见的runtime用法。
针对第二个问题,我采用的办法是在适当的时候去修改 IDEConsoleTextView 的 _startLocationOfLastLine 和 _lastRemovableTextLocation 属性。经实验,崩溃的方法主要是 IDEConsoleTextView 的这些方法:
- (void)insertText:(id)arg1; - (void)insertNewline:(id)arg1; - (void)clearConsoleItems; - (BOOL)shouldChangeTextInRanges:(id)arg1 replacementStrings:(id)arg2;
我给 IDEConsoleTextView 在运行时添加了以下的方法:
- (void)fk_insertText:(id)arg1; - (void)fk_insertNewline:(id)arg1; - (void)fk_clearConsoleItems; - (BOOL)fk_shouldChangeTextInRanges:(id)arg1 replacementStrings:(id)arg2;
之后,使用 JRSwizzle 来交换、混合方法,类似于这样:
- (void)addMethodWithNewMethod:(SEL)newMethod originMethod:(SEL)originMethod { Method targetMethod = class_getInstanceMethod(NSClassFromString(@"IDEConsoleTextView"), newMethod); Method consoleMethod = class_getInstanceMethod(self.class, newMethod); IMP consoleIMP = method_getImplementation(consoleMethod); if (!targetMethod) { class_addMethod(NSClassFromString(@"IDEConsoleTextView"), newMethod, consoleIMP, method_getTypeEncoding(consoleMethod)); if (originMethod) { NSError *error; [NSClassFromString(@"IDEConsoleTextView") jr_swizzleMethod:newMethod withMethod:originMethod error:&error]; NSLog(@"error = %@", error); } } }
在 fk_ 开头的系列方法中,添加了对 IDEConsoleTextView 的检查:
- (void)fk_checkTextView:(IDEConsoleTextView *)textView { if (textView.textStorage.length < [[textView valueForKeyPath:kStartLocationOfLastLineKey] longLongValue]) { [textView setValue:@(textView.textStorage.length) forKeyPath:kStartLocationOfLastLineKey]; } if (textView.textStorage.length < [[textView valueForKeyPath:kLastRemovableTextLocationKey] longLongValue]) { [textView setValue:@(textView.textStorage.length) forKeyPath:kLastRemovableTextLocationKey]; } } - (void)fk_insertText:(id)arg1 { [self fk_checkTextView:(IDEConsoleTextView *)self]; [self fk_insertText:arg1]; }
这样,就解决了第二个问题。
OK, FKConsole 这就基本开发完成了。
Alcatraz
上文也提到了, Alcatraz 是一个开源的Xcode包管理器。事实上, Alcatraz 也成为了我们目前安装Xcode插件的最主要的工具。
现在我们将 FKConsole 提交到 Alcatraz 上。
填写
alcatraz-packages 是 Alcatraz 的包仓库列表, packages.json 保存了所有 Alcatraz 支持的插件、色彩主题、模板。
我们fork一下 alcatraz-packages 到 我们的代码仓库 中。之后,仿照这种格式,添加上我们的项目。
{ "name": "FKConsole", "url": "https://github.com/Forkong/FKConsole", "description": "FKConsole is a plugin for Xcode to adjust console display(about Chinese).", "screenshot": "https://raw.githubusercontent.com/Forkong/FKConsole/master/Screenshots/demo.gif" }
respec
rspec 是用ruby写的一个测试框架,这里作者写了一个用于测试你修改过后的 packages.json 是否合法的脚本。直接切到 alcatraz-packages 目录下,运行 rspec 命令即可。通过的话,会这样显示:
rspec 使用ruby的gem就能直接装上。
pull
校验没有问题之后,我们 Pull Request ,我们的提交就出现在 alcatraz-packages 的 Pull Request 上了:
https://github.com/alcatraz/alcatraz-packages/pull/461
(大家千万不要像我一样,没看清除,直接添加到最后面了。它是有三个分类的,一定要看清楚,要添加到插件的分类上。)