Skip to main content

如何用XcodeKit编写一个XcodeExtension?

· 预计阅读6分钟

HEADer Xcode提供了XcodeKit,来允许我们编写Xcode的扩展。但是这个Extension功能有限,只能处理当前打开的文件的源码。 我们可以用来:格式化、自动生成代码等。 常用的Extension有XCFormat,可以格式化Swift和OC代码,可以在AppStore中下载。 整体开发插件的功能还是比较简单,我们从头演示一个移除一行开头的数字的插件。

创建Xcode Extension工程

创建一个macOS工程

image-20211129180100344

这个macOS工程,对应从App Store下载完Extension后,在桌面上生成的那个App。你可以在这个App中做Extension设置相关的界面。

新建一个Xcode Source Editor Extention Target

截屏2021-11-29 下午6.09.11

运行Extension Target

点击运行Extension Target,选择Xcode,此时会打开一个灰色的Xcode。

打开我们的插件的macOS工程。

选择Editor菜单,底部就出现了我们的自定义菜单。

image-20211129182015367

如果菜单没有出现,那么请修改一下Extension Target中XcodeKit的链接方式,选择Embed & Sign

image-20211129182127182

Xcode Source Editor Extention

默认的Xcode Source Editor Extention会为你生成两个文件:

  • SourceEditorExtension: 这个文件定义你小菜单中的内容和extension启动时的回调。
  • SourceEditorCommand: 是小菜单中的命令对应的实现类。

小菜单的定义

小菜单的定义有两种方式,一种是通过SourceEditorExtension的代码定义。一种是通过Info.plist定义。代码定义的优先级更高,会覆盖Info.plist中的定义。

在Info.plist中定义

image-20211129183151859

每个命令就是小菜单中的一个命令对应。

  • XCSourceEditorCommandClassName: 这个命令对应的实现类。格式是moduleName + 实现类的类名: $(PRODUCT_MODULE_NAME).YourImplClassName
  • XCSourceEditorCommandIdentifier:你的这个命令的一个唯一id。格式是bundleId +一个字符串: $(PRODUCT_BUNDLE_IDENTIFIER).SourceEditorCommand
  • XCSourceEditorCommandName:菜单中出现的命令的名字

在SourceEditorExtension中定义小菜单

class SourceEditorExtension: NSObject, XCSourceEditorExtension {

/*
func extensionDidFinishLaunching() {
// If your extension needs to do any work at launch, implement this optional method.
}
*/


var commandDefinitions: [[XCSourceEditorCommandDefinitionKey: Any]] {
// If your extension needs to return a collection of command definitions that differs from those in its Info.plist, implement this optional property getter.
let command: [XCSourceEditorCommandDefinitionKey: Any] = [
//格式moduleName.命令实现类的类名
.classNameKey: "GxEditorToolsExtension.SourceEditorCommand",
//格式bundleId.自定义字符串
.identifierKey: "com.haixue.GxXcodeEditorTools.GxEditorToolsExtension.removeLinePrefixNum",
//自定义名称
.nameKey: "我的小菜单"
]
return [command]
}

}

image-20211129184031347

命令的实现

实现类都遵循XCSourceEditorCommand协议。

当我们点击小菜单,会通过command的XCSourceEditorCommandClassName找到对应的实现类。调用实现类的func perform(...)方法。

我们看到新建项目默认为我们生成了一个command模板SourceEditorCommand。

class SourceEditorCommand: NSObject, XCSourceEditorCommand {

func perform(with invocation: XCSourceEditorCommandInvocation, completionHandler: @escaping (Error?) -> Void ) -> Void {
// Implement your command here, invoking the completion handler when done. Pass it nil on success, and an NSError on failure.

completionHandler(nil)
}

}

实现我们自己的命令

import Foundation
import XcodeKit

class RemoveLinePrefixNumCommand: NSObject, XCSourceEditorCommand {

func perform(with invocation: XCSourceEditorCommandInvocation, completionHandler: @escaping (Error?) -> Void ) -> Void {
// Implement your command here, invoking the completion handler when done. Pass it nil on success, and an NSError on failure.
let lines = invocation.buffer.lines
let result = lines.map { "\($0)" }.map{ self.removePrefixNum(str: $0) }
invocation.buffer.lines.removeAllObjects()
invocation.buffer.lines.addObjects(from: result)
completionHandler(nil)
}

func removePrefixNum(str: String) -> String {
var chars: [Character] = []
//空格和数字
let sets: Set<Character> = [" ", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
var shouldCheck = true
for c in str {
if shouldCheck {
if sets.contains(c) {
chars.append(" ")
} else {
chars.append(c)
shouldCheck = false
}
} else{
chars.append(c)
}
}
return String(chars)
}
}

//可以看到invocation有用的也只有一个Buffer对象
@interface XCSourceEditorCommandInvocation : NSObject
/** The identifier of the command the user invoked. */
@property (readonly, copy) NSString *commandIdentifier;
/** The buffer of source text on which the command can operate. */
@property (readonly, strong) XCSourceTextBuffer *buffer;
@property (copy) void (^cancellationHandler)(void);
@end
///buffer,又只能操作tab、lines、selections。
@interface XCSourceTextBuffer : NSObject

@property (readonly, copy) NSString *contentUTI;
@property (readonly) NSInteger tabWidth;
@property (readonly) NSInteger indentationWidth;
@property (readonly) BOOL usesTabsForIndentation;

@property (readonly, strong) NSMutableArray <NSString *> *lines;
@property (readonly, strong) NSMutableArray <XCSourceTextRange *> *selections;
@property (copy) NSString *completeBuffer;
@end

xcodeKit只能操作文本,像语法解析等都得借助别的工具来实现。

调试

在工程中打上断点,点击运行。

在灰色xcode中打开我们工程,点击小菜单。就会进入我们断点。

集成到Xcode的Editor菜单

进入系统偏好设置--扩展,选中我们的插件钩上即可

image-20211129234423601

制作安装包

archive ,然后选择copy app,得到一个.app

image-20211129235140936

image-20211129235317200

新建一个文件夹,将.app和应用程序的提升文件一起放入,重命名提升文件为Application。

image-20211129235452152

打开磁盘工具

image-20211129235529816

image-20211129235549564

选择刚刚的文件夹,就制作成了dmg。