kokoro.ioアプリが引っかかったXamarin.Formsの罠を振り返る (後編)

by pgrho 2017-12-14 00:00

前回はXamarin.Formsのすばらしさについて説明しましたので、後編ではXamarin.Formsを支えているかもしてないサードパーティライブラリについて触れていくことにしましょう。つまり蛇足です。

MediaPicker

kokoro-io-appの開発に着手した理由の一つに Web版に画像アップロード機能がないという不満がありました。クライアントを自分で作れば自分自身のニーズは好きなだけ満たせますからね。ともあれこのようなニーズに対してモバイルアプリケーションプラットフォームは Document interaction (iOSの用語)と呼ばれる仕組みを提供しています。実現方法はともかく、アプリケーションが特定の種類のリソースを要求すると他のアプリケーションが自身の保有するコンテントを選択するUIを表示し、元のアプリケーションに一時的なアクセス権を付与したうえで処理を戻すものです。Xmarin.FormsはUIしか提供してくれないのでアプリではXLabsというライブラリを採用することにしました。XLabsはNuGetのダウンロード数から判断するとXamarin.Formsユーザーの数割が使用していたと考えられる人気ライブラリです。現在はメンテナンスが停止していますが、他の選択肢もないためこれを利用することにしました。

さてXLabsは画像を選択するためのインタラクションをIMediaPickerインターフェイスで提供しています。プラットフォーム別のインスタンスをDependencyServiceから解決し、画像または動画をストレージやカメラから得るための都合4種類のメソッドを使い分けます。

var mp = MediaPicker;

if (mp != null)
{

    try
    {
        var mf = await mp.SelectPhotoAsync(new CameraMediaStorageOptions() { });

        string url;
        using (var ms = mf.Source)
        {
            url = await uploader.UploadAsync(ms, Path.GetFileName(mf.Path));
        }
        App.Current.Properties[nameof(GetSelectedUploader)] = uploader.GetType().FullName;

        await App.Current.SavePropertiesAsync();

        callback(url);
    }
    catch (Exception ex)
    {
        Debug.WriteLine(ex);
        // TODO: alerr
    }
}

しかし上記の実装をAndroid版テスターたちに流したところ(MediaPickerはUWPには提供されていません...)、人によって動かないという結果に終わりました。調べるとGoogle Photos上のファイルを選択した場合に発生しているようでしたが、私はPhotosのストレージを利用していなかったため長らく調査せずに放置していたのですが、結論から申し上げるとXLabsの実装が極めて酷かったのです。

コミットが分割されていないため分かりにくいですが、上記はXLabsのソースをコピーしたうえで修正しているものです。オリジナルに存在した選択ファイルを一時ファイルに保存する部分のコメントアウトが解除されています。しかしXamarinに限らないAndroid SDKの情報ではPICKインテントの結果選択されたファイルはContentResolverによって解決するということになっているのに、なぜこちらが後回しにされたうえ削除されてしまったのでしょうか。

