これは、DroidKaigi 2016のセッション「Instant Runを実現する仕組み」と連動するエントリです。セッションは30分という短い時間で行われるので、細かい説明はこちらでまとめていきます。
Part I: Instant Runを理解するためのAndroidビルド概説
はじめに
2015年11月に、Android Studio 2.0 previewと同時に公開されたInstant Runは、開発中のアプリケーションのAndroidデバイスあるいはエミュレーター(以下"target")へのデプロイメントを高速化して、また実行中のアプリケーションを再起動することなくコードを置き換えることを可能にして、デバッグ開始までの待ち時間を劇的に減らすことが可能になりました。開発者がIDE上でデバッグを指示してから、実際のデバッグ開始まで、場合によっては1秒程度で出来てしまう、というものです。これはAndroid 4.0以降の任意のtargetで実行可能です。
このような劇的な改善はどのようにして可能になったのでしょうか? 今回は、Instant Runを実現する仕組みについて解説していきます。
Instant Runの仕組みを理解すると、アプリケーションのビルドや実行に、ある種のフックを仕掛けるなど、ある程度素直でないことを行う場合に、どのような点に気をつければ良いのかが、見えてくるかもしれません。
Instant Runを実現する技術要素は、主にビルドシステムの改善ですが、Android 4.0以降のフレームワークの事情もある程度関連してきます。GoogleがInstant Runと呼ぶ技術はGradleの上に成り立っていますが、技術的には必ずしもGradleに依存するものではありません。いずれにしろ、その辺りの領域に関心のある人向けの議論です。
ビルドとデプロイメント - デバッグ作業をスローダウンさせる要因
Perl、PHP、Python、RubyなどでWebアプリケーションを開発したことがあれば、アプリケーションをデバッグ実行するのは、ストレスの大きい作業ではないことが分かるでしょう。アプリケーションは概ね直ちに実行開始できます。C#でWindowsアプリケーションを開発している時も、ビルドにはちょっと時間はかかるかもしれませんが、アプリケーションのデバッグ実行はすぐに開始します。
一方、Android Studioを起動して、新しいアプリケーションを作成し、デバッグを指示してから、Android Studio上で実際にデバッグが行えるようになるには、かなりの時間がかかります。いったい何に時間がかかっているのでしょうか?
Android Studioが新規アプリケーションのテンプレートを展開したり、Gradleのダウンロード、依存ライブラリのダウンロード、Androidエミュレーターの起動や実行など、一度しか発生しないタスクや、本質的に回避しようのないものを除外して考えると、デバッグ実行のたびに、反復的に必要になるのは、大まかには次の2つです。
- アプリケーションのビルド
- アプリケーションのデプロイメント
Androidアプリケーションのビルドは特に時間がかかります。なぜかというと、ビルドの際に行う作業が多いためです。その主な内容を列挙すると…
- aaptを呼び出してリソースを処理して、9-patchを施し、リソースIDをインデックス化して、バイナリデータとR.javaを生成します
- JavaソースコードをJavaバイトコード(*.class)にコンパイルします
- もし指定されていたら、proguardを使用して不要なJavaバイトコードを削ります
- 場合によっては、multidexに対応するためにJavaバイトコードを全てスキャンして設定ファイルを生成します
- JavaバイトコードをDalvikバイトコード(*.dex)に変換します
- これらをzip圧縮してapkにまとめ、zipalignでアラインメントを整理します
- apkに署名を施します
ビルドが終わっても、直ちにデバッグを開始できるわけではありません。デバッガーを起動するホストと、アプリケーションをtargetは別のマシンであり、デバッグする前に、実行するAndroidアプリケーションのapkパッケージをtargetに転送、すなわちデプロイしなければならないのです。このデプロイメント作業が、もうひとつの大きな遅延要因です。なぜかというと…
- アプリケーションのapkパッケージをtargetにTCP経由で転送しなければなりません
- アプリケーションが既にインストールされている場合は、一度アンインストールする作業が発生します
- アプリケーションのapkパッケージをインストールすると、中では適切なパーミッションの設定(アプリケーションごとにLinuxユーザーアカウントが作成される)などの追加作業が発生します
- Android 5.0以降(あるいはランタイムにARTを選択したAndroid 4.4)であれば、target側でアプリケーション コードのネイティブ変換 (dex2oat)が行われます
これらが終わってから、ようやくIDEがアプリケーションに対してinstrumentationを使用してデバッガーのセッションを開いてデバッグ処理を行えるようになる、というわけです。
いま、上記のような細かい事柄を把握している必要はありません。ポイントは、Androidアプリケーションのビルドとデプロイメントは、数多くのタスクの積み重ねによって遅くなっている、という部分です。
もし、仮にこれらのビルドタスクを、いくつか省略することが出来るとしたら、どうでしょう? ビルドタスクが多すぎるなら、それらを可能な限り減らしてやれば、ビルドやデプロイメントを高速化できるのではないでしょうか? Instant Runは、これを(いくらか)実現したものなのです。
ビルド キャッシュ
ここまでは、ビルドとデプロイメントの高速化が重要だ、という話を書きました。では、ビルドを高速化するには、どうしたら良いでしょうか? まず、おおまかな説明から始めようと思います。
Androidアプリケーションのビルドに限らず、あらゆるビルドシステム - make, ant, cmake, ndk-build, bazel, MSBuildなど - に共通する機能として、ビルド出力のキャッシュと再利用が挙げられます。
ビルド出力がキャッシュされる、というのは、「ビルド」を構成する一連の「タスク」を実行する際に、そのタスクへの入力が出力よりも古いなど、一定の条件を満たしている場合には、そのタスクの実行がスキップされる、ということです。ソースファイルが変更されていないなら、そのソースをコンパイルしても、得られるライブラリや実行ファイルは既存のものと実質的に変わりません。それなら、コンパイルするだけ無駄です。
一般的には、入力と出力はファイルで指定され、入力ファイルのタイムスタンプがいずれも出力ファイルのタイムスタンプより古い場合には、ビルドがスキップされます。以下にいくつかの(Androidではない)例を挙げます。
Makefile:
all: libfoo.so
libfoo.so: foo.o
ld -o libfoo.so foo.o
foo.o: foo.c
gcc -c foo.o foo.c
MSBuild:
<Target Name="BuildApk"
Inputs="$(DEX);$(ARSC);$(MANIFEST)"
Outputs="$(PACKAGE).apk"> ...
Androidアプリケーションのビルドで最終的に必要なのはapkパッケージなので、Android Gradle pluginでは、そのビルドを高速化するために、ビルド過程で生成されるファイル(Gradleのビルド出力にintermediaries
というディレクトリがあることを知っているという人も少なくないでしょう)を効率的に活用しています。
想像がつくかもしれませんが、これは簡単なことではありません。タスクには依存関係があり、ビルドスクリプトを書く人(ビルドタスクを実装する人)は、これを正確に設計・把握していないといけません。間違った入力・出力の指定があると、実行されるべきタスクがスキップされたり、逆にキャッシュが機能せずに、毎回タスクを不要に実行することにもなります(そうなると、出力が変化することになり、それらを入力とするタスクの実行も軒並み非効率になります)。これらは、ビルドスクリプトのバグと言って良いでしょう。
たとえば、Androidリソースファイルに変更が加えられると、Javaソースのコンパイルタスクがトリガーされます。リソースの変更はaaptによるR.javaの生成を引き起こすためです。これを忘れて、自動生成されるR.javaがjavac呼び出しのタスクをトリガーしないと、リソースだけ変更した開発者のビルドはJavaコンパイルを行うことなく完了し、ユーザーは変更が反映されていないコードを含んだアプリケーションをデバッグすることになります。このような、不正確な依存関係の記述に起因するバグは、現実にAndroid Studio 2.0 betaで使用しているAndroid Gradle pluginに 存在しています。
(一般的なアプリケーション開発者は、assembleDebug
などを呼び出すだけなので、通常は気にする必要はありません。)
どのビルドシステムにも、タスクの入力と出力、依存関係を記述する仕組みはあると思いますが、記述できるスクリプトが柔軟すぎると入力・出力が把握しづらくなり、バグを引き起こしやすくなります。これは、Googleが、既にGradleがあるのにBazelの開発を始めた理由のひとつです。
ビルドされるapkの構成要素
抽象論だけで話を進めると全体像が掴みにくいので、ここで具体的にAndroidのアプリケーション パッケージであるapkに、何が含まれていて、どのようにビルドされているのかを、ざっと追ってみましょう。
まず、apkには、以下の内容が含まれています。
- マニフェスト (AndroidManifest.xml)
- Dalvikバイトコードとなったアプリケーション コード (classes.dex)
- コンパイルされたリソースとアセット (resources.arsc)
- ネイティブライブラリ (libFooBarBaz.so)
(ちなみにapkファイル自体はzipアーカイブですが、zipalignというツールを使用してファイルの境界を調整しているため、内容は普通には読めなくなっていることが多いです。)
通常、DalvikバイトコードはJavaソースやライブラリjarから、リソースパッケージはリソースファイルやアセットファイルから、ネイティブライブラリはCなどのソースからビルドされます(Gradleでビルドすることは無いかもしれません)。また、既に軽く言及しましたが、Androidリソースに変更を加えると、R.javaが変更されてJavaコンパイルのタスクがトリガーされる可能性があります。
AndroidManifest.xmlも、たとえば参照しているaarのライブラリにAndroidManifest.xmlが含まれている場合、開発者が作成したものが直接パッケージされるのではなく、manifest mergerという機能によって自動生成されたものが、アプリケーションに最終的に含まれることになる可能性があります。この場合、ライブラリ参照に対する変更が、manifest mergerのタスクをトリガーすることになります。
これらのapkの内容のいずれかが更新されると、最終的にapkをパッケージするタスクが実行されることになります。そして、apkが更新されたら、Gradleはデバッグ実行のために(時間のかかる)インストール タスクを実行することになるでしょう。
たとえ、ささいなファイルの更新がひとつあっただけでも、apk全体のパッケージングがトリガーされるというのは、ビルド パフォーマンスに悪影響を与える設計です。たとえば巨大な動画のアセットファイルがひとつあるだけで、それをzip圧縮するビルドタスクの処理は非常に重くなるでしょう(さらにtargetへの転送処理にも悪影響が出ます)。
最終的なapkのパッケージングが、遅いビルド処理の原因になっているのであれば、これをなるべく回避できるようになることが求められます。
デバッグビルドとリリースビルド
もうひとつ、Androidに限らない一般論ですが、多くのプログラミング環境では、アプリケーションのビルドには、だいたい、デバッグビルドとリリースビルドの2種類が用意されています。当然ながら、これらの間には違いがあります。
最も典型的な違いは、デバッグモードでビルドされたアプリケーションには、デバッグシンボルが埋め込まれている、というものです。デバッグシンボルが埋め込まれていると、デバッガーをアタッチしてアプリケーションを実行した時に、実行中のバイナリコードの、ソースコード上の位置が分かることになります。また、コンパイラによるコードの最適化の度合いが変わることもあります。デバッグ実行する場合には、コードが最適化されてブレークポイントごと消えてしまうと不都合ですし、言語環境によってはコードの最適化にはそれなりに時間のかかる流れ解析 (flow analysis) が必要なので、頻繁に行いたいデバッグビルドでは無効になることが多いです。
もう少し大局的に、デバッグビルドとリリースビルドの性格の違いをまとめてみると、概ね次のような対照表になるのではないでしょうか。
Type | Debug | Release |
---|---|---|
実行する人 | 開発者 | エンドユーザー |
実行環境 | 開発者の環境 | あらゆるユーザーの環境 |
ビルド頻度 | 完成するまで何度も | 一度~数回 |
ビルド速度要件 | 早いほど良い | 重要ではない |
アプリケーションのサイズ | 重要ではない | 小さいほど良い |
目的に特化して違いが拡大するデバッグビルドとリリースビルド
さて、ここから少しずつAndroidの話をしていきます。
Androidのように、アプリケーションの実行環境が特殊なものであると、単なるデバッグシンボルの埋め込みやコードの最適化以外でも、異なるビルド処理が行われることがあります。
わかりやすい例として、iOSアプリケーションが挙げられます。XcodeでiOSアプリケーションをビルドする場合、iOSの実行環境には、iOSシミュレーターとiOSデバイスの2種類があって、この両者は実行環境として大きく異なります。iOSシミュレーターはx86アーキテクチャの仮想マシンであり、実行可能なコードに制約はありません。一方、iOSデバイスはarm系のCPUアーキテクチャを前提としており、また動的なコード生成が禁止されています。また、かつてはデバイスへのデプロイメントには有償のiOS Developer Programへの参加が義務付けられていました。コードの実行はiOSシミュレーターのほうが圧倒的に速いですが、iOSシミュレーター上で動作するappパッケージをリリースすることは出来ないので(しても意味が無いので)、デバッグはiOSシミュレーター上で行い、リリース用ビルドはiOSデバイスで動作確認する、といった使い分けが想定されます。
Androidの場合、デバッグとリリースの必然的な違いはほぼ無いといえますが、先の表に照らして考えるなら、たとえばデバッグビルドでproguardを実行する意味はほぼありません。デバッグ用apkを使うのは当の開発者なので、難読化する意味は無く、配布するわけでもないapkのサイズを縮小する意味もほぼありません。
その他、特にAndroidのビルドに関して、デバッグとリリースで違いが生じうる点を、先ほどの(一般的なデバッグとリリースのビルドの違いに関する)表のようにまとめます。
Type | Debug | Release |
---|---|---|
署名 | 適当でOK | 厳密 |
補助外部ファイルや補助パッケージのインストール | OK | NG |
付加的なServiceや外部から操作できるProviderの追加 | OK | NG |
Androidフレームワーク上には、デバッガーと通信してアプリケーションの実行にフックを仕掛ける、adb
と呼ばれる仕組みが用意されています。Android StudioやEclipseは、Androidアプリケーションのデバッグを行う時、このadbを通じてアプリケーションを制御しています(当然ながら、セキュリティ上の懸念から、誰もがこのadbでtarget上のアプリケーションを自由に制御できるわけではなく、target側で明示的に開発者モードをonにした上で、ホスト側とUSBで接続するか、少なくとも、いったん接続した上でTCPフォワードを確立する必要があります)。ですので、通常のデバッグにおいては、付加的なセットアップが必要になることはありません。一方で、Instant Runでデバッグを行う際には、(後で説明しますが)自動的にいくつかの部品が追加されます。
冒頭でも言及しましたが、Androidアプリケーションを愚直にパッケージしてデプロイしてデバッグしていると、時間がかかりすぎてしまいます。Gradleビルドシステムのキャッシュシステムを活用しながら、なるべく無駄なビルドタスクの実行を抑え、迅速にデバッグが開始できるようになることが求められます。
Part II: Instant Runのビルドとデプロイメント
Instant Runの基本的な動作
さて、ここまでの説明で、いよいよInstant Runの基本を説明する準備が整いました。
Instant Runは、apkのアップロードおよびアプリケーションの再起動を可能な限り省略する、特殊なデバッグビルド機構です。リリースビルドには適用されません。
Instant Runが有効になっているビルドでは、apkの再インストールは「必要が無い限り」行われません。これは、開発者のtargetに、多少手を加えたアプリケーションと、補助ファイルをセットアップすることで実現しています。アプリケーションを初めてデバッグする時は、apkのインストールは当然に行われますが、2度目以降は、既にインストールされているapkを可能な限り使い回します。
一度ビルドされたapkは、apk自体に再インストールが必要となるような変更が加えられない限り、使い回されます。apkの再インストールが行われる条件がいくつかあるので、例を挙げましょう。
- AndroidManifest.xmlが変更された
- AndroidManifest.xmlで参照された文字列リソース(@string/app_nameなど)の値が変更された
- 新しくライブラリ参照を(dependenciesに)追加した
(ライブラリ参照がapk再インストールを引き起こすのは、そのaarに含まれるAndroidManifest.xmlが、manifest mergerに対する新しい入力となって、最終的に生成されるAndroidManifest.xmlに対する変更をトリガーするためです。)
これらは、頻繁に発生することではなく、通常はアプリケーションの再起動のみで足りることでしょう。
Instant Runは、アプリケーションの再インストールを省略できるだけでなく、条件が整えば、アプリケーションの再起動すら行わない__warm swap__、さらには 現在のActivityの再起動すら省略する__hot swap__、という実行モードも備えています(単純にアプリケーションを再起動するモードは__cold swap__と呼ばれています)。これらのモードの詳細および実現方法については、節を改めて触れることにして、ここでは主にapkのビルドをスキップする手法について解説していきます。
Instant Runを実現する「ランタイム」
動的なコードやリソースの置き換えを実現するApplication
apkを再インストールすること無く、更新されたアプリケーションが実行できるということは、すなわち、実行するアプリケーションの内容をapkの外部から変更できる、ということを意味します。
実のところ、Instant Runでは、アプリケーションがDalvikバイトコードを読み込むパスや、リソースを読み込むパスを、動的に置き換えています。これは、Applicationクラスの非パブリックメンバーをリフレクションで呼び出す、実装に強く依存した方式によって、実現しているはずです。この置き換え処理は、Gradleが、ユーザーが実際にAndroidManifest.xmlに指定したアプリケーションを、Instant Runのための独自のApplicationクラスに置き換えて、その中で上記のリフレクションによるインジェクションを行った上で、処理を本来のアプリケーションに委譲するように行われます。MonkeyPatcherというクラスが、これを実装しているはずです。
Gradle 2.0.0-alphaは本稿執筆時点でソースコード非公開であるため、直接的な根拠はありませんが、状況証拠としては以下の事実が挙げられます。
- BazelプロジェクトはAndroidのビルドをサポートしていますが、その中には「インクリメンタル インストール」という、Instant Runと基本的に同様の目的を実現している機能があり、ソース中にもStubApplicationというクラスが存在しています。この中に、ApplicationクラスやActivityThreadクラスのnon-publicなメンバーを呼び出しているコードが存在します。
- Bazelの
com.google.devtools.build.android.incrementaldeployment
パッケージと、Gradleのcom.android.tools.fd.runtime
パッケージは、含まれるクラスの内容がかなり似ています。(ついでに言えば、fd
という謎のacronymは、fast deploymentあるいはfastdevと呼ばれるものでしょう。)
- Bazelの
- Android Tools Project SiteにあるInstant Runのドキュメント ページには、"We have tested Instant Run on many devices, but it’s impossible to try it on every single device model. " という記述があります。これは、Androidのpublicでない部分の実装をカスタマイズしている端末がありうることから追加された記述である、と解釈することができます。
以上から、Instant Runが、割と危ういレベルで実装されている(らしい)ことが、見てとれたかと思います。Applicationの内部実装を動的に置き換えるなんて大丈夫なのか?…と思ってしまいますが、Instant Runは実際にこれで概ね問題なく動作しているようです。
Dalvikバイトコードの動的ロードを実現するIncrementalClassLoader
もうひとつ重要なのは、Dalvikバイトコードを動的に置き換えているIncrementalClassLoader
というクラスです。これはどのように行っているかというと、BaseDexClassLoaderというクラスの機能を応用して、動的に任意のdexファイルをロードできるクラスローダーを実装した上で、アプリケーションのデフォルトのClassLoaderが実装クラスを探索する際に使用する「親ClassLoader」を置き換える、というかたちで実現します(ここでも、ClassLoaderクラスのprivateフィールドに対して、リフレクションを活用しています)。ちなみに、BaseDexClassLoaderはAndroid API Level 14から存在していますが、Instant RunがサポートするAndroidのtargetもAndroid 4.0以降です。
ちなみに、これ以外にカスタム クラスローダーを活用している機構の一つに、multidexが挙げられるでしょう。multidexは、MultiDexApplicationというクラスを使用することで、複数dexに分散したコードを自動的に読み込めるようにセットアップします。Instant Runでも類似の操作が行われていると推測されます(Bazelはそうしています)。
置き換えられるDalvikバイトコードやリソースは、adbによって、アプリケーションのデータ ディレクトリ上にpushされます。Android Studio 2.0 beta3時点では、{ApplicationInfo#dataDir}/files/studio-fd/dex/slice-*/ 辺りに展開されるようです(Bazelの場合は、本稿執筆時点では、 /data/local/tmp 以下に独自のディレクトリを追加しているようですが、そうするとアプリケーションをアンインストールした後でもファイルが残ってしまうので、あまり喜ばしくありません)。adbを活用したファイルの追加push作業は、IDEでなくても、Gradleのビルドスクリプト単体で行えることなので、IDEはこの部分では何もしていない可能性が高いです。
実のところ、動的にアプリケーションのコードやリソースを置き換えるという機能は、標準的なAndroid開発環境ではなく、XamarinやTitanium Mobileなどでは実現していたものです。さらには、AndroidのJava開発環境においても、Nuwa、LayoutCastといったプロジェクトにおいて、断片的に実装されてきたとも言えます。
Dalvikバイトコードの分割コンパイル
さて、Dalvikバイトコードについては、もうひとつ言及しておくべき重要な要素があります。それは、従来のapkビルドとは異なり、Instant Runにおいては、dexファイルが分割コンパイルできるということです。
従来のビルド モデルでは、Javaバイトコードをdexに変換する工程では、全てのコードをまとめて dx
ツールに渡して行っていました。その結果、単一のclasses.dexが生成されて、アプリケーション実行時に一括して読み込まれたのです。
(Androidでは、一般的なJVM環境と比べて、動的なクラスのロードが困難です。ClassLoader
クラスのdefineClass
メソッドは実装されていません。なぜなら、AndroidにはJavaバイトコードを直接実行する機構は無いからです。全てがいったんDalvikバイトコードに変換される必要があります。そして、JVMはスタックマシン、Dalvikはレジスタマシンであり、根本的にアーキテクチャが異なるのです。Androidに用意されていたクラスローダーはDexClassLoader
のみでしたし、dexバイトコードを生成できるのは、比較的最近になってasmプロジェクトがasmdexを公開するまでは、dxツールのみであったと言ってよいでしょう。)
複数のdexファイルを読めるようにする試みは、Dalvikの最大65536メソッドという制限を突破するために発明されたmultidexの機構から発芽しました(さらなる先行技術があったかもしれませんが、技術を知らしめたのはmultidexでしょう)。multidexは、それでもdxに全てのJavaバイトコードを渡して、アプリケーションのブートストラップに必要なクラスを集める仕組みになっていますが、Instant Runにおいては、ClassLoaderを置き換える部分までを全て自前で用意してしまって、multidexのようなブートストラップ依存関係のスキャニングを伴うことなく、外部ライブラリなどを全て事前にdexに変換しておくことが可能になっています。
一般的なAndroidのビルド工程で、最も時間がかかるのはdexのコンパイルです。これは特にsupport-v*、play services、wearなど、依存ライブラリが増えれば増えるほど顕著な傾向になります。もしこれがキャッシュされて、一度だけコンパイルされるようになれば、かなりの時間短縮に繋がります。
さらに、そのdexファイルが、すでにInstant Runの仕組みによってtarget上にadb pushされていたら、再度アップロードしなくても良いのです。これも大幅なビルド速度の改善に繋がります。
その上、ランタイムがARTであるtarget環境においては、dexがロードされると、内部的にdex2oatが起動し、ネイティブコードへの変換処理が実行されます。これはdexがpushされるたびに行われるものです。adb pushがスキップされれば、これにかかる時間もありません。
アプリケーションのJavaバイトコードのみを変換すれば足りる世界では、dxによる変換ステップは、もはや重要な問題ではありません。
Instant Runのビルドでは、classes.dexがさらに「シャーディング」(sharding)という手法によって、適宜複数のdexファイルに分割されます。分割は自動的に行われ、既定値では最大10ファイルに分割されるようです。これによって、変更されないdexが増え、更新処理が短縮されます。(あまり小さい単位に分割して大量のファイルが生成されると、それはそれで効率が悪いので、10程度のファイルになっているのでしょう)。
もっとも、Android Studio 2.0 beta3時点での実装では、外部ライブラリ(aar)に含まれるjarは、全て1つにまとめられた上でdexに変換されているようです。これではせっかくdexを分割ロードできるメリットが失われているとも言えます(single dexの65kメソッド制約に引っかかりやすい、実行時に使わないクラスもロードすることになる、等)。この辺りは、今後の改善があることが期待されます。
ここまでの総括
ビルドシステムがキャッシュによって高速化を実現する、という前編の話を思い出してください。適切に設計されたキャッシュに基づくタスク構成は、ビルドを高速化するのです。そして、これは複数dexを読み込めるアプリケーション ランタイムの登場によって、初めて可能となったのです。ここまでの長いストーリーは、ここでようやく全て繋がるのです。
Part III: アプリケーションのインスタント実行
Instant Runのアドバンテージの大部分は、ビルドとデプロイメントの改良にある、というのが筆者の暗黙的な主張ですが、Instant Runのもうひとつの特徴が、実行中のアプリケーションのコードおよびデータの動的な置き換えです。「実行中」というのがここでの重要なキーワードで、これはつまり、アプリケーションを終了せずに、その内容を置き換えるということを意味しています。
この部分は、インクリメンタルなビルドとデプロイメントを実装していたBazelなどには含まれていない、新しい機能です。Bazelはオープンソースで開発されており、そのソースコードには、インクリメンタルデプロイメントの実装に必要なアプリケーション ランタイムのソースも含まれていました(前述のStubApplication.java)。一方、Instant Runについては、Bazelに対応するコードはありません。
Gradleのソースコードはbintray(Gradle Android pluginのデフォルトmavenリポジトリ)から取得可能ではあるものの、アプリケーション ランタイムはinstant-run.jar
という、コンパイル済みバイナリとなっており、動作の詳細は本稿執筆時点ではわかりません(クラス構造は前節で説明した程度には推測可能です)。
Instant Runのランタイムのコード自体は公開されていませんが、既存のドキュメントといくつかの状況証拠から、ある程度は実装を類推することができます。
- Instant Runは、Android 4.0以降でサポートされています。これはつまり、ARTなど新しいAndroidフレームワークの機能に依存していないことを意味しています。Android 4.0が必要なのは、アプリケーション ランタイムがBaseDexClassLoaderに依存しているためでしょう。
- Gradleのソース上には、Instant Runをサポートするための実装が含まれており、たとえばcom.android.build.gradle.internal.Redirection.javaには、メソッドのコードのポインタを置き換えて実行内容をリダイレクト出来そうな様子が見られます。
- Instant Runモードでデバッグを実行すると、targetに
\{android.app.ApplicationInfo.dataDir\}/files/studio-fd/
というディレクトリが作成され、dex/slice-\*/
ディレクトリに\*.dex
ファイルがコピーされ、left
,right
といったディレクトリにresources.ap_
がコピーされます。これらのファイルは前節で説明した通り、更新されたapkの内容ということになるでしょうが、さらにどのような内容のdexやリソース アーカイブがコピーされているのか、ここから見ることができるでしょう。
4つのアプリケーション書き換えモード
Instant Runに関する公式ドキュメントでは、以下の4種類の更新モードについて説明しています。
- hot swap - アプリケーションの実行状態がそのままで、実行されるコードが書き換えられます
- warm swap - 実行中のアプリケーション プロセスを維持しますが、Activityは再起動されます
- cold swap - アプリケーションの再起動が必要になります
- reinstall - アプリケーションの再インストールが必要になります
これまで、すなわちInstant Run以前が有効でない場合、アプリケーションを更新したら、常に4.の状態でした。Instant Runが有効になっていても、AndroidManifest.xmlや、そこから参照するリソースに何らかの変更があった場合は、apkの再インストールが行われます。
ただし、注意すべきことですが、Android Studioのデフォルト テンプレートでアプリケーションを作成すると、AndroidManifest.xmlはvalues/strings.xmlに含まれるapp_nameを参照してアプリケーション名を取得するため、これに変更を加えると、それだけでapk再インストールの発生条件となってしまい、Instant Runが機能しないことになってしまいます。AndroidManifest.xmlからは、頻繁に更新するようなリソースを参照するのは回避したほうが良いでしょう。
cold swapは、具体的には、dexファイルの置き換えが必要になるような変更が加えられた場合の処理で、アプリケーションは再起動することになります。アプリケーションの再インストールまでは行われません。dexファイルは、(デバッグ対象アプリケーションのデータ領域に直接アップロードされ、Instant Runではそれが実行されます。)
既にビルドされたJavaクラスの中で、オーバーライド メンバーが追加されたり、メソッドのシグネチャーが変更されたり、といった変更が生じた場合に必要になります。これは、一般的なオブジェクト指向の仮想マシンにおいては、クラスをロードすると、その時点でvirtualメソッドのオーバーライドの有無をチェックして、ランタイム内部のクラス情報におけるvirtual method tableの内容を調整するようなことが行われるため、いったんロードしたクラスのメンバーを後から追加ないし削除するのは困難である、といった事情によると考えられます。Oracleのhotspotも同様の制約をかかえています。
warm swapは、アプリケーション自体を再起動する必要まではないが、現在実行しているActivityの再起動は必要になる、という性質の処理で、具体的には文字列リソースに変更が加えられた場合にトリガーされます。文字列リソースを参照する各種XMLリソースの読み込みは(大概は)Activity#onCreate()などのタイミングで、ユーザーコードによって呼び出されるもので、いったん読み込まれたリソースは全てロードされてUIに反映されてしまうため、変更を反映するには同じロード部分を最初からやり直すしか無い、という事情によるものでしょう。
hot swapは、概ね、メソッドの実装内容を書き換えた場合に行われるものです。これを実現するために、Instant Runは、Gradle Plugin 1.5で追加された、バイトコード操作の基盤となるTransform APIを使用しています。ここで、メソッド実装が置き換えられた場合に、その実装内容をディスパッチできるようなコードを、ユーザーコードの周辺に追加しているということでしょう。
hot swapは、Visual StudioにおけるEdit and Continueに限りなく近い機能と言えますし、その制約も似ていますが、Edit and Continueでは、生成されるコードはローカル ネイティブでそのまま実行できるものであり、メソッドポインタを置き換える程度で足りる(そのため、ビルド環境と異なる実行環境をターゲットとするプロジェクトでEdit and Continueが適用できることは無い。Silverlightなど)のに対して、Instant Runの場合は、ホストとtargetが異なるため、そこまで直感的な実装はできません。おそらく、cold swapレベルのビルドが行われた際に、Transform APIを使用してメソッドの実装をinjectして、後からメソッドのディスパッチが可能になるような細工をしておいて、hot swap可能な変更が加えられた場合は、新しいdexをビルドしアップロードして、既存のアプリケーションが、新しいほうのdexに含まれるメソッドにディスパッチできるようにしている、と筆者は想像しています。この辺りの挙動は、Gradleのソースから推測可能かもしれません。
(.dexファイルはいったん削除しないと更新できないので、おそらくアクティブなdexとの「入れ替え」が発生していると筆者は推測しています。Android Studio 2.0 beta3の時点では、リソースについては"left" "right"という2つのディレクトリが生成されて、更新されたresources-debug.ap_などがその両者を行ったり来たりするような挙動になっているのを観測しました。)
なお、Gradleのソース上には、ビルドされたアプリケーションのコードのどの部分が変更されたかを検出する実装が含まれています(com.android.build.gradle.internal.InstantRunVerifierなど)。アプリケーションのコードが膨大な場合は、この検出処理も無視できない負荷になるのではないかと思います。筆者が厳密に計測した数値はありませんが、「hot swapなら1秒とかからないこともある」かのように言われていても、実際にこの辺りの処理も含めた上での処理時間は、体感的にはもう少しかかっているように思えます。
今後のAndroid動的コーディング環境の期待と展望
AppleのBret Victorは、2012年にCUSEC 2012というカンファレンスでInventing on Principleと題する講演を行いました。そこでは、実行中のアプリケーションのプログラムやデータを書き換えて、変更された処理をその場で見ることが出来るような、ソフトウェアのアイディアが、いくつも提示されていました。
数週間後、XamarinのEric Maupinはこの動画を見て感銘を受け、Visual Studio上でC#で書かれたコードを、実行環境上でそのまま動的に変更できるようなコーディング環境を試作し、Making Instant C# Viable – Part 1と題する記事を、自身のブログに投稿し、Instantという名前でこれを公開しました。これは、技術的には、当時プレビュー版のみ公開されていたMicrosoftのRoslynに含まれていたC# Scripting engine(いわゆるREPLのようなもの)を使用したものです。
Bret Victorのアイディアはその後Xcode Playgroundというかたちで、Eric MaupinのアイディアもXamarin Sketchesというかたちで、それぞれ実現しています。Instant Runという名称のもとになった __Instant__という単語も、この辺りの技術革新から登場したものではないでしょうか。
この方面で筆者が現在最も注目しているのが、Xamarinのコミュニティ ハッカーFrank Kruegerが開発しているContinuous Codingというプロジェクトです。これは、MonoのCSharp REPLの機能を使用して、アプリケーション側は変更コードを受け付けて反映するサーバを起動しておき、IDE側ではアプリケーションのコードを動的にコンパイルして、その場でtargetに送信して協調実行する、というものです。PlaygroundやSketchesがインスタント コードをフルスクラッチで書く必要があるのに比べて、こちらは既存のアプリケーションのコードから実行できます。
これはIDEアドインを必要とするので、Gradleだけで完結している(と思われる)Instant Runの設計思想とは相容れないものがあるかもしれませんが、Instant Runのような迂遠な方法で差分を検出し送信する方式に比べて、とても直感的です。もちろん、出来ることには限りがあるでしょう。しかしhot swap相当の範囲でも十分に期待される機能と言えるでしょう。
Android Studioで、このような機能をJavaベースで実装できるリソースがあるかは筆者の知識の範囲を超えていますが、将来的には、Android Studioにも、これらのような機能が実装されると、気軽にコードを試行錯誤できるようになり、作業がより楽しくなるのではないか、と思っています。