使用 Sketch Plug-In 改善工作流程

KKBOX 在六月推出的手機版本加入了主題面板功能,到目前為止,我們已經與 BOSEnanoblock 等合作夥伴一同推出了專屬 KKBOX 主題面板,打造不一樣的 KKBOX 使用體驗。接下來我們還會繼續推出更多的主題面板。

因為這個功能剛推出,我們將力氣花在產品功能的創意發想、視覺設計、規格與檔案格式的制定上,開始進入營運之後回顧時發現—我們產生佈景主題的流程不怎麼有效率。

KKBOX 的主題面板

theme

KKBOX 的佈景主題是一包包含文字定義檔案與圖片素材的集合,一開始由 iOS 與 Android 開發部門決定了主題面板包的格式與 App 中的實作,所以最早幾個主題面板的產生流程是,我們的設計師先做好切圖,再分別把 iOS 與 Android 的切圖機交給 iOS 與 Android App 開發部門手工打包—過程中有許多的手動操作,可能會打錯字或什麼的—完成後再上傳到 server side 開放下載。

產品上線的時候這麼做沒問題,進入營運還這麼做,一定會耗費大量人力,身為工程師必定覺得難以忍受:工程師的工作就是在跟機器打交道,機器的目的就是讓人從重複的勞動中解放,身為工程師自然會想要用機器完成重複的工作。

一群工程師與產品經理於是聚在一起看看有什麼方法。我們要不要做一個 Document-based 的 Desktop App 呢?讓這個 App 預留了可以拖入圖片的 slot,在切圖完成之後,我們再將切圖一張張拖到指定的 slot 上,這個 Desktop App 接下來就負責打包,這樣如何?話說我們有 iPhone、iPad、 Android Phone 與 Android Tablet 四種不同的主題面板,那這種 Desktop App 豈不就是要做四套?

還是做成 Web App 呢?把需要的圖檔一張一張透過 Web 上傳到 Server 上,上傳之後,還可以用 Canvas 之類的技術在 Web 上預覽手機 App 套用佈景主題的畫面,這樣如何?嗯,一張一張上傳圖片,想起來反而更沒效率。

討論一輪之後沒想到什麼好的方案,同時間,KKBOX 還是繼續推出新的主題面板,還是使用手動操作,還是出現檔案格式不對的問題,才想到:

  • 不管工程師與產品經理怎麼設計流程,最後還是要由設計師製作主題面板,這套流程必定要和設計師切圖之前的流程銜接。我們現在都只想到做出切圖之後的流程,是不是該整個看一下切圖之前是怎麼運作的,再來考慮怎麼規劃流程?
  • 我們的設計師才是這套內部流程真正的用戶,我們是不是應該先看一下設計師現在怎麼工作?

我們拿到了設計師在做切圖之前的原始檔。他使用的是 Mac 平台上的 Sketch 這套工具,在每一個專案開始之後,他會將 iOS、Android 等各平台所需要的素材,都放在同一個 Sketch 檔案中,將不同平台需要的素材用 Artboard 隔開,你可以在某個 Artboard 裡頭找到全部需要的圖片;由於他使用 Sketch 的 Export 功能輸出切圖,所以也都幫每張需要的切圖設好了 Slice 的範圍,每個 Slice 也都已經取好了固定的名稱。這樣的 Sketch 檔案已經有七八個了,格式都固定。

Screen Shot 2015-07-18 at 7.42.38 PM

看到這樣的原始檔,第一眼的印象就是:「根本就超級適合自動化處理啊!」

Sketch 本身也提供 Plug-In 架構,所以我們就去查文件、試著寫一個 Sketch Plug-In,看看能不能用一個 script 一次把原始 Sketch 檔案輸出成 KKBOX 的佈景主題格式,搞了大約半天時間寫出想要的自動化工具,從選單執行後,原本要花上少說三個小時的流程,直接縮短成只需要三秒鐘。

Sketch 的 Plug-In 機制

在 Sketch 的選單上有個叫做「Plug-In」的子選單,裡頭有個叫做「Custom Plug-In」的選單,按下去之後會出現一個程式碼編輯器,可以在這邊撰寫自己的程式碼;也可以用「Reveal Plug-In Folder」選單叫出安裝 Plug-Ins 的檔案夾,把寫好的程式放進這個檔案夾之後,接下來就可以在 Sketch 中重複呼叫使用。在網路上可以看到像 Sketch App Resources 這個網站就整理好了一份 Plug-Ins 列表,另外也有人整理好了 GitHub 上有哪些 Sketch Plug-Ins

開發 Sketch Plug-In 時使用的程式語言是 CocoaScript ,相信對絕大多數人來說,這是一套陌生的程式語言,我們在決定寫 Sketch Plug-In 之前也沒聽說過。不過這個語言的概念其實沒有很難:基本語法與 JavaScript 相同,但是裡頭可以混和 Objective-C 語法,可以使用 Objective-C 語法呼叫 Cocoa Framework 裡頭的物件。也就是,CocoaScript 就是一套在 Cocoa Framework 上發展出來的 Scripting Language,可以把 F-ScriptNU、以及 JSTalk 想成是同一個家族。其中 CocoaScript 與 JSTalk 的作者都是 Gus Miller

