seraphyの日記

日記というよりは過去を振り返るときのための単なる備忘録

Pure JavaでMacOSX対応のアプリケーションを作る方法 (実行可能JAR編)

JavaアプリケーションをMac OS Xで動かすための注意点

Mac OS Xには、Apple謹製のJavaVMがあるので、動かすことが第一義であれば、通常のJavaマルチプラットフォーム対策ができていれば良い。
基本的には特別な対策は必要ない。
さすがWrite Once, Run Anywareのコンセプトのことだけはある。

...。

といっても、注意点や改善箇所がないわけではない。

JDKのバージョン

バージョンについては注意する必要がある。
OSXJavaの関係は以下のとおり。

バージョン J2SE1.4 J2SE5 Java SE 6
Mac OS X 10.3 (Panther) × ×
Mac OS X 10.4 (Tiger) ×
Mac OS X 10.5 (Leopard)
Mac OS X 10.6 (Snow Leopard)

Snow LeopardはJavaSE6である。
Leopardは初期状態ではJ2SE5、UpdateによりJavaSE6に対応する。
(ただし、JavaSE6はIntel-64bit環境でなければならない。Leopard on PPCではJDK5どまりということ。)
TigerはUpdateによりJ2SE5に対応、JavaSE6には未対応。
PantherはJ2SE1.4どまりである。


Leopard/J2SE5以降には64ビット版JavaVMが存在する。

Snow Leopardの事情

Mac OS X 10.6 Snow LeopardにはJavaSE6しか入っていない。
Java Preferences.app」で見るとアプリケーションとして起動するのはJDK1.6(Java SE 6)しかない。
/System/Library/Frameworks/JavaVM.framework/Versions の下には1.5.0, 1.4.2といったバージョンも存在するが、
これは単に1.6へのシンボリックリンクであるため、実体はJavaSE6である。

Javaアプリを動かす分には、たいていの場合には問題ないだろうが、JDK1.5でコンパイルしたい場合に困る事になる。
一応、方法があるようで、LeopardのUpdaterをダウンロードして、この中にあるJDK1.5を抜き出して差し替えてしまう、という荒技が有名のようだ。

http://builder.japan.zdnet.com/sp/snow-leopard-09/story/0,3800100196,20402666,00.htm
http://wiki.oneswarm.org/index.php/OS_X_10.6_Snow_Leopard (←こちらは未確認.)

これを実行すると、Java Preferences.appでも使用できるバージョンが増えているのが確認できる。
(ただし、試した限りではJDK1.4は実行環境としてはGUI周りでエラーがでるのでコンパイル用として使えるぐらいのようである。)

ファイルエンコーディングの違いなど

Mac OS XのJavaSE6はファイルエンコーディングが「SJIS」であるのに対して、J2SE5は「UTF-8」である。
このような環境の違いについて考慮していない場合、JavaSE6しか選べないSnow Leopardでは問題になる可能性がある。

OSXのスクリーンメニューに対応する

GUIアプリケーションの場合、Mac OS Xらしい外観や操作性とするためにはメニューやキーボードの違いについても考慮しておく必要がある。

まず、大きな点としてMac OS Xではウィンドウの中にメニューバーは存在せず、画面上につくスクリーンメニューになっている点である。
何も対策をしなければJavaは、以下の画面キャプチャ例のように、JFrame#setJMenuBar()でウィンドウの中にメニューバーを作る。



動作はするけれど、これはMac OS X的な外観ではない。


これをスクリーンメニューにするのは簡単で、起動時もしくは起動直後GUIを読み込む前にシステムプロパティ「apple.laf.useScreenMenuBar」を「true」に設定しておくだけでよい。(あるいは、javaの起動オプション-Dで指定するか、もしくはapplication bundle化してinfo.plistで指定する。)

これだけでコーディングの変更などいっさいなしにJMenuBarがスクリーンメニューとして設定されるようになる。

http://developer.apple.com/mac/library/documentation/Java/Reference/Java_PropertiesRef/Articles/JavaSystemProperties.html

アプリケーション名の対応

しかし、これは単にJMenuBarがウィンドウの上部からスクリーンの上部に移動しただけのことではない。
メニュー項目にはJMenuBarで指定した以外の固有の項目が追加されていることにも対応しなければならない。
このスクリーンメニューの一番左側は「アプリケーション名」を表示するのが慣例であり、これはJMenuBarの定義にはない。
なにも対策をしなければ、ここはエントリポイントとなるmainのあるクラス名になる。

(仮にJMenuBarをスクリーンメニューに統合しなかったとしても、スクーンメニュー自身は常に存在しているので何らかの対応は必要である。)


これもシステムプロパティで設定可能であり、起動時もしくは起動直後GUIを読み込む前に「com.apple.mrj.application.apple.menu.about.name」の値としてアプリケーション名を設定すれば良い。(もしくはJAVAの起動オプション -Xdock:name=、もしくはApplication Bundle化してinfo.plistで指定する。)

