Xcode7 插件开发:从开发到pull到Alcatraz

博文地址:http://ifujun.com/xcode7-cha-jian-kai-fa-cong-kai-fa-dao-pulldao-alcatraz/git

开发

Xcode很强大,可是有些封闭,官方并无提供Xcode插件开发的文档。喵神的教程比较全,也比较适合入门。本文的教程只是做为我在开发FKConsole的过程当中的总结,并不会很全面。github

FKConsole是我开发的一个用于在Xcode控制台显示中文的插件,很小,很简单。这个插件开发的初衷是由于一个朋友有这种需求,而又没有找到相应的插件。若是不使用插件,就要在工程中嵌入文件,他并不乐意。因此FKConsole在设计上只会去修改Xcode控制台内的文字显示,毫不会去修改你的文件,这点你们能够放心。objective-c

模板

由于如今已经有不少人作Xcode插件开发了,因此插件模板这种东西也就应运而生了。json

Xcode-Plugin-Template是一个Xcode插件开发的基本模板,可使用Alcatraz直接安装,支持Xcode 6+。xcode

安装完成以后,在建立工程的时候,会出现一个Xcode Plugin的选项,这个就是Xcode的插件工程模板。ruby

模板会生成NSObject_Extension和你的工程名称同样的两个文件(.m)。框架

NSObject_Extension.m中的+ (void)pluginDidLoad:(NSBundle *)plugin方法也是整个插件的入口。ide

通常来讲,咱们但愿咱们的插件是存活于整个Xcode的生命周期的,因此通常是一个单例,这个在另外一个文件中会有体现。工具

添加按钮

这篇博文是记录FKConsole开发过程的,天然以此举例。测试

Xcode启动以后,会发出NSApplicationDidFinishLaunchingNotification的通知,模板上已经作了监听,咱们在程序启动以后要在头部工具栏上加一个FKConsole的选项,以设置FKConsole插件的开关。

Mac软件开发和iOS开发有一些不一样,它使用的是AppKit的UI库,而不是UIKit,因此可能会感受有些别扭。

NSApp中的[NSApp mainMenu]方法能够获取到头部的主按钮,里面会包含颇有NSMenuItem,咱们将在Xcode的Window选项以前插入一个Plugins选项(参考破博客的作法),而后在这个选项中添加一个FKConsole的选项。(之因此添加一个Plugins选项是由于有些插件会添加到Edit中,有些会添加到ViewWindow中,我找半天都没找到选项在哪,还不如直接建一个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的那种图层查看工具。喵神推荐了一个NSViewDumping 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,这是facebook开源的一个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-\>NSTextViewNSTextView中保存文字内容使用的是NSTextStorage *textStorage,因此咱们要修改的是IDEConsoleTextViewtextStorage。可是咱们在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属性。

运行,确实能够,可是有一些问题。

  1. 若是使用findView的方式去查找IDEConsoleTextView,而后去设置代理的话,那么,在何时去findView呢,若是这时候又新打开几个页面呢,这是不肯定的。

  2. 修改后的文字长度和原先的不同,哪怕修改了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-packagesAlcatraz的包仓库列表,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-packagesPull Request上了:

https://github.com/alcatraz/alcatraz-packages/pull/461

(你们千万不要像我同样,没看清除,直接添加到最后面了。它是有三个分类的,必定要看清楚,要添加到插件的分类上。)

相关文章
相关标签/搜索