作为界面开发工程师,各种UI相关的代码,大部分都是类似的,手动敲起来也没啥意思,很耽误开发的时间
我们一般可以编写一些代码块,可以快捷的去使用;同时也可以编写Xcode的小插件来自动生成这些代码
在github上也找到了类似的插件,自己刚好最近也想着学习下Swift语言,所以就站在巨人的肩上,把原作者的插件用Swift去写了一遍;本文就记录下学习写插件的过程
1. Xcode Source Editor Extension介绍
在macOS 10.12及以上,苹果提供了XcodeKit用来给Xcode增加源码编辑器扩展;
使用XcodeKit框架,您可以使用源代码编辑器扩展名自定义Xcode,以向源代码编辑器添加功能和特殊行为。源代码编辑器扩展可以读取和修改源文件的内容,以及在编辑器中读取和修改当前的文本选择。
Using the XcodeKit framework, you can customize Xcode with source editor extensions to add functionality and specialized behavior to the source editor. Source editor extensions provide a group of editor commands alongside the built-in commands in the Editor menu in Xcode. Source editor extensions can read and modify the contents of a source file, as well as read and modify the current text selection within the editor. Include source editor extensions in developer apps distributed on the Mac App Store.
1.1 创建macOS工程
Xcode菜单选择 File -- New -- Project,选择macOS,创建App
1.2 添加Extension
Xcode菜单选择 File -- New -- Target,选择macOS,创建Xcode Source Editor Extension,如下图所示:
这里我们是写Xcode代码相关的插件,所以选择的是Xcode Source Editor Extension;如果你想编写其他类型的插件,可以按需选择对于的Extension
创建好target之后,会自动生成2个文件SourceEditorExtension
及SourceEditorCommand
我们可以按需在这里添加插件的功能及对应功能的实现
2.关键类介绍
2.1 SourceEditorExtension
SourceEditorExtension
遵循XCSourceEditorExtension
用来创建Xcode源代码编辑器扩展的协议,也就是Xcode的Editor增加的Extension的功能菜单
增加功能菜单支持2种方式,一种是代码的方式,一种是在Extension的Info.plist文件中去配置
2.1.1 代码的方式
实现commandDefinitions
方法,里面返回功能菜单的数组
- XCSourceEditorCommandDefinitionKey.classNameKey : 功能的实现的类名,我这里就是
kSourceEditorClassName
这里需要注意一下,需要带上模块名let kSourceEditorClassName = "HCXcodeTools.SourceEditorCommand"
- XCSourceEditorCommandDefinitionKey.identifierKey : 功能的唯一ID,一般是Extension的bundleId+一个后缀,这里主要是在执行该功能的时候,可以通过这个Id去区分是哪个功能
- XCSourceEditorCommandDefinitionKey.nameKey : 功能的名称,展示在Extension功能菜单的名称
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 addLazyCodeItem : [XCSourceEditorCommandDefinitionKey: Any] = [
XCSourceEditorCommandDefinitionKey.classNameKey : kSourceEditorClassName,
XCSourceEditorCommandDefinitionKey.identifierKey: kAddLazyCodeIdentifier,
XCSourceEditorCommandDefinitionKey.nameKey: kAddLazyCodeName
]
let initViewItem : [XCSourceEditorCommandDefinitionKey: Any] = [
XCSourceEditorCommandDefinitionKey.classNameKey : kSourceEditorClassName,
XCSourceEditorCommandDefinitionKey.identifierKey: kInitViewIdentifier,
XCSourceEditorCommandDefinitionKey.nameKey: kInitViewName
]
let addImportItem : [XCSourceEditorCommandDefinitionKey: Any] = [
XCSourceEditorCommandDefinitionKey.classNameKey : kSourceEditorClassName,
XCSourceEditorCommandDefinitionKey.identifierKey : kAddImportIdentifier,
XCSourceEditorCommandDefinitionKey.nameKey : kAddImportName
]
return [addLazyCodeItem,
addImportItem,
initViewItem
]
}
}
2.1.2 通过Info.plist配置的方式
- XCSourceEditorExtensionPrincipalClass : 这个对应的就是功能实现回调的类;当我们执行Extension的功能的时候,会执行该类定义的方法,下面会介绍
- XCSourceEditorCommandDefinitions : 这里就是定义Extension的功能菜单
2.1.3 运行看看效果
如果你的Xcode菜单是灰掉的,那么可能就是Extension运行报错了
解决方法:
1.调试 Extension,直接运行看看控制台输出;具体操作如下图所示
2.分析控制台日志,针对性的去处理
dyld: Library not loaded: @rpath/XcodeKit.framework/Versions/A/XcodeKit
Referenced from:
比如我这里是报XcodeKit.framework
这个库找不到,那么就去项目中
看看是否添加了这个库,如果没添加就添加一下,有添加,检查一下是否是Embed & Sign(如下图所示);如果是这样配置的,那么删掉再重新加一下
3.当Extension能正常运行了,Xcode的菜单就不会显示是灰色的了
菜单定义好了,接下来就去实现对应的菜单的功能了
2.2 SourceEditorCommand
这个就是功能菜单执行的回调的类了,当我们在Xcode的某个类文件,执行功能的时候,就会调用perform函数,XCSourceEditorCommandInvocation就是源码编辑命令的内容,包含了源码的行信息,选中信息等等
func perform(with invocation: XCSourceEditorCommandInvocation, completionHandler: @escaping (Error?) -> Void ) -> Void
在perform函数,我们就可以根据commandIdentifier
来判断是调用了哪个功能菜单,然后分发给对应的实现去处理;这里也就是为什么上面说的再定义的时候需要设置identifierKey
为唯一的ID
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.
let identifier = invocation.commandIdentifier
print(identifier)
if identifier == kAddLazyCodeIdentifier {
AddLazyCodeManager.sharedInstance.processCodeWithInvocation(invocation: invocation)
} else if identifier == kInitViewIdentifier {
InitViewManager.sharedInstance.processCodeWithInvocation(invocation: invocation)
} else if identifier == kAddImportIdentifier {
AddImportManager.sharedInstance.processCodeWithInvocation(invocation: invocation)
}
completionHandler(nil)
}
}
2.2.1 XCSourceEditorCommandInvocation
源码编辑器的内容对象,包含了内容缓冲区buffer以及功能的ID,我们主要就是通过操作XCSourceTextBuffer *buffer
来修改编辑器的内容
@interface XCSourceEditorCommandInvocation : NSObject
- (instancetype)init NS_UNAVAILABLE;
@property (readonly, copy) NSString *commandIdentifier;
@property (readonly, strong) XCSourceTextBuffer *buffer;
@property (copy) void (^cancellationHandler)(void);
@end
2.2.2 XCSourceTextBuffer
编辑器内容缓冲区,包含了源码编辑器的配置信息,以及正在编辑的源码文件的行信息NSMutableArray <NSString *> *lines
,以及用户在源码文件中选中的区域信息NSMutableArray <XCSourceTextRange *> *selections
XCSourceTextRange
则包含了选中区域的开始行和列XCSourceTextPosition start
以及结束的行和列信息XCSourceTextPosition end
/** A single text position within a buffer. All coordinates are zero-based. */
typedef struct {
NSInteger line;
NSInteger column;
} XCSourceTextPosition;
/** A buffer representing some editor text. Mutations to the buffer are tracked and committed when a command returns YES and has not been canceled by the user. */
@interface XCSourceTextBuffer : NSObject
/** An XCSourceTextBuffer is not directly instantiable. */
- (instancetype)init NS_UNAVAILABLE;
/** The UTI of the content in the buffer. */
@property (readonly, copy) NSString *contentUTI;
/** The number of space characters represented by a tab character in the buffer. */
@property (readonly) NSInteger tabWidth;
/** The number of space characters used for indentation of the text in the buffer. */
@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
我们就是通过修改buffer的内容来达到修改源码编辑器文件的内容,从而实现一些功能,比如插入懒加载代码、初始化类文件的代码、import头文件等等操作
3. 如何调试
官方提供的调试方式
我则习惯直接Xcode 运行项目,然后通过Xcode的Debug -- Attach to Process的方式去调试我们写的插件的功能
在实际调试过程中,如果Attach to Process报错,那么就大退一下Xcode,然后在运行 或者在终端执行kill -9 95148
95148是extension的processId;接下来我们就可以边写代码,边调试来完善提供的功能了
4. 编写小插件Commond实现代码
以选中代码源文件的某一行来导入头文件的功能为例:
- 解析选中的行列信息得到选中的文本内容
- 拼装需要插入的内容文本
- 查找需要插入的位置,就是遍历源码编辑器内容的lines信息得到最后一个import行的行号+1
- 将拼装的内容文本插入到对应的位置
class AddImportManager : HCEditorCommondHandler {
static let sharedInstance = AddImportManager()
func processCodeWithInvocation(invocation : XCSourceEditorCommandInvocation) -> Void {
print("add import")
guard invocation.buffer.selections.count > 0 else {
return
}
let selectRange: XCSourceTextRange = invocation.buffer.selections.firstObject as! XCSourceTextRange
let startLine = selectRange.start.line // 选中的开始行
let endLine = selectRange.end.line // 选中的结束行
let startColumn = selectRange.start.column // 选中的内容开始列
let endColumn = selectRange.end.column // 选中的内容结束列
guard startLine == endLine && startColumn != endColumn else { // 支持单行选中,并且需要选中内容
return
}
let selectedLineString: NSString = invocation.buffer.lines.object(at: startLine) as! NSString
let selectedContentString : NSString = selectedLineString.substring(with: NSMakeRange(startColumn, endColumn - startColumn)).trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) as NSString
guard selectedContentString.length > 0 else {
return
}
// 拼接导入头文件的内容
let insertString: NSString = NSString.init(format: "#import \"%@.h\"", selectedContentString)
var alreadyIndex: NSInteger = 0
alreadyIndex = invocation.buffer.lines.indexOfFirstItemContainString(string: insertString) // 获取是否已经导入过了
if alreadyIndex != NSNotFound { // 已经导入过头文件了
return
}
// 查找import的最后一行的index
var lastImportLine = NSNotFound
for index in 0...invocation.buffer.lines.count-1 {
let lineString = invocation.buffer.lines[index]
if lineString is NSString {
var tempString: NSString = lineString as! NSString
tempString = tempString.deleteSpaceAndNewLine()
if tempString.contains("import") {
lastImportLine = index
}
}
}
// 设置插入的行号,如果buffer中已经有import过则lastImportIndex不为NSNotFound,此时插入到lastImportIndex的后一行;否则就插入在首行
var insertLine = 0
if lastImportLine != NSNotFound {
insertLine = lastImportLine + 1
}
invocation.buffer.lines.insert(insertString, at: insertLine)
}
}
其他的功能也都类似,大部分都是在操作lines信息,来读取选中的内容,以及插入需要插入的文本内容;我们边编码边调试最终就能完成对应的功能。
具体的代码实现HCXcodeToolsExtension
5. 集成到Xcode的Editor菜单
进入系统偏好设置--扩展,选中我们的插件钩上即可
如果更新Xcode之后发现扩展里面没有Xcode Source Editor的选项,那么有一个骚操作可以解决:将Xcode.app命名改一下再改回去就出来了
设置操作的快捷键
按照自己的操作习惯设置对应功能的快捷键,然后就可以愉快的玩起来了
6. 打包出dmg
我们如果想要把插件给其他人用,可以直接让他运行源代码,然后按照上面的步骤集成到Xcode的Editor菜单去
这里我们使用一种将app打包成dmg的方式,让别人直接安装、配置一下就可以使用
6.1 准备打包需要的文件
- app包 : 直接Xcode运行,然后选中xxx.app -- 右键Show In Finder就可以找到了
-
Mac Application的快捷方式 : 选中Mac的应用程序文件夹--右键选择“制作替身”即可
新建一个文件夹,将以上两个文件放进来
6.2 使用磁盘工具导出dmg文件
打开磁盘工具
新建基于文件夹的镜像
选中我们上面的文件夹,然后点击存储即可
导出完成,就生成了dmg文件
安装包地址:HCXcodeTools.dmg
看看成果
双击解压dmg文件,将app拖到应用程序,运行下,在按照上面的步骤设置一下,配置下快捷键就可以了
7 总结
苹果提供的Extension的种类还是挺多的,自己学习Swift语言,光看语法也没啥意思,就想着用Swift写一个Xcode的小插件来顺便学习下Swift的语法
学习了下打包dmg文件分发mac app,还挺简单的