http://developer.apple.com/mac/library/releasenotes/Java/java141/system_properties/system_properties.html

Mac OS X 10.7 Lionでは、この方法は使えなくなったようである。起動オプションに指定するかバンドル化する必要があるようである。(2011/9/16追記)


ここまではコードにはほとんど手を入れずに対応できる。
コードで対応するならばmainメソッドの最初の方でシステムプロパティを更新する。

    public static void main(String[] args) throws Exception {
        // MacOSXでのJava実行環境用のシステムプロパティの設定.
        if (isMac()) {
            // JFrameにメニューをつけるのではなく、一般的なOSXアプリ同様に画面上端のスクリーンメニューにする.
            System.setProperty("apple.laf.useScreenMenuBar", "true");

            // スクリーンメニュー左端に表記されるアプリケーション名を設定する
            // (何も設定しないとクラス名になる。)
            System.setProperty(
                    "com.apple.mrj.application.apple.menu.about.name",
                    "MacJavaSample");
        }

        // システム標準のL&Fを設定.
        // MacOSXならAqua、WindowsXPならLuna、Vista/Windows7ならばAeroになる.
        UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());

          .....
      }

Snow Leopardでの実行結果

システムにより自動的に設定されるメニュー項目への対応

しかし問題は、このアプリケーション名のポップアップメニューとして各アプリケーション共通のメニュー項目がある点である。

  • 「〜について」というAboutボックスのメニュー項目
  • 「環境設定」のメニュー項目(オプション)
  • 「〜を終了」のメニュー項目



これらはJMenuBarで定義するものではないため、JMenuItemのActionとしてハンドリングすることはできない。
これらはMac OS Xのアプリケーションのイベントとしてハンドルする必要がある。


また、Windows/LinuxGUIでは「ファイル」メニューの一番下に「終了」メニューを持たせるであろうし、一番右側の「ヘルプ」メニューの中に「バージョン情報」というメニュー項目を持たせるであろうが、そのまま使ってしまうと同じ機能をもつメニューが重複して存在することになってしまう。
したがって、実行環境がMac OS Xであれば「バージョン情報」「環境設定」「終了」のメニューはJMenuBarには入れずに、Mac OS X側が自動的につけるメニューに任せるようにしなければならない。


それに関連すると、Windows/Linuxの一般的なアプリケーションであれば、JFrame#setWindowListener()などでウィンドウを閉じたらアプリケーションを終了する、というような処理を行うものと考えられるが、Mac OSXではメインウィンドウを閉じることとアプリケーションの終了は必ずしもイコールではなく、
また、ウィンドウを閉じるアクションを経由せずともcmd+Qキーで画面を閉じずに終了することも可能である点を考慮する必要がある。
実際、cmd-Qで終了する場合、アプリケーションのイベントとしてハンドリングすることはできるが、ウィンドウを閉じるイベントとしてJFrame#setWindowListener()からは感知することはできない。
ウィンドウを閉じるイベントに対応して設定ファイルの書き戻しや終了処理をしているような場合は、うまく動かないことになる。


これに対応するには、AppleによるJAVAの拡張ライブラリ(Apple Java Extensions)を使う必要がある。
http://developer.apple.com/mac/library/documentation/Java/Reference/1.5.0/appledoc/api/index.html

※ 2014/11現在 AppleのサイトからはJavadocリファレンスが参照できなくなっているようである。ただし、com.apple.eawt.ApplicationクラスOracleのJava7, Java8に引き継がれており、Mac上であればJRE中に含まれているため、とくに何も入れなくても使える。 (2014/11/06追記)


これを使うことにより、「〜について」「環境設定」「〜を終了する」といったアプリケーションイベントをハンドリングできるようになる。

public class MainFramePartialForMacOSX {

    ....

    public static void setupScreenMenu(final MainFrame mainFrame) {
        if (mainFrame == null) {
            throw new IllegalArgumentException();
        }
        
        Application app = Application.getApplication();
        
        app.setEnabledAboutMenu(true); // 「このアプリについて」のメニュー項目。デフォルトでtrue
        app.setEnabledPreferencesMenu(true); // 「環境設定」のメニュー項目、デフォルトはfalse

        // スクリーンメニューやアプリケーションのイベント通知を受け取るリスナーを設定する。
        app.addApplicationListener(new ApplicationAdapter() {
            public void handleAbout(ApplicationEvent arg0) {
                mainFrame.onAbout();
                arg0.setHandled(true); // OSXデフォルトのダイアログを表示させない
            }
            public void handleQuit(ApplicationEvent arg0) {
                mainFrame.quit();
                arg0.setHandled(true);
            }
            public void handlePreferences(ApplicationEvent arg0) {
                mainFrame.onPreference();
                arg0.setHandled(true);
            }
        });        
    }
}

ハンドリングしたイベントの処理先は、JMenuItemのものと同じにすれば良いだろう。

Mac OS X以外でも実行・コンパイルできるようにする

