こんにちは、ヤフーで Android アプリの開発などをしている菅井です。
Yahoo! JAPANのアドベントカレンダー12月8日のAdvent Calendarを担当させていただきます。
弊社ではプログラミング言語ごとや、OS,プラットフォームごとに社内勉強会が盛んに行われており
業務に携わる中で気になったことや、ハマったこと、単に趣味嗜好が似ているもの同士が集まって部署を超えて情報を共有するような文化があります。
それらのイベントは勉強会やライトニングトーク会などの形で行われており、自分も何度か発表しているのですが
社内・社外を問わず多くの人達の発表から新たな見識を広げたり刺激を受けることが多く業務中なのにネタに使えるな?などと思わずにやけることも。
昨今では iOS では Swift の登場によりアプリケーションのプログラミング環境が
飛躍的な変化を遂げ従来のC/C++の延長線上に合ったプログラミング言語から一変し
開発者に大きなインパクトを与えました。
Android では新しいプログラミング言語 Kotlin の登場や開発環境を AndroidStudio に完全に移行するなど
大小様々な変化が起きています。
そのような背景も踏まえまして、今回は Android の新しい Toolchain/ビルド環境として登場した Jack and Jill について触れたいと思います。
アプリエンジニアの方々には、今ある目の前の課題を解決するような内容ではありませんが日常利用している技術の裏側に目を向け、プラットフォームの根幹を支える 根っこの技術 に触れることで何かの刺激になれば幸いです。
自分も一人のアプリエンジニアとしてコンパイラ、リンカといったブラックボックスの中にある技術(特に最適化技術)といった物に触れることで色々な発見がありました。
プログラミング言語毎に存在する誤解や、オマジナイ、迷信などは、全部ではないにしろ一部については多少は理屈で理解できるようになったのは大きな財産になっています。
こと Toolchain やコンパイラの全貌となると、コードリーディングだけで何年かかるか分かりませんが
Jack and Jill で採用されている構文解析・パーシングは ANTLR を利用していたりと
ソースコードの解釈からスタートして、最終的な DEX 出力までを見ると、コード変換と最適化、DEX出力ぐらいと小規模な分量であることも読みやすさの1つだと思います。
Android の Java
Android の Java は「最新の Java言語仕様(=Java8)」 と比べると少し仕様が古いままです。
Java 7で導入された Try-with-resources も使えるのですが「minSdkVersion >= 19」以上となるためサービスのフロントエンドアプリを開発する側としても minSdkVersion が高すぎて自分が担当するサービスでは見送らざるを得ないことも多いのではないでしょうか。
Java 7 でも ダイアモンド演算子はあるけども InvokeDynamic などはサポートされず、プラットフォームに必要な機能を取捨選択して導入しているようです。
実際、既存のJava言語で規定されている全ての言語仕様に対応するにしても、言語仕様としてコードの記述が容易になるとか、あたらしいVM環境が登場して高速に動作する機能が提供されているなどの変化がないかぎり、言語仕様の拡張に着手するのは SDK の開発負担が大きすぎるので仕方ないのではと思います。
特徴
実験的という位置づけでも有り、既存の Android アプリのコンパイル環境とは大きく異なります。
現在知り得る限りの情報ですが以下のようになります。
- Java 1.7 のサポート
- Repackaging, shrinking, obfuscation や multidex をサポート
- アノテーション処理はサポートされていません
実際に利用する場合は Gradle で useJack を指定するだけなので気軽に試すことが出来ます。
// こんな感じ
useJack = true
まだ、ビルドの時間が遅かったり、コンパイル時にメモリを大量に消費するケースもあるようですが、それを差し引いても今後に期待したくなるものが多く実装されています。
アノテーションについては /annotationprocessor を見ると、ソレらしい処理はあるので将来的には対応されていくのではないでしょうか。
Jack and Jill の変遷
Jack and Jill と呼ばれる実験的なコンパイラは 2014/03/20 にひっそり登場しました。
記念すべき最初のコミットメッセージはシンプルな一行です。
Initial Jack import.
Change-Id: I953cf0a520195a7187d791b2885848ad0d5a9b43
その後 2014/04/07 にpart2 がコミットされています。
Initial Jack import part 2.
Change-Id: Ic439604a1f030700d9049800fbf62422e0004d35
内容を見ると、もともとは別の場所で開発が行われていたものが公開されたようです。
コミットメッセージの中には現在のブランチ構成には存在しないブランチ名などがいくつか見えています。
例えばコレの jack-wip-dev など。
Merge “Fix various classpath issues in Jack projects” into jack-wip-dev
大規模なコミット内容や、Jack and Jill のビルド環境は現在と比べても、ほとんど変わりません。
6月頃になると初期特有の激しいコミットに落ち着きが見られますが、翌月からは再度調子が戻り安定的に月に100以上のコミットを重ねながら 2014/12 には、ついに Android Developer Blog にて存在が公になりました。
コミット状況をグラフ化すると下図のようになります。
2014/05 は 69 commit で、2015/05 は 101 commit と Google I/O のタイミングでコミット数が大きく減っているのが分かります。
例年は Google の主催する開発者イベント Google I/O が開催される時期なのでコミッターも参加されているんだと思います。
Git のリポジトリの状態を図にしてみました。
運用されている中で変化はありますが、開発は ub-jack を追いかけていれば最新の Jack and Jill の環境を見ることができます。
ub-jack についで開発が盛んなのは ub-jack-lang-dev です。
コチラでは主に Java 言語に関する開発が行われており、特に Java8 への対応が進んでいます。
と、言っても Java8 の機能を全て導入するのではなく主に Lambda への対応に重点がおかれているようです。
先進的であり実験的な環境
Jack and Jill の特徴として従来の Android アプリのコンパイル環境とは大きく異なるプロセスでコンパイルが行われることです。
従来、APK を作成するまでには一度 .class ファイルを生成してから DEX に変換する必要がありました。
Jack and Jill では class ファイルを経由せずに独自の中間コード jayce から DEX への変換を行います。
- Jack はコンパイラとリンカ(DEXの生成を行う)
- Jill は aar や jar などの Bytecode や外部プロジェクトを対象とするコンパイラ
従来の環境と大きく異なるのは Jack はclass(Java Bytecode)を読み込む必要なく Jayce と Java ソースコードのみを扱うということです。
処理の流れ(おさらい)
すでに Jack and Jill についての解説は世の中に多くあるので、実際の Jack の実装から触れていきたいと思います。
Jack の構造は Main.java から Jack の本体? である Jack.java の機能を呼び出しています。
Main はコマンドラインからの受付やビルドプロセスの中で呼びだされたりする Jack のフロントエンド的な役割のようです。
/jack/jack/src/com/android/jack/Main.java
/jack/jack/src/com/android/jack/Jack.java
Jack はコンパイラフロントエンドの Main もしくはコマンドラインのフロントエンドの CommandLine から呼び出されると、どのような形でコンパイルを行うかを決定します。
コンパイルに必要なデフォルト設定を呼び出し指定された形式でコンパイルを行う準備をするのですが、その際にコンパイル処理の入り口に当たる run メソッドの引数には Options が渡されます。
これはコンパイラの動きを制御するパラメータで一般的に言う
コンパイルオプション に相当するようです。
Jack のコンパイラへの設定は run メソッドの引数である Options に収められています。
コンパイルオプションの種類は Options クラスに定義されている内容だけでも40個以上。
その多くは「デバッグ情報の付与の有無」や「各種最適化の適用の有無」のような一般的なものでばかりです。
// Jack.java
// options から
public static void run(@Nonnull Options options, @Nonnull RunnableHooks hooks)
throws JackUserException, ProcessException {
buildSession(session, options, hooks);
if (config.get(Options.GENERATE_JACK_LIBRARY).booleanValue()) {
InputFilter inputFilter = session.getInputFilter();
assert inputFilter != null;
session.setJackOutputLibrary(inputFilter.getOutputJackLibrary());
}
....
他に、目につくのは DEX 出力で行うコンパイル最適化に関するオプションの数々です。
Android 環境で言えばインストール時や実行中にコンパイル、最適化を行うのが ART ですが
DEX 生成時点で最適化を行うのは意外でした。
// 内部クラスアクセス最適化
if (config.get(Options.OPTIMIZE_INNER_CLASSES_ACCESSORS).booleanValue()) {
request.addFeature(AvoidSynthethicAccessors.class);
}
// 末尾呼び出し最適化
if (config.get(Options.OPTIMIZE_TAIL_RECURSION).booleanValue()) {
request.addFeature(TailRecursionOptimization.class);
}
...
DEX への出力は、下記のように設定されます。
fillDexPlan(planBuilder);
// DEX ファイルへの出力
// DEX 以外は .jar や .arr での出力?
if (targetProduction.contains(DexFileProduct.class)) {
planBuilder.append(DexFileWriter.class);
}
// リソースの出力
if (features.contains(Resources.class)) {
if (targetProduction.contains(DexFileProduct.class)) {
planBuilder.append(ResourceWriter.class);
}
if (targetProduction.contains(JayceInLibraryProduct.class)) {
planBuilder.append(LibraryResourceWriter.class);
}
}
// Jayce の設定
if (targetProduction.contains(JayceInLibraryProduct.class)) {
planBuilder.append(LibraryMetaWriter.class);
}
この Jack#run() メソッドが Jack の中核であり、コア機能の入り口になるので読みやすく情報のポインタとしても優れているため、今後 Jack and Jill の特に Jack 側を読む際には是非参考にしてみてください。
最近の出来事
やはり Java8 への対応です。(コレが一番書きたかった)
Java8 への対応は半年以上前から進んでいましたが、ようやく具体的な処理が形になりつつあります。
ソースバージョンが Java8 の場合は Java8 用の処理がコンパイルに組み込まれます。
if (sourceVersion.compareTo(JavaVersion.JAVA_8) >= 0) {
request.addFeature(SourceVersion8.class);
}
ソースコードのバージョンはコンパイラから渡される場合とコマンドラインの -source で指定された場合にデフォルト 1.7(JAVA_7)以外をコンパイル対象としています。
ソースコードバージョンと Java バージョンのマッピングは JavaVersionPropertyId 内で定義されています。
見た感じでは 1.3 から 1.8 までの定義が有りますが、理由はわかりませんが 1.3 などは互換のための定義かもしれません。
/**
* Supported Java source version.
*/
public enum JavaVersion {
JAVA_3("1.3"),
JAVA_4("1.4"),
JAVA_5("1.5"),
JAVA_6("1.6"),
JAVA_7("1.7"),
JAVA_8("1.8");
Lambda への取り組み
Java_8 もしくは SourceVersion8 である場合に対象となる処理は下記のとおりです。
実際の処理は後者のフラグ(SourceVersion8)だけを見ています。
if (features.contains(LambdaToAnonymousConverter.class)) {
{
SubPlanBuilder<JDefinedClassOrInterface> typePlan =
planBuilder.appendSubPlan(ExcludeTypeFromLibWithBinaryAdapter.class);
SubPlanBuilder<JMethod> methodPlan = typePlan.appendSubPlan(JMethodOnlyAdapter.class);
methodPlan.append(LambdaConverter.class);
}
...
LambdaToAnonymousConverter は、名前から推測するとラムダ式で表現された匿名メソッドを利用した制御構文の処理のようです。
簡単にいえばラムダ式で書かれた処理を、匿名クラスを利用した記述と同様の形に変形する処理のことのようです。
LambdaConverter には以下の様な説明があります。
// Build <init> method of class implementing lambda and fields for all captured variables.
// Generated code looks like
// public final synthetic class <current class name>$LambdaImpl<class counter> {
// private synthetic <captured variable type> val$<captured variable name>;
// ....
// public synthetic <current class name>$LambdaImpl<class counter>(<captured variable type>
// <captured variable name>, ...) {
// super.init();
// val$<captured variable name> = <captured variable name>;
// ...
// }
// }T
ラムダ式で記述した処理は、匿名クラス、匿名メソッドを隠蔽した簡易的な記述などを容易にしますが実際に Bytecode のようなプリミティブなバイナリに変換される場合、従来の処理の変換するのは一つのセオリーかもしれません。
例えばラムダ式で記述した処理と、無名関数で記述した処理の実行速度を比較しても、だいたい同じぐらいの速度(パフォーマンス)になるのであれば
「人間が理解しやすいプログラムの書き方」 に自然に淘汰されていくと思うし、処理を考える側にとってもパフォーマンスの問題を無視して良いのであれば、このようなコンパイラの設計は理にかなっていると思います。
ただそれは、あくまで実行されるロジックのみの速度であって現在の変換方法は、大量の無名クラス、無名メソッドを作成することになるのでクラスロード時のコストが跳ね上がってしまう問題が有ります。
今後、どのような形で Jack が対応していくのか大変興味深いところです。
他の理由としては、むしろコチラの理由がメインだと思いますが。
Android では InvokeDynamic がサポートされていないということです。
Jack では InvokeDynamic を検出すると CheckMethod のロジックの中で例外を throw しています。
public void visitInvokeDynamicInsn(String name, String desc, Handle bsm,
Object... bsmArgs) {
checkStartCode();
checkEndCode();
checkMethodIdentifier(version, name, "name");
checkMethodDesc(desc);
if (bsm.getTag() != Opcodes.H_INVOKESTATIC
&& bsm.getTag() != Opcodes.H_NEWINVOKESPECIAL) {
throw new IllegalArgumentException("invalid handle tag "
+ bsm.getTag());
}
...
Java8 仕様では Java 言語のラムダ式は全て InvokeDynamic に置き換えられるのという仕様はなかったと思うのですが(=実装依存)現在の Dalvik 環境の仕様では、Java8 のラムダ式を一つとってもパフォーマンス的なメリットは難しいのかもしれません。
もう一つのラムダ式のコード変換
もう少し解説すると現在の Jack にはラムダ式のコード変換は先に述べた LambdaToAnonymousConverter と LambdaNativeSupportConverter と呼ばれる実装が用意されています。
優先度としては LambdaToAnonymousConverter でない場合は LambdaNativeSupportConverter を使う流れです。
LambdaNativeSupportConverter これはラムダ式の中身を static method に置き換えて呼び出し元のインターフェースにラッピングして呼び直す方法です。
JMethod lambdaMethod = lambdaExpr.getMethod();
lambdaMethod.getMethodId().setName(lambdaClassNamePrefix + anonymousCountByMeth++);
lambdaMethod.setModifier(lambdaMethod.getModifier() | JModifier.STATIC);
JParameter closure = new JParameter(SourceInfo.UNKNOWN, "closure",
lambdaExpr.getType(), JModifier.DEFAULT, lambdaMethod);
closure.addMarker(ForceClosureMarker.INSTANCE);
lambdaMethod.getParams().add(0, closure);
こちらのコード変換方法はパフォーマンスが想像しにくいのと、実際のチューニングの度合いなどは未測定な部分も多いのかもしれません。
これから Jack and Jill を読みたい人のために
何もわからない所からソースコードを読んで行くのは大変かもしれないので、オススメの Jack のソースについて紹介します。
ただ、個別に書くと大変なのでザックリとした単位となります。
/transformations -
Jack におけるコード変換のすべての実装が詰まっている。ソースコードがいかにしてVM にとって都合の良い形に変換されていくのか、その際のコード最適化のロジックなど Jack の一番の仕事が収められています。/ir -
ソースコード解析後にコンパイラ内で取り扱う内部表現(中間表現)。ast の抽象構文木や formatter の実装を収められています。/jayce -
Jack and Jill で取り扱う中間表現(Jayce)へのファイル入出力や file formatter などが収められています。
最後に
Yahoo! JAPAN では、モバイル環境でのユーザーファーストを考える一方でデザインや、基盤/基礎技術、先進的なテクノロジーなどへの取り組みも活発で、それらを組織的に支える枠組みなども発足しエンジニアが目の前のサービスだけでなく、より多角的に、より先鋭的にテクノロジーに関わり、組織全体に広めることも強く推奨する組織に変化しています。
こんなYahoo! JAPANに、刺激を感じませんか?
Yahoo! JAPANでは、情報技術を駆使して人々や社会の課題を一緒に解決していける方を募集しています。詳しくは採用情報をご覧ください。
こちらの記事のご感想を聞かせください。
- 学びがある
- わかりやすい
- 新しい視点
ご感想ありがとうございました