Kotlin
IntelliJ
AndroidStudio

KotlinとGradleで始めるAndroid Studioプラグイン開発

More than 1 year has passed since last update.

KotlinとGradleでAndroid Studio/IntelliJ Idea向けのプラグインAndroid API Levelの開発してみて得られた情報をまとめておきます。
この組み合わせのプラグイン開発の情報があまりなかったので、少しでもフォローできればと思います。

このプラグインは、AndroidのAPIレベルとバージョンの対応をよく忘れるため、IDEから簡単に確認できれば便利ではないかと思い作ってみました。
ss
(作った後にSDK ManagerでもAPIレベルとバージョンを確認できることを思い出しました)

プラグインのソースコードはGitHubで公開しています。

プラグイン開発の前に、全体の流れを丁寧に説明されている次の記事を一読されることをおすすめします。

プラグイン開発

  • 2016/04/06 各種バージョンを更新と補足を追加

開発環境

  • IntelliJ IDEA CE 2016.01
  • Kotlin 1.0.1-2
  • Gradle 2.9

上記記事でも言及されていますが、Mac向けのAndroid StudioはJava6上で動作するため、開発用にJDK1.6をインストールされることをおすすめします。

プロジェクト作成

Gradle + Kotlinなプロジェクトのテンプレートを公開してくださっている、こちらを利用させていただくのが手っ取り早いです。

プロジェクトの変更点

上記テンプレートを使う際、次の点を変更しました。

Project Structure
  • Project Settings > Project > Project SDK: 1.6
build.gradle
buildscript {
-    ext.kotlin_version = '0.13.1513'
+    ext.kotlin_version = '1.0.1-2'
}

plugins {
-    id 'org.jetbrains.intellij' version '0.0.24'
+    id 'org.jetbrains.intellij' version '0.0.43'
}

// 以下のDSLの詳細はGitHubを参照
// https://github.com/JetBrains/gradle-intellij-plugin
intellij {

// 依存するプラグインなし(※1)
-    plugins 'coverage'

// ※公開されている他のプラグインを参考にして設定しています
+    updateSinceUntilBuild false
}

※1 プラグインの指定方法
プラグインはデバッグ時に起動するIDEにプレインストールされているプラグイン名を指定します。
プラグイン名を調べる際は、インストール先のIntelliJ IDEA以下のpluginsフォルダ以下などから分かります。

*.imlファイル
- <module .. type="JAVA_MODULE">
+ <module .. type="PLUGIN_MODULE">
+   <component name="DevKit.ModuleBuildProperties" url="file://$MODULE_DIR$/src/main/resources/META-INF/plugin.xml" />
</module>

モジュールの種類が PLUGIN_MODULEになっていないと、アクションを追加する際にコンテキストメニューに表示されないため、モジュールもしくはプロジェクトの.imlファイルを修正する必要があります。
アクションを追加する際にファイルが見つかりませんと言われるため、plugin.xmlのパスを指定しておきます。

  • 追記: 2016/04/06

IntelliJ IDEA CE 2016.01を使用してビルドした場合、*.imlファイルの出力場所が変わります。
今まではルートプロジェクト及びモジュールフォルダ直下でしたが、.idea/modules以下に生成されるようになりました。

.idea
 |_modules
   |_module1
   | |_module1.iml
   | |_module1_main.iml
   | |_module1_test.iml
   |
   |_root-project.iml

モジュールだけでなく、ソースセットごとにも.imlファイルが出力されます。上記の場合ですと、モジュールの種類を変更するのはメインソースセットに対応するmodule1_main.imlになります。

アクションの追加

Imgur

上記のようにツールバーにアイコンを表示し、押下されるとアクションを実行するような場合、新規アクション追加後、次のようにplugin.xmlに記述します。

plugin.xml
<actions>
    <action ..
            class="ShowAndroidApiLevelAction"   
            icon="/icon.png">
        <add-to-group group-id="MainToolBar" anchor="last"/>
    </action>
</actions>

アイコンはsrc/main/resources/ (リソースフォルダに設定されている場所)以下に配置し、そのパスを指定します。
その他、指定できるアイコンの仕様は公式ドキュメントにあります。

アクション用のJavaコードはKotlinに変換しておきます。

ShowAndroidApiLevelAction.kt
class ShowAndroidApiLevelAction : AnAction() {

    override fun actionPerformed(e: AnActionEvent) {
        // TODO: ダイアログの表示
    }
}

ダイアログ表示

ダイアログの追加はコンテキストメニューから行います。
(javaファイルもできるため、javaフォルダ以下で追加します)

Imgur

Imgur

