天天看點

PS插件圖層周遊性能優化

Photoshop 中的 ExtendScript 和浏覽器環境下 JavaScript 一樣存在 DOM API 用于通路 PS 中的各種對象。圖層類似 Web 中的 Element 是最基本的操作對象,也是我們編寫 CEP 插件最常通路的 DOM 對象。一個 PSD 文檔由衆多的圖層組成一棵 n 叉樹,經常性我們需要去完整周遊這棵 n 叉樹,而且往往是一次調用 JSX 接口就需要周遊多遍。這個時候周遊圖層樹就會成為性能瓶頸,本文将探讨各種周遊圖層方式的性能差異,最終給出一種兼具性能和實用性的圖層周遊方案。

使用 DOM API 周遊

剛接觸 CEP 插件開發的人周遊最容易想到的就是像下面這樣使用 DOM API 周遊圖層樹:

```javascript function traverse(layerSet) { const ids = [];

function dfs(layerSet) { for (var i = 0; i < layerSet.layers.length; i++) { var layer = layerSet.layers[i]; ids.push(layer.id);

if (layer.typename === 'LayerSet') {
    dfs(layer);
  }
}
           

}

dfs(layerSet); return ids; }

const start = Date.now(); $.writeln( '圖層數:' + traverse(activeDocument).length + ',周遊耗時:' + (Date.now() - start) + 'ms', ); ```

我拿一個公司業務中遇到的一個圖層數量相對較多的 psd 來跑上面的代碼,就隻是周遊整個樹,擷取周遊到圖層的 id,測試三次的結果:

圖層數:323,周遊耗時:5894ms

圖層數:323,周遊耗時:5792ms

圖層數:323,周遊耗時:5786ms

可以看到結果很離譜,我還沒摻雜任務實際的業務邏輯進去,裸周遊就耗時近 6s。搞前端的都應該聽過一個說法,一個互動超過 3s 沒有響應,使用者體驗就很糟糕了。

而實際上,在我開發的一個插件中,目前就需要進行 7,8 次這樣的周遊,并且随着業務的規則變多,需要周遊的次數也會變多。将近 1 年的時間我都在公司開發 adobe 的插件,最近半年是在寫一個 PS 的插件。在某個版本釋出後,内部的設計師測試後就向我回報說上面這個 psd 一次流程耗時就需要 10 幾分鐘。這是不能接受的,那個時候我才真正意識到 jsx 環境險惡,需要死扣各種性能優化細節。剛開發的時候我更多的是考慮從系統的擴充性和可維護性,并沒有特别性能問題。事實上我覺得這也是我入行以來,第一次在工作的業務中碰到嚴重的性能問題。

DOM 操作真的慢嗎?

通過下面的簡單測試代碼,我們來直覺感受下 DOM 操作和非 DOM 操作的性能差距:

```javascript const traverseCount = 10 * 10000;

// 通路内置的 DOM 對象 layer const layer = activeDocument.activeLayer; var start = Date.now(); for (var i = 0; i < traverseCount; i++) { layer.name; } const cost1 = Date.now() - start;

// 通路純 JS 對象 const layerObj = { name: 'xxx', }; start = Date.now(); for (var i = 0; i < traverseCount; i++) { layerObj.name; } const cost2 = Date.now() - start;

$.writeln( 'DOM 對象耗時:' + cost1 + 'ms,純 JS 對象耗時:' + cost2 + 'ms,相差:' + cost1 / cost2 + '倍', ); // => DOM 對象耗時:1586ms,純 JS 對象耗時:12ms,相差:132.166666666667倍 ```

可以看到通路 DOM 對象的屬性比通路純對象屬性的性能慢了百倍。我這裡說純對象就是指普通 JS 對象。

是以我們首先可以得到結論:

在 PS 的 jsx 環境下,DOM API 比普通對象性能差很多

所有的 DOM 屬性通路速度都比純對象慢嗎?

我們知道 ExtendScript 的文法是基于 EcmaScript3 的,并夾雜了很多私貨,例如操作符重載,@include 指令,三引号字元串等。其中對于對象的屬性還區分了可變和不可變屬性:

javascript const layer = activeDocument.activeLayer; $.writeln([layer.name, layer.id, layer.typename].join(', ')); layer.name = 'xxx'; layer.id = 11111; layer.typename = 'xxx'; $.writeln([layer.name, layer.id, layer.typename].join(', ')); // => // 裝飾, 462, ArtLayer // xxx, 462, ArtLayer

通過設定

$.strict = true

來開啟對屬性通路的嚴格限制,可以在修改隻讀屬性屬抛出異常:

PS插件圖層周遊性能優化

那問題來了,隻讀屬性的通路速度會不會和純對像通路速度一樣。因為我猜測 DOM API 之是以慢是因為每次通路底層都要和 c++ 做一次通過擷取最新的資料,但是既然是隻讀的,那應該就不用了。

還是之前的測試代碼,隻不過這次通路的屬性從可寫的

name

變成隻讀的

id

```javascript const traverseCount = 10 * 10000;

// 通路内置的 DOM 對象 layer const layer = activeDocument.activeLayer; var start = Date.now(); for (var i = 0; i < traverseCount; i++) { layer.id; } const cost1 = Date.now() - start;

// 通路純 JS 對象 const layerObj = { id: 666, name: 'xxx', typeName: 'LayerSet', }; start = Date.now(); for (var i = 0; i < traverseCount; i++) { layerObj.id; } const cost2 = Date.now() - start;

$.writeln( 'DOM 對象耗時:' + cost1 + 'ms,純 JS 對象耗時:' + cost2 + 'ms,相差:' + cost1 / cost2 + '倍', ); // => DOM 對象耗時:1522ms,純 JS 對象耗時:12ms,相差:126.833333333333倍 ```

從測試結果來看并無差異,但是當我把測試屬性換成

typeName

時,測試結果居然出乎意料的性能差不了多少:

```javascript const traverseCount = 10 * 10000;

// 通路内置的 DOM 對象 layer const layer = activeDocument.activeLayer; var start = Date.now(); for (var i = 0; i < traverseCount; i++) { layer.typename; } const cost1 = Date.now() - start;

// 通路純 JS 對象 const layerObj = { id: 666, name: 'xxx', typeName: 'LayerSet', }; start = Date.now(); for (var i = 0; i < traverseCount; i++) { layerObj.typeName; } const cost2 = Date.now() - start;

$.writeln( 'DOM 對象耗時:' + cost1 + 'ms,純 JS 對象耗時:' + cost2 + 'ms,相差:' + cost1 / cost2 + '倍', );

// => DOM 對象耗時:29ms,純 JS 對象耗時:16ms,相差:1.8125倍 ```

說實話,我也摸不着頭緒,

typeName

這個屬性确實就是和純對象通路速度差不多,其它很多隻讀屬性測試結果都和

id

一樣有百倍的性能差距。

優化 DOM API 周遊速度

前面我們知道了在 jsx 中 DOM 通路性能很差,是以我們之前使用 DOM API 周遊圖層樹的速度才會那麼慢。在使用 DOM API 的前提下,我們可以對之前的周遊算法做怎樣的優化呢?

其實就是盡量減少 DOM API 的通路次數,重寫之前的周遊算法:

```javascript function traverse(layerSet) { var ids = [];

function dfs(layerSet) { // 構造純數組對象,layers 本身就是 DOM 對象 const layers = [].slice.call(layerSet.layers); for (var i = 0; i < layers.length; i++) { var layer = layers[i]; ids.push(layer.id);

if (layer.typename === 'LayerSet') {
    dfs(layer);
  }
}
           

}

dfs(layerSet); return ids; }

const start = Date.now(); $.writeln( '圖層數:' + traverse(activeDocument).length + ',周遊耗時:' + (Date.now() - start) + 'ms', );

// => 圖層數:323,周遊耗時:4539ms ```

PS插件圖層周遊性能優化

其實

layerSet.layers

本身就是個響應式的 DOM 對象,通過将它轉換成一個純數組對象,避免後續多次通路

