こんにちは。学校も始まり最近は健康な時間に起きています*1。
今回は 手元の Android Studio からビルドのみをクラウドで行う ことによって快適な生活を手に入れる話です。
概要
- ビルドを移譲できる Android Sutudio のプラグインを書きました
- これを使ってクラウドのリッチなマシンでビルドを行い高速化
- ビルドのみが移譲されるので操作感は変わらない
- 実装は割りと無理矢理
本題
動機
最近 Android のビルドが遅くてつらいなあという気分でいました。
Java のコンパイル (Make
) ぐらいならいいのですが、デバッグ等をするときにライブラリが多いため Dex 処理にすごく時間がかかります。Dex 処理は一行変えただけでも行われるので非常につらい。
足りないのはメモリとCPUで、とにかくマシンパワーが必要なのですが、まだ自分の PC を変える時期ではない*2。
結局 クラウドで強いインスタンス借りてビルドすればいいじゃん! という結論にたどり着き、いろいろ試しました。マシンパワーは大事。
そのうちの一つがこの記事です。(完全にネタ)
Android Studio で外部でビルドだけ行う方法をググったりしたのですがどこでも見つからず、Gradle プラグインでも作って無理矢理タスク実行するしかないかなあ…と思っていたのですが、よく見ると APK 作成するタスクが端末にインストールするタスクと分離されていました。今回はそこを弄ってみることにしました。
環境
- Android Studio 1.5 / 2.0 / 2.1
流れ
Android Studio プラグインが実行すべきタスクとパラメーターを取得する
予め設定されたコマンド(今回の場合 Shell Script)を叩き、タスク等をオプションとして渡す
Shell Script では受け取ったオプションを基に外部に
ssh
してソース等をrsync
しgradlew
を実行する。外部から成果物を
rsync
で持ってきて、それを端末にインストールしデバッグ
プラグイン
Android Studio はデバッグなどで Run
を行う前に Gradle-aware Make
を行い、そこで APK が生成されます。
ここでは他のタスクを追加することができ、例えば External Tool
を選択すると Shell などの適当なプログラムを実行することができます。
ここで Gradle-aware Make
相当のことを外部で行えば、手元では APK を端末にインストールするのみとなります。もし外部ビルドをデバッグだけに限定するなら専用の Shell Script を書けば良いのですが、それでは Release や Instant Run などの時に使うことができません。
"今どんな Gradle タスクを実行すべきか" はこの機能では渡せないのでプラグインを作成しました。
このプラグインでは Android Studio の内部メソッドを直接叩くことによって 行うべきタスクとパラメーター を取得しています。
GradleInvokerOptions options = GradleInvokerOptions.create(myProject, context, configuration, env, null);
こうして得たオプションを基にコマンドを組み立て、先ほどの External Tool
の機能を利用するために内部的に Tool
を(リフレクションを一部用いて)作成し、コマンド付きの Shell Script を実行する、というものです。
このプラグインを入れると Gradle Make on External Tool
という項目が追加されるので Gradle-aware Make
をこれに置き換えます。
するとインストールされる前に Shell Script を実行できます。
実行するコマンドはプラグインの設定画面(External Tool
のダイアログを簡略化したもの)で設定するようにしました。
$GRADLE_TASKS$
と書くと :app:assembleDebug,:app:assemble,:app:assembleRelease,
のように置き換わります。
プラグインから SSH
や rsync
を直接行うこともできたのですが、環境依存しやすいのと気軽に弄ることができないのでタスクを取り出して渡すだけにしました。
Shell Script で外部ビルドを実行
まずはサーバーを用意します。僕は下のような Ansible Playbook を用いています。
そしてオプションからタスクを受け取り rsync
したり gradlew
を打ったりするスクリプトを書きました。
#!/usr/bin/env bash cd `dirname $0` ## set remote server ip address or alias name server="" set -e while getopts a?t:m:p: OPT; do case $OPT in a) arguments=$OPTARG ;; t) tasks=$OPTARG ;; m) module_dir=$OPTARG ;; p) project_dir=$OPTARG ;; esac done # unescape gradle_args=`echo $arguments | perl -pe "s/(?<!\\\\\\),/ /g; s/\,/,/g"` gradle_tasks=`echo $tasks | perl -pe "s/(?<!\\\\\\),//g; s/\,/,/g"` echo "Tasks: $gradle_tasks" echo "Args: $gradle_args" echo "Project Dir: $project_dir" echo "Module Dir: $module_dir" project_relative=`realpath --relative-to=$HOME $project_dir` module_relative=`realpath --relative-to=$HOME $module_dir` ctl_path="$HOME/.ssh/ctl/%L-%r@%h:%p" rsync_ssh="ssh -S $ctl_path" mkdir -p ~/.ssh/ctl/ # create session with Control Master echo "Opening session..." ssh -fNM -S "$ctl_path" "$server" # sync source files echo "Syncing source files..." ssh -S "$ctl_path" "$server" mkdir -p "~/$project_relative" rsync -Cavz --delete --filter=":- .gitignore" -e "$rsync_ssh" $project_dir/ "$server":$project_relative/ # also sync build directory mkdir -p $project_dir/build/ $module_dir/build/ rsync -CavzS --delete -e "$rsync_ssh" $project_dir/build/ "$server":$project_relative/build/ rsync -CavzS --delete -e "$rsync_ssh" $module_dir/build/ "$server":$module_relative/build/ echo "Starting Build..." ssh -S $ctl_path "$server" " export ANDROID_HOME=~/android-sdk-linux cd ~/$project_relative ./gradlew $gradle_tasks $gradle_args" echo "Syncing output files..." rsync -CavzS --delete -e "$rsync_ssh" --exclude='*unaligned.apk' "$server":$project_relative/build/ $project_dir/build/ rsync -CavzS --delete -e "$rsync_ssh" --exclude='*unaligned.apk' "$server":$module_relative/build/ $module_dir/build/ # close session with Control Master echo "Closing session..." ssh -O exit -S "$ctl_path" "$server" echo "Remote build and sync finished." exit 0
GradleExternalBuildPlugin/example.sh at master · nonylene/GradleExternalBuildPlugin · GitHub
コードは長いですがやってることは引数を受け取り unescape して、 rsync
とビルドを行っているぐらいです。
ちなみに SSH 接続部は高速化のために ControlMaster
を用いていて、これで毎回 SSH 接続をリセットされることがなくなります。
外部で実行しているのは
ssh -S $ctl_path "$server" " export ANDROID_HOME=~/android-sdk-linux cd ~/$project_relative ./gradlew $gradle_tasks $gradle_args"
この Shell Script が完了すると手元にビルド後の /build
ディレクトリが同期されているので、後は普段同様 IDE が自動でインストール・デバッグ立ち上げなどを行ってくれます。
こうして、
- 柔軟なタスク実行
- プロジェクトで専用の設定を作らなくて良い*5
- タスクだけを渡す機能のみなので実行部分はそれぞれの環境に合った設定が可能
が達成できました。
External Tool
は実行できさえすれば良いので Windows でも使える*6!!最高!!!
Google Compute Engine で 8コア、メモリ 8GB を借りてやってみたところ*7 速度が二倍ぐらいになりました。top で眺めてるとCPU使用率が 798% になってたりしてすごい。
ビルド待ちの間ブラウザ見てても重くなりませんしファンが回る音もしなくなって最高です。
ただ、今の方法だとビルド毎に rsync
で APK を転送しているのでネットワークの遅い場所ではここの時間がネックになってきます。
rsync
のフェーズで 20秒ぐらいかかっていたら元も子もありません。GCEだとその点回線が太いので安心。
コードの話など
プラグインについて
書き方
Android Studio / IntelliJ のプラグインの情報を探しても、セットアップ程度であまり良いページはありません*8。ドキュメントも余り充実してるとは言えないです。
プラグインを作成するのに一番参考になるのは他人のコードです。GitHub で似たプラグインを検索してそのコードを眺めましょう。
Android Studio のソースを落としてくると Android Studio プラグイン作るときに使う内部のコードも閲覧できて便利。
$ git clone https://android.googlesource.com/platform/tools/adt/idea
また、IntelliJ-Idea Community のコードは GitHub で公開されているので、それも参考にすると良いです。
$ git clone git@github.com:JetBrains/intellij-community.git --depth 1
僕はプラグイン作るときにもう一つ IDE 立ち上げてそこで IntelliJ-Community のコードを見ていました。
また、 Android Studio では JDK 6 で開発する必要があるみたいですが、最新の IntelliJ Community では Java6 でのプラグイン開発が行えないみたいなので 2016年 1月版を入れて行っています。
Android Studio 関係のメソッドを叩くにはプロジェクトの依存関係に Android Studio 関係の library を入れる必要がありました。
コード
Kotlin で書いています。Kotlin 最高。
Android Studio の内部で叩いてるコードを叩いてる時点で割りとアレなのですが、Tool
を作成しているところは闇。タイトルを設定するために private
なメソッドをリフレクションを使って無理矢理叩いています。
parameters = parameters .replace("\$GRADLE_TASKS\$", tasks) .replace("\$GRADLE_ARGS\$", args) with(javaClass.superclass.getDeclaredMethod("setName", String::class.java)) { isAccessible = true invoke(this@ExternalBeforeRunTaskTool, "Gralde External Build Task") } with(javaClass.superclass.getDeclaredMethod("setUseConsole", Boolean::class.java)) { isAccessible = true invoke(this@ExternalBeforeRunTaskTool, true) }
まあ本来 Tool
は専用の画面で設定されるものであるためこうなるのは仕方ないです… 他の方法で行おうとすると独自のコンソール画面を作り、独自にプロセスの管理をするなどする必要があり色々と面倒なので…
基本的に画面は JFrame で構成します。JFrame 書いたことなかったのでここが一番難しかった。
課題
また、今の Shell Script ではプロジェクトの build
ディレクトリとモジュールの build
ディレクトリを rsync
しています。モジュールは現在選択しているファイルから取得しているので、プロジェクト全体の build.gradle
を編集してたりするとうまく同期されなかったりします。
Android Studio のビルドのみを切り出す処理、全く見つからなかったので参考になれば嬉しいです。もっと簡単にできるよ!等あれば教えて下さい。