追加すると次のようなファイルが生成されます。

  • AndroidApiLevelDialog.java
  • AndroidApiLevelDialog.form

イベントハンドリングなどはJava側で行うため AndroidApiLevelDialog.java をKotlinに変換…してしまうと上記ファイルの対応付が解除される上、アクションを実行してもダイアログが表示されなくなります。
恐らくUIデザイナーがkotlinに対応していないため、現時点では画面の構築に関してはJavaを使わないといけないようです。

AndroidApiLevelDialogをデリゲート(staticメソッドVer)

今回作成したAPI一覧はJTableというSwingコンポーネントを使用しており、このコンポーネントを初期化には色々コードを書かないといけません。しかし、Java6のコードを書くのは辛い、ということでKotlin側に委譲したらうまくいきました。

AndroidApiLevelDialog.java
public class AndroidApiLevelDialog {

    // 自動追加されるコンポーネント
    private JPanel contentPane;
    private JTable table;   

    public AndroidApiLevelDialog() {
         // Kotlinクラスに委譲
        AndroidApiLevelDialogDelegate.init(this);
    }

    // Kotlin側からコンポーネントを参照するためのゲッタを手動で追加
    JTable getTable() {
        return table;
    }    

    // 他にも必要な物をパッケージプライベートで公開  
}

委譲先のKotlin側では、拡張関数を使うことでいい感じに書くことができました。
(デリゲートクラスはJava側と同じパッケージです)

AndroidApiLevelDialogDelegate.kt
// Javaで参照する際に"AndroidApiLevelDialogDelegateKt"とならないように変更
@file:JvmName("AndroidApiLevelDialogDelegate")

// ダイアログの初期化
fun init(dialog: AndroidApiLevelDialog) {
    dialog.apply {
        initTable()
        initAppearance()
         // その他初期化
    }
}

// ダイアログの外観の初期化
private fun AndroidApiLevelDialog.initAppearance() {
    title = ...  // ダイアログのタイトルの設定
    isModal = true
}

// テーブルの初期化
private fun AndroidApiLevelDialog.initTable() {
    val headers = ... // ヘッダの定義
    val contents = ... // API一覧の読み込み
    val tableModel = DefaultTableModel(headers, 0).apply {
        for (content in contents) {
            addRow(content)
        }
    }
    apiTable.model = tableModel
    // その他初期化
}

ダイアログ継承元をDialogWrapperに変更

コンテキストメニューから作成したダイアログの継承元はJDialogになっています。シングルディスプレイ環境ではこのクラスを使用していても問題ないのですが、マルチディスプレイを使用しているとIDEの中心にダイアログが表示されないことがありました。

そのため、継承元を手動で DialogWrapper に変更しています。これによりダイアログの表示位置、Escボタン押下でダイアログの非表示、OKボタンにフォーカスされるなどなど便利な機能を使うことができます。

AndroidApiLevelDialog.java
public class AndroidApiLevelDialog extends DialogWrapper {

    private JPanel contentPane;

    public AndroidApiLevelDialog() {
        super(true);

        AndroidApiLevelDialogDelegate.init(this);

        init();
    }   

    // ダイアログに表示する画面を返す。
    @Nullable
    @Override
    protected JComponent createCenterPanel() {
        return contentPane;
    }

    JTable getApiTable() {
        return apiTable;
    }

    // 省略
}

ダイアログを表示する際は次のようになります。

ShowAndroidApiLevelAction.kt
class ShowAndroidApiLevelAction : AnAction() {

    override fun actionPerformed(e: AnActionEvent) {
        AndroidApiLevelDialog().show()
    }
}

AndroidApiLevelDialogをデリゲート(Class Delegation Ver)

  • 追記: 2016/04/06

上記のデリゲート処理では、単純に画面の生成部分をKotlinに委譲していましたが、他にもDialogのOKボタンをクリックした時など、複数の処理を委譲したいことがあると思います。
Java側からAndroidApiLevelDialogDelegate のstaticメソッドを呼ぶことでも実現できますが、別パターンとしてClass Delegationを使いつつ、デリゲートクラスをインスタンス化する方法をご紹介します。

AndroidApiLevelDialog.java
public class AndroidApiLevelDialog extends DialogWrapper 
        implements AndroidApiLevelDialogComponent {

    private JPanel contentPane;
    private AndroidApiLevelDialogDelegate delegate;

    public AndroidApiLevelDialog() {
        super(true);

        delegate = new AndroidApiLevelDialogDelegate(this);
        delegate.init(this);

        init();
    }   

    // OKボタンがクリックされた時に呼ばれる
    @Override
    protected void doOKAction() {
        delegate.doOKAction();

        super.doOKAction();
    }

    // APIレベル情報を表示するテーブル
    @Nullable
    @Override    
    JTable getApiTable() {
        return apiTable;
    }

    // 省略
}

変更として、デリゲートクラスをインスタンス化する他に、ダイアログクラスは AndroidApiLevelDialogComponent を実装するようにしています。
これは、デリゲートクラスでDialog内のコンポーネントにプロパティとしてアクセスするためです。そのために、Class Delegationを使用します。

AndroidApiLevelDialogComponent は単純なインターフェースで、コンポーネントのゲッターのみを宣言しています。

AndroidApiLevelDialogComponent.kt
interface AndroidApiLevelDialogComponent {
    val apiTable: JTable

    // 省略
}

最後にAndroidApiLevelDialogをクラスとして実装します。

AndroidApiLevelDialog.kt
class AndroidApiLevelDialogDelegate(private val dialog: AndroidApiLevelDialog)
        : AndroidApiLevelDialogComponent by dialog {

    fun init() {
        dialog.initAppearance()
        initTable()
    }

    fun DialogWrapper.initAppearance() {
        // Dialogの外観の初期化処理
    }

    fun initTable() {
        val headers = ... // ヘッダの定義
        val contents = ... // API一覧の読み込み
        val tableModel = DefaultTableModel(headers, 0).apply {
            for (content in contents) {
                addRow(content)
            }
        }

        // 拡張関数ではないが、テーブルにアクセス可能
        apiTable.model = tableModel        
    }

    fun doOKAction() {
        // クリックされた時の処理
    }
}

このように実装することでComponentを使用する際、デリゲートクラスが側でも元のDialogと同じようにアクセスすることができます。

今回の規模のプラグインだとstaticメソッドでも、Class Delegationでも、結果的にはあまり変わらないので、どう書いていくのがいいのかは今後のプラグイン開発で見えてくるのではないかと思っています。

文字列のリソース化

ダイアログのタイトルなどなどベタ書きしてしまうと管理が大変なのでリソース化して読みこむようにしています。
フレームワーク側に文字列を管理するような機能がなさそうなので、ResourceBundleクラスを使用して、Javaの標準的な方法で読み込みを行います。

  • string.propertiesの配置
    リソースフォルダ以下に文字列用のプロパティファイルを追加します。
string.properties
titleAndroidApiLevelDialog=Android API Level
  • プロパティファイルを読み込み
    ResourceBundleクラスは文字列をキーにして値を取得するインターフェースになっています。
    せっかくなので、Kotlinのデリゲートプロパティを使い、変数として参照できるようにしてみました。
StringBundle.kt
// プロパティファイルをマップに変換する
internal fun bundleAsMap(propPath: String): Map<String, String> {
    return ResourceBundle.getBundle(propPath).run {
        keys.asSequence().associate{ it to getString(it) }
    }
}

// フィールド名をキーにマップから値を取得するようにする
class StringBundle(private val stringMap: Map<String, String>) {
    // string.propertiesのキーと変数名を合わせる必要あり
    val titleAndroidApiLevelDialog by stringMap
}
val stringBundle: StringBundle by lazy { StringBundle(bundleAsMap("strings")) }
  • 文字列リソースの参照
AndroidApiLevelDialogDelegate.kt
// ダイアログの初期化
fun init(dialog: AndroidApiLevelDialog) {
    dialog.apply {
        initAppearance()
         // その他初期化
    }
}

// ダイアログの外観の初期化
private fun AndroidApiLevelDialog.initAppearance() {
    title = stringBundle.titleAndroidApiLevelDialog  // ダイアログのタイトルの設定
}

量の多くない文字列リソースなら上記の方法で簡単に使えるようになるのではないかと思います。

その他

JSONファイルの読み込み

標準でGsonが使えるので、特に理由がなければJSONの読み込みにはこのライブラリを使用することをおすすめします。

プラグインで表示しているAPI一覧のデータはJSONファイルにしています。
当初、このJSONファイルを読み込む際にSquare製のMoshiというライブラリを追加し、使おうとしていました。
しかし、MacのAndroid Studio1.3環境でAPI一覧ダイアログを表示しようとしたところ、プラグインがクラッシュする問題がありました。

READMEに特に書いてなかったので気にしていなかったのですが、Moshiを使うにはJava7以上が必要でした。
Android Studio向けのプラグインの場合、外部ライブラリを追加する際はJava7以上を要求していないか確認したほうがいいでしょう。


フルKotlinで書くことはできませんでしたが、委譲することでJavaのコードをほぼ書かなくてよくなり、
快適に開発することができました。Androidアプリの開発だけでなく、プラグインの開発でも便利なので使ってみてはいかがでしょうか。