Xcode7 插件開發:從開發到pull到Alcatraz

srvp6842 8年前發布 | 10K 次閱讀 Xcode 移動開發

來自: 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];</pre>

我們需要一個狀態來表示插件的開關,剛好 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];  
    }  
}  

} </pre>

效果類似于如下:

除了這種做法之外,我用的是 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);</pre>

    textStorage 中字符或者描述被修改之后,會觸發這個代理,那我們實現一下這個代理方法:

    self.fkConsoleTextView.textStorage.delegate = self;

  • (void)textStorage:(NSTextStorage *)textStorage willProcessEditing:(NSTextStorageEditActions)editedMask

            range:(NSRange)editedRange
    

    changeInLength:(NSInteger)delta {

}</pre>

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]);
} </pre>

我們這里只需要監聽 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;</pre>

    我給 IDEConsoleTextView 在運行時添加了以下的方法:

    - (void)fk_insertText:(id)arg1;

  • (void)fk_insertNewline:(id)arg1;
  • (void)fk_clearConsoleItems;
  • (BOOL)fk_shouldChangeTextInRanges:(id)arg1 replacementStrings:(id)arg2;</pre>

    之后,使用 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);
      }
    

    } }</pre>

    在 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]; }</pre>

    這樣,就解決了第二個問題。

    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"
    

    }</pre>

    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

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

    </div>

 本文由用戶 srvp6842 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!