从头构建你的第一个 Xcode 扩展

几天前的 WWDC 发布了不少新特性,其中之一便是 Xcode官方插件支持。用户可以像安装普通软件一样安装插件,增强它的源代码编辑功能。这也直接导致了原有的第三方插件都不可用。目前开放的接口非常有限,只能对代码进行纯文本处理,不过相信以后会逐渐开放更多功能。

本文向读者展示如何利用 Xcode 8 构建一个编辑器扩展,并实现一个具体的功能。以下项目的所有代码均可以在 Github 上找到。

创建 Mac app 工程

下载最新的 Xcode 8,新建一个项目。如下图所示,从工程模版中选择 Cocoa application

填写工程名称:DemoExtension,去掉 test 相关选项

然后选中工程文件,新建一个 Target

从工程模板中选择 Xcode Source Editor Extension,填写 Target 名为:DemoXcodeExtension

然后可以看到,Xcode 已经为我们生成了四个文件(Swift 是两个):

两个类均继承自 NSObject,其中 SourceEditorExtension 服从 XCSourceEditorExtension 协议:

@protocol XCSourceEditorExtension <NSObject>
@optional
/** Invoked when the extension has been launched, which may be some time before the extension actually receives a command (if ever).

 \note Make no assumptions about the thread or queue on which this method will be invoked.
 */
- (void)extensionDidFinishLaunching;
@property (readonly, copy) NSArray <NSDictionary <XCSourceEditorCommandDefinitionKey, id> *> *commandDefinitions;
@end

SourceEditorCommand 服从 XCSourceEditorCommand 协议,它仅有一个方法:

@protocol XCSourceEditorCommand <NSObject>
@required
- (void)performCommandWithInvocation:(XCSourceEditorCommandInvocation *)invocation completionHandler:(void (^)(NSError * _Nullable nilOrError))completionHandler;
@end

查看刚刚新建的那个 TargetInfo.plist,可以看到比一般的文件多了个 NSExtension 的字段,展开后是一个字典:

  • NSExtensionPointIdentifier,一个常量,不要动它
  • NSExtensionAttributes,是一个字典,包含:
    • XCSourceEditorExtensionPrincipalClass,它指定了扩展程序的入口类名,在我们这个例子中就是 SourceEditorExtension
    • XCSourceEditorCommandDefinitions,它是一个数组,定义了一系列这个扩展程序支持的命令,每个命令包含一个 Identifier, 一个入口类名,及命令名,我们可以将命令名修改为自己喜欢的名字:

这个没有任何功能的 Xcode 扩展已经可以运行了,在运行对象框中选中 DemoXcodeExtension 对象,Command R 运行,它需要附属到一个应用程序上,请注意选择 Xcode 8。运行后,在新起的灰色的 Xcode 8 中打开一个项目,在代码编辑器中,打开 Editor 菜单,不出意外的可以看到我们的新扩展程序的菜单了:

出意外是很正常的,目前有一定概率扩展无法正常加载,多试几次。另外,如果你运行的是 EI Capitan 系统,请在运行此扩展前在终端中输入以下命令并重启电脑:sudo /usr/libexec/xpccachectl

添加功能

接下来我们正式编码,我们实现一个可以让用户快速选择最小闭合区代码的功能,即:找到最近的成对的包含光标所在位置的 {},或 [],并选中它们中间的代码:

- (void)performCommandWithInvocation:(XCSourceEditorCommandInvocation *)invocation
                   completionHandler:(void (^)(NSError * _Nullable nilOrError))completionHandler
{
    NSInteger bracketStack = 0;
    NSInteger braceStack = 0;

    XCSourceTextRange *begin = invocation.buffer.selections.firstObject;
    XCSourceTextRange *end = invocation.buffer.selections.lastObject;

    XCSourceTextRange *range = [[XCSourceTextRange alloc] init];
    range.end = (XCSourceTextPosition){invocation.buffer.lines.count - 1, invocation.buffer.lines.lastObject.length};

    BOOL found = NO;

    for (NSInteger line = begin.start.line; !found && line >= 0; --line) {
        NSString *text = invocation.buffer.lines[line];
        NSInteger max = line == begin.start.line ? begin.start.column : text.length - 1;
        for (NSInteger col = max; !found && col >= 0; --col) {
            unichar ch = [text characterAtIndex:col];
            switch (ch) {
                case '[':
                    // Found
                    if (bracketStack == 0) {
                        range.start = (XCSourceTextPosition){line, col};
                        found = YES;
                    }
                    else {
                        --bracketStack;
                    }
                    break;
                case ']':
                    ++bracketStack;
                    break;
                case '{':
                    // Found
                    if (braceStack == 0) {
                        range.start = (XCSourceTextPosition){line, col};
                        found = YES;
                    }
                    else {
                        --braceStack;
                    }
                    break;
                case '}':
                    ++braceStack;
                    break;
                default:
                    break;
            }
        }
    }

    found = NO;
    braceStack = 0;
    bracketStack = 0;

    for (NSInteger line = end.end.line; !found && line < invocation.buffer.lines.count; ++line) {
        NSString *text = invocation.buffer.lines[line];
        NSInteger min = line == end.end.line ? end.end.column : 0;
        for (NSInteger col = min; !found && col < text.length; ++col) {
            unichar ch = [text characterAtIndex:col];
            switch (ch) {
                case ']':
                    // Found
                    if (bracketStack == 0) {
                        range.end = (XCSourceTextPosition){line, col + 1};
                        found = YES;
                    }
                    else {
                        --bracketStack;
                    }
                    break;
                case '[':
                    ++bracketStack;
                    break;
                case '}':
                    // Found
                    if (braceStack == 0) {
                        range.end = (XCSourceTextPosition){line, col + 1};
                        found = YES;
                    }
                    else {
                        --braceStack;
                    }
                    break;
                case '{':
                    ++braceStack;
                    break;
                default:
                    break;
            }
        }
    }

    [invocation.buffer.selections removeAllObjects];
    [invocation.buffer.selections addObject:range];

    completionHandler(nil);
}

以上方法中包含两个参数:

  • invocation,它提供了源代码文件的文本内容相关的信息,对它的修改将直接生效
  • completionHandler,这是一个回调 Block,用于通知 Xcode,扩展程序的操作已经完成

为了方便操作,我们可以为扩展命令添加键盘绑定。如下图,在 Xcode 中打开偏好,在 Key Bindings 下找到我们的新菜单命令,添加快捷键:Control Shift S

结果

最终结果如下所示,每次按下 Control Shift SXcode 将选中最小的闭合区域:

此项目为 Demo 展示用,请不要在生产环境中使用