Mac OS X以外でも実行できるように、Apple Java Extensionsをリフレクションにより疎結合にする

しかし、Apple Java Extensionsを、そのまま使ってしまうと今度はMac OS X以外での環境で動作しなくなってしまう。
したがって使用するにあたっては実行環境に応じてライブラリをロードできるようにリフレクションなどを使うように細工しなければならない。

    // メインフレーム構築
    final MainFrame mainFrame = new MainFrame();

    if (isMac()) {
        // Mac用にスクリーンメニューとアプリケーション終了(cmd-Q)のハンドリングを設定する.
        // com.apple.eawt.Applicationクラスで処理するが、MacOSX以外の実行環境では存在しないので、
        // このクラスを直接使用するとMacOSX以外で起動できなくなってしまう.
        // そのため、サポートクラスの中で処理させ、そのサポートクラスをリフレクションにより間接的に
        // 必要になったときに呼び出す.(クラスのロードに失敗したら、そのときにコケる.)
        Class clz = Class.forName("macjava.MainFramePartialForMacOSX");
        Method mtd = clz.getMethod("setupScreenMenu", new Class[] {MainFrame.class});
        mtd.invoke(null, new Object[] {mainFrame});
    }

    // 画面表示
    mainFrame.setVisible(true);

これで、Mac OS X以外の環境で実行した場合はApple Java Extensionsがなくても動作できるようになる。

Mac OS X以外でコンパイルできるように、Apple Java Extensionsのダミースタブを用意する

しかし、コンパイルするのにもApple Java Extensionsが必要である。
このままではMac OS X上でしかコンパイルできないプロジェクトになってしまう。
Mac OS X上では、このライブラリはデフォルトで存在するが、Mac OS X以外には存在しないため多プラットフォームに対応するアプリケーションであれば、このライブラリのスタブなどをコンパイル時に持たせなければならない。

こちらはApple謹製の常にRuntimeExceptionを返すだけのダミーのスタブがあるので、これを使うことになる。
http://developer.apple.com/mac/library/samplecode/AppleJavaExtensions/Introduction/Intro.html

ここから「Download Sample Code」とすると、目的のスタブjarが入手できる。
これで、Mac OS X以外の環境でもコンパイルできるようになる。
(ただし、上記Apple配布のスタブはJ2SE5以降をターゲットにコンパイルされていることにも注意。)

ショートカットキーとニーモニックに対応する

次にメニューとキーボードの関係にも注意しなければならない。

ショートカットキーをシステムの標準にする

LinuxWindowsではctrl-?キーでキーボードショートカットを定義するのが慣例であるが、Mac OS Xの場合はcmd-?キーが慣例となっている。
そのため、メニューを定義する際にショートカットキーをcontrolで決め打ちするとMac OS Xでの操作感が不自然なものとなってしまう。
JDK付属のサンプルコードでもctrlで決めうちしているところがあるが、これについてはシステムのデフォルトの修飾キーを得るためのツールキットがJAVAの標準APIとして用意されており、きちんとお作法を守って作るならば難しいことではない。

        // システムのデフォルトのコマンド修飾キーを取得する.
        // WindowsならCTRL_MASK, OSXならばMETA_MASKになる.
         Toolkit tk = Toolkit.getDefaultToolkit();
         int shotcutKey = tk.getMenuShortcutKeyMask();

         // コマンドのアクセラレータをシステムのデフォルトのキーの組み合わせで登録
         menuSaveAs.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_S, shotcutKey));
ニーモニックMac OS Xでは機能しないのでメニューの表記と設定を変更する。

厄介な問題なのが「ニーモニック」の扱いである。
たとえばWindowsなどのアプリケーションのメニューにある「ファイル(F)」の「F」がニーモニックである。
Windows環境ではメニューやボタン等にALTキーとあわせて選択できるようにするためのニーモニックキーを割り当てることができるが、
Mac OS Xは伝統的にニーモニックキーなるものをサポートしていない。
なので、Mac OS X実行環境上でニーモニックを設定するコードは単に無視されて何も機能しない。

エラーになる訳ではないので英語圏では単にアンダーバーが表示されなくなるだけの話で特に問題にはならないだろう。
だが、日本語の場合は「ファイル(F)」のように括弧+英文字の余計なメニュー表記になっており、ニーモニックが作用しない環境だと、この「(F)」が全く意味不明な表記になってしまう。
これに対応するには、実行環境がMac OSであるか判断してリソースファイルの切り替えのようなものをしなければならないだろう。
(おそらく多言語対応するならばリソースファイルの切り替えも必要になるので、その一環でos.nameでの切り替えにも拡張すればよいと思われる。)

Mac OS X上で実行しているか判断する

実行環境がMac OS Xであるかの判定方法は簡単であり、単にシステムプロパティ「os.name」が「Mac OS X」であるかをチェックすれば良い。
http://developer.apple.com/jp/technotes/tn2110.html

    protected static boolean isMac() {
        // MacOSXで動作しているか?
        String lcOSName = System.getProperty("os.name").toLowerCase();
        return lcOSName.startsWith("mac os x");
    }