protected override void OnActivityResult(int requestCode, Result resultCode, Intent data)
{
    base.OnActivityResult(requestCode, resultCode, data);

    if (_tasked)
    {
        var future = resultCode == Result.Canceled
            ? TaskUtils.TaskFromResult(new MediaPickedEventArgs(requestCode, true))
            : GetMediaFileAsync(this, requestCode, _action, _isPhoto, ref _path, (data != null) ? data.Data : null);

        Finish();

        future.ContinueWith(t => RaiseOnMediaPicked(t.Result));
    }

その理由はMediaPickerのエントリポイントであるOnActivityResultにありました。上記ifブロックのコードは要するに

  1. GetMediaFileAsync()
  2. Finish()
  3. future.ContinueWith()

の3段階ですが、GetMediaFileAsync()は名前の通り非同期実行されます。その完了前にFinish()をコールすればPhotosから一時的に付与されていた読み取り権限が取り消されるので、後からContentResolverを使用しても動かなかったためにコメントアウトされているのでしょう。なのでわざわざ外部ストレージの読み取り権限をアプリケーションに付与させて、ローカルファイルパスのフィールドを読み取っているのです。こんな情けない実装のライブラリを使い続けるべきか真剣に悩むところです。

Droid.MediaPickerには(すくなくとも)もう1個お笑い不具合があります。前述のコメントアウト範囲にはGetOutputMediaFile(context, "temp", null, isPhoto)という"temp"フォルダにファイル名未指定(null)で一時ファイルを作成する処理があります。このメソッドの実装を見てみましょう。

private static Uri GetOutputMediaFile(Context context, string subdir, string name, bool isPhoto)
{
    subdir = subdir ?? String.Empty;

    if (String.IsNullOrWhiteSpace(name))
    {
        name = MediaFileHelpers.GetMediaFileWithPath(isPhoto, subdir, string.Empty, name);
    }

    var mediaType = (isPhoto) ? Environment.DirectoryPictures : Environment.DirectoryMovies;
    using (var mediaStorageDir = new Java.IO.File(context.GetExternalFilesDir(mediaType), subdir))
    {
        if (!mediaStorageDir.Exists())
        {
            if (!mediaStorageDir.Mkdirs())
                throw new IOException("Couldn't create directory, have you added the WRITE_EXTERNAL_STORAGE permission?");

            // Ensure this media doesn't show up in gallery apps
            using (var nomedia = new Java.IO.File(mediaStorageDir, ".nomedia"))
                nomedia.CreateNewFile();
        }

        return Uri.FromFile(new Java.IO.File(MediaFileHelpers.GetUniqueMediaFileWithPath(isPhoto, mediaStorageDir.Path, name, File.Exists)));
    }
}

ここから更にネストしているメソッドには触れませんが、subdirパラメーターがnameの生成に使用されたあと、フォルダーの取得にも使用されていることが分かります。結果得られるパスは/temp/temp/を含むものになり、ディレクトリの作成に失敗します。

コメントアウトされている部分を穿り出して文句を言うのも何ですが、動作確認をしたら絶対に気づくのでは。

UWPアクティベーション

前述のとおりXLabsはUWPに対してMediaPickerを提供していません。別に作る気になれば100行ぐらいでできるのでUWPのローンチ時期の問題なのでしょう。ですのでUWPプラットフォームに対してはプッシュ型のファイルアップロードを機能を追加することにしました。コンテント共有と拡張子に対する関連付けです。UWPでは前述の2個を含めた様々な種類のパラメーター付きのエントリポイントが定義されており、たとえばOnFileActivatedというようなメソッドで拡張子を処理することができます。複数のエントリポイントを横断的に処理したい場合はOnActivated

-App.OnActivated

と引数の型を判断してやればよいです。上の実装で処理しているのはトーストのコールバックですが。

さてファイル共有のエントリポイントはWindowsアプリ開発に詳しい人なら想像ができるかと思いますが、STAスレッド上で動作します。詳しくない人がSTAスレッドを知っているのかは知りません。大雑把に言うと通常のMTAスレッドより分離度が高く、COMコンポーネントの使用などが許可されるリッチなモードです。WindowsアプリはMTAスレッド上でも一応動作しますが、そんなのじゃクリップボードにアクセスできません。[STAThread]属性は飾りではありません。

話が意図的にそれましたが、Windowsアプリでは主たるSTAスレッド(=UIスレッド)でメッセージループが動作しており、UIに関わる操作は必ずメインスレッドで行うという制限が課せられます。クロススレッドアクセスの許容範囲はさておきこの制限はOSに関わらずUIフレームワークでは一般的です。そしてWindows PhoneやWinRTといった愚劣な、もとい制約の厳しい環境で動作するUWPではクロススレッドアクセスの検知が厳しく、呼び出した時点で例外が発生します。

アプリ固有の要件に立ち返るとファイル共有にせよ拡張子にせよ、kokoro.ioクライアントは受け取ったファイルをアップロードするところまでは自動で行えますが、どのチャンネルにどのようなメッセージと共に投稿するのかは判断できません。ですのでまずチャンネルを選択させ、その後メッセージ一覧を表示する必要があります。つまり通常とは若干異なるフローでXamarin.Formsアプリを初期化すればよいわけです。

しかし上掲リンクをたどればわかりますが、OnSharedTargetActivatedメソッドは現状すべてコメントアウトされています。これはなぜかというと、単体起動以外の方法でアクティベートされたXamarin.Formsから非同期処理を行った場合、継続処理のスレッドがUIスレッドではない方のSTAスレッドで実行される問題が起きたからです。このようなSTA間のスレッドウォークを行うとUIが不安定になり、ボタンを操作したタイミングで異常終了したりしていました。

……と長々と書いていましたが、問題の処理を現行バージョンで動かしても落ちないですね…。Xamarin.Forms2.4かFall Creators Updateの効果だと思います…。なので気にせずUWPで共有しまくるといいと思いますよ!!


以上、三週にわたってXamarin.Forms初心者が見たクロスプラットフォームの実際を紹介してきました。(中編以外の内容は薄いですが)

個人的にはXamarin.Formsのアーキテクチャが致命的に失敗しているとは思っていません。適切に実装さえすればネイティブアプリと遜色のないアウトプットが1環境分の労力で実現できるようになると思います。そのためには仮想DOMレベルでの表現力の低下も甘受できますが、実際の標準プラットフォーム別コンポーネントが吐き出すコントロールはXamarin.Forms要素の非常に乏しい項目すらきちんとマッピングしているとは言い難いです。またビュー以外の側面では標準ライブラリはほとんどサポートしてくれないので、本体よりもさらに品質が怪しくなるサードパーティライブラリを使用するか、自分で複数環境分の実装を行う羽目になります。

個人的には.NET Frameworkエコシステムの魅力はMicrosoft寡占によって生まれた統一感とクオリティ保証だと思っています。今C#がWindows環境以外でも受け入れられてはじめているのは間違いなくMono=Xamarinの功績なのですが、現状のXamarin製品にMicrosoftのラベルをつけて出荷してよいかは非常に疑問です。
昔の人は「Microsoft製品はバージョン3まで待て」としたり顔で言ったものですが、Xamarin.Formsも次期メジャーアップデートで生まれ変わってくれると楽になるのですが。