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];</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 屬性。
運行,確實可以,但是有一些問題。
- 如果使用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]);
} </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>