Xamarin.Android で View を非同期で Inflate するための簡潔な方法(あるいはダイアログのせいで起動が遅い時の解決法)


挨拶

こんにちは。Xamarin + Visual Studio + C# で賢いお絵描きアプリを作ろうとして苦戦している shiatsumat です。

カスタムダイアログのせいで起動が遅い

前々回の記事と同じ挨拶で始めましたが、あの記事で作ったカスタムダイアログが問題なのです。あれのせいで起動が遅い。実は Android の Inflate は結構遅くて、二重に Inflate をしているあのダイアログはもろにその影響を受けているというわけです。

では AXML を諦めてプログラムで記述しようかとも考えましたが、程なくしてこれは人間のする作業ではないと思ってやめました。

疲れたのでアプリを10か国語に対応させるなどしていたのですが、よく考えると起動時にダイアログを完成させる必要はなく、ダイアログを実際に開くまでにバックグラウンドで Inflate させれば良いと今日気づきました。

同期処理は大変そうだなとも思いましたが、C# には async と await という便利な同期機能があります(使ったことはありませんでしたが)。きっと簡単に出来るのでしょう。ということでやってみました。

見よう見まねで非同期処理を書いてみたらビルドが通りました。デバッグしてみると起動が実に早い。ダイアログを開くために安心してボタンをタップしました。すると、出たのがこのエラーです。

Java.Lang.RuntimeException: Can't create handler inside thread that has not called Looper.prepare()

こういうよく分からない例外が出るから非同期処理は嫌ですね。また諦めかけたのですが、なんとか粘って解決できました。解決法は次の通りです。

ダイアログは UI スレッドでしか Create しない

つまり、ダイアログの Create は非同期におこなってはならないのです。ダイアログの Create だけはボタンを押す直前に行うことにしましたが、Create は一瞬で終わるので、依然として動作は軽いです。大成功です。

具体的な方法

これで終わっても良いのですが、C# による非同期は意外にわかりにくいので、コードの例を書いておきます。

まず、view を初期化する関数を作りましょう。

View InitializeView()
{
    var view = myActivity.LayoutInflater.Inflate(Resource.Layout.MyDialog, null);
    var myButton = view.FindViewById<Button>(Resource.Id.MyButton);
    // ...
    //(こんな調子で様々な初期設定を行う)
    // ...
    return view;
}

返り値を void ではなく View にするのがポイントです。
そしてクラスのメンバ変数あたりで Task<View> 型のデータと AlertDialog 型のデータを保持しましょう。

Task<View> viewTask;
AlertDialog dialog;

さて、ここからです。起動時(Activity の OnCreate など)に

viewTask = Task.Run<View>(InitializeView);

と書きます(InitializeView の部分はラムダ式にしても OK です)。ここから別スレッドでの実行がスタートします。
viewTask は別スレッドで行われている InitializeView の実行の進み具合を UI スレッドに伝える役割をしていると思ってください。

ボタンを押す段階になったら viewTask のタスクが終わるのを待つ必要があります。

var view = await viewTask;

と書くと、viewTask の実行が終わるのを待ち、View 型の実行結果を得ることができます。ただし、await を使えるのは async 修飾子のついた関数だけです。また async な関数を実行できるのも async な関数内だけです。

async というと何やら怪しい響きですが、騙されたと思ってラムダ式に async を付けたら動きます。次のような処理を UI スレッド(Activity の OnCreate あたり)で行いましょう。

dialogShowingButton.Click += async (o, e) => {
    var view = await viewTask;
    if (dialog == null)
        dialog = new AlertDialog.Builder(context)
            .SetTitle(title)
            .SetView(view)
            .SetPositiveButton(...)
            .SetNegativeButton(...)
            .Create();
    dialog.Show();
};

async な関数でもイベントに登録できるんですね。ここで一番外側の関数を async にする必要はありません。つまり最終的に async な振る舞いをイベントが吸収してくれるという仕組みになっています。すごい。

まとめ

  • Android の Inflate は遅い。しかし非同期という道がある。
  • Java.Lang.RuntimeException: Can't create handler inside thread that has not called Looper.prepare() にひるまない。別スレッドでは禁止されている操作がある。dialog の Create() は UI スレッドで行うこと。
  • task = Task.Run<T>(function); で別スレッドでの実行がスタート。task の型は Task<T> になる。
  • T result = await task; で結果をゲット。await 文は async な関数内で行う必要があるが、イベントが async を吸収してくれるので大丈夫。

改めて見てみると簡単な話ですが意外にネットにまとまった情報がないので記事を書いてみました。参考になればうれしいです。今制作中の tegaki hack の方もよろしくお願いします。以上、読んでくださりありがとうございました。