Sketch插件開發總結
經過一段拖拖拉拉的開發,終於把同事留給的我插件項目重寫完畢了(雖然仍然有很多地方有待改善),這裡把踩坑的經驗記錄一下,希望能幫助到大家。
準備
Sketch插件與Atom、Photoshop、Chrome等插件開發相比,資料似乎少了一些。但你只要會JS或者OC,就可以通過參考官方教程學習開發。
插件本身可以視為一個比較特別的bundle,像其他插件一樣,對目錄結構有一定要求,如下面這個例子:
mrwalker.sketchpluginn Contents/n Sketch/n manifest.jsonn shared.jsn Select Circles.cocoascriptn Select Rectangles.cocoascriptn Resources/n Screenshot.pngn Icon.pngn
插件開發主要藉助的是CocoaScript,這個不能算是一門新的語言,只能算是一種bridge,你可以在後綴名為cocoascript的文件裡面混寫OC與JS(當然你還可以寫JS風格的OC,不過看上去有點怪就是了),比如Zeplin插件的樣子是這樣的:
var onRun = function (context) {n var doc = context.document;nn if (![doc fileURL] || [doc isDraft]) {n [NSApp displayDialog:@"Please save the document before exporting to Zeplin." withTitle:@"Document not saved"];n return;n }nn if ([doc isDocumentEdited]) {n var alert = [NSAlert alertWithMessageText:@"Document not saved" defaultButton:@"Save and Continue" alternateButton:@"Cancel" otherButton:@"Continue" informativeTextWithFormat:@"To capture the latest changes in this Sketch document, Zeplin needs to save it first.nn?? This might take a bit, depending on the document size."];nn var response = [alert runModal];n if (response == NSAlertDefaultReturn) {n [doc showMessage:@"Saving document…"];nn [doc saveDocument:nil];n while ([doc isDocumentEdited]) {n [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];n }n } else if (response == NSAlertAlternateReturn) {n return;n }nn response = nil;n alert = nil;n }nn var artboards = [context valueForKeyPath:@"selection.@distinctUnionOfObjects.parentArtboard"];n if (![artboards count]) {n [NSApp displayDialog:@"Please select the artboards you want to export to Zeplin.nn?? Selecting a layer inside the artboard should be enough." withTitle:@"No artboard selected"];n return;n }nn var artboardIds = [artboards valueForKeyPath:@"objectID"];nn var layers = [[[doc documentData] allSymbols] arrayByAddingObjectsFromArray:artboards];n var pageIds = [layers valueForKeyPath:@"@distinctUnionOfObjects.parentPage.objectID"];nn layers = nil;n artboards = nil;nn var format = @"json";n var readerClass = NSClassFromString(@"MSDocumentReader");n var jsonReaderClass = NSClassFromString(@"MSDocumentZippedJSONReader");n if (!readerClass || !jsonReaderClass || ![[readerClass readerForDocumentAtURL:[doc fileURL]] isKindOfClass:jsonReaderClass]) {n format = @"legacy";n }nn jsonReaderClass = nil;n readerClass = nil;nn var name = [[[NSUUID UUID] UUIDString] stringByAppendingPathExtension:@"zpl"];n var temporaryDirectory = NSTemporaryDirectory();n var path = [temporaryDirectory stringByAppendingPathComponent:name];nn temporaryDirectory = nil;n name = nil;nn var version = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"];n var sketchtoolPath = [[NSBundle mainBundle] pathForResource:@"sketchtool" ofType:nil inDirectory:@"sketchtool/bin"];n var sketchmigratePath = [[NSBundle mainBundle] pathForResource:@"sketchmigrate" ofType:nil inDirectory:@"sketchtool/bin"];nn var directives = [NSMutableDictionary dictionary];n [directives setObject:[[doc fileURL] path] forKey:@"path"];n [directives setObject:artboardIds forKey:@"artboardIds"];n [directives setObject:pageIds forKey:@"pageIds"];n [directives setObject:format forKey:@"format"];n if (version) {n [directives setObject:version forKey:@"version"];n }n if (sketchtoolPath) {n [directives setObject:sketchtoolPath forKey:@"sketchtoolPath"];n }n if (sketchmigratePath) {n [directives setObject:sketchmigratePath forKey:@"sketchmigratePath"];n }nn version = nil;n sketchmigratePath = nil;n sketchtoolPath = nil;n format = nil;n pageIds = nil;n artboardIds = nil;nn [directives writeToFile:path atomically:false];n directives = nil;nn var workspace = [NSWorkspace sharedWorkspace];nn var applicationPath = [workspace absolutePathForAppBundleWithIdentifier:@"io.zeplin.osx"];n if (!applicationPath) {n [NSApp displayDialog:@"Please make sure that you installed and launched it: https://zpl.io/download" withTitle:"Could not find Zeplin"];n return;n }nn [doc showMessage:@"Launching Zeplin!"];nn [workspace openFile:path withApplication:applicationPath andDeactivate:true];nn workspace = nil;n applicationPath = nil;n path = nil;n}n
可以看到,Zeplin調用了一些Sketch工程裡面的方法,比如從context取出來的document,它是NSDocument的一個子類,showMessage是其增加的方法。如果你想了解Sketch有哪些類,這些類有哪些方法,有人已經用class-dump把Sketch的頭文件導出並上到了Github的代碼倉庫里,稍微看一下有助於更好的開發。
如果你想練習一下CocoaScript的使用,Sketch提供了一個類似Xcode的Playground的東西,打開Plugins -> Run Script,會出現一個面板:
你可以在這裡練習CocoaScript的使用。
開發方案選擇
1. 純使用CocoaScript開發
實際上,很少有人使用cocoascript進行開發,在我看來有兩個致命缺點:
- 工程細節全部暴露,包括一些後台介面;
- 目前沒有任何IDE支持cocoascript這種格式,你只能靠手寫(雖然Sublime、Atom、VSCode裡面你可以把類型選擇為JS,並獲得一些提示,但是這種提示完全是基於文本的,沒法做任何類型推斷),出錯幾率大大增加;
雖然Zeplin是完全用CocoaScript寫的,但通過研究Zeplin代碼我們發現,他們的插件本身沒有UI界面,插件把artboard一些必要信息提取出來,然後通過NSWorkSpace提供的方法,將信息交由Zeplin來處理。
這不太符合我們的需求,需要另找方案。
2. JS+OC:
這是主流的方案,也是我接手插件項目時的方案。
由於Sketch用到了Mocha, 提供了載入framework的方法,這才使我們這個方案的實現成為了可能。
通過CocoaScript裡面調用Framework的示例如下:
var loadFramework = function(pluginRootPath, frameworkName) {n if (NSClassFromString(frameworkName) == null) {n var mocha = [Mocha sharedRuntime]n return [mocha loadFrameworkWithName:frameworkName inDirectory:pluginRootPath]n } else {n return truen }n}n
在前端是一個基於create-react-app的 SPA。在CocoaScript的部分通過WebView載入,使用 delegate 的方式,在 WebView 和 CocoaScript 之間通信,CocoaScript這裡實現了一個 Message Hub,再將信息後發給封裝好的 Cocoa framework 對 Sketch 文件進行處理。反之亦然。
對 sketch 文件的處理,主要是使用了sketch-tool這個隨 Sketch 應用一起安裝的命令行工具。我們利用它來 dump 出 sketch 文件的信息,解析、過濾,以及生成 artboards,slices 的圖片,處理後發送回後端伺服器。
工程構成示意圖如下:
這個方案極大提高了開發效率,你可以隨意使用成熟的JS和OC的工具與三方庫,但是在我看來還是有一些蛋疼的地方:
- 工程複雜。這麼一個簡單的插件,開發者除了掌握最少OC、JS兩種語言(不用提本身你需要CocoaScript進行載入,還有工程構建要寫的一些Shell腳本),還要對React和原生Mac framework(iOS的經驗在這裡幫助不大)開發都要掌握,維護起來成本太大;
- 調試蛋疼。如果只是調試UI還好,由於是Web Based的,所以可以直接在瀏覽器里進行 debug,但由於在插件中所有的交互,數據都來自framework, 所以需要在 console 中 dispatch 一些 mock 數據。但這不是最蛋疼的,你的插件需要在Sketch中運行,這個方案在這裡的調試方法只能通過Console.app列印的Log,就像這篇文章說的。
出於以上兩種考慮,我決定完全使用OC對插件進行重寫,把包括UI在內的所有邏輯全部移到Framework裡面。
3. 純原生方案:
其實這麼乾的人我並不是頭一個,比如Sympli,他們的Sketch插件都是以dmg格式發行的。
具體開發細節由於大家業務不同,這裡不詳細展開了,這裡主要談一談開發中踩的坑:
- AppKit
原本以我iOS開發的經驗來看,重新做一個界面大概只需要最多一下午時間,但是UIKit與NSAppKit差別不是一點半點,結果花費了很長的時間。
比如很多組件都需要自己做,有些在UIKit里的已經有的東西,比如UILabel,AppKit是沒有的,你需要自己實現一個。
Mac開發中你可以明顯的感到MVC的得到了徹底的貫徹,各種Cell成為了一個很重要的部分。在UIKit裡面明明是一個組件,比如UITextField,在AppKit裡面就被進一步拆分,產生了NSTextField和NSTextFieldCell,你要使用一個NSextField組件,第一件事大概是創建一個NSTextFieldCell的子類,不然顯示樣式的問題會煩死你:
@implementation MBVerticalCenteredTextFieldCellnn-(instancetype)init {n if (self = [super init]) {n self.editable = YES;n self.scrollable = NO;n self.attributedStringValue = [NSAttributedString new];n self.usesSingleLineMode = YES;//啟用單行模式n self.drawsBackground = YES;n }n return self;n}nn-(NSRect)drawingRectForBounds:(NSRect)rect {n CGFloat stringHeight = self.attributedStringValue.size.height + 1;n return [super drawingRectForBounds:NSMakeRect(0, (rect.size.height - stringHeight)/2, rect.size.width, stringHeight)];n}nn-(void)drawFocusRingMaskWithFrame:(NSRect)cellFrame inView:(NSView *)controlView {n controlView.layer.borderColor = [NSColor primary].CGColor;n controlView.layer.borderWidth = 1;n [super drawFocusRingMaskWithFrame:cellFrame inView:controlView];n}nnn//編輯中讓文字居中n-(void)editWithFrame:(NSRect)rect inView:(NSView *)controlView editor:(NSText *)textObj delegate:(id)delegate event:(NSEvent *)event {n CGFloat stringHeight = self.attributedStringValue.size.height + 1;n [super editWithFrame:NSMakeRect(0, (rect.size.height - stringHeight)/2, rect.size.width, stringHeight)n inView:controlViewn editor:textObjn delegate:delegaten event:event];n}nn-(void)selectWithFrame:(NSRect)rect inView:(NSView *)controlView editor:(NSText *)textObj delegate:(id)delegate start:(NSInteger)selStart length:(NSInteger)selLength {n CGFloat stringHeight = self.attributedStringValue.size.height + 1;n [super selectWithFrame:NSMakeRect(0, (rect.size.height - stringHeight)/2, rect.size.width, stringHeight)n inView:controlViewn editor:textObjn delegate:delegaten start:selStartn length:selLength];n}nn- (NSRect)titleRectForBounds:(NSRect)frame {n CGFloat stringHeight = self.attributedStringValue.size.height + 1;n NSRect titleRect = [super titleRectForBounds:frame];n titleRect.origin.y = (frame.size.height - stringHeight) / 2.0;n return titleRect;n}nn- (void)drawInteriorWithFrame:(NSRect)cFrame inView:(NSView*)cView {n [super drawInteriorWithFrame:[self titleRectForBounds:cFrame] inView:cView];n}nn//設置Cursor的Colorn-(NSText *)setUpFieldEditorAttributes:(NSText *)textObj {n NSText *text = [super setUpFieldEditorAttributes:textObj];n [(NSTextView*)text setInsertionPointColor: _cursorColor ? : [NSColor primary]];n return text;n}nn@endn
這樣的例子還有很多,就不一一吐槽了。
PC和移動平台的差異決定了UIKit和AppKit設計上的不同。比如AppKit基本是以視窗為出發點進行設計,一般NSWindowViewController為根控制器。然而移動App是無法多窗口的,導航成了重點,根控制器一般是UINavigationController。iOS開發的很多經驗是無法直接照搬的。
Mac開發與iOS開發相比,只能用簡陋來形容。除了API設計不盡人意(比如NSButton就沒有一個addTarget:action:這麼一個簡便的方法),三方庫也是少的可憐。很多三方是不支持mac平台的,比如MBProgressHUD。這種情況下你可能需要自己造。
- 工程構建
由於插件必須是以.sketchplugin格式的文件安裝到Sketch中,你需要把生成的framework手動拷貝到插件目錄下才行,這個過程略顯蛋疼,好在以前有做Cocoapods私有庫的經驗,我們可以把官方那個腳本稍加改造,就能實現自動拷貝的目的了。
(然而我們在這個項目使用的是Carthage,並沒有用Cocoapods)
首先,你要改一下插件Bundle的名字,確保與你Framework的名字一致;
然後,需要在Xcode的Build Setting -> Skip Install設置為NO:
接著在Product -> Scheme -> Edit Scheme ,在彈出的面板中選中Archive -> Post-actions:
最後添加的腳本大致與官網一致,你只需要改一下拷貝的部分:
# Step 5. Convenience step to copy the framework to the SketchPlugins directorynecho "Copying to project dir"nyes | cp -Rf "${UNIVERSAL_OUTPUTFOLDER}/${FULL_PRODUCT_NAME}" "${PROJECT_DIR}/../${TARGET_NAME}.sketchplugin/Contents/Sketch"n#check if it is copy successfullynopen "${PROJECT_DIR}/../${TARGET_NAME}.sketchplugin/Contents/Sketch"nfin
Framework裡面的各種資源文件是在另外一個Bundle的Target裡面的,為了保證每次Build時Bundle資源都是最新的,建議在Build Phrases -> Target Dependencies裡面添加Bundle的Target,並添加一段Run Script Phase:
#copy bundle 資源包到項目Framework 裡面來ncp -R -f $BUILT_PRODUCTS_DIR/MockingBotSketchPlugin.bundle $BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH/n
- DEBUG
Xcode可以把調試器attach到運行的程序上,你只需要選中framework的Target,然後cmd+R,會出現這樣的窗口:
你只要點擊Run,就可以看到Xcode的控制台部分就把Sketch的信息輸出了,這裡你可以隨意打斷點,比如我們想要看一下context的document到底有哪些內容:
如果你想進一步調試Sketch,可以藉助lldb:
LLDB的使用比較複雜,可以參考Raywenderlich出品的這本Advanced Apple Debugging & Reverse Engineering。
- 瑣碎細節
還有一些東西沒法完整的說了,這裡簡單提一下:
1. 資源Bundle在使用裡面的資源前,記得先調用一下load方法。
2. Sketch插件所在路徑在Debug模式、自帶Run Script的環境以及實際打包成插件的路徑都不一樣,你需要加一個判斷:
#ifdef DEBUGn rootPath = [context[@"scriptPath"] stringByDeletingLastPathComponent];n#elsen rootPath = [[[context valueForKeyPath:@"plugin.url"] path] stringByAppendingPathComponent:@"Contents/Sketch"];n#endifn
3. 調用Sketch私有方法時,只需要傳一個參數的直接用performSelector:方法就好,兩個及以上參數的你就需要使用NSInvocation了:
SEL sel = NULL;n// 這裡定義了一個宏,以消除警告n SuppressPerformSelectorUndeclaredWarning(sel = @selector(displayDialog:withTitle:));n NSString *string1 = [MBFile getStringForKey:@"pls_select" fromStringTable:Prompt];n NSString *string2 = [MBFile getStringForKey:@"no_select" fromStringTable:Prompt];n NSMethodSignature *sig = [NSApp methodSignatureForSelector:sel];n NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:sig];n [invocation setTarget: NSApp];n [invocation setSelector:sel];n [invocation setArgument:&string1 atIndex:2];n [invocation setArgument:&string2 atIndex:3];n [invocation invoke];n
當然安全起見,你最好加一個try catch,以防哪天Sketch改方法名稱了。
4. MAS(Mac App Store)版的Sketch(雖然我沒見過)做了很多限制,插件是無法使用的,你最好先判斷一下:
if (pluginRoot.rangeOfString(Containers).length != 0) {n doc.showMessage(暫不支持 Mac App Store 版本的 Sketch,請通過 Sketch 官網升級到最新版本)n returnn }n
5. 如果你的工程與與Sketch使用了同樣的三方庫,比如AFNetworking,會提示報錯,但不影響插件的工作:
objc[73207]: Class AFCompoundResponseSerializer is implemented in both /Applications/Sketch.app/Contents/MacOS/Sketch (0x1006e6f50) and /Users/modao/Library/Developer/Xcode/DerivedData/SkethPlugin-gcgsgnegfugvphahqifsurtfrbqa/Build/Products/Debug/MockingBotSketchPlugin.framework/Versions/A/Frameworks/AFNetworking.framework/Versions/A/AFNetworking (0x11eaad370). One of the two will be used. Which one is undefined.n
這裡我也沒有找到除了改名字外更好的解決辦法,求大神指點。
其他的坑可能以後還會再補充,希望能幫助到大家。
推薦閱讀:
※「教程乾貨」- 我敢打賭你不知道Sketch 39的響應式新玩法 - 響應式系列二
※sketch能打開.psd格式文件嗎?要是能打開能不能導出UI圖片?
※Sketch Runner — 更快捷的設計
※「原創教程」- Sketch 從零開始學(一)
※團隊不願意為使用sketch更換系統,現在做設計還在使用PS和FW,作為一名網頁設計師該如何是好?