より古い記事ではMac OS Xの実行環境で必ず設定される「mrj.version」というシステムプロパティの有無で判断すると書いてあるものもあるが、少なくともJDK1.4以降ではos.nameで判断するのがApple推奨の方法のようである。
(mrj.versionというプロパティは無くなるかも?とのこと。os.nameはJava仕様的に必ず設定されるシステムプロパティで、これが存在しないことはない。http://java.sun.com/j2se/1.5.0/ja/docs/ja/api/java/lang/System.html#getProperties() )
(ちなみに「Mac OS」が含まれているかで判定すると、Mac OS/Mac OS Xの双方にひっかかるのでOS9以前と区別する場合はMac OS Xで判定せよ、とのこと。)

Dockアイコンに対応する

最後に、アイコンの問題がある。

Windows/Linux(Gnome Desktop等)の場合、JFrameにアイコンを設定すると、それがタスクバーのアイコンとして表示される。
しかし、Mac OS XではDockのアイコンはJFrameとは関連していない。
そのため、JFrame#setIconImage() を呼び出したとしてもDockアイコンはデフォルトのままとなる。

Dockアイコンの設定はApple Java Extensionsの Application#setDockIconImage() で設定する。

ただし、対応しているのはMac OS X 10.5 Leopard以降である。
Leopardよりも以前のMac OS XでDockアイコンを設定するためには、アプリケーションバンドル化して起動時に設定するような形にしなければならない。
以下は、Pantherでもエラーにならないように、あえてリフレクションでメソッドを間接的に呼び出している。

        // Dockアイコンの設定 Leopard/J2SE5以降のみ
        try {
            Class clz = app.getClass();
            Method mtd = clz.getMethod("setDockIconImage", new Class[] {Image.class});
            mtd.invoke(app, new Object[] {mainFrame.icon});

        } catch (Exception ex) {
            // サポートされていない場合は単に無視する.
        }

ここまでの結果

ここまでの結果を反映させて、実行可能jarを作ってみる。
jarファイルはantのjarタスクで以下のようなマニフェストを指定した。
単にMain-Classを指定しているだけである。
javacはsource=1.4で、J2SE1.4ターゲットとしている。(Pantherでも実行させるため。)

build.xml抜粋
        <jar basedir="work" destfile="${jarName}">
            <manifest>
                <attribute name="Main-Class" value="macjava.Main"/>
            </manifest>
        </jar>

コンパイルされた同一のjarバイナリを用いて、各プラットフォームで実行した結果を以下に示す。

Mac OS X 10.6 Snow Leopard

JavaSE6で実行。
Mac OS Xにはニーモニックキーはないので表示させていない。
ショートカットキーもcmdを修飾キーにしているのがわかる。


Dockアイコン

Leopard以降ならば、Apple Java ExtensionsのApplicationクラスにDockアイコンやDockメニューを操作するメソッドがある。
アイコンを出すだけならばコードで制御するのではなく、Application Bundle化してinfo.plistで設定する方法もある。

Mac OS X 10.3 Panther (on PowerPC)

PantherはJ2SE1.4どまりなので、J2SE1.4で実行。


Dockアイコン

Pure JavaとしてDockアイコンを設定する方法はないようである。
回避方法としてApplication Bundle化し、info.plistでアイコンファイルを指定する方法がある。

Windows XP (32bit)

JavaSE6で実行。
一見してJAVAで動作しているとは分からない外観である。

Windows7 (64bit)

JavaSE6で実行。
Windows Vista/Windows 7では、XPまでとは異なり、ALTキーを押さない限りニーモニックにアンダーラインは引かれない。
(ALTキーを押すまでメニューを隠せるようにするのがVista/7の流儀だが、ここではメニューは常に表示している。このような動作は標準のJAVAでは用意されていないので、自前で実装することになるようだ。)

Ubuntu10.04

ここではapt-getで、sun-java6-jdkをインストールしたもので実行した。
open-javaは未確認。
Ubuntuではjarをダブルクリックしても実行してくれず、アーカイバが開いてしまう。
実行させるには、まず実行可能ビットを立てて、それからSun Javaで実行するを選択する。

実験コード

Main.java
package macjava;

public class Main implements Runnable {

    /**
     * Event Dispatch Thread(EDT)でSwingを初期化し、フレームを開始する.
     */
    public void run() {
        try {
            // システム標準のL&Fを設定.
            // MacOSXならAqua、WindowsXPならLuna、Vista/Windows7ならばAeroになる.
            // Aeroの場合、メニューに表示されるニーモニックのアンダースコアはALTキーを押さないとでてこない.
            UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
    
            // メインフレーム構築
            final MainFrame mainFrame = new MainFrame();
            if (isMac()) {
                // Mac用にスクリーンメニューとアプリケーション終了(cmd-Q)のハンドリングを設定する.
                // com.apple.eawt.Applicationクラスで処理するが、MacOSX以外の実行環境では存在しないので、
                // このクラスを直接使用するとMacOSX以外で起動できなくなってしまう.
                // そのため、サポートクラスの中で処理させ、そのサポートクラスをリフレクションにより間接的に
                // 必要になったときに呼び出す.(クラスのロードに失敗したら、そのときにコケる.)
                Class clz = Class.forName("macjava.MainFramePartialForMacOSX");
                Method mtd = clz.getMethod("setupScreenMenu", new Class[] {MainFrame.class});
                mtd.invoke(null, new Object[] {mainFrame});
            }
            
            // 画面表示
            mainFrame.setVisible(true);

        } catch (Exception ex) {
            throw new RuntimeException(ex);
        }
    }

    // エントリポイント
    public static void main(String[] args) throws Exception {
        // MacOSXでのJava実行環境用のシステムプロパティの設定.
        if (isMac()) {
            // JFrameにメニューをつけるのではなく、一般的なOSXアプリ同様に画面上端のスクリーンメニューにする.
            System.setProperty("apple.laf.useScreenMenuBar", "true");

            // スクリーンメニュー左端に表記されるアプリケーション名を設定する
            // (何も設定しないとクラス名になる。)
            System.setProperty(
                    "com.apple.mrj.application.apple.menu.about.name",
                    "MacJavaSample");

            // これらのプロパティは「Jar Bundler」を使用してappとしてバンドル化すれば、
            // info.plistファイルの中で直接指定できる.
            // 上記の他、JavaVMへの起動パラメータ(例えば-Xmxとか。)も指定できるうえ、
            // 依存jarファイルや各種ファイルも一緒にバンドルできるので、
            // 手間を惜しまなければ、jar化したのちに、さらにapp化するとよいかもしれない.
        }

        // JFrameの構築と表示はEDTで行う必要がある.
        // またUIManagerの初期化自身はGUI要素を扱わないかもしれないが、スレッド間のメモリ共有を
        // synchronizeする必要は残るため、同一スレッドで初期化するのが安全である。
        // http://java.sun.com/javase/ja/6/docs/ja/api/javax/swing/package-summary.html#threading
        SwingUtilities.invokeLater(new Main());
    }

    protected static boolean isMac() {
        // MacOSXで動作しているか?
        String lcOSName = System.getProperty("os.name").toLowerCase();
        return lcOSName.startsWith("mac os x");
    }
}
MainFrame
package macjava;

// MacOSX 10.3(Panther)はJ2SE1.4どまりなので、あえて1.4で記述している.
// 10.5(Leopard)以降を対象にするならば、JavaSE6でよし.

public class MainFrame extends JFrame {
    private static final long serialVersionUID = 1L;
    
    // JFrameおよびDockのアイコン
    protected Image icon;
    
    // パネル
    protected FileDialogTypePanel fileDlgTypePanel;

    // コンストラクタ
    public MainFrame() throws Exception {
        super();

        // ウィンドウイベントのハンドリング
        setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
        addWindowListener(new WindowAdapter() {
            public void windowClosing(WindowEvent e) {
                // JFrameを閉じるときに呼び出されるが、
                // OSXでcmd-Qで終了するときは呼び出されない.
                // ApplicationListenerで受け取る必要あり.
                quit();
            }
        });
        
        // ウィンドウアイコンの設定
        // ただし、OSXにはウィンドウアイコンはないため表示されない
        // Dockアイコンは、これによって設定することはできない.
        // Leopard以降はApple Java ExtensionsのApplicationクラスで設定可能.
        this.icon = ImageIO.read(getClass().getResource("window_icon.png"));
        this.setIconImage(this.icon);
        
        // メニューの登録
        JMenuBar menuBar = new JMenuBar();

        JMenu fileMenu = new JMenu("ファイル");
        menuBar.add(fileMenu);

        JMenuItem menuSaveAs = new JMenuItem("名前をつけて保存");
        menuSaveAs.setMnemonic(KeyEvent.VK_S);
        menuSaveAs.addActionListener(new AbstractAction() {
            private static final long serialVersionUID = 1L;
            public void actionPerformed(ActionEvent e) {
                onSaveAs();
            }
        });
        fileMenu.add(menuSaveAs);
        
        // システムのデフォルトのコマンド修飾キーを取得する.
        // Windowsならctrl, OSXならばmetaになる.
        Toolkit tk = Toolkit.getDefaultToolkit();
        int shotcutKey = tk.getMenuShortcutKeyMask();

        // コマンドのアクセラレータをシステムのデフォルトのキーの組み合わせで登録
        menuSaveAs.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_S, shotcutKey));
         
        // バージョン情報と終了コマンド
        // "apple.laf.useScreenMenuBar"が定義されている場合はシステムで用意するので不要.
//         if (System.getProperty("apple.laf.useScreenMenuBar") == null) {
        if (! Main.isMac()) {
            fileMenu.add(new Separator());
            JMenuItem quitMenu = new JMenuItem("終了(Q)");
            quitMenu.setMnemonic(KeyEvent.VK_Q);
            quitMenu.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_Q, shotcutKey));
            quitMenu.addActionListener(new AbstractAction() {
                private static final long serialVersionUID = 1L;
                public void actionPerformed(ActionEvent e) {
                    quit();
                }
            });
            fileMenu.add(quitMenu);

            JMenu helpMenu = new JMenu("ヘルプ(H)");
            helpMenu.setMnemonic(KeyEvent.VK_H);
            menuBar.add(helpMenu);
             
            JMenuItem menuPreference = new JMenuItem("環境設定(E)");
            menuPreference.setMnemonic(KeyEvent.VK_E);
             menuPreference.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_COMMA, shotcutKey));
            menuPreference.addActionListener(new AbstractAction() {
                private static final long serialVersionUID = 1L;
                public void actionPerformed(ActionEvent e) {
                    onPreference();
                }
            });
            helpMenu.add(menuPreference);

            JMenuItem menuAbout = new JMenuItem("バージョン情報(V)");
            menuAbout.setMnemonic(KeyEvent.VK_V);
            menuAbout.addActionListener(new AbstractAction() {
                private static final long serialVersionUID = 1L;
                public void actionPerformed(ActionEvent e) {
                    onAbout();
                }
            });
            helpMenu.add(menuAbout);
        }

        // OSXでなければニーモニックを設定する.
        // ニーモニックはOSXでは使われない(無視される)ので、OSXでは設定しない。
        if (! Main.isMac()) {
            fileMenu.setText(fileMenu.getText() + "(F)");
            fileMenu.setMnemonic(KeyEvent.VK_F);
            menuSaveAs.setText(menuSaveAs.getText() + "(S)");
            menuSaveAs.setMnemonic(KeyEvent.VK_S);
        }


        // システムプロパティ「apple.laf.useScreenMenuBar」がtrueであると、
        // JFrameの中にではなく、スクリーンメニューにメニューが設定される.
        // 同時に「〜を終了」「〜について」のメニューも自動的に追加される.
        setJMenuBar(menuBar);
        
        setTitle("MacJavaSample");
        setSize(300, 300);
        
        // パネルコンテンツ
        Container contentPane = getContentPane();
        fileDlgTypePanel = new FileDialogTypePanel();
        contentPane.add(fileDlgTypePanel, BorderLayout.NORTH);
        
        // 画面中央に表示
        setLocationRelativeTo(null);
    }
    
    // ファイルダイアログのタイプを選択するパネル
    protected static class FileDialogTypePanel extends JPanel {
        
        private static final long serialVersionUID = 1L;

        private JRadioButton r1 = new JRadioButton("Awt");

        private JRadioButton r2 = new JRadioButton("Swing");
        
        private ButtonGroup grp = new ButtonGroup();

        public FileDialogTypePanel() {
            super();
            setBorder(new CompoundBorder(new EmptyBorder(5, 5, 5, 5),
                    new TitledBorder("ファイルダイアログの形式")));
            setLayout(new GridLayout(2, 1));
            grp.add(r1);
            grp.add(r2);
            add(r1);
            add(r2);
            r1.setSelected(true);
        }

        public boolean isAWT() {
            return r1.isSelected();
        }
    }
    
    protected void onSaveAs() {
        File outFile;
        if (fileDlgTypePanel.isAWT()) {
            FileDialog fdlg = new FileDialog(this,
                    "システムプロパティの一覧を保存します.", FileDialog.SAVE);
            fdlg.setDirectory(System.getProperty("user.home"));
            try {
                // FileDialogはモーダルダイアログのため
                // JFrameの中にメニューがある場合はメニューも操作できない。
                // しかし、OSXのスクリーンメニューは操作可能であるため明示的にディセーブルしておく.
                getJMenuBar().setEnabled(false);
                
                fdlg.setVisible(true);
            } finally {
                getJMenuBar().setEnabled(true);
            }

            String fname = fdlg.getFile();
            if (fname == null) {
                // cancel
                return;
            }
            // MacOSXのHFSファイルシステムは文字コードは濁点等を分離している、いわゆるUTF-8-MACとも
            // 呼ばれる形式(NFD)であるが、JAVA上では分離されていない通常の状態(NFC)として扱われる.
            // そのため、ファイルの文字コードについてはMac特有の考慮事項はない。
            outFile = new File(fdlg.getDirectory(), fdlg.getFile());

        } else {
            // Swingのファイルダイアログを使う場合
            JFileChooser fdlg = new JFileChooser();
            fdlg.setDialogTitle("システムプロパティの一覧を保存します.(Swing)");

            getJMenuBar().setEnabled(false);
            try {
                int ret = fdlg.showSaveDialog(this);
                if (ret != JFileChooser.APPROVE_OPTION) {
                    return;
                }
            } finally {
                getJMenuBar().setEnabled(true);
            }

            outFile = fdlg.getSelectedFile();
        }
        try {
            // システムプロパティの一覧をファイルに出力
            // プラットフォームに関係なく、UTF-8/LF形式で出力する.
            // なお、同じOSXでもLeopardのJDK5はUTF-8がデフォルトだが、
            // JDK6/SnowLeopardではSJISがデフォルトになる.
            Writer wr = new OutputStreamWriter(new FileOutputStream(outFile),
                    Charset.forName("UTF-8"));
            try {
                // ファイル名を記録
                wr.write(outFile.toString() + "\n"); // Macの場合 Option+\ で入力
                wr.write(getSystemProperties("\n"));
                wr.flush();
            } finally {
                wr.close();
            }
        } catch (Exception ex) {
            ex.printStackTrace();
            JOptionPane.showMessageDialog(this, ex.getMessage(), "ERROR", JOptionPane.ERROR_MESSAGE);
        }
    }
    
    protected void quit() {
        JOptionPane.showMessageDialog(this, "終了します.");
        System.exit(0);
    }
    
    protected void onAbout() {
        JOptionPane.showMessageDialog(this, "MacJavaSample バージョン1.0",
                "バージョン情報", JOptionPane.INFORMATION_MESSAGE);
    }
    
    protected void onPreference() {
        JTextArea textArea = new JTextArea();
        textArea.setText(getSystemProperties(System.getProperty("line.separator")));
        
        JScrollPane scr = new JScrollPane(textArea);
        scr.setPreferredSize(new Dimension(400, 300));
        
        JOptionPane.showMessageDialog(this, scr);
    }
    
    // システムプロパティをダンプする
    private String getSystemProperties(String lineSep) {
        ArrayList keys = new ArrayList();
        StringBuffer buf = new StringBuffer();
        for (Enumeration enm = System.getProperties().keys(); enm.hasMoreElements();) {
            String key = (String) enm.nextElement();
            keys.add(key);
        }
        Collections.sort(keys);
        for (Iterator ite = keys.iterator(); ite.hasNext();) {
            String key = (String) ite.next();
            buf.append(key + "=" + System.getProperty(key) + lineSep);
        }
        buf.append("*EOF*");
        return buf.toString();
    }
}
MainFramePartialForMacOSX
package macjava;

