Sketch插件开发总结

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:

这是主流的方案,也是我接手插件项目时的方案。

由于Sketch用到了Mocha, 提供了加载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格式发行的。

具体开发细节由于大家业务不同,这里不详细展开了,这里主要谈一谈开发中踩的坑:

  • 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。这种情况下你可能需要自己造。

  • 工程构建

由于插件必须是以.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/
  • 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.

这里我也没有找到除了改名字外更好的解决办法,求大神指点。

其他的坑可能以后还会再补充,希望能帮助到大家。

编辑于 2017-12-03

文章被以下专栏收录

    墨刀是一款免费在线原型工具,即使不懂设计和代码也可以轻松使用墨刀制作出接近真实App交互的产品原型。