layerSet.layers

。 從測試結果來看,改版後的圖層周遊時間減少 1.5s 左右。我們具體減少了哪些 DOM API 操作呢?

一個是三段式 for 循環中的條件判斷,重複周遊了

layerSet.layers.length

次,再就是循環體裡面

var layer = layerSet.layers[i]

這裡又通路了一次。

使用 ActionManager 周遊

線性結構-至底向上周遊

對 AM 不熟的同學可以看小強的 AM 教程,這裡不做過多講解。使用 AM 周遊圖層樹:

```javascript const s2t = stringIDToTypeID; const AMLayerKind = { AnySheet: 0, PixelSheet: 1, AdjustmentSheet: 2, TextSheet: 3, VectorSheet: 4, SmartObjectSheet: 5, VideoSheet: 6, LayerGroupSheet: 7, ThreeDSheet: 8, GradientSheet: 9, PatternSheet: 10, SolidColorSheet: 11, BackgroundSheet: 12, HiddenSectionBounder: 13, };

function traverseLayersDesc(visit, property) { const docRef = new ActionReference(); docRef.putProperty(s2t('property'), s2t('numberOfLayers')); docRef.putIdentifier(s2t('document'), activeDocument.id); const docDesc = executeActionGet(docRef); const layerCount = docDesc.getInteger(s2t('numberOfLayers'));

// 索引起始值,會受是否有背景圖層影響 const startItemIndex = app.activeDocument.layers[app.activeDocument.layers.length - 1] .isBackgroundLayer ? 0 : 1;

for (var i = startItemIndex; i <= layerCount; i++) { var layerRef = new ActionReference(); layerRef.putProperty(s2t('property'), s2t('layerKind')); layerRef.putIndex(s2t('layer'), i); var layerDesc = executeActionGet(layerRef); var layerKind = layerDesc.getInteger(s2t('layerKind'));

// 13 是組邊界,是一個不可見的輔助圖層,大多數情況沒有用
if (layerKind !== AMLayerKind.HiddenSectionBounder) {
  layerRef = new ActionReference();
  layerRef.putProperty(s2t('property'), s2t(property));
  layerRef.putIndex(s2t('layer'), i);
  layerDesc = executeActionGet(layerRef);
  var result = visit(layerDesc);

  if (result === false) {
    break;
  }
}
           

} }

const start = Date.now(); const ids = []; traverseLayersDesc(function (layerDesc) { ids.push(layerDesc.getInteger(s2t('layerID'))); }, 'layerID'); $.writeln('圖層數:' + ids.length + ',周遊耗時:' + (Date.now() - start) + 'ms'); // => 圖層數:323,周遊耗時:80ms ```

通過将 DOM API 改寫成 AM 代碼,同樣一個包含 323 個圖層的 psd,周遊時間從 4000ms 優化到 80ms,相差 50 倍。

思路大緻就是:

  1. 擷取文檔的圖層數量
  2. 圖層的 itemIndex 是遞增的,itemIndex 0 始終是留給背景圖層的,如果有背景圖層就從 0 開始周遊,如果不存在就從 1 開始
  3. AM 擷取一個圖層有很多方式,例如目前選中圖層,id,itemIndex,我們這裡使用 itemIndex 來定位圖層
  4. AM 中 layerKind 為 13 圖層表示一個組的邊界,對于業務來說沒用實際意義,用來輔助定位組的範圍的,是以周遊的時候跳過
  5. 出于性能的考慮我們這裡讓使用者傳 property 而不是直接傳回一個圖層的完整 ActionDescriptor
  6. 如果回調傳回 false,停止周遊

實際的業務需求中可能會需要通路多個屬性,你可以在此基礎上繼續封裝,比如我封裝的一個周遊圖層樹的函數簽名:

javascript traverseLayersDesc( visit: (layerDesc: ActionDescriptor) => boolean, property: StringID | StringID[], layerKind: AMLayerKind, ): void;