由於 Cocoa Framework 是在 Mac 上做 native 開發時的基礎,所以,如果曾經寫過 JavaScript,又有一些 Mac 開發的經驗,就可以準備開始寫 Sketch Plug-In 了—其實 Sketch 所提供的 API 就是一系列內部 Objective-C 物件的介面,我們便是透過 CocoaScript 操作這些物件。

在 CocoaScript 語言中,可以透過呼叫 Cocoa Framework 補足 JavaScript 不足之處。由於 JavaScript 起初是用來在瀏覽器中操作 DOM 的語言,長久以來 JavaScript 沒有多少跟檔案操作相關的部份,在 CocoaScript 中我們就可以用 NSData 、 NSString 等 Foundation 物件處理檔案。

實際撰寫還是會遇到一些問題,我們會稍後討論。

KKBOX 的 Sketch Plug-In

我們想要的 Plug-In 可以自動完成三件事情:

  1. 從 Sketch 文件中找到指定的 Slice,將 Slice 存成指定檔名的圖片檔案。
  2. 從 Sketch 文件中找到指定的文字 layer,讀取 layer 中的文字,然後轉存成 JSON 格式的檔案。
  3. 把輸出的檔案合併成一個壓縮檔案。

找到指定的 Slice 存檔

Sketch API 裡頭沒有什麼直接拿名字找到 Slice 的好方法。說到要在一個畫面中找東西,就很容易讓人想到開發 Web 時對 DOM 物件呼叫 getElementById(),不過 Sketch 裡頭沒有這樣的 function,只能用迴圈一層一層往下找。

sketch_heirarchy

Sketch 的物件 Heirarchy 大概是:

  1. context: Heirarchy 的最頂層,不過唯一的用途就是可以讓我們從這個物件中拿到 document 物件—代表目前正在操作的 Sketch 檔案。
  2. document: 型別是 MSDocument。一份 Sketch 檔案裡頭可以有很多頁面(pages),我們可以透過 pages 屬性取得所有的頁面,另外也可以用 children 屬性取得所有的 Layer 與 Slice,不過這樣會拿到太多東西,所以我們只選擇指定頁面裡頭的內容。
  3. page: 型別是 MSPage,我們可以透過 artboardsslices 拿到這一頁裡頭的所有 artbooard 與 slice。由於我們的設計師已經將素材都包在 artboard 裡頭,所以我們只往 artboard 裡頭繼續找東西。
  4. artboard: 型別是 MSArtboardGroup,裡頭包含各種 Layer,包括圖片、文字與 Slice Layer。
  5. layers: 型別是 MSLayer,我們在 page 或 artboard 中放置任何的素材,包括文字、圖片,都是一個 layer,也可以在一些圖片或文字 layer 的範圍外放置一個裁切框,方便我們將這個裁切範圍變成一張切圖,這樣的裁切框稱為 slice,sliace 也是一種 layer。

由於 artboard 與 layer 都有 name 這個屬性,我們就從 document 裡頭指定 page,再從 page 的 artboards 跑一輪迴圈找到名稱符合的 artboard,從 artboard 的 children 屬性尋找名稱符合的 slice,最後,只要對 document 呼叫 saveArtboardOrSlice:toFile:,傳入 slice 物件與指定的檔名,便可以將指定的 slice 存成切圖,檔案格式會自動從附檔名決定。我們將切圖先放在一個暫存目錄中,就完成了切圖這件工作。

從指定的 Text Layer 讀取文字,轉存 JSON 檔案

我們將一些文字資訊,像是主題面板的名稱、一些色碼定義裡,也寫在 Sketch 檔案中,這些文字在 Sketch 檔案中會變成 text layer 物件。從前述步驟,我們也可以找到指定的 text layer,不過,Sketch 原廠就 MSTextLayer 的文件,並沒有寫到怎樣從 text layer 取得上頭的文字;查了一下網路討論,作法是呼叫 [[yourTextLayer storage] string]

在 CocoaScript 裡頭,處理字串這件事情相當微妙。由於 CocoaScript 混和了 JavaScript 與 Objective-C 的世界,因此,CocoaScript 裡頭同時有 JavaScript 字串與來自 Objective-C 的 NSString

絕大多數時候,你必須使用其中一種字串,不能混用,呼叫 Cocoa Framework 的 API 時(像是存檔時的檔名等),傳入的物件都必須是 NSString,如果你原本手上有一個 JavaScritp 字串,就得要先轉成 NSString 才能傳遞,從 Cocoa Framework API 回傳的結果,像上面那行 [[yourTextLayer storage] string],也都會是 NSString。

把 JavaScript 字串轉換成 NSString 的方法是:

var nsstring = [[NSString alloc] initWithFormat:@"%@", jsstring];

