アプリ用画像アセットを書き出す Sketch Plugin を作ってみた話 - Re.Ra.Ku アドベントカレンダー day 6

Re.Ra.Ku アドベントカレンダー 6日目です。

こんにちは。ヘルステックチームの磯貝です。主に iOS アプリ開発を担当しています。

iOS 担当です……が、今回は、アプリ内で用いる画像アセットの生成に関して書いていこうと思います!

背景

弊チームでは長らく、モバイルアプリケーションのモックづくりや画像アセット作成に Adobe Illustrator を使用してきました。

しかし最近ではチームメンバーが増え、開発スピードも上がってきたため、モックやアセットに対してより多くのメンバーが簡単にアクセスできるよう、Sketch への転換を進めています。このあたりの話については、後日別の記事で書ければと思います。

転換にあたり、ワークフローの中で必要となるいくつかの機能は、Sketch デフォルトでは実現できないことがわかりました。そこで、その中の一つである、モバイルアプリケーションのプラットフォームに合わせて最適な画像アセットを書き出す機能を、プラグインとして実装してみました。

Sketch Plugin 概要

Sketch はプラグインによる機能拡張に対応しています。プラグインの仕様は詳しく公開されており、ある程度 Mac (もしくは iOS) のフレームワークや JavaScript に通じていれば、作成することができます。現にプラグインのほとんどはサードパーティから提供され、公式サイトや GitHub 等で数多く公開されています。

Sketch Plugin は、CocoaScript という、JavaScript に Objective-C 記法での Cocoa フレームワークアクセス機能を付加した言語を用いて作成します。近い将来 Swift でのプラグイン作成も可能になるとのことですが、今のところはまだ対応していません。

Sketch Plugin では CocoaScript を用いることで、Sketch の提供する API だけでなく、Cocoa フレームワークを通して macOS へアクセスすることができます。そのため、ファイルの読み書きをはじめとした多くの機能を、思ったままに実現することができます。

var helloWorld = function() {
  log("hello, world!");

  var osVersion = [[NSProcessInfo processInfo] operatingSystemVersionString];
  log(osVersion);
}

helloWorld();

// => "hello, world!"
// => "Version 10.11.6 (Build 15G1004)"

ちなみに Sketch は、以前は App Store でも公開されていましたが、今は公式サイトからのダウンロードのみとなっています。そのため、App Sandboxing に関しては、この記事では触れません。

Tips

プラグインの実装に入る前に、開発における Tips をいくつか紹介します。

コード片実行環境

Sketch.app には、1ファイルに収まる CocoaScript を Sketch 上で実行するための環境が用意されています。
メニューの、[Plugins] > [Custom Plugin]を選択してください。先程の Hello World コード片は、この画面からも実行できます。

log() メソッドを使えば、実行結果をコンソール上に出力することができます。

Console.app

プラグイン内における log() の結果は、Mac 標準のグローバルなログとして、/var/log/system.log へ出力されます。同時に、Sketch.app 自体が吐き出すログも確認することができるので、エラーメッセージもある程度は読むことができます。

このログ出力を簡単にフィルタして閲覧するために、Mac にデフォルトでインストールされている Console.app を使います。

Console.app を開いたら、メニューの [ファイル] > [新規システムログクエリー] を選択し、新たなフィルタ設定を追加しましょう。参考までに、私が使用している設定を以下に挙げます:

f:id:y1soga1:20161205020423p:plain

CocoaScript のブラケット表記に関して

CocoaScript は、Sketch や Cocoa フレームワークの API を、Objective-C のブラケット表記を用いて実行することができます。しかし、JavaScript のコード内にブラケット表記が紛れているのは、正直気持ち悪いです。JavaScript 向けのコードフォーマッティングも効きません。

実は、ブラケット表記は JavaScript 形式のメソッドに置き換えることができます。外部引数名をアンダースコア _ で繋いだものをメソッド名とし、引数はまとめて与えます。

var month;

// こんなブラケット表記は
month = [[NSCalendar currentCalendar] component:NSCalendarUnitMonth fromDate:[NSDate new]];

log(month) // => "12"

// こう書くこともできます
month = NSCalendar.currentCalendar().component_fromDate(NSCalendarUnitMonth, NSDate.new());

log(month) // => "12"

画像アセット書き出しプラグイン

さて、ここからは実際に画像を書き出すプラグインを作っていきます。

要件

今回の要件は、以下の通りです。

  • アートボード直下に存在するレイヤーを、透過 png 画像として書き出す。
  • iOS 向けの書き出しは、Asset Catalog 形式として書き出す。その際、アートボードごとに名前空間を切る。
  • Android 向けの書き出しは、resources ディレクトリ以下に各解像度の画像を書き出す。
  • 書き出しの設定には、アートボード名やレイヤー名を用いる。
    • アートボード名は、export:format_a:format_b:base_name とする。
      • アートボードに含まれるルートレイヤーが、それぞれ画像アセットとして書き出される。
      • フォーマットは複数指定できる。対応するのは、iphone, ipad, ios (universal), android (/resources), android-mipmap (/mipmap)
      • base_name は、Asset Catalog においてはサブディレクトリ名となり、名前空間が切られる。Android resources においては、ファイル名の prefix となる。
    • レイヤー名の頭に noexport: が含まれる場合、そのレイヤーは書き出さない。

あとは要件に合わせて粛々と書いていくだけなのですが、数点、ポイントとなりそうな部分をピックアップして解説します。

