返回上一页

墨刀 Sketch 插件开发总结


经过一段拖拖拉拉的开发,终于把同事留给的我插件项目重写完毕了(虽然仍然有很多地方有待改善),这里把踩坑的经验记录一下,希望能帮助到大家。

准备

Sketch 插件与 Atom、Photoshop、Chrome 等插件开发相比,资料似乎少了一些。但你只要会 JS 或者 OC ,就可以通过参考官方教程学习开发。
插件本身可以视为一个比较特别的 Bundle,像其他插件一样,对目录结构有一定要求,如下面这个例子:
mrwalker.sketchplugin
  Contents/
    Sketch/
      manifest.json
      shared.js
      Select Circles.cocoascript
      Select Rectangles.cocoascript
    Resources/
      Screenshot.png
      Icon.png


插件开发主要借助的是 CocoaScript,这个不能算是一门新的语言,只能算是一种bridge,你可以在后缀名为 CocoaScript的文件里面混写 OC 与 JS(当然你还可以写JS风格的 OC,不过看上去有点怪就是了),比如 Zeplin 插件的样子是这样的:
var onRun = function (context) {
    var doc = context.document;

    if (![doc fileURL] || [doc isDraft]) {
        [NSApp displayDialog:@"Please save the document before exporting to Zeplin." withTitle:@"Document not saved"];
        return;
    }

    if ([doc isDocumentEdited]) {
        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.\n\n☝️ This might take a bit, depending on the document size."];

        var response = [alert runModal];
        if (response == NSAlertDefaultReturn) {
            [doc showMessage:@"Saving document…"];

            [doc saveDocument:nil];
            while ([doc isDocumentEdited]) {
                [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
            }
        } else if (response == NSAlertAlternateReturn) {
            return;
        }

        response = nil;
        alert = nil;
    }

    var artboards = [context valueForKeyPath:@"selection.@distinctUnionOfObjects.parentArtboard"];
    if (![artboards count]) {
        [NSApp displayDialog:@"Please select the artboards you want to export to Zeplin.\n\n☝️ Selecting a layer inside the artboard should be enough." withTitle:@"No artboard selected"];
        return;
    }

    var artboardIds = [artboards valueForKeyPath:@"objectID"];

    var layers = [[[doc documentData] allSymbols] arrayByAddingObjectsFromArray:artboards];
    var pageIds = [layers valueForKeyPath:@"@distinctUnionOfObjects.parentPage.objectID"];

    layers = nil;
    artboards = nil;

    var format = @"json";
    var readerClass = NSClassFromString(@"MSDocumentReader");
    var jsonReaderClass = NSClassFromString(@"MSDocumentZippedJSONReader");
    if (!readerClass || !jsonReaderClass || ![[readerClass readerForDocumentAtURL:[doc fileURL]] isKindOfClass:jsonReaderClass]) {
        format = @"legacy";
    }

    jsonReaderClass = nil;
    readerClass = nil;

    var name = [[[NSUUID UUID] UUIDString] stringByAppendingPathExtension:@"zpl"];
    var temporaryDirectory = NSTemporaryDirectory();
    var path = [temporaryDirectory stringByAppendingPathComponent:name];

    temporaryDirectory = nil;
    name = nil;

    var version = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"];
    var sketchtoolPath = [[NSBundle mainBundle] pathForResource:@"sketchtool" ofType:nil inDirectory:@"sketchtool/bin"];
    var sketchmigratePath = [[NSBundle mainBundle] pathForResource:@"sketchmigrate" ofType:nil inDirectory:@"sketchtool/bin"];

    var directives = [NSMutableDictionary dictionary];
    [directives setObject:[[doc fileURL] path] forKey:@"path"];
    [directives setObject:artboardIds forKey:@"artboardIds"];
    [directives setObject:pageIds forKey:@"pageIds"];
    [directives setObject:format forKey:@"format"];
    if (version) {
        [directives setObject:version forKey:@"version"];
    }
    if (sketchtoolPath) {
        [directives setObject:sketchtoolPath forKey:@"sketchtoolPath"];
    }
    if (sketchmigratePath) {
        [directives setObject:sketchmigratePath forKey:@"sketchmigratePath"];
    }

    version = nil;
    sketchmigratePath = nil;
    sketchtoolPath = nil;
    format = nil;
    pageIds = nil;
    artboardIds = nil;

    [directives writeToFile:path atomically:false];
    directives = nil;

    var workspace = [NSWorkspace sharedWorkspace];

    var applicationPath = [workspace absolutePathForAppBundleWithIdentifier:@"io.zeplin.osx"];
    if (!applicationPath) {
        [NSApp displayDialog:@"Please make sure that you installed and launched it: https://zpl.io/download" withTitle:"Could not find Zeplin"];
        return;
    }

    [doc showMessage:@"Launching Zeplin!"];

    [workspace openFile:path withApplication:applicationPath andDeactivate:true];

    workspace = nil;
    applicationPath = nil;
    path = nil;
}


可以看到,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
这是主流的方案,也是我接手插件项目时的方案。
由于 CocoaScript 提供了加载 Framework 的方法,这才使我们这个方案的实现成为了可能。
在 CocoaScript 里面调用 Framework 的示例如下:
var loadFramework = function(pluginRootPath, frameworkName) {
  if (NSClassFromString(frameworkName) == null) {
    var mocha = [Mocha sharedRuntime]
    return [mocha loadFrameworkWithName:frameworkName inDirectory:pluginRootPath]
  } else {
    return true
  }
}


在前端是一个基于 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 格式发行的。
具体开发细节由于大家业务不同,这里不详细展开了,这里主要谈一谈开发中踩的坑:
3.1 AppKit
原本以我 iOS 开发的经验来看,重新做一个界面大概只需要最多一下午时间,但是 UIKit 与 NSAppKit 差别不是一点半点,结果花费了很长的时间。
比如很多组件都需要自己做,有些在 UIKit 里的已经有的东西,比如 UILabel,AppKit 是没有的,你需要自己实现一个。
Mac 开发中你可以明显的感到 MVC 得到了彻底的贯彻,各种 Cell 成为了一个很重要的部分。在 UIKit 里面明明是一个组件,比如 UITextField,在 AppKit 里面就被进一步拆分,产生了 NSTextField 和 NSTextFieldCell,你要使用一个 NSextField 组件,第一件事大概是创建一个 NSTextFieldCell 的子类,不然显示样式的问题会烦死你:
@implementation MBVerticalCenteredTextFieldCell

-(instancetype)init {
    if (self = [super init]) {
        self.editable = YES;
        self.scrollable = NO;
        self.attributedStringValue = [NSAttributedString new];
        self.usesSingleLineMode = YES;//启用单行模式
        self.drawsBackground = YES;
    }
    return self;
}

-(NSRect)drawingRectForBounds:(NSRect)rect {
    CGFloat stringHeight = self.attributedStringValue.size.height + 1;
    return [super drawingRectForBounds:NSMakeRect(0, (rect.size.height - stringHeight)/2, rect.size.width, stringHeight)];
}

-(void)drawFocusRingMaskWithFrame:(NSRect)cellFrame inView:(NSView *)controlView {
    controlView.layer.borderColor = [NSColor primary].CGColor;
    controlView.layer.borderWidth = 1;
    [super drawFocusRingMaskWithFrame:cellFrame inView:controlView];
}


//编辑中让文字居中
-(void)editWithFrame:(NSRect)rect inView:(NSView *)controlView editor:(NSText *)textObj delegate:(id)delegate event:(NSEvent *)event {
    CGFloat stringHeight = self.attributedStringValue.size.height + 1;
    [super editWithFrame:NSMakeRect(0, (rect.size.height - stringHeight)/2, rect.size.width, stringHeight)
                  inView:controlView
                  editor:textObj
                delegate:delegate
                   event:event];
}

-(void)selectWithFrame:(NSRect)rect inView:(NSView *)controlView editor:(NSText *)textObj delegate:(id)delegate start:(NSInteger)selStart length:(NSInteger)selLength {
    CGFloat stringHeight = self.attributedStringValue.size.height + 1;
    [super selectWithFrame:NSMakeRect(0, (rect.size.height - stringHeight)/2, rect.size.width, stringHeight)
                    inView:controlView
                    editor:textObj
                  delegate:delegate
                     start:selStart
                    length:selLength];
}

- (NSRect)titleRectForBounds:(NSRect)frame {
    CGFloat stringHeight = self.attributedStringValue.size.height + 1;
    NSRect titleRect = [super titleRectForBounds:frame];
    titleRect.origin.y = (frame.size.height - stringHeight) / 2.0;
    return titleRect;
}

- (void)drawInteriorWithFrame:(NSRect)cFrame inView:(NSView*)cView {
    [super drawInteriorWithFrame:[self titleRectForBounds:cFrame] inView:cView];
}

//设置Cursor的Color
-(NSText *)setUpFieldEditorAttributes:(NSText *)textObj {
    NSText *text = [super setUpFieldEditorAttributes:textObj];
    [(NSTextView*)text setInsertionPointColor: _cursorColor ? : [NSColor primary]];
    return text;
}

@end


这样的例子还有很多,就不一一吐槽了。
PC 和移动平台的差异决定了 UIKit 和 AppKit 设计上的不同。比如 AppKit 基本是以视窗为出发点进行设计,一般 NSWindowViewController 为根控制器。然而移动 App 是无法多窗口的,导航成了重点,根控制器一般是 UINavigationController。iOS 开发的很多经验是无法直接照搬的。
Mac 开发与 iOS 开发相比,只能用简陋来形容。除了 API 设计不尽人意(比如 NSButton 就没有一个 addTarget:action: 这么一个简便的方法),三方库也是少的可怜。很多三方是不支持 Mac 平台的,比如 MBProgressHUD。这种情况下你可能需要自己造。
3.2 工程构建
由于插件必须是以 .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 SketchPlugin's directory
echo "Copying to project dir"
yes | cp -Rf "${UNIVERSAL_OUTPUTFOLDER}/${FULL_PRODUCT_NAME}" "${PROJECT_DIR}/../${TARGET_NAME}.sketchplugin/Contents/Sketch"
#check if it is copy successfully
open "${PROJECT_DIR}/../${TARGET_NAME}.sketchplugin/Contents/Sketch"
fi

Framework 里面的各种资源文件是在另外一个 Bundle 的 Target 里面的,为了保证每次 Build 时 Bundle 资源都是最新的,建议在 Build Phrases -> Target Dependencies 里面添加 Bundle 的 Target,并添加一段 Run Script Phase:
#copy bundle 资源包到项目Framework 里面来
cp -R -f $BUILT_PRODUCTS_DIR/MockingBotSketchPlugin.bundle $BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH/

3.3 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 DEBUG
    rootPath = [context[@"scriptPath"] stringByDeletingLastPathComponent];
#else
    rootPath = [[[context valueForKeyPath:@"plugin.url"] path] stringByAppendingPathComponent:@"Contents/Sketch"];
#endif

3. 调用 Sketch 私有方法时,只需要传一个参数的直接用 performSelector: 方法就好,两个及以上参数的你就需要使用 NSInvocation 了:
SEL sel = NULL;
// 这里定义了一个宏,以消除警告
            SuppressPerformSelectorUndeclaredWarning(sel = @selector(displayDialog:withTitle:));
            NSString *string1 = [MBFile getStringForKey:@"pls_select" fromStringTable:Prompt];
            NSString *string2 = [MBFile getStringForKey:@"no_select" fromStringTable:Prompt];
            NSMethodSignature *sig = [NSApp methodSignatureForSelector:sel];
            NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:sig];
            [invocation setTarget: NSApp];
            [invocation setSelector:sel];
            [invocation setArgument:&string1 atIndex:2];
            [invocation setArgument:&string2 atIndex:3];
            [invocation invoke];

当然安全起见,你最好加一个 try catch,以防哪天 Sketch 改方法名称了。
4. MAS(Mac App Store)版的 Sketch(虽然我没见过)做了很多限制,插件是无法使用的,你最好先判断一下:
if (pluginRoot.rangeOfString('Containers').length != 0) {
    doc.showMessage('暂不支持 Mac App Store 版本的 Sketch,请通过 Sketch 官网升级到最新版本')
    return
  }

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.

这里我也没有找到除了改名字外更好的解决办法,求大神指点。
其他的坑可能以后还会再补充,希望能帮助到大家。
为你推荐
评论 6