给自己的Xcode写个自动生成代码小插件

作为界面开发工程师,各种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

图片.png

1.2 添加Extension
Xcode菜单选择 File -- New -- Target,选择macOS,创建Xcode Source Editor Extension,如下图所示:

图片.png

这里我们是写Xcode代码相关的插件,所以选择的是Xcode Source Editor Extension;如果你想编写其他类型的插件,可以按需选择对于的Extension

创建好target之后,会自动生成2个文件SourceEditorExtensionSourceEditorCommand
我们可以按需在这里添加插件的功能及对应功能的实现

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配置的方式
图片.png
  • XCSourceEditorExtensionPrincipalClass : 这个对应的就是功能实现回调的类;当我们执行Extension的功能的时候,会执行该类定义的方法,下面会介绍
  • XCSourceEditorCommandDefinitions : 这里就是定义Extension的功能菜单
2.1.3 运行看看效果
图片.png

如果你的Xcode菜单是灰掉的,那么可能就是Extension运行报错了
解决方法:
1.调试 Extension,直接运行看看控制台输出;具体操作如下图所示

图片.png

2.分析控制台日志,针对性的去处理

dyld: Library not loaded: @rpath/XcodeKit.framework/Versions/A/XcodeKit
  Referenced from: 

比如我这里是报XcodeKit.framework这个库找不到,那么就去项目中
看看是否添加了这个库,如果没添加就添加一下,有添加,检查一下是否是Embed & Sign(如下图所示);如果是这样配置的,那么删掉再重新加一下

图片.png

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的方式去调试我们写的插件的功能

图片.png

在实际调试过程中,如果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菜单

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

图片.png

如果更新Xcode之后发现扩展里面没有Xcode Source Editor的选项,那么有一个骚操作可以解决:将Xcode.app命名改一下再改回去就出来了

设置操作的快捷键
按照自己的操作习惯设置对应功能的快捷键,然后就可以愉快的玩起来了

图片.png

图片.png
6. 打包出dmg

我们如果想要把插件给其他人用,可以直接让他运行源代码,然后按照上面的步骤集成到Xcode的Editor菜单去

这里我们使用一种将app打包成dmg的方式,让别人直接安装、配置一下就可以使用

6.1 准备打包需要的文件
  • app包 : 直接Xcode运行,然后选中xxx.app -- 右键Show In Finder就可以找到了
  • Mac Application的快捷方式 : 选中Mac的应用程序文件夹--右键选择“制作替身”即可


    图片.png

新建一个文件夹,将以上两个文件放进来


图片.png
6.2 使用磁盘工具导出dmg文件

打开磁盘工具

图片.png

新建基于文件夹的镜像

图片.png

选中我们上面的文件夹,然后点击存储即可


图片.png

导出完成,就生成了dmg文件


图片.png

安装包地址:HCXcodeTools.dmg
看看成果
双击解压dmg文件,将app拖到应用程序,运行下,在按照上面的步骤设置一下,配置下快捷键就可以了

图片.png

7 总结

苹果提供的Extension的种类还是挺多的,自己学习Swift语言,光看语法也没啥意思,就想着用Swift写一个Xcode的小插件来顺便学习下Swift的语法

学习了下打包dmg文件分发mac app,还挺简单的

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342