之是以說是線性結構,因為你周遊的時候使用的就是 itemIndex,類似一個一維數組。它不像樹結構是有層級順序,你沒法在周遊到某個圖層的時候停止周遊它的子圖層。

自底向上,那是因為 itemIndex 的順序就是從最底層的圖層開始的。

PS插件圖層周遊性能優化

對于上面的圖層結構,看一下周遊的結果就很容易了解什麼叫線性結構-至底向上了:

javascript const names = []; traverseLayersDesc(function (layerDesc) { names.push(layerDesc.getString(s2t('name'))); }, 'name'); $.writeln(names.join(', ')); // => Background, Layer 3, Layer 1, Group 2, Layer 2, Group 1

樹結構-自頂向下周遊

實際的業務需求中,上面的那種周遊應用場景很有限。主要有以下缺點:

  1. 它是至底向上的,而往往我們需要的是至頂向下的周遊
  2. 線性結構無法不能像樹結構那樣知道目前所處的層次資訊,無法擷取兄弟圖層
  3. 無法周遊到某個圖層停止周遊子圖層

這三個缺點我們來一一破解。

如何至頂向下

學過資料結構的應該都知道三種 dfs 周遊順序,前中後三種順序的周遊,這裡的前中後指的是根節點的周遊順序。PS 的圖層樹是 n 叉樹,這裡為了簡化問題,我們先從 2 叉樹來考慮。

上面的至底向上周遊的順序是下面這樣,按數字從小到大,數字可以對應到 itemIndex。簡單來說就是:

右子節點 -> 左子節點 -> 根節點
PS插件圖層周遊性能優化

而至頂向下(這裡指前序周遊)的順序是:

根節點 -> 左節點 -> 右節點
PS插件圖層周遊性能優化

可以看出周遊的順序剛好相反,也就是說如果已知至底向上的周遊結果,我們直接逆轉一下周遊結果就得到了至頂向下的結果。!

對于下面一棵字母樹,至底向上周遊結果是:

E -> D -> C -> B -> A

而自頂向下:

A -> B -> C -> D -> E

是不是就是:至頂向下的周遊結果就是至底向上結果的逆序。

PS插件圖層周遊性能優化

為了實作逆序,我們隻需要周遊 itemIndex 的時候從大到小周遊就行了:

PS插件圖層周遊性能優化

如何轉換為樹狀結構

刷過 LeetCode 樹相關的題應該都做過重建二叉樹類或者叫反序列化二叉樹的題目,我們目前面對的就是這麼一道題。我們已知前序周遊的結果是

A -> B -> C -> D -> E

,怎樣将它轉換為一棵 n 叉樹?

問題的關鍵是我們必須想辦法确定每一層的邊界,而這個邊界我們之前其實就已經提到過,就是 layerKind 為 13 的組邊界圖層。

你可以假象下面的紅框就是組邊界圖層,它實際上對使用者是不可見的,但是它占用了一個 itemIndex,是一個底層的輔助圖層。

PS插件圖層周遊性能優化

對于上面的圖層樹至頂向下周遊結果為:

Group 1, Layer 2, Group 2, Layer 1, , , Layer 3, Background

其中

</Layer group>

就是組邊界圖層。

利用它我們就可以寫出周遊圖層的最優解:

```javascript // @include '../../JSX/polyfill/json2.jsx'

const s2t = stringIDToTypeID; const AMLayerKind = { AnySheet: 0, PixelSheet: 1, AdjustmentSheet: 2, TextSheet: 3, VectorSheet: 4, SmartObjectSheet: 5, VideoSheet: 6, LayerGroupSheet: 7, ThreeDSheet: 8, GradientSheet: 9, PatternSheet: 10, SolidColorSheet: 11, BackgroundSheet: 12, HiddenSectionBounder: 13, };

function createLayerTree() { const docRef = new ActionReference(); docRef.putProperty(s2t('property'), s2t('numberOfLayers')); docRef.putIdentifier(s2t('document'), activeDocument.id); const docDesc = executeActionGet(docRef); const layerCount = docDesc.getInteger(s2t('numberOfLayers'));

const startIndex = app.activeDocument.layers[app.activeDocument.layers.length - 1] .isBackgroundLayer ? 0 : 1; const root = { // 暫時注釋掉避免 JSON.stringify 因為循環引用報錯 // parent: null, layerKind: -1, itemIndex: -1, id: null, // 實際上業務開發中 name 并不常用,這裡為了示範目的設定了 name name: 'root', layers: [], isRoot: true, isLayerSet: false, }; var currentIndex = layerCount; function recursive(node) { while (currentIndex >= startIndex) { var ref = new ActionReference(); ref.putIndex(s2t('layer'), currentIndex); currentIndex--; // 沒有 putProperty,擷取的是完整的 ActionDescriptor var desc = executeActionGet(ref); var name = desc.getString(s2t('name')); var id = desc.getInteger(s2t('layerID')); var layerKind = desc.getInteger(s2t('layerKind')); var isGroup = layerKind === AMLayerKind.LayerGroupSheet; var isGroupEnd = layerKind === AMLayerKind.HiddenSectionBounder; if (!isGroup && !isGroupEnd) { node.layers.push({ // parent: node, layerKind: layerKind, itemIndex: currentIndex, id: id, name: name, layers: [], isRoot: false, isLayerSet: false, }); } else if (isGroup) { var layerSet = { // parent: node, layerKind: layerKind, itemIndex: currentIndex, id: id, name: name, layers: [], isRoot: false, isLayerSet: true, }; node.layers.push(layerSet); recursive(layerSet); } else { return; } } } recursive(root); return root; }

$.writeln(JSON.stringify(createLayerTree(), null, 4)); ```

輸出結果:

json { "layerKind": -1, "itemIndex": -1, "id": null, "name": "root", "layers": [ { "layerKind": 7, "itemIndex": 6, "id": 2, "name": "Group 1", "layers": [ { "layerKind": 1, "itemIndex": 5, "id": 7, "name": "Layer 2", "layers": [], "isRoot": false, "isLayerSet": false }, { "layerKind": 7, "itemIndex": 4, "id": 4, "name": "Group 2", "layers": [ { "layerKind": 1, "itemIndex": 3, "id": 6, "name": "Layer 1", "layers": [], "isRoot": false, "isLayerSet": false } ], "isRoot": false, "isLayerSet": true } ], "isRoot": false, "isLayerSet": true }, { "layerKind": 1, "itemIndex": 0, "id": 8, "name": "Layer 3", "layers": [], "isRoot": false, "isLayerSet": false }, { "layerKind": 1, "itemIndex": -1, "id": 1, "name": "Background", "layers": [], "isRoot": false, "isLayerSet": false } ], "isRoot": true, "isLayerSet": false }

使用下面代碼測試耗時:

```javascript function traverse(layerTree) { var ids = [];

function dfs(layerTree) { const layers = layerTree.layers; for (var i = 0; i < layers.length; i++) { var layer = layers[i]; ids.push(layer.id);

if (layer.isLayerSet) {
    dfs(layer);
  }
}
           

}

dfs(layerTree); return ids; }

const start = Date.now(); $.writeln( '圖層數:' + traverse(createLayerTree()).length + ',周遊耗時:' + (Date.now() - start) + 'ms', ); // => 圖層數:323,周遊耗時:418ms ```

相比之前 DOM API 的周遊方式還是快了近 10 倍。

相比之前那個至底向上的周遊慢,主要是因為:

  1. 這個算法擷取了完整的 ActionDescriptor 對象
  2. 構造樹本身也要耗時

兩種 AM 周遊方式選哪種

上面倆種使用 AM 來周遊的方式各有優缺點,前者速度更快,二者應用場景更多,如果前者能夠滿足你的應用場景直接用前者就好了。

總結

  1. 在 PS 的 ExtendScript 中,DOM API 非常耗性能
  2. 對性能要求較高的場景盡量使用 AM 而不是 DOM API
  3. 沒事刷刷 LeetCode 還是有好處的,面試考算法還是有其合理之處的

繼續閱讀