1. 程式人生 > >使 Mac 應用資料指令碼化

使 Mac 應用資料指令碼化

當為應用新增 AppleScript 支援的時候 - OS X 10.10 中也可以是 JavaScript 支援(譯者注:10.10 中我們可以使用 JavaScript 作為指令碼語言了),最好以應用的資料作為開始。這裡的指令碼並不是說自動按鈕點選什麼的;而是在說將你的 model 層暴露給那些會在自己的工作流程中使用你的應用的人。

有的使用者會向朋友和家人推薦應用,雖然通常像這樣的使用者極少,但是他們是超級使用者。他們的部落格和 twitter 上有關於應用的內容,人們關注了他們。他們會成為你的應用的最大傳播者。

總體而言,新增指令碼支援最重要的原因是它使得應用更加專業,而這所能得到的回報是值得我們努力的。

Noteland

Noteland 是一個除了空白視窗之外沒有任何 UI 的應用,但是它有 model 層,並且可以指令碼化。你可以在 GitHub 上找到它。

Noteland 支援 AppleScript(10.10上還支援 JavaScript)。它是在 Xcode 5.1.1 中用 Objective-C 寫的。我們最初試圖使用 Swift 和 Xcode 6 Beta 2,但是出現了困難。這完全可能是我們自己的錯誤,因為畢竟我們仍然在學習 Swift。

Noteland 的物件模型

有兩個類,notes(筆記) 和 tags(標籤)。可能有多個筆記,而且一個筆記也許有多個標籤。

NLNote.h 聲明瞭幾個屬性: uniqueID

textcreationDatearchivedtags 和一個只讀的 title 屬性。

Tags 類更加簡單。NLTag.h 聲明瞭兩個可指令碼屬性: uniqueIDname

我們希望使用者能夠建立,編輯和刪除筆記和標籤,並且能夠訪問和改變除了只讀以外的屬性。

指令碼定義檔案 (.sdef)

第一個步驟是定義指令碼介面,概念上可以理解為為指令碼建立一個 .h 檔案,但是是以 AppleScript 能夠識別的格式進行建立。

過去,我們需要建立和編輯 aete 資源(“aete” 代表 Apple Event Terminology)。現在容易了很多:我們可以建立一個 sdef(scripting definition 指令碼定義)XML 檔案。

你可能更傾向於使用 JSON 或者 plist,但是 XML 在這裡會更加合適,至少它毫無疑問戰勝了 aete 資源。事實上,曾有一段時間有 plist 版本,但是它要求你保持 兩個 不同的 plist 同步,這非常痛苦。

原來的資源的名字 (aete,Apple Event Terminology) 其實沒什麼特別的意思。Apple event 是由 AppleScript 生成,傳送和接受的低級別訊息。這本身是一種很有趣的技術,而且有指令碼支援以外的用途。而且實際上,它從 90 年代初的 System 7 開始就一直存在,而且在過渡到 OS X 的過程中存活了下來。

(猜測:Apple event 的存活是由於很多印刷出版商依賴於 AppleScript,在 90 年代中後期的 '黑暗日子' 中,出版商們是 Apple 最忠實的使用者。)

一個 sdef 檔案總是以同樣的頭部作為開始:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE dictionary SYSTEM "file://localhost/System/Library/DTDs/sdef.dtd">

頂級項是字典 (dictionary),“字典” 是 AppleScript 中專指一個指令碼介面的詞。在字典中你會發現一個或多個套件 (suite)。

(提示:開啟 AppleScript Editor,然後選擇 File > Open Dictionary...你會看到有指令碼字典的應用列表。如果你選擇 iTunes 作為例子,你會看到類,屬性和 iTunes 能識別的命令。)

<dictionary title="Noteland Terminology">

標準套件

標準套件定義了應用應該支援的所有類和操作。其中包括退出,關閉視窗,建立和刪除物件,查詢物件等等。

將它新增到你的 sdef 檔案,從位於 /System/Library/ScriptingDefinitions/CocoaStandard.sdef 的標準套件中複製和貼上。

<suite name="Standard Suite", 從頭到尾且包括結尾 </suite> 複製所有東西。

將它貼上到你的 sdef 檔案中 dictionary 元素的正下方。

