原文地址 翻译:DeveloperLx
16年9月21日更新: 这个教程已更新至Xcode 8及Swift 3。
作为一个app开发者,几乎是不可能想到人们 所有的 使用你app的方法的。如果能让你的用户创建脚本,来定制你的app去满足他们个人化的需求,难道不是很酷?
使用Applescript和Javascript来自动化(JXA),你可以!在这个让mac app可脚本化的教程中,你将了解到,怎样添加脚本的处理的能力到示例应用上。你将从了解怎样使用脚本控制存在的app开始,然后扩展一个app去允许定制脚本行为。
下载 示例工程 ,在Xcode中打开它,build并运行,查看它的样子:
这个app展示了一个带有到期日的,在接下来几天的任务的列表,还有关联到每个任务的标签。它使用一个outline view来根据到期日给任务分租。
你可能注意到,你不能添加,编辑,或删除任务。这是由设计决定的 - 这些动作将通过你的用户的自动化脚本来处理。
看一下项目中的文件:
- 有两个模型类的文件: Task.swift 和 Tag.swift 。这是你将要编写的类。
- ViewController 这组处理展示和观察数据的变化。
- Data 组有一个带有示例任务的文件,还有一个 DataProvider 来读取那些任务,并处理到达的任何修改。
-
这个
AppDelegate
使用一个
DataProvider
对象来持有一个app任务的记录。 - ScriptableTasks.sdef 是一个重要的文件...你将要在之后详细地探索。
本教程还有一些示例脚本; 从这里下载 。在这个包中有两个目录:一个用于AppleScript,另一个用于JavaScript。由于这个教程的重点不在于写脚本,你将使用每一个下载到的脚本来测试你将添加到 Scriptable Tasks 中的功能。
说得已经足够了 - 是时候移步到脚本这里了!:]
打开 Applications/Utilities 中的 Script Editor app ,并打开一个新的文档:
你将在顶部的工具栏中看到四个按钮: Record , Stop , Run ,和 Compile 。Compile检查你的脚本是语法正确的,而Run做的大致就是你所期望的。
而在window的底部,你会看到三个图标,用来在view之间切换。 Description 让你添加一些关于你的脚本的信息,而 Result 则展示了你运行一个脚本最后的结果。最有用的选项则是第三个按钮: Log 。
而Log还提供了四个选项: Result , Messages , Events 和 Replies 。Replies是信息量最大的,因为它展示了每个命令和命令返回的值。当测试任何脚本的时候,我强烈推荐 Log in Replies 模式。
注意:
Note:
如果你曾经打开过一个AppleScript文件,并发现它包含类似下面的代码:
«class TaSk» whose «class TrFa» is false and «class CrDa»
,点击
Compile
,它将转变为可读的AppleScript,前提是你已安装了目标的app。
在这个教程中,你将cover到两种脚本语言。第一种是AppleScript,它是在1991年的Mac系统7是被引入的,使其可以被码农和非码农使用。
第二种则是JavaScript for Automation (JXA),它是在OSX Yosemite系统中被引用的,让码农可以使用它们熟悉的JavaScript语法来构建它们的自动化任务。
在本教程中的脚本将同时通过AppleScript和JXA来展现,因此你可以自由地沿着你想要探索那种语言去漫游。:]
注意: 在本教程中,脚本的代码片段将首先通过AppleScript来展示,之后则紧跟着等效的JavaScript的版本。
有一个很棒的小app早已安装到了你的Mac上:TextEdit,它支持脚本的撰写。在 Script Editor 中,选择 Window/Library 并找到 TextEdit 的入口。如果它不在这里,单击顶部的 Plus 按钮,找到你的 Applications 目录并添加 TextEdit 。然后双击 TextEdit 入口来打开TextEdit字典:
每一个可编写脚本的app有一个字典,储存在脚本的定义文件(SDEF)中。这个字典告诉你这个app中包含哪些对象,这些对象中包含什么属性,这个app能响应什么命令。在上面的屏幕截图中,你可以看到TextEdit含有若干个段落,这些段落含有颜色和字体的特性。你将使用这些信息来风格化一些文本。
从 AppleScript 或 JavaScript 目录中打开 1. TextEdit Write.scpt 目录。运行脚本;你将看到TextEdit创建并保存了一个文档。
现在你有了一个新的文档,但它需要一点样式。打开 2. TextEdit Read Edit.scpt ,运行这个脚本,你将看到这个文档根据脚本再次打开并样式化。
尽管深入钻研实际的脚本超过了这个教程的范围,也请随意仔细阅读脚本,来查看它如何在TextEdit的文档上执行。
正如在介绍中提到的,所有的app在一定程度上都是可脚本化的。为了看到这个起作用,确保Scriptable的任务正在执行。下一步,在Script Editor中打开一个新的脚本window并输入下列脚本中的一个,取决于你正在使用的语言:
-- AppleScript tell application "Scriptable Tasks" to quit
或
// JavaScript Application("Scriptable Tasks"). quit();
点击 Run ,应该可编写脚本的程序就退出了。改变脚本为下面的样子,并在此点击 Run :
tell application "Scriptable Tasks" to launch
或
Application("Scriptable Tasks").launch();
这个app会重新启动,但不会到达前台。要使它成为“焦点”,在上面的脚本改变
launch
为
activate
,并单击
Run
。
既然你已看到这个app可以响应脚本命令了,那就时候将这个能力添加到你的app上了。
你的App的脚本定义文件定义了这个app可以做的事;这就有点像一个API。这个文件存在在你的app项目中,并指定了几件事情:
-
标准的脚本对象和命令,例如
window
,make
,delete
,count
,open
和quit
。 - 你自己的可编写脚本的对象,属性和自定义命令。
为了让你的app中的类可脚本化,你需要对其做出一些改变。
首先,脚本的接口使用Key-Value-Coding来get和set对象的property。在OC中,所有的对象都自动地遵守KVC协议,但Swift的对象并不是这样的,除非你使其成为
NSObject
的子类。
接下来,可脚本的类需要一个脚本接口可以识别的OC的名字。为了避免命名空间的冲突,Swift对象的名称是很难以给出一个独立的表示的。通过使用
@objc(YourClassName)
来给类添加前缀,你就给了它们一个可以被脚本引擎使用的名称。
可编写脚本的类,需要对象说明符协助在应用或父对象中定位一个特定的对象,最终,这个app的delegate必须能够访问的到储存数据的地方,这样它才能返回应用的数据到脚本中。
你并不必须从scratch来开始你自己的脚本定义文件,因为苹果提供了一个标注的SDEF文件供你使用。它就是 /System/Library/ScriptingDefinitions/ 目录中的 CocoaStandard.sdef 文件。在Xcode中打开它并查看;它是XML格式的,带有指定的header,其中还有一个字典,里面是标准的套件。
这时一个有用的起点,你 This is a useful starting point, and you 可以 拷贝并粘贴这个XML到你自己的SDEF文件中。然而,从干净的代码的角度考量,让SDEF文件充满你的app不支持的命令和对象并不是一个好主意。因此,到了最后,这个示例工程应当只包含起始的SDEF文件移除了全部非必需记录的部分。
关闭
CocoaStandard.sdef
并打开
ScriptableTasks.sdef
。在靠近结尾
Insert Scriptable Tasks suite here
注释的这里添加下列代码:
<!-- 1 --> <suite name="Scriptable Tasks Suite" code="ScTa" description="Scriptable Tasks suite."> <!-- 2 --> <class name="application" code="capp" description="An application's top level scripting object."> <cocoa class="NSApplication"/> <!-- 3 --> <element type="task" access="r"> <cocoa key="tasks"/> </element> </class> <!-- Insert command here --> <!-- 4 --> <class name="task" code="TaSk" description="A task item" inherits="item" plural="tasks"> <cocoa class="Task"/> <!-- 5 --> <property name="id" code="ID " type="text" access="r" description="The unique identifier of the task."> <cocoa key="id"/> </property> <property name="name" code="pnam" type="text" access="rw" description="The title of the task."> <cocoa key="title"/> </property> <!-- 6 --> <property name="daysUntilDue" code="CrDa" type="number" access="rw" description="The number of days before this task is due."/> <property name="completed" code="TrFa" type="boolean" access="rw" description="Has the task been completed?"/> <!-- 7 --> <!-- Insert element of tags here --> <!-- Insert responds-to command here --> </class> <!-- Insert tag class here --> </suite>
这个XML的代码块做了很多的工作。一步一步来看:
-
最外层的元素是
suite
,所以你的SDEF文件现在有两个suite: Standard Suite 和 Scriptable Tasks Suite 。在SDEF文件中的每件事都需要一个四字符的code。苹果的code几乎总是小写的,你将使用其中的几个用于特定的目的。对于你自己的suite,class和property,则最好使用大写、小写和符号的随机混合来避免冲突。 -
下一部分定义了应用,并必须使用code值
"capp"
。你必须制定application的class;如果你子类化了NSApplication
,你就该在这里使用你子类的名称了。 -
这个application包含
element
。在这个app中,element被储存在app的delegate,一个叫做tasks
的数组中。在脚本术语中,element是app和其它对象可以包含的对象。 -
最后的一个块定义了application包含的
Task
class。访问多个的复数名称是tasks
。在这个app中,支持这个对象类型的class是Task
。 -
前两个property是特定的。请看它们的code:
"ID "
an和d"pnam"
。"ID "
(注意字母之后的两个空格)指定了这个对象的唯一标识符。"pnam"
指定了这个对象的name
property。你可以使用它们中的任一个,来直接访问对象。"ID "
是只读的,因为脚本不应该改变唯一标识符,但"pnam"
是可读写的。它们都是text类型的property。"pnam"
property映射到了Task
对象的title
property。 -
还剩两个property,一个是number类型的property
daysUntilDue
,另一个是Boolean类型的propertycompleted
。它们可以在对象和脚本中使用相同的名称,因此你不需要指定cocoa key
。 - “Insert…”的注释是为你需要添加更多内容到这个文件时的占位符。
打开 Info.plist ,在记录下方的空白处右击,并选择 Add Row 。输入一个大写的 S ,将建议的列别滚动到 Scriptable 。选择它并将设置更改为 YES 。
重复这个过程来选择下一项: Scripting definition file name 。设置它为你的SDEF文件的文件名: ScriptableTasks.sdef
如果你喜欢以源码的形式编辑Info.plist,你也可以添加下列的记录到主字典中:
<key>NSAppleScriptEnabled</key> <true/> <key>OSAScriptingDefinition</key> <string>ScriptableTasks.sdef</string>
现在你必须修改app的delegate,来处理从脚本中传来的请求。
打开 AppDelegate.swift 文件,并添加下列代码到文件的尾部:
extension AppDelegate { // 1 override func application(_ sender: NSApplication, delegateHandlesKey key: String) -> Bool { return key == "tasks" } // 2 func insertObject(_ object: Task, inTasksAtIndex index: Int) { tasks = dataProvider.insertNew(task: object, at: index) } func removeObjectFromTasksAtIndex(_ index: Int) { tasks = dataProvider.deleteTask(at: index) } }
上面的代码执行了以下的事:
-
当一个脚本请求
tasks
的数据时,这个方法将确认app的delegate可以处理它。 -
如果一个脚本尝试插入,编辑或删除数据,这些方法将传递那些请求到
dataProvider
中。
为了使
Task
的model类对脚本可用,你必须在做一点coding。
打开 Task.swift ,并将类的定义修改成下面的样子:
@objc(Task) class Task: NSObject {
Xcode会立刻抱怨说
init
要求
override
关键字,所以让Fix-It来做吧。这是必需的,因为这个类现在有一个父类:
override init() {
Task.swift
需要更多的修改:一个对象说明符。插入下列的方法到
Task
的类中:
override var objectSpecifier: NSScriptObjectSpecifier { // 1 let appDescription = NSApplication.shared().classDescription as! NSScriptClassDescription // 2 let specifier = NSUniqueIDSpecifier(containerClassDescription: appDescription, containerSpecifier: nil, key: "tasks", uniqueID: id) return specifier }
以此来对每个编号评论:
- 因为app是task的容器,获取app的类的描述。
-
通过id在app中获取任务的描述。这就是为什么Task类有一个
id
的property - 这样每个任务就可以被正确地指定。
你终于可以开始脚本化你的app了!
在你开始之前,确保退出任何这个app运行中的实例,它们可能是被Script Editor打开的。
Build并运行Scriptable Task;右击Dock上的icon,并在Finder中选择 Options/Show 。退出 Script Editor app并重启,让它对你的app做出的改变生效。
打开 Library 的window,并从 Finder 中拖拽 Scriptable Tasks 到 Library window中。
如果你收到了一个错误,说这个app是不可编写脚本的,尝试退出Script Editor并重启它,因为它有时没有注册一个新build的app。如果它仍然没能成功地导入,请返回并仔细检查你对SDEF文件进行的修改。
双击Library中的 Scriptable Tasks 来查看app的字典:
你将看到Standard Suite和Scriptable Tasks Suite。单击 Scriptable Tasks suite,你将看到你在SDEF文件中添加了什么。这个应用包含任务,一个任务包含四个property。
使用工具栏中的 Language 弹出菜单,来改变目录中的脚本语言为 JavaScript 。你将看到基本相同的信息,但有一个重要的变化。class和property的case发生了改变。我不清楚这是因为什么,但他是那些你需要注意的“陷阱(gotchas)”之一。
在 Script Editor 中,创建一个新的脚本文件,并设置这个编辑器展示 Log/Replies 。测试下面的脚本之一,确保在语言的弹出菜单中选择恰当的语言:
tell application "Scriptable Tasks" get every task end tell
或
app = Application("Scriptable Tasks"); app.tasks();
在log中,你将看到一个通过ID的task的列表。为了让信息更有用,编辑脚本就像下面这样:
tell application "Scriptable Tasks" get the name of every task end tell
或
app = Application("Scriptable Tasks"); app.tasks.name();
再尝试一些你之前下载的脚本。当运行脚本的时候,确保你将Script Editor设置为 Log/Replies 这样你就能一路上看到结果。
每个脚本在重写运行它之前退出这个app;这是为了在任何的编辑之后重置数据,这样示例脚本就如同期望一般地工作。你通常不会在你自己的脚本中执行此操作。
注意: Script Editor会在你build更新版本的app时感到非常困惑,因为如果你有一个打开的正在使用这个app的脚本,它会尝试一直保持其同一版本来运行。这通常会以app的老版本形式结束,因此在每次build之前,退出app。
任何时候,如果你看到两个副本的Scriptable Tasks的app正在运行,或在任何示例中出现了脚本的错误,你可以确定,这是Script Editor已跑在了错误版本的app上。最简单的修复就是退出所有副本的app,并退出Script Editor。Clean Xcode的build( Product/Clean ),然后build并再次运行。
重新启动Script Editor,当它打开脚本的时候,单击 Compile ,然后单击 Run 。如果失败了的话,请在 ~/Library/Developer/Xcode/DerivedData 里删除app中的Derived Data。
尝试下面的两个示例脚本:
3. 获取Tasks.scpt
这个脚本使用各种过滤器检索任务的数量和任务的名称。记下下列事项:
- JavaScript从0开始计数,而AppleScript从1开始计数。
- 文本搜索是不区分大小写的。
4. 添加编辑Tasks.scpt
这个脚本添加了新的任务,在第一个任务上切换了
completed
的标记,并尝试创建另一个相同名称的任务。
嗯...创建一个同名的任务work了!现在你有了两个”喂猫“的任务。猫会很激动,但对于这个app的目的,任务的名称应当是唯一的。尝试添加一个名称早已存在的任务可能产生一个错误。
回到
Xcode
,查看
AppDelegate.swift
,你会看到当脚本想要插入一个对象时,app的delegate会将这个调用床底给
dataProvider
。在
DataProvider.swift
中,查看
insertNew(task:at:)
,它将一个存在的任务插入到数组中,或添加了一个新的任务到结尾。
到了在这里添加一次检查的时候了。用下列的代码来替换这个方法:
mutating func insertNew(task: Task, at index: Int) -> [Task] { // 1 if taskExists(withTitle: task.title) { // 2 let command = NSScriptCommand.current() command?.scriptErrorNumber = errOSACantAssign command?.scriptErrorString = "Task with the title '\(task.title)' already exists" } else { // 3 if index >= tasks.count { tasks.append(task) } else { tasks.insert(task, at: index) } postNotificationOfChanges() } return tasks }
这里是每条评论处所做的事:
- 使用现有的函数来检查是否这个名称的任务早已存在。
-
如果这个名称
不是
唯一的:
- 获取对调用这个函数的脚本命令的引用。
-
查看命令的
errorNumber
和errorString
property;errOSACantAssign
是AppleScript的标准错误码之一。这些将被发送回调用的脚本。
-
如果这个名称是
是
唯一的:
- 像之前一样地处理任务。
- 发送数据变化的通知。ViewController会看到这个并更新展示。
如果app正在运行,退出,然后build并运行你的app。再次运行 4. 添加Edit Tasks 脚本。这次你应当会得到一个错误的对话框,而不是创建副本的任务。对不起,猫...
5. 删除Tasks.scpt
这个脚步删除了一个任务,检查是否存在一个特定的任务,并删除它,最终删除全部完成的任务。
在示例的app中,第二列展示了一个,分配给每项任务的标签的列表。到目前为止,你仍然没有办法通过脚本来使用它们 - 是时候来修复这个了!
对象的说明符可以处理对象的层次结构。这是你在这里拥有的,而应用程序拥有着任务,并且任务拥有它的标签。
与
Task
类一样,你需要使
Tag
脚本化。
打开 Tag.swift ,并作出下面的变化:
-
将类的定义这行修改为:
@objc(Tag) class Tag: NSObject {
-
添加
override
关键字到init
上。 - 添加对象说明符的方法:
override var objectSpecifier: NSScriptObjectSpecifier { // 1 guard let task = task else { return NSScriptObjectSpecifier() } // 2 guard let taskClassDescription = task.classDescription as? NSScriptClassDescription else { return NSScriptObjectSpecifier() } // 3 let taskSpecifier = task.objectSpecifier // 4 let specifier = NSUniqueIDSpecifier(containerClassDescription: taskClassDescription, containerSpecifier: taskSpecifier, key: "tags", uniqueID: id) return specifier }
上面的代码相对是比较简单的:
- 检查标签是否具有分配的任务。
- 检查任务是否具有正确的类的类描述。
- 获取父任务的对象说明符。
- 为包含在任务中的标签,构建对象说明符,并返回。
在评论
Insert tag class here
处,添加下列的代码到SDEF文件中:
<class name="tag" code="TaGg" description="A tag" inherits="item" plural="tags"> <cocoa class="Tag"/> <property name="id" code="ID " type="text" access="r" description="The unique identifier of the tag."> <cocoa key="uniqueID"/> </property> <property name="name" code="pnam" type="text" access="rw" description="The name of the tag."> <cocoa key="name"/> </property> </class>
它和
Task
class的数据非常相似,但一个标签只有两个暴露的property:
id
和
name
。
现在必须要对
Task
部分进行一下编辑,来指出它包含着tag元素了。
添加下列的代码到Task类的XML中,在
Insert element of tags here
的注释处:
<element type="tag" access="rw"> <cocoa key="tags"/> </element>
退出app,然后再次build并运行app。
返回 Script Editor ;如果 Scriptable Tasks dictionary 已打开,就关闭并重新打开它。观察它是否包含关于标签的信息。
如果不存在,就从 Library 中移除 Scriptable Tasks 记录并在此添加,通过将app拖拽到window中:
尝试下列脚本中的一个:
tell application "Scriptable Tasks" get the name of every tag of task 1 end tell
或
app = Application("Scriptable Tasks"); app.tasks[0].tags.name();
现在你可以在app中检索tag了 - 但添加一些新的怎么样?
你可能会注意到在
Tag.swift
中,每个
Tag
都有一个弱引用指向它自己的任务。当获取了对象说明符后,它可以帮助创建连接,因此当分配一个熄灯tag到任务中时,任务的property必须被设置。
打开
Task.swift
并添加下列的方法到
Task
的类中:
override func newScriptingObject(of objectClass: AnyClass, forValueForKey key: String, withContentsValue contentsValue: Any?, properties: [String: Any]) -> Any? { let tag: Tag = super.newScriptingObject(of: objectClass, forValueForKey: key, withContentsValue: contentsValue, properties: properties) as! Tag tag.task = self return tag }
为什么你将它放到
Task
类而不是
Tag
类中,是因为这个方法被发送到了新对象的容器中。这个调用被传递到了
父类
中来获取新的标签,然后这个task的property就被赋值了。
退出,build并运行你的app。现在运行示例的脚本 6. Tasks With Tags.scpt ,它列出标签的名称,通过指定的标签列出任务,并可以删除和创建tag。
当制作一个app脚本时,你还可以采取一步:添加定制的命令。在之前的脚本中,你直接切换了任务的
completed
标记。但难道不应该更好一些 - 更安全一些么?能否可以使用一个命令来完成,而不是直接改变property?
考虑下列的脚本:
mark the first task as "done" mark task "Feed the cat" as "not done"
我相信你已达到了SDEF文件,你完成的是正确的:这个命令必须首先被定义。
这里需要两个步骤:
- 告诉应用这个命令存在,和它的参数是什么。
- 告诉Task类它响应命令,以及要调用的方法来实现它。
在Scriptable Tasks suite中,所有的类之外,在 Insert command here 注释中,添加下列的代码:
<command name="mark" code="TaSktext"> <direct-parameter description="One task" type="task"/> <parameter name="as" code="DFLG" description="'done' or 'not done'" type="text"> <cocoa key="doneFlag"/> </parameter> </command>
“等一下!“你说。“之前你说code必须是 四个 字符,但现在我有了一个八个字符的?这里发生了什么?”
当定义一个方法的时候,你提供一个两个部分的code。它组合了参数的code或类型 - 在这个case中是一个
Task
对象和一些文本。
在
Task
类的定义中,
Insert responds-to command here
的注释处,添加下列代码:
<responds-to command="mark"> <cocoa method="markAsDone:"/> </responds-to>
现在返回 Task.swift 并添加下列代码:
func markAsDone(_ command: NSScriptCommand) { if let task = command.evaluatedReceivers as? Task, let doneFlag = command.evaluatedArguments?["doneFlag"] as? String { if self == task { if doneFlag == "done" { completed = true } else if doneFlag == "not done" { completed = false } // if doneFlag doesn't match either string, leave un-changed } } }
markAsDone(_:)
的参数是
NSScriptCommand
类型的,它含有两个有用的property:
evaluatedReceivers
和
evaluatedArguments
。从它们这里,你会尝试获取任务和字符创的参数,并使用它们来调整相应的任务。
退出,再次build并运行你的app。在Script Editor中查看字典,如果
mark
命令未显示,请删除并重新导入它:
现在,你应该可以运行 7. Custom Command.scpt 脚本,并看到你的新的脚本正在执行中了。
mark
命令在JavaScript中却失效了。我已经添加了
completed
property的手动切换到JavaScript版本的
7. Custom Command.scpt
中,但也在这里留下了原来的版本。希望它可以在Swift更新之后生效。
你可以在 这里 下载最终版本的示例项目。
在这个mac app的可脚本化的教程中,没有覆盖到app间的通信。对于如何在app之间工作,请访问 8. Inter-App Communication.scpt 来查看一些例子。这个例子收集了今天和明天未完成的任务,并将它们插入到了一个新的TextEdit文件,样式化文本并保存文件。
对于可执行脚本app的更多信息,苹果官方的文档在这里 Scriptable Applications ,它是一个很好的开始,苹果的 Overview of Cocoa Support for Scriptable Applications 也是这样。
感兴趣于了解更多的JXA?请访问 Introduction to JavaScript for Automation Release Notes 。