插件是給你已經(jīng)發(fā)布的 App 增加功能的一個好辦法,Mac 上的 App 支持插件已經(jīng)有很長的歷史了,比如 Adobe Photoshop,在 1991 年的 version 2.0 就開始支持了。
在以前的 OS X 系統(tǒng)中,給你的 App 在運(yùn)行時動態(tài)載入可執(zhí)行代碼比較困難?,F(xiàn)在,在 NSBundle 的幫助和你的一些前瞻性思維的幫助下下,它從未如此簡單。
如果你打開 Xcode 5 并且創(chuàng)建一個新項(xiàng)目,你會看見 OS X 選項(xiàng)卡下有一個 "Application Plug-in" 的分類和 "System Plug-in" 的分類,從 Screen Savers 到 Image Units,在 Xcode 里面一共有 12 中不同的模板可以編寫 App 的插件。如果你點(diǎn)擊 "Framework & Library" 的選項(xiàng)卡,你將可以看見一個 Bundle 條目。我會在今天探索一個非常簡單的的項(xiàng)目,那就是在一個修改過的 TextEdit 里面加入加載 bundle 的功能。
注意:Apple 稱這些為 plug-ins ,而通常大家更喜歡用 plugins 稱呼。為了一致性,在開發(fā)和 UI 相關(guān)的東西的時候,我想用和平臺一致的 plug-in 稱呼會更好。雖然在應(yīng)用的 UI 里你會看到 "plug-ins",但是在這篇文章和代碼里面,我會用 plugin。(同時我偶爾會混用 bundle 和 plugin 這兩個詞。)(譯者注:在本譯文中會把 plugin 統(tǒng)一翻譯成插件,偉大的中文)
什么是 bundle ?如果你創(chuàng)建一個 Xcode 的 bundle 模版項(xiàng)目,你會發(fā)現(xiàn)它內(nèi)容并不多。當(dāng)構(gòu)建它的時候你會得到一個很像構(gòu)建 App 時產(chǎn)生的目錄 —— 一個 Contents 目錄,里面包含了 Info.plist 和 Resource 目錄。如果你在你的項(xiàng)目下加入了新的類,你可以看見包含一個可執(zhí)行文件的 MacOS 目錄。Bundle 工程里缺少的一個東西是 main() 函數(shù)。它是被宿主 App 調(diào)用執(zhí)行的。
我會介紹兩種插件的方式,第一個用最少的工作來為你的 app 加入插件支持,希望讓你知道實(shí)現(xiàn)這個有多簡單。
第二個技術(shù)有點(diǎn)復(fù)雜,它展現(xiàn)來一個為你的 app 加入插件的合理的方式,這可以使你不會在未來陷入到被鎖死在某一種實(shí)現(xiàn)的窘境中。
本文章的項(xiàng)目文件仍然會放在 GitHub 供大家參考。
請打開 "01 TextEdit" 目錄下面的 TextEdit.xcodeproj 工程,同時瀏覽它里面包含的代碼。
這個改寫過的 TextEdit 里面有三個簡單的組成部分:掃描 bundle,加載 bundle,并且添加了調(diào)用 bundle 的 UI。
打開 Controller.m,你可以看見 -(void)loadPlugins 方法 (它在 applicationDidFinishLaunching: 中被調(diào)用)。
loadPlugins 方法在你的界面菜單右側(cè)加入了一個新的 NSMenuItem,來為你調(diào)用你的插件提供一個入口(通常你會在 MainMenu.xib 做這件事情并且鏈接 outlets,但是我們這次偷下懶)。然后獲得你的插件目錄(在 ~/Library/Application Support/Text Edit/Plug-Ins/ )下,并且掃描這個目錄。
NSString *pluginsFolder = [self pluginsFolder];
NSFileManager *fm = [NSFileManager defaultManager];
NSError *outErr;
for (NSString *item in [fm contentsOfDirectoryAtPath:pluginsFolder error:&outErr]) {
if (![item hasSuffix:@".bundle"]) {
continue;
}
NSString *bundlePath = [pluginsFolder stringByAppendingPathComponent:item];
NSBundle *b = [NSBundle bundleWithPath:bundlePath];
if (!b) {
NSLog(@"Could not make a bundle from %@", bundlePath);
continue;
}
id <TextEditPlugin> plugin = [[b principalClass] new];
NSMenuItem *item = [pluginsMenu addItemWithTitle:[plugin menuItemTitle] action:@selector(pluginMenuItemCalledAction:) keyEquivalent:@""];
[item setRepresentedObject:plugin];
}
到目前,看起來是非常簡單的。掃描插件目錄,確保得到的是一個 .bundle 文件(你當(dāng)然不希望載入 .DS_Store 文件),然后用 NSBundle 載入你找到的 bundle 并且實(shí)例化里面的類。
你會注意到一個 TextEditPlugin 的 protocol 的引用。在 TextEditMisc.h 能找它的定義:
@protocol TextEditPlugin <NSObject>
- (NSString*)menuItemTitle;
- (void)actionCalledWithTextView:(NSTextView*)textView inDocument:(id)document;
@end
這說明你實(shí)例化的類需要響應(yīng)這兩個方法。你可以驗(yàn)證這個類是否響應(yīng)這兩個方法(這是一個好主意),但是簡單起見,我們現(xiàn)在就不這樣做了。
OK,你在 bundle 里面調(diào)用的 principalClass 方法是什么呢?當(dāng)你創(chuàng)建一個 Bundle 的時候,你可以在里面創(chuàng)建一個或者多個類,同時你需要讓 TextEdit 知道哪一個類需要被實(shí)例化。為了幫助宿主 App 調(diào)用,你可以在 Info.plist 文件加入一個 NSPrincipalClass 的鍵,同時設(shè)置它的值為實(shí)現(xiàn)插件方法的類的名字。你可以用 [NSBundle principalClass] 方便地從 NSPrincipalClass 的值里面尋找并創(chuàng)建這個類。
繼續(xù):在 Plug-Ins 菜單加入一個新的按鈕,設(shè)置 action 為 pluginMenuItemCalledAction:,并且設(shè)置它表示你已經(jīng)實(shí)例化的對象。
注意你沒有在 menu item 里面設(shè)置一個target。如果一個menu item的目標(biāo)是nil,那么它會尋找響應(yīng)鏈,來尋找第一個實(shí)現(xiàn) pluginMenuItemCalledAction: 方法的對象。如果它找不到,那么這個菜單選項(xiàng)將會不能用。
舉一個例子,實(shí)現(xiàn) pluginMenuItemCalledAction 的最好的地方是在 Document 的 window controller 類中。打開 DocumentWindowController.m,然后定位到到 pluginMenuItemCalledAction
- (void)pluginMenuItemCalledAction:(id)sender {
id <TextEditPlugin>plugin = [sender representedObject];
[plugin actionCalledWithTextView:[self firstTextView] inDocument:[self document]];
}
代碼本身很清晰,搜集插件實(shí)例,調(diào)用 actionCalledWithTextView:inDocument: 方法(被定義在 protocol 里面的),運(yùn)行你插件里面的代碼。
打開 "01 MarkYellow" 工程看一下。這是一個 Xcode (通過OS X ? Framework & Library ? Bundle template 建立) 的標(biāo)準(zhǔn)工程,里面只添加了一個類:TEMarkYellow。
如果你打開 MarkYellow-Info.plist,你可以看到 NSPrincipalClass 的值設(shè)置成了上面提到的 TEMarkYellow。
接著,打開 TEMarkYellow.m,你將會看見定義在協(xié)議里面的方法。一個返回你插件的名字,就是在 menu 里面顯示的那個,更有意思的是另外一個方法 (actionCalledWithTextView:inDocument:),它把所有選中的文字變成黃色的背景。
- (void)actionCalledWithTextView:(NSTextView*)textView inDocument:(id)document {
if ([textView selectedRange].length) {
NSMutableAttributedString *ats = [[[textView textStorage] attributedSubstringFromRange:[textView selectedRange]] mutableCopy];
[ats addAttribute:NSBackgroundColorAttributeName value:[NSColor yellowColor] range:NSMakeRange(0, [ats length])];
// 先測試text view是否能改變文字內(nèi)容,這樣可以自動做正確的撤銷操作。
By asking the text view if you can change the text first, it will automatically do the right thing to enable undoing of attribute changes
if ([textView shouldChangeTextInRange:[textView selectedRange] replacementString:[ats string]]) {
[[textView textStorage] replaceCharactersInRange:[textView selectedRange] withAttributedString:ats];
[textView didChangeText];
}
}
}
運(yùn)行 TextEdit (它會創(chuàng)建Plug-Ins目錄),然后構(gòu)建 MarkYellow 工程。把 MarkYellow.bundle 丟到你的 ~/Library/Application Support/Text Edit/Plug-Ins/ 目錄下面,重啟你的 TextEdit 應(yīng)用。
一切看起來都很好,掃描,加載,插入一個菜單,然后,當(dāng)你使用菜單項(xiàng)的時候,傳遞到參數(shù)到插件里面。試一試,點(diǎn)擊 Plug-Ins ? Mark Selected Text Yellow,選擇的文字的背景顏色就變成黃色的了。
這真是令人驚嘆,但是其實(shí)它很脆弱,也不夠先進(jìn)。
所以關(guān)掉這兩個項(xiàng)目,扔進(jìn)廢紙簍,然后嘗試忘掉它們吧。
上述的途徑有什么問題?
Bundle 中只有一個方法被調(diào)用。對于插件的作者來說太不方便了。有沒有更簡單的方法為 bundle 加入更多功能和菜單按鈕呢?
這一次,我們先從 bundle 開始探究。打開 02 MarkYellow 里面的 xcodeproj 工程,定位到 TEMarkYellow.m, 你馬上可以看見這里有更多代碼,同時它也做了更多事情。
這里實(shí)現(xiàn)了一個接收一個 interface 作為參數(shù)的 pluginDidLoad: 方法,而不是返回插件名字的方法。你可以用它來告訴 TextEdit 你的方法名字和調(diào)用它們的時候使用的 selector ,以及一個幫助存儲一些特別的文本操作的狀態(tài)的 user object。
這個插件從單一行為變成了實(shí)現(xiàn)了三個操作:一個把你的文本變成黃色,一個把你的文字變成藍(lán)色,一個把你選中的文本作為 AppleScript 運(yùn)行。我們充分發(fā)揮了 userObject 這個參數(shù)的優(yōu)點(diǎn),所以只需要實(shí)現(xiàn)兩個方法。
這個方法比第一種有擴(kuò)展性。然而,它也增加了 app 端的復(fù)雜度。
打開 02 TextEdit 看看 Controler.m , 它沒有做太多事情。但是它在 applicationDidFinishLaunching: 設(shè)置了一個新的類,叫 PluginManager,打開 PluginManager.m 并且導(dǎo)航到 -loadPlugins 里面。
這個和剛才的 -loadPlugins 幾乎一樣,只不過代替了原來使用 for 循環(huán)加入菜單項(xiàng),現(xiàn)在只對從 bundle 中取到的 principalClass 進(jìn)行初始化,然后調(diào)用了 pluginDidLoad:,以此來驅(qū)動影響 TextEdit 的執(zhí)行。
看一下 -addPluginsMenuWithTitle:…,我們在這里創(chuàng)建了菜單項(xiàng)。并且這里不再設(shè)置菜單項(xiàng)的 representedObject 為插件實(shí)例本身,而是實(shí)例化一個 helper 類 (PluginTarget),同時關(guān)聯(lián)了對 text aciton 和 friends 的引用,然后設(shè)置它為菜單項(xiàng)的 representedObject 以備使用。
然而,設(shè)置到菜單項(xiàng)的 selector 仍然還是 pluginMenuItemCalledAction:,可以在 DocumentWindowController.m 里面看看這個方法到底干了什么:
- (void)pluginMenuItemCalledAction:(id)sender {
PluginTarget *p = [sender representedObject];
NSMethodSignature *ms = [[p target] methodSignatureForSelector:[p action]];
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:ms];
NSTextView *tv = [self firstTextView];
id document = [self document];
id userObject = [p userObject];
[invocation setTarget:[p target]];
[invocation setSelector:[p action]];
[invocation setArgument:&tv atIndex:2];
[invocation setArgument:&document atIndex:3];
[invocation setArgument:&userObject atIndex:4];
[invocation invoke];
}
因?yàn)槟阋幚砀嘈畔ⅲ赃@個版本相比之前的實(shí)現(xiàn)有一點(diǎn)復(fù)雜,創(chuàng)建一個 NSInvocation,設(shè)置它的參數(shù),然后從插件的實(shí)例里面調(diào)用它。
宿主 (app) 端需要更多工作,但是對于插件的作者來說寫插件更加靈活了。
基于這個接口,你可以寫一個插件,加載其他的自定義的插件。假設(shè)你想要加入讓你的用戶用 Javascript 寫插件的功能,那么在 pluginDidLoad 調(diào)用之后,掃描指定目錄下面的 js 文件,在 addPluginsMenuWithTitle:… 中為每一個 js 文件增加對應(yīng)的條目,然后,當(dāng)插件被調(diào)用的時候,可以用 JavaScriptCore 來執(zhí)行對應(yīng)的腳本 。你也可以用 Python,Ruby,Lua 來做這些事情(我之前做過這些事情)。
“插件讓安全的人抽搐” — 匿名
一個顯而易見但是容易被忽略的事情是安全。當(dāng)你在你的進(jìn)程里面加載一個可執(zhí)行的 bundle 的時候,你相當(dāng)于在說:“這里有一把我房間的鑰匙,確保走的時候關(guān)上燈燈,不要把牛奶喝光,無論你干什么都請把火盆放在外面。” 你需要相信插件的作者不會犯錯,但是有可能事與愿違。
可能會發(fā)生什么糟糕的情況呢?一個實(shí)現(xiàn)的不好的的插件可以占用所有可用的內(nèi)存,讓 CPU 占用始終保持 100%,crash 一大堆東西?;蛟S有的家伙寫了一個看起來很好的插件,但是一個月以后,它的代碼把你的聯(lián)系人數(shù)據(jù)庫偷偷發(fā)給第三方……我還能舉出很多例子,我相信你懂的...
如何解決這個問題?你可以在單獨(dú)的地址空間運(yùn)行你的插件(解決 crash 問題,同時可能可以解決內(nèi)存和 cpu 問題),同時強(qiáng)制插件到沙盒里面運(yùn)行。(如果你正確地確認(rèn)插件的權(quán)限,那么你的聯(lián)系人數(shù)據(jù)庫就不會被讀取了)。我一時就能想到很多方法,但是最好的解決方法是使用蘋果的 XPC。
我把探索的過程留給讀者,但是你在處理插件的時候應(yīng)該一直有安全性的觀念。當(dāng)然,把一個插件放在沙盒里面或者另外一個進(jìn)程里面會缺少一些樂趣,并且增加一些工作量,所以這對于你的 App 或許沒那么重要。