然後,在你的 sdef 檔案中,遍歷並刪除所有沒有用到的東西。Noteland 不基於文件且無需列印,所以我們去掉了開啟和儲存命令,檔案類,以及與列印有關的一切。

(建議:Xcode 在 XML 的縮排方面做得很好,為了重新縮排,選中所有文字並且選擇 Editor > Structure > Re-Indent。)

當你完成編輯後,使用命令列 xmllint 程式 xmllint path/to/noteland.sdef 以確保 XML 是正常的。如果它只顯示了 XML,沒有錯誤和警告,那麼就是正確的。(記住你可以在 Xcode 的視窗標題欄拖拽檔案的代理圖示到終端,然後會貼上檔案的路徑。)

Noteland 套件

一個單一的應用定義套件通常是最好的,雖然並不強制:當確實合情合理的時候,你可以有超過一個的套件。Noteland 只定義一個,下面是 Noteland 套件:

<suite name="Noteland Suite" code="Note" description="Noteland-specific classes.">

指令碼字典所期望的是某些部件被包含在其他東西中。頂級容器是應用程式物件本身。

在 Noteland 中,它的類名是 NLApplication。對於應用的類你應該總是使用 capp 作為編碼 (code) 值:這是一個標準的 Apple event 編碼。(注意它也存在於標準套件中。)

<class name="application" code="capp" description="Noteland’s top level scripting object." plural="applications" inherits="application">
    <cocoa class="NLApplication"/>

該應用包含一個筆記的陣列。區分元素(這裡可以有不止一項)和屬性非常重要。換句話說,編碼中的資料應該作為你字典中的一個元素。

<element type="note" access="rw">
    <cocoa key="notes"/>
</element>`

Cocoa 指令碼使用 KVC,字典用來指定鍵的名稱。

Note 類

<class name="note" code="NOTE" description="A note" inherits="item" plural="notes">
    <cocoa class="NLNote"/>`

上面的編碼是 NOTE。這幾乎可以是任何東西,但是請注意,Apple 保留所有的小寫編碼供自己使用,所以 note 是不被允許的。它可以是 NOT*, 或 NoTe, 或 XYzy,或者任何你想要的。(理想情況下自己的編碼不會與其他應用的編碼衝突。但是我們沒有辦法確保這一點,所以我們只能夠 猜測。也就是說, 猜想 NOTE 可能並不是一個很好的選擇。)

你的類應該繼承自 item。(理論上,你可以讓一個類繼承自你的另一個類,不過我們沒有做過這個嘗試。)

note 類有多個屬性:

<property name="id" code="ID  " type="text" access="r" description="The unique identifier of the note.">
    <cocoa key="uniqueID"/>
</property>
<property name="name" code="pnam" type="text" description="The name of the note — the first line of the text." access="r">
    <cocoa key="title"/>
</property>
<property name="body" code="body" description="The plain text content of the note, including first line and subsequent lines." type="text" access="rw">
    <cocoa key="text"/>
</property>
<property name="creationDate" code="CRdt" description="The date the note was created." type="date" access="r"/>
<property name="archived" code="ARcv" description="Whether or not the note has been archived." type="boolean" access="rw"/>

如果可能,最好為你的物件提供獨一無二的 ID。否則,指令碼不得不依賴於可能發生改變的名字和位置。對唯一的 ID 使用編碼 'ID '。(注意有兩個空格;編碼應該是四個字元。)而這個唯一 ID 的名字必須是 id

只要有意義,提供 name 屬性就是標準的做法,編碼應該是 pnam。在 Noteland 中它是一個只讀屬性,因為名稱只是筆記中文字的第一行,而且筆記的文字通過可讀寫的 body 屬性編輯。

對於 creationDatearchived,我們並不需要提供 Cocoa 的鍵元素,因為鍵和屬性名字相同。

注意型別:text, date 和 boolean。AppleScript 支援它們和其它幾個,詳細地在本文件中列出

筆記可以有標籤,下面是一個標籤元素:

<element type="tag" access="rw">
    <cocoa key="tags"/>
</element>
</class>`

Tag 類

Tags 是 NLTap 物件:

<class name="tag" code="TAG*" description="A tag" inherits="item" plural="tags">
    <cocoa class="NLTag"/>`

Tags 只有兩個屬性,idname

<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">
    <cocoa key="name"/>
</property>
</class>

下面的程式碼是 Noteland 套件和整個字典的結束:

    </suite>