import java.awt.Image;
import java.lang.reflect.Method;

import com.apple.eawt.Application;
import com.apple.eawt.ApplicationAdapter;
import com.apple.eawt.ApplicationEvent;

// MainFrameクラスのOSX用拡張部.
public class MainFramePartialForMacOSX {

    private MainFramePartialForMacOSX() {
        super();
    }

    public static void setupScreenMenu(final MainFrame mainFrame) {
        if (mainFrame == null) {
            throw new IllegalArgumentException();
        }
        
        Application app = Application.getApplication();
        
        app.setEnabledAboutMenu(true); // 「このアプリについて」のメニュー項目。デフォルトでtrue
        app.setEnabledPreferencesMenu(true); // 「環境設定」のメニュー項目、デフォルトはfalse

        // スクリーンメニューやアプリケーションのイベント通知を受け取るリスナーを設定する。
        app.addApplicationListener(new ApplicationAdapter() {
            public void handleAbout(ApplicationEvent arg0) {
                mainFrame.onAbout();
                arg0.setHandled(true); // OSXデフォルトのダイアログを表示させない
            }
            public void handleQuit(ApplicationEvent arg0) {
                mainFrame.quit();
                arg0.setHandled(true);
            }
            public void handlePreferences(ApplicationEvent arg0) {
                mainFrame.onPreference();
                arg0.setHandled(true);
            }
        });
        
        // Dockアイコンの設定 Leopard以降のみ
        try {
            Class clz = app.getClass();
            Method mtd = clz.getMethod("setDockIconImage", new Class[] {Image.class});
            mtd.invoke(app, new Object[] {mainFrame.icon});

        } catch (Exception ex) {
            // サポートされていない場合は単に無視する.
        }
    }
    
}
build.xml
<?xml version="1.0" encoding="UTF-8"?>
<project name="MacJava" default="default">
    <description>MacJavaSample</description>

    <property name="jarName" value="MacJava.jar"/>
    
    <condition property="isMacOSX">
        <and>
            <os family="mac"/>
            <os family="unix"/>
        </and>
    </condition>
    
    <target name="init" unless="isMacOSX">
        <path id="cp">
            <fileset dir="lib">
                <include name="**/AppleJavaExtensions.jar"/>
            </fileset>
        </path>
    </target>
    
    <target name="init_osx" if="isMacOSX">
        <path id="cp"/>
    </target>
    
    <target name="default" depends="init, init_osx" description="description">
        <delete dir="work"/>
        <mkdir dir="work"/>
        <javac encoding="UTF-8" destdir="work" srcdir="src" source="1.4">
            <classpath refid="cp"/>
        </javac>
        <copy toDir="work">
            <fileset dir="src">
                <include name="**/*"/>
                <exclude name="**/*.java"/>
            </fileset>
        </copy>
        <jar basedir="work" destfile="${jarName}">
            <manifest>
                <attribute name="Main-Class" value="macjava.Main"/>
            </manifest>
        </jar>
        <delete dir="work"/>
    </target>
