唐突だが、仕事で糞ドはまりしていたのでメモ。
ここには仕事関連のことは書かないつもりだったが、探しても該当する(日本語の)記事が少ない、見つけにくい、(自分が)ピンとこなかったため、「かわいそうな日本人専用」たる自分のためのメモである。
キーワード:JavaFX, Eclipse,e(fx)clipse, SceneBuilder, 実行可能jar, Resource, リソース, 「getClass().getResourceAsStream()」,「javafx.scene.image.Image」
なにをやっていた?
- JavaFXにてGUIアプリケーションを開発している。
- 開発中はEclipseにてデバッグ、最終的に「実行可能jar」にエクスポートする。
- デザイナ(FXMLの編集)はSceneBuilder2.0を使用している。
- FXMLLoaderで定義済みのmain.fxmlをロードすると「fx:controller」で指定してあるControllerがnewされるという、サンプルなどでよく見るMVC。
- アプリケーションにて画像(png)を動的に差し替える必要があるため、Controller上からImageViewに対してImageView#setImage()を呼んでnewしたImageインスタンスを渡して更新するはずだった。
どうなった?
- デバッグ時には正常にImageが反映されたが、実行可能jar化した状態で実行するとImageのnewでぬるぽ。
- jarを展開すると中(
/resource/icon/)に画像ファイルが入っていることが確認できたので、問題はファイルへの指定方法のはず。
この問題は比較的メジャーらしく、それっぽい記事はいくつかみつかる、のだが、「じゃぁどうすればいいか」についてはいまいち理解できなかった。
なぜ今更はまった?
いままではリソースとしてjarに格納した画像を使うことがなかった。なぜなら、画像を読む必要がでてきたとき、物理ストレージのディレクトリに展開した画像をフルパスで指定して読んでいたから。
画像を読み込むディレクトリのパスは*.iniファイルみたいに設定ファイルに記述しておいて、起動時に読んで決定するが、プラットフォームが変わったときにはそれに合わせたパス表記に変更する必要があるので大体は起動引数で対応していた。
主に更新されたりダウンロードされた画像を読んだり、テストモードで画像の保存先を切り替えたりすることが多かったのでこんな感じなんだが、今回は画像ファイルは変更しないし、毎回物理ディレクトリに展開するのが面倒なので、全部リソースとして管理したかった。
SceneBuilderでImageViewにImageを張り込む
静的なやりかた。
SceneBuilder上からImageView(javafx.scene.image.ImageView)に画像を張り込むには、単純にInspectorのPropertiesの「Image」のファイル選択ダイアログから選択すれば即座に反映される。<プロジェクトディレクトリ>/src/resource/icon/icon_blank.png
を、<プロジェクトディレクトリ>/src/application/module/main.fxml (パッケージは「application.module」)
に張った場合、main.fxmlの該当部分は以下のように書き換えられる。
<ImageView fx:id="ivIcon" (省略) > <image> <Image url="@../../../resource/icon/icon_blank.png" /> </image> </ImageView>
「url="@../../../resource/icon/icon_blank.png"」部分が該当している。
よく考えたら、この表記の意味にもっと早く気が付いていたらはまらなかったかもしれない。
Controller上からImageViewのImageを更新する
javafx.scene.image.Imageのコンストラクタに、URIかURLを示すStringを渡してやるとそれを読み込んでくれる。
Stringの場合、該当するスキーム(httpとか)に併せてネットワーク越しにファイルを読み込んでくれる。
オンライン上の画像とかもGUIに張れるので結構便利。ただしライセンスには気を付けよう。
だが、jarにしてターゲット環境に配布したときに失敗する。
String imagepath = "<フルパス>" + File.separator + "icon_blank.png"; File imagefile = new File(imagepath); String imageurl = imagefile.toURI().toURL().toString(); Image img = new Image(); this.ivIcon.setImage(img);
java.io.File→java.net.URI→java.net.URL経由ならいける(絶対パスを勝手にjarに対応したパスに展開してくれる)かと思ったが、そう甘くはなかった。
Eclipse上からだと実在のディレクトリが帰ってくるので読み込むことが可能だが、これはあくまで開発環境のソース上のディレクトリ、例えば「/home/user/workspace/project/src/resource/icon/icon01.png」を指すのだからだから。
つまり、開発環境と同じソースディレクトリが展開されていればjarにしていても動作するが、ソースディレクトリを展開しないターゲット環境では該当ファイルはとれない。これではわざわざjarに画像ファイルを入れておく意味がない。
そうじゃなくて、「jarに含まれているはずのicon01.png」を読みたいということだ。jarさえターゲット環境に放り込んでおけば、jarの配置場所や別に画像ディレクトリを展開しなくても使えるようにしたい訳で。
ファイルやURLを経由する方法ではファイルシステム上のパスを使ってしまう。
ということは、今までとは別のなにかしらの「リソース読む」仕組みを経由する必要がありそうだが、「jarのリソース」ってどんな管理してるんだ?
リソースを読む
javafx.scene.image.Imageのコンストラクタは、java.io.InputStreamも渡すことができる。
【覚書】jarファイルで画像などのリソースを扱う方法 - 〜ぷかぷかたゆたうだけのひび〜
リソースにアクセスするためには「getClass().getResourceAsStream()」で該当するリソースのInputStreamを渡せばよい、らしい。
Image img = Image(getClass().getResourceAsStream("icon01.png"));
この記述は「ソースファイルと同じディレクトリにicon01.pngが存在する」場合に読むことができる。
「ファイル名だけ書けばリソースファイルと同じディレクトリのファイルを読む」、ということは「ファイル名を相対パスで指定すれば目的のファイルを指定できる」ということか。
ん?相対パス?
jarに入っている状態って、相対パスってどうやって指定するの?パスの区切りは?
つまりどういうことだってばよ?
ここでかなり悩んだ末、fxmlファイルの「url="@../../../resource/icon/icon_blank.png"」を思い出す。
fxmlファイルはXML形式の定義ファイルだが、Javaで動いている以上実行時にはインスタンス化されるはずだ。実際にfxmlの定義の頭には(そのfxmlが所属する)パッケージ名が入っている。
インスタンス化されるということ、すなわち、fxmlファイルはClassと同等(同意)ということか。
逆に言えば、fxmlファイル上で「"../../../resource/icon/icon_blank.png"」などと書いてあるのなら、パスの区切りは「/」に違いない。
リソースを相対パスで読む
<プロジェクトディレクトリ>/src/application/module/Controller.java (パッケージは「application.module」)
から、<プロジェクトディレクトリ>/src/resource/icon/icon_blank.png
を読む場合。
Image img = Image(getClass().getResourceAsStream("../../../resource/icon/icon01.png"));
これでいけた。
どのディレクトリからでもアクセスしたい場合
相対パス指定の仕方はわかった。しかし、パッケージの階層が深くなったりするとトラバース(../../・・・)を記述するのが面倒だし指定ミスの原因になりやすい。
ためしに実行可能jarにエクスポートした後、unjarして確認すると
(root)
├application
│├Controller.class
├META-INF
└resource
└icon
だった。
つまり、「jarにおけるルートディレクトリはjar直下」であり、「ソースディレクトリ上におけるsrc配下」じゃないか?ということは、jar直下(ソースディレクトリでいえば 「src直下」 )をルートとしてフルパス指定すればいい、のか?
Image img = Image( getClass().getResourceAsStream( "/resource/icon/icon01.png" ) );
OK、いけた。
これなら、共通メソッドを作って、リソースのディレクトリ構造が変わったらそれに合わせて内部の定義をかえてやればいい。
みたいな感じで解決!
長かった・・・。
おまけ:もうちょっとスマートにしとこうか
解決・・・・なのだが、どうせここまでやったんなら、もうちょっとスマートにしたい。
getClass().getResourceAsStream(String)メソッドはそのメソッドが存在するクラス(のパッケージ?)をカレントとして相対パス指定できるということが解っている。
それなら、リソースを管理したい一番根元のディレクトリをパッケージ名とした管理クラスを作って、そいつに一括管理させれば、そのソースディレクトリの相対位置を動かしても変更は管理クラスだけに局所化できる。
今回の例でいけば、resouceパッケージとして、/resource/ResourceLoader.java を作成する。
この時、getResouceAsStreamはインスタンスメソッドなので、どこかのタイミングでnewしなければならないが、一箇所にまとめるためにシングルトンにしておく。
package resource; import java.io.InputStream; public class ResourceLoader { private static ResourceLoader singleton; public ResourceLoader() { } public static ResourceLoader getInstance(){ if (singleton == null){ singleton = new ResourceLoader(); } return singleton; } private static final String DIR_ICON = "icon/"; /** * アイコンファイルのリソース用InputStreamを呼び出す。 */ public InputStream getResourceStreamIcon(final String iconfilename){ return singleton.getClass().getResourceAsStream( DIR_ICON + iconfilename); } private static final String DIR_SOUNC = "sound/"; /** * 音声ファイルのリソース用InputStreamを呼び出す。 */ public InputStream getResourceStreamSound(final String soundfilename){ return singleton.getClass().getResourceAsStream( DIR_SOUND + soundfilename); } }
・・・シングルトンパターンっていつもこうやってるけど、本当にこれでいいんだろうか。今のところ問題ないけど。
呼出方は、
Image img = new Image(ResourceLoader.getInstance().getResourceStreamIcon("icon01.png"));
こんな感じでどうだろ。直接Imageインスタンスを返してしまってもいいかな。
とりあえずこれでいこうか。
というか、このgetResourceAsStreamメソッドってなんなんだ?ということで調べると、ClassLoaderのインスタンスメソッドだった。
ClassLoader (Java Platform SE 6)
名前からして、一番最初に説明した「FXMLLoader」を思い出す。
FXMLLoader (JavaFX 8)
ソースを見ていないが、おそらくFXMLLoaderがfxmlをロードしてインスタンス化する時に、Imageとか貼り付けられてたら内部でClassLoader#getResourceAsStream(String)を呼んでいるんだろうな。
以上
ということで、蓋を開けたら別にどうということではない話なのだけど。
自分の生来の気質なのか、こういうところで躓いて時間がかかることが多い。
中途半端に理解してるから余計にたちが悪い。
もうちょっとマシなエンジニアになりたいもんだ。