最終的に出来上がったものは、GitHub で公開しています: sketch-mobile-assets-generator

ファイル構造

Sketch Plugin のファイル構造は、以下のようになっています。

GenerateMobileAssets.sketchplugin    // プラグイン Bundle の拡張子は .sketchplugin
  Contents/                       // 必須
    Sketch/                       // 必須
      manifest.json               // 必須
      generate.cocoascript        // manifest.json から、コマンドに応じて実行するスクリプトを指定する
      foo_script.js
      bar_script.any_extension    // 拡張子はなんでも ok
      SomeOptionalDirectory/      // ディレクトリも追加可能
        shared_contents.js

manifest.json

プラグインのメタデータです。

{
  "name": "Generate mobile assets",
  "description": "Plugins to generate assets for iOS and Android.",
  "author": "yisogai",
  "version": 0.1,
  "identifier": "jp.co.reraku.GenerateMobileAssets",
  "commands": [                          // プラグインとして実行できる操作を記述する
    {
      "name": "Generate All",            // メニューに表示されるタイトル
      "identifier": "all",               
      "shortcut": "ctrl shift e",
      "script": "generate.cocoascript"   // コマンド選択時に実行されるスクリプトファイル
                                         // デフォルトでは 'onRun()' メソッドが実行される
    },
    {
      "name": "Generate Android",
      "identifier": "android",
      "script": "generate.cocoascript",
      "handler": "android"               // 'onRun()' 以外のメソッドを実行したい場合に指定
    },
    {
      "name": "Generate iOS",
      "identifier": "ios",
      "script": "generate.cocoascript",
      "handler": "ios"
    }
  ],
  "menu": {                              // 実際にプラグインメニューに表示される内容
    "title": "Generate Mobile Assets",   // ルートディレクトリ名
    "items": [
      "all",                             // コマンドの 'identifier' を指定する
      "-",                               // '-' は区切り線となる
      {                                  // ネストすることもできる
        "title": "Platforms",
        "items": [
          "android",
          "ios"
        ]
      }
    ]
  }
}

詳しくはこちら

generate.cocoascript

スクリプトの拡張子は特に決まっていませんが、ここでは公式でも用いられている .cocoascript を使用します。

@import "lib/document_parser.js"
@import "lib/config_loader.js"
@import "lib/exporter.js"

// manifest.json で指定したエントリーポイントとなるメソッドには、context オブジェクトが渡される。
// そこからプラグイン実行対象のファイルへアクセスできる。
var onRun = function(context) {
  _generate(context, ["xcode_asset_catalog", "android_res"])
}

var android = function(context) {
  _generate(context, ["android_res"])
}

var ios = function(context) {
  _generate(context, ["xcode_asset_catalog"])
}

var _generate = function(context, configTypes) {
  var document = context.document
  var baseDir = document.fileURL().path().split(document.displayName())[0] + "build"
  var exporter = new Exporter(document, baseDir)

  var layerGroups = DocumentParser.parse(document)
  var configs = ConfigLoader.load(configTypes)

  exporter.export(layerGroups, configs)
}

lib/*

cocoascript ファイルは、相互にインポートすることができます。ここでは、プラグインの機能を実装しています。
ほとんどは JavaScript のコードであり、特殊な操作は行っていません。一部、Sketch API や Cocoa フレームワークを操作している箇所のみを抜粋します。

// exporter.js

...

Exporter.prototype._exportAsAndroidRes = function(baseName, layers, config) {
  var that = this
  var root = this.baseDirectory + "/" + config.id

  layers.forEach(function(layer){
    config.densities.forEach(function(density){
      // レイヤーのエクスポートには、Sketch API である MSExportRequest を使用する
      var request = MSExportRequest.exportRequestsFromExportableLayer(layer)[0]
      // request に scale を設定することにより、書き出す画像の解像度を変更できる
      request.scale = density.scale
      var filename = baseName + "_" + layer.name() + ".png"
      var file = root + "/" + density.folder + "/" + filename
      // ブラケット表記をアンダースコアによる JavaScript 風表記へ変換して記述
      that.document.saveArtboardOrSlice_toFile(request, file)
    })
  })
}

...
// exporter.js

...

Exporter.prototype._writeObjectToJsonFile = function(object, path, filename) {
  var json = JSON.stringify(object, undefined, 2)
  var string = NSString.stringWithFormat(@"%@", json)
  var file = path + "/" + filename

  // Cocoa フレームワークへのアクセス
  var manager = NSFileManager.defaultManager()
  
  // こちらも、ブラケット表記をアンダースコアによる表記へ変換している。
  // また、null と nil は同等のものとして扱われる。
  manager.createDirectoryAtPath_withIntermediateDirectories_attributes_error(path, true, null, null)

  string.writeToFile_atomically_encoding_error(file, true, NSUTF8StringEncoding, null)
}

...

まとめ

いかがでしたでしょうか。かなり駆け足になってしまいましたが、自分が Sketch Plugin を作るにあたり、引っ掛かりを感じた箇所については一通り共有できたかと思います。

用途が限定される CocoaScript ですが、みてみると分かる通り、案外簡単に書くことができます。
普段づかいのエディタを秘伝のスクリプトでモリモリ拡張しているそこのあなた! エディタ以外の環境も、ぜひ使いやすいように改良してみてください。

とはいったものの、だんだん iOS が恋しくなってきたので、次回は iOS や Swift に関しての話題を書ければと思います。それでは!


今回作成したプラグイン: sketch-mobile-assets-generator