</project>

OS固有のファイル選択ダイアログの使用

JavaにはAwtのファイルダイアログと、Swingによるファイルダイアログが存在する。
Swingによるものはカスタマイズ性・汎用性が高い。
が、MacOSXにおいてはAwtのファイルダイアログはMac OS Xのダイアログをそのまま使っている反面、SwingのものはSwingの独自レイアウトとなる。
Windows XPにおいては双方とも外見は大差ないので、どちらでも良さそうな感じであるが、
Windows 7においては双方とも似ている外観ではあるが、AwtのファイルダイアログはWindows7の「ライブラリ」の仕組みを認識している。

Mac OS X/Windows 7での利用を考えた場合、ダイアログを独自にカスタマイズするのでなければ、Awtを使う方が良いケースが多いと思われる。

(なお、AwtとSwingは一般的には「混ぜるな危険」の注意が必要だが、Awtのファイルダイアログはモーダルダイアログであるため、表示されている間は他のウィンドウは操作不能になるため特に注意せずとも安全に使えるとのこと。)


注意点としては、スクリーンメニューはウィンドウがディセーブルであっても明示的にディセーブルにしないかぎり有効でありつづけることがある。
JFrameが操作できない状態の場合、JFrameの中にメニューバーがある他の環境ではメニューも使えないが、Mac OS Xの場合はJFrameがディセーブルであってもスクリーンメニューバーは操作できるためコマンドを受け付けてしまう。
そのような動作が望ましくない場合は、以下のように明示的にメニューバーもディセーブルする必要がある。

    FileDialog fdlg = new FileDialog(this,
            "システムプロパティの一覧を保存します.", FileDialog.SAVE);
    fdlg.setDirectory(System.getProperty("user.home"));
    try {
        // FileDialogはモーダルダイアログのため
        // JFrameの中にメニューがある場合はメニューも操作できない。
        // しかし、OSXのスクリーンメニューは操作可能であるため明示的にディセーブルしておく.
        getJMenuBar().setEnabled(false);
        
        fdlg.setVisible(true);
    } finally {
        getJMenuBar().setEnabled(true);
    }
