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

(你們千萬不要像我同樣,沒看清除,直接添加到最後面了。它是有三個分類的,必定要看清楚,要添加到插件的分類上。)

相關文章
相關標籤/搜索