如果你對該傳入 NSString 的地方傳入了 JavaScript 字串,會跳出一些非常難懂的錯誤訊息,比方說,你對 JavaScript 字串呼叫了 NSString 的 length(也就是 ["123" length] 而不是 [@"123" length]),跳出的錯誤是:「Unexpected identifier ‘length’. Expected either a closing ‘]’ or a ‘,’ following an array element..」,讓人難以理解。這些奇怪的錯誤訊息造成 CocoaScript 並不容易 Debug,很多時候 runtime 告訴你的錯誤所在行也不是真正發生錯誤的地方。

JavaScript 與 Cocoa Framework 都有辦法將物件 serilize 成 JSON 格式的文字檔,不過,我們建議都使用 Cocoa Framework 的 NSJSONSerialization,因為使用 JSON.stringify() 會出現奇怪的輸出結果。像以下這個例子

var d = {a: "abcd", b: "efg"};
log(JSON.stringify(d))

這樣會輸出 {"a":"abcd","b":"efg"},是我們預期的結果。那,我們把 JavaScript 字串換成 NSString 會怎樣呢?

var d = {a: @"abcd", b: @"efg"};
log(JSON.stringify(d))

結果變成 {"a":{},"b":{}},原本的 NSString 物件都變成了 JavaScritp 的空物件。

nsstring_in_object

所以,要把混和了 NSString 的物件轉成 JSON,就只能夠用屬於 Cocoa Framework 的作法:

var data = [NSJSONSerialization 
    dataWithJSONObject:d 
    options:NSJSONWritingPrettyPrinted 
    error:nil];
[data writeToFile:path atomically:true];

壓縮檔案

在暫存目錄裡頭已經準備好了切圖與 JSON 檔案,下一步就是製作壓縮檔。直覺應該要呼叫 command line 的壓縮指令,那就用 NSTask 呼叫吧!

var task = [[NSTask alloc] init];
var zip_path = temp_folder + "/" + title + "_theme.zip";
[task setCurrentDirectoryPath:temp_folder];
[task setLaunchPath:@"/usr/bin/zip"];
var argsArray = [NSArray arrayWithObjects:@"-r", @"-q", zip_path, @".", @"-i", @"*", nil];
[task setArguments:argsArray];
[task launch];
[task waitUntilExit];

壓縮完畢後,就可以用 NSWorkspace 打開暫存目錄,看看壓縮的成果。

使用 CocoaScript 還需要注意的地方

CocoaScript 在回傳 Cocoa Framework API 執行結果的時候,看來會做一些預期之外的轉換。

我們在處理 NSNotFound 的時候就遇到了問題—在說明我們遇到的狀況前,先解釋一下什麼是 NSNotFound:在 Cocoa Framework 裡頭,會有很多種不同的方式表達「沒有東西」,包括

  • 因為 Objective-C 語言架構在 C 語言上,所以 Objective-C 裡頭也有在 C 語言中代表空指標的 NULL。
  • nil:代表空的 Objective-C instance。
  • Nil:代表空的 Objective-C class。
  • NSNull:這是一個用來代表「沒有東西」的實體,本身是一個實在的實體,但是所代表的意義是「沒有東西」。由於我們不能在 Objective-C 的 array 或 dictionary 中插入 nil,所以,我們想要在 array 中放置一種代表「沒有東西」的東西,就會用上 NSNull。NULL、nil、Nil 都會指向 0,而 NSNull 則是指向一個 Objective-C 物件的指標。

至於 NSNotFound,代表的是「沒找到」。比方說,我們有時候想要知道某個物件是 array 中的第幾筆,但如果這個物件根本就不在 array 中,就會回傳 NSNotFound,像我們呼叫

var a = [NSArray arrayWithObjects:1, 2, 3];
var index = [a indexOfObject:4];

這時候就該回傳 NSNotFound。NSNotFound 實際上就是整數的最大值,所以 32 位元與 64 位元下的 NSNotFound 也不一樣。而或許是因為 JavaScript 裡頭並沒有整數,所有的數字都是浮點數,原本在 CocoaFramework API 中該回傳 NSNotFound 的時候,你在 CocoaScript 中反而會拿到一個很奇怪的浮點數,如果你用寫 Objective-C 的習慣寫這塊就會採到地雷。

nsnotfound

如上圖,index 預期會是 NSNotFound,但是拿到了 9.223372036854776e+18 這個怪數字,而 CocoaScript 定義的 NSNotFound 是 2147483647。這點需要格外小心。

About zonble

iOS Developer at KKBOX.
This entry was posted in Mac and tagged , , . Bookmark the permalink.

2 Responses to 使用 Sketch Plug-In 改善工作流程

  1. Pingback: 使用 Sketch 改善網頁前端與設計的標註與合作方式 - Partner Studio

  2. Pingback: 使用 sketch 改善網頁前端與設計的標註與合作方式 | 設計大舌頭

Leave a Reply

Your email address will not be published. Required fields are marked *