Snow LeopardにおけるAWTのFileDialog

よく見慣れた外観である。

Snow LeopardにおけるSwingのJFileChooser

JFileChooserは、J2SE1.4.1以前の場合はシステムプロパティ「com.apple.macos.use-file-dialog-packages」を設定しない場合は、バンドルファイルを、その実体であるフォルダのように扱ってしまったが、J2SE1.4.1以降では特に指定せずともファイルのように扱う。(そして、このプロパティはJ2SE1.4.1以降ではサポートされていない。)
上記画面では「MacJava.app」がアプリケーションバンドルであり実体はフォルダであるが、バンドル属性を認識していることがわかる。

Windows7におけるAWTのFileDialog

エクスプローラタイルではないが、左ペインに「ライブラリ」があり、ユーザがライブラリとして登録したフォルダへのアクセスが簡単になっている。

Windows7におけるSwingのJFileChooser

こちらはWindows7のメリットが何もない。
特にダイアログをカスタマイズする予定がなければAwtのほうがユーザ的にはメリットがあると思われる。

結論

Mac OS Xらしい外観としてはスクリーンメニューとDockアイコンへの対応が必要となるが、
基本的には、システムプロパティとApple Java Extensionsを使えば、Pure Javaの範囲内で、ある程度実現できる。
しかし、それらを使うことによる、こまごまとした副次的な問題もあり、ちょっとした手間をかけてあげる必要がある。
意外なところとして、スクリーンメニューやDockアイコンよりもニーモニックへの対応が厄介であった。


次回はJavaアプリケーションをMac OS X用のアプリケーションバンドルにしてみる。

謝辞および訂正

2010/6/25


私が所有しているのはMacBook一台だけで、今回、Mac OS X 10.3 Panther (on PPC)でテストするにあたり友人の休眠状態のMacMiniをお借りしました。
テスト機に貸し出しを快諾してくれたカイノ君に感謝いたします。
このMacMiniのバージョンを、ずっとMac OS X 10.4 Tigerだと勘違いしていたため文中の記事もTigerとか書いてありましたが、すべてPantherでした。
本文は訂正済みです。

2011/4/5

Main.javaで、mainメソッドの中で直接 new MainFrame().setVisible(true) としていましたが、EDTの規約に違反していたため訂正しました。
SwingのUIコンポーネントは、すべからくEvent Dispatch Threadで操作しなければなりません。