</dictionary>

應用程式配置

應用不是預設就能指令碼化的。我們在 Xcode 中,需要編輯應用的 Info.plist。

因為應用使用了一個自定義的 NSApplication 子類,用來提供頂級容器,我們編輯主體類 (NSPrincipalClass) 來宣告 NLApplication (Noteland 的 NSApplication 子類名字)。

我們還添加了一個指令碼化的鍵(OSAScriptingDefinition)並且設定它為 YES。最後,我們新增一個名為(OSAScriptingDefinition) 的鍵來表示指令碼定義檔案的名字,並將它設定為 sdef 的檔案命名為:noteland.sdef。

程式碼

NSApplication 子類

你可能會驚訝竟然只需要寫那麼少的程式碼。

參見 Noteland 工程中的 NLApplication.m 檔案。它惰性地建立了一個筆記陣列且提供了一些 dummy 資料。說惰性只是因為它沒有連線指令碼支援。

(注意這裡沒有物件持久化,因為我想讓 Noteland 儘可能自由,而不僅僅是指令碼支援。你可以使用 Core Data 或 archiever(歸檔)或者其它東西來儲存資料。)

它也可以跳過 dummy 資料並提供一個數組。

在本例中,陣列是 NSMutableArray 型別的。它可以不必是 NSMutableArray,而是一個 NSArray,但這樣的話 Cocoa 指令碼在筆記陣列發生改變時將會替換整個陣列。但是如果我們讓它作為 NSMutableArray 陣列 提供下面兩個方法的話,這個陣列就不必被替換。取而代之,物件將會被新增到可變陣列中,以及從中移除。

- (void)insertObject:(NLNote *)object inNotesAtIndex:(NSUInteger)index {
    [self.notes insertObject:object atIndex:index];
}

- (void)removeObjectFromNotesAtIndex:(NSUInteger)index {
    [self.notes removeObjectAtIndex:index];
}

另外需要注意,筆記陣列在類擴充套件的 .m 檔案中被宣告。不需要將它放到 .h 檔案中。因為 Cocoa 指令碼使用 KVC,而且不關心你的header,它會找到這個屬性的。

NLNote 類

NLNote.h 聲明瞭筆記的各個屬性:uniqueIDtextcreationDatearchivedtitletags

init 方法中設定 uniqueIDcreationDate,以及將標籤陣列設為空的 NSArray。這次我們使用 NSArray 而不是 NSMutableArray,僅僅為了說明它也可以達到目的。

tilte 方法返回一個計算後的值:筆記中文字的第一行。(回想一下,這會成為指令碼字典的 name。)

要注意 objectSpecifier 方法。這是你的類的關鍵;指令碼支援需要這個使其能夠理解你的物件。

幸運的是,這個方法很容易實現。雖然物件說明符 (object specifiers) 有不同型別,通常情況下最好使用 NSUniqueIDSpecifier,因為它很穩定。(其它選項包括:NSNameSpecifier, NSPositionalSpecifier 等。)

物件說明符需要了解容器相關的東西,而且容器是頂級應用的物件。

程式碼如下所示:

NSScriptClassDescription *appDescription = (NSScriptClassDescription *)[NSApp classDescription];
return [[NSUniqueIDSpecifier alloc] initWithContainerClassDescription:appDescription containerSpecifier:nil key:@"notes" uniqueID:self.uniqueID];

NSApp 是全域性應用的物件;我們獲取它的 classDescription。鍵為 @"notes"containerSpecifier 為 nil 指的是頂級(應用)的容器, uniqueID 是筆記的 uniqueID

Note 作為容器

我們需要超前考慮一點。標籤也會需要 objectSpecifier,而且標籤是包含在筆記中的,所以標籤需要引用包含它的筆記。

Cocoa 指令碼處理標籤的建立,但是我們可以重寫讓自己自定義行為的方法。

NSObjectScripting.h 定義了 -newScriptingObjectOfClass:forValueForKey: withContentsValue:properties:。這正是我們需要的。在 NLNote.m 中,它看起來是這樣的:

NLTag *tag = (NLTag *)[super newScriptingObjectOfClass:objectClass forValueForKey:key withContentsValue:contentsValue properties:properties];
tag.note = self;
return tag;

我們使用父類的實現來建立標籤,然後設定標籤的 note 屬性為該筆記。為了避免可能的迴圈引用,NLTag.h 的 note 是 weak 屬性。

(你可能認為這並不太不優雅,我們同意這麼說。我們希望取代那種為了子類的 objectSpecifiers 而需要存在的容器。像是 objectSpecifierForScriptingObject: 這樣可能會更好。我們提出了一個 bug rdar://17473124。)

NLTag 類

NLTaguniqueID, name, 和 note 屬性。

NLTagobjectSpecifier 在概念上和 NLNote中的程式碼相同,除了容器是筆記而不是頂級應用類。

它看起來像下面這樣:

NSScriptClassDescription *noteClassDescription = (NSScriptClassDescription *)[self.note classDescription];
NSUniqueIDSpecifier *noteSpecifier = (NSUniqueIDSpecifier *)[self.note objectSpecifier];
return [[NSUniqueIDSpecifier alloc] initWithContainerClassDescription:noteClassDescription containerSpecifier:noteSpecifier key:@"tags" uniqueID:self.uniqueID];

就是這樣。完成了。並沒有太多程式碼,大量的工作都是設計介面和編輯 sdef 檔案。

在過去,你需要編寫 Apple event 處理程式,並與 Apple event 描述符和各種一團亂麻的玩意兒一起工作。換句話說,要完成這些你需要走很長的路。值得慶幸的是,現在已經不是過去的日子了。

接下來才是有趣的東西。

AppleScript Editor

啟動 Noteland。啟動 /Applications/Utilities/AppleScript Editor.app。

執行下面的指令碼:

tell application "Noteland"
    every note
end tell

在底部的結果視窗中,你會看到下面這樣的資訊:

{note id "0B0A6DAD-A4C8-42A0-9CB9-FC95F9CB2D53" of application "Noteland", note id "F138AE98-14B0-4469-8A8E-D328B23C67A9" of application "Noteland"}

當然,ID 會有所不同,但是這些跡象表明,它在工作。

試一試這個指令碼:

tell application "Noteland"
    name of every note
end tell

你會在結果窗中看到 {"Note 0", "Note 1"}

再試一下這個指令碼:

tell application "Noteland"
    name of every tag of note 2
end tell

結果:{"Tiger Swallowtails", "Steak-frites"}

(請注意 AppleScript 陣列是基於 1 的,所以 2 指的是第二個筆記。當我們明白這個以後,就一點也不奇怪了)

你也可以建立筆記:

tell application "Noteland"
    set newNote to make new note with properties {body:"New Note" & linefeed & "Some text.", archived:true}
    properties of newNote
end tell

結果將會是類似這樣的(詳細資訊有相應改變):

{creationDate:date "Thursday, June 26, 2014 at 1:42:08 PM", archived:true, name:"New Note", class:note, id:"49D5EE93-655A-446C-BB52-88774925FC62", body:"New Note\nSome text."}`

你還可以建立新的標籤:

tell application "Noteland"
    set newNote to make new note with properties {body:"New Note" & linefeed & "Some text.", archived:true}
    set newTag to make new tag with properties {name:"New Tag"} at end of tags of newNote
    name of every tag of newNote
end tell

結果會是:{"New Tag"}

完美工作!

擴充套件學習

將物件模型指令碼化只是新增指令碼支援的一部分;你也可以為命令新增支援。例如,Noteland 可以有一個將筆記寫到硬碟檔案的匯出命令。RSS 閱讀器可能有一個重新整理命令,郵件應用可能有下載郵件命令,等等。

Matt Neuburg 的 AppleScript 權威指南 值得一讀,儘管它是 2006 年出版的,但是從那以後並沒有發生太大的改變。Matt 還寫有一篇 Cocoa 應用新增指令碼支援的教程。該教程絕對值得一讀,它比這篇文章更加詳細。

這有一個 WWDC 2014 Session 的視訊,是關於 JavaScript 的自動化的,其中談到了新的 JavaScript OSA 語言。(多年以前 Apple 曾提出,總有一天會出現 AppleScript 的程式設計師的特有語言,因為自然語言對寫 C 和 C 類語言的人說略有一點怪。JavaScript 可以被認為是程式設計師的特有語言。)

當然,Apple 有關於這些技術的文件:

此外,請參閱 Apple 的 Sketch 應用,它實現了指令碼化。