Android Architecture Components for XamarinのProof of Concept実装

表題のようなものをproof of conceptとして作成したので出しました。

www.nuget.org

実質2日程度の成果で、基本的にはまだ使い物になりませんが、Lifecycleの雰囲気を味わうことくらいはできます。あ、Roomのサポートは実装していません。ORMはJava方面でも他にいろいろあるので(いやRoomはORMじゃないけど)、その辺を眺めつつ、Roomは後回しでいいやと思っています。

Android Architecture Components(以下AAC)は、Google I/O 2017で公表された新しいライブラリで、いずれサポートライブラリにも統合されるはずのものです。ここではAACがどんなものであるかについての説明はしません(既にさまざまな 解説が 出ているので)。

ただ、単なるAndroidライブラリであればバインディングを用意するだけなので瞬殺なのですが、AACにはapt(annotation processor)に依存する部分があるので、そこを何とかして代替しなければいけませんでした。実際にわたしが作ったのは以下の2点です。

ライブラリのバインディング

基本的には普通のAndroid Bindingライブラリです。

ここでも書かれていますが、android.arch.lifecycle_runtime.aar中にAndroidManifest.xmlが含まれており、その中でカスタムproviderが定義されている仕組みです。これを参照しているアプリケーションではmanifest mergerが必要な要素を追加してくれます。(Xamarin.Androidには簡易manifest mergerが実装されています。)

java.lang.Enumを使っている部分については、C#でAttributeとして指定できないため、JavaEnumに対応するC#Enumを定義し、それに対応するimplicit operatorと、enumをパラメータとするAttributeのコンストラクタを定義すればOK…と思っていましたが、実際にはenumにすることは出来ませんでした。enumにすると、後でMSBuildタスクの中でcecilで値を取り出すときに、そのenum型がデスクトップ上でインスタンス化出来なければならないところ、モバイルプロファイル上でビルドしてしまったenumを復元することはできなかったためです。仕方ないのでenumはあきらめてintにしています。早いとこenumは滅ぼして@IntDefに相当するIntDefAttributeに移行したい(ま、Googleがまともなannotations.zipを提供していないので難しいですが)。

aptツールの代替手段

基本的には、NuGetパッケージを参照するかたちにしておけば、このライブラリを参照しているアプリケーション プロジェクトのビルド時に、NuGetパッケージに含まれるMSBuild targetsが自動的に追加されるので、BeforeTargets/AfterTargetsを利用してビルドに追加タスクを差し込むことが出来ます。Xamarin.FormsのXamlCタスクがコレで出来ているはず。

LifecycleAdapterの自動生成

Lifecycleについては、以下のようなコードに対して:

class TestObserver : Java.Lang.Object, ILifecycleObserver
{
    [OnLifecycleEvent (OnLifecycleEvent.OnAny)]
    public void OnAny (ILifecycleOwner owner, Lifecycle.Event evt)
    {
        Console.WriteLine ("OnAny invoked.");
    }
    [OnLifecycleEvent (OnLifecycleEvent.OnStop)]
    public void OnStopped ()
    {
        Console.WriteLine ("OnStopped invoked.");
    }
    [OnLifecycleEvent (OnLifecycleEvent.OnStart)]
    public void OnStarted ()
    {
        Console.WriteLine ("OnStarted invoked.");
    }
    [OnLifecycleEvent (OnLifecycleEvent.OnStart | OnLifecycleEvent.OnStop)]
    public void OnStartedStopped ()
    {
        Console.WriteLine ("OnStartedStopped invoked.");
    }
}

以下のようなコードが自動生成されてビルドされる必要があります:

class TestObserver_LifecycleAdapter : Java.Lang.Object, IGenericLifecycleObserver
{
    readonly TestObserver mReceiver;

    public TestObserver_LifecycleAdapter (TestObserver receiver)
    {
        this.mReceiver = receiver;
    }

    public Java.Lang.Object Receiver => mReceiver;

    public void OnStateChanged(ILifecycleOwner owner, Lifecycle.Event evt)
    {
        mReceiver.OnAny(owner, evt);

        if (evt == Lifecycle.Event.OnStart)
        {
            mReceiver.OnStarted();
        }
        if (evt == Lifecycle.Event.OnStart)
        {
            mReceiver.OnStartedStopped ();
        }
        if (evt == Lifecycle.Event.OnStop)
        {
            mReceiver.OnStartedStopped ();
        }
        if (evt == Lifecycle.Event.OnStop)
        {
            mReceiver.OnStopped ();
        }
    }
}

やっていることは、ILifecycleObserverを実装するクラスを全て洗い出して、そのLifecycleAdapterを定義して、その中のOnStateChangedの中で、関連属性の付いているものを呼び出すようにコードを生成するだけです(と、理解していますが、もしほかにもやるべきことがあったら教えてください)。

どこでLifecycleAdapterを自動生成すべきか

やり方はいくつか考えられます:

(1) コンパイル前にソースコードを自動生成し、まとめてビルドする

Roslynを使ってプロジェクトを解析するやり方。これはどの時点でタスクを挟み込めるのか見極めが必要になるし、セマンティック分析まで行ってからコード生成しないといけないので、それなりに重いです(ビルドを二重に行っているような状態になるので)。

(2) コンパイル後にバイナリを解析しソースを自動生成し、別途ビルドする

Csc後に出力アセンブリをロードして、バイナリを解析し、コードを生成した後、別のコードファイルを生成し、参照を追加した上で別のアセンブリコンパイルします。実装は比較的素直になりますが、コンパイルは(生成コードは小さいとはいえ)二度発生します(キャッシュしておけば回避できるでしょう)。また、アプリケーション上のinternalクラスもきちんとサポートするためにはInternalsVisibleToAttributeを追加してビルドすることになるでしょう。そして、GenerateJavaStubsタスクがこの後に実行される必要があるので、順序を気にしなければなりません。

(3) 実行時にコードをクラスを生成する方式。Reflection.EmitやExpression Treeやらを駆使して動的にこのクラスを生成するアプローチですが、これはAndroid Architecture Componentsと組み合わせることは出来ません。なぜなら、Architecture ComponentsのLifecycleRuntimeTrojanProviderはJavaのLifecycleObserverインターフェースの実装(か何か)を探索して、それをフレームワーク側から呼び出しているため、Java Callable Wrapperを生成する必要があるからです(これはXamarin.Androidのドキュメントで「出来ない」と明記されている制限事項)。またAOTビルドと相性が悪いのもあまり好ましくないでしょう。

以上のように考えて、結局(2)を採用したわけですが、Xamarin.Androidチームが愚劣な設計を行っていて、生成されるJavaコードのパッケージ名はmd5でmanglingされてしまう問題があって、これがあるせいで、別のアセンブリにあるクラスは同じネームスペースに含まれていてもJava上では違うパッケージになってしまいます。これはAndroid Architecture Componentsの実行時型処理を根本的に台無しにしてしまうし、Javaパッケージ名をC#から推測できるようにする類のライブラリにとって害悪でしか無いので、生まれる前に消し去る必要があります。いずれにせよ、現状ではC#で書いているすべての型について、[Register(javapackagename.ClassName)]を追加する必要があります。

ちなみに、なんでそんなmd5hashを使うような設計になっているかというと、Javaアプリと違って.NETでは1つのプログラム(エントリポイントから参照・実行される複数のアセンブリということにしておいて)で複数のアセンブリが同じ名前空間と同じ名前の型をもつことが許されるので、JCWでは識別性のある名前にしないとサポートできない…という理由によります。そんなのサポートしなければいいんですが。library package developersを救ってextensibility developersとend user developersに迷惑をかける仕様じゃ意味ないし。

特に最後の問題は、Xamarin.Androidのまともなユーザーであれば絶対に採用し得ないレベルのものなので、コレはproof of conceptです。(1)にシフトすれば解決する問題ですが、md5 manglingは滅ぶべきなので、(2)で通したいところです。パフォーマンスも全然違うだろうし。

あと、MSBuildのAfterTargetsなどで指定している対象が、(暗黙的に)パブリックなターゲットではないので(名前が_で始まっている)、Xamarinが内部実装を書き換えたら動かなくなる可能性があります。この辺はpublicなMSBuildプロパティとしてGenerateJavaStubsDependsOnとかXamarin.Androidの中に作ってしまえば良いことです(まあ開発者だから言えることですが)。

そんなわけでいろいろalpha qualityというよりproof of conceptですが、試したい方はどうぞ。

補遺: パッケージング

NuGetパッケージング プロジェクトはなぜかビルド時にNullReferenceExceptionを発生させていたのと、MSBuild targetsをうまく扱えなかったのとあって、今回は使いませんでした。