用Swift开发Xcode插件

最近发现Xcode上有个好玩的插件Miku,敲代码时Miku会唱歌和跳舞,停止敲代码时Miku的动作就会慢下来,它是atom-miku的盗版。

Miku插件是使用Objective-C开发,可惜不怎么懂objc,就寻思着用Swift翻译一遍,学习一下怎样用Swift开发Xcode的插件。

模板

Xcode的插件大部分都是通过Alcatraz管理的,在Xcode的Window菜单中打开Package Manager就是Alcatraz,在打开的插件管理界面中选择templates页签,然后在搜索框中输入xcode plugin就能找到Xcode Plugin Template插件。Xcode Plugin Template是已经写好可运行的Xcode插件模板,在github上也可下载安装Xcode Plugin Template

Xcode的插件会安装到~/Library/Application Support/Developer/Shared/Xcode/Plug-ins/目录下,文件名后缀是.xcplugin,卸载时直接到这个目录中把对应的.xcplugin删除即可。

安装完后重启Xcode,新建项目时在OSX上就可以选择新建Xcode Plugin的工程,记得项目语言选择Swift。

开发过程

菜单

虽然很多Xcode插件是没有菜单的,但是选项多或者需要通过特殊的触发条件可以使用菜单。

使用模板的话会自动生成菜单,如下:

1
2
3
4
5
6
7
let item = NSApp.mainMenu!.itemWithTitle("Edit")
if item != nil {
let actionMenuItem = NSMenuItem(title:"Do Action", action:"doMenuAction", keyEquivalent:"")
actionMenuItem.target = self
item!.submenu!.addItem(NSMenuItem.separatorItem())
item!.submenu!.addItem(actionMenuItem)
}

上面代码的意思是先找到Edit的菜单项,然后在里面添加“Do Action”的按钮,点击“Do Action”则会执行doMenuAction方法。添加键盘快捷键如Control+Shift+M,可以用以下方式:

1
2
let actionMenuItem = NSMenuItem(title:"Do Action", action:"doMenuAction", keyEquivalent:"M")
actionMenuItem.keyEquivalentModifierMask = Int(NSEventModifierFlags.ControlKeyMask.rawValue)

逻辑

按照Miku里面的说法,网页里已经实现了大部分功能,如果要移植到Xcode的话,只需写出以下逻辑就行了:

  1. 在代码编辑框上面加上一个WebView来加载网页。
  2. WebView支持用鼠标拖动,防止有时候挡住代码。
  3. hook代码编辑框输入文字时的方法,调用网页的addFrame()方法来增加播放时间。
  4. 由于网页的资源过大,在线加载速度比较慢,所以把网页都打包到插件里。

第一第二点先不说,主要说说第三点,怎么hook代码编辑框输入文字的方法。在github上有个叫JRSwizzle的项目,JRSwizzle是让你可以在objc runtime运行时交换类方法,有点想AOP的概念。

JRSwizzle的用法很简单,在XXX-Briding-Header.h中引入#import "JRSwizzle.h"即可。

然后在hook方法中使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func hook() {
// 先找到我们要hook的类,因为是要在代码编辑框输入文字时启用,所以要hook “IDESourceCodeEditor”这个类。
guard let srcEditorClass = NSClassFromString("IDESourceCodeEditor") as? NSObject.Type else {
return
}

do {
// 把IDESourceCodeEditor的viewDidLoad方法替换为mikuViewDidLoad
try srcEditorClass.jr_swizzleMethod("viewDidLoad", withMethod: "mikuViewDidLoad")
// 把IDESourceCodeEditor的textView方法替换为mikuTextView
try srcEditorClass.jr_swizzleMethod("textView:shouldChangeTextInRange:replacementString:", withMethod: "mikuTextView:shouldChangeTextInRange:replacementString:")
}
catch {
print(error)
Swift.print("Swizzling failed")
}
}

上面的代码中有个问题是mikuViewDidLoad和textview方法写在哪里,第一反应肯定是IDESourceCodeEditor,但是这个是私有的,在Xcode中是不可以直接使用。所有的类都是继承自NSObject,那么利用swift强悍的extension功能去扩展NSObject也可以实现到类似的效果。但是NSObject毕竟是父类,拿不到子类的一些属性,Miku的实现是把WebView添加到IDESourceCodeEditor的containerView中的,但是在NSObject拿不到这个属性,只能是在textView把WebView添加到textView.superview!.superview!.superview!上。

1
2
3
4
5
6
7
8
9
func mikuTextView(textView: NSTextView,
shouldChangeTextInRange affectedCharRange: NSRange,
replacementString: String?) -> Bool {
let mikuDragView = MikuDragView.getSharedInstance()
mikuDragView.mikuWebView!.setPlayingTime(10)
// 不要问为什么这么多superview
textView.superview!.superview!.superview!.addSubview(mikuDragView)
return self.mikuTextView(textView, shouldChangeTextInRange: affectedCharRange, replacementString: replacementString)
}

更多的逻辑实现见https://github.com/wendyeq/MikuSwift

安装升级

模板默认是使用Xcode打开插件的,也就是说可以在在Xcode启动出来的Xcode里开发Xcode的插件。
编辑Edit Scheme把Run中的Build Configuration修改为Release,Executable修改为None。点击运行后重启Xcode即可。

若是Xcode工具升级而工程中的Info.plist里面没有包含升级版本的DVTPlugInCompatibilityUUID着需手工添加。

先在终端中运行defaults read /Applications/Xcode.app/Contents/Info DVTPlugInCompatibilityUUID,得到对应Xcode版本的DVTPlugInCompatibilityUUID,把终端中输出的字符串copy到Info.plist 的DVTPlugInCompatibilityUUIDs数组中去。

最后

参考项目:

MikuSwift

Miku

KZLinkedConsole