ボイラーのチラシ裏

テクノロジーやフィギュア、ゲームに関すること

.NETコードからCuPyを使用できるラッパーライブラリを作った

こんばんは。

まえおき

私には、AI開発やってみたいなぁというささやかな望みがあるのですが、AI開発のためにPythonの軍門に下るのは何か違う気がして。

で、世の中には.NETコードからNumpy等のAI開発でよく利用されているライブラリを作って、利用できるようにしている謎?の団体「SciSharp」がいらっしゃる様子。

NuGet Gallery | SciSharp

SciSharpが作ったTensorFlow.NETを利用するのも悪くなかったと思うのですが、何せまだまだ私自身の理解が浅いので、書籍「ゼロから作るディープラーニング」シリーズの著者の斎藤 康毅(さいとう こうき)さんが開発したDeZeroを見よう見真似で.NETに移植してみようというのが私の野望です。

で、「ゼロから作るディープラーニング③ フレームワーク編」を読んでみますと、ステップ52 GPU対応という節があります。

えっ、NumpyってGPU対応してないの?と思った方、安心してください。私も最初はそう思いました。

Numpyの互換でGPUに対応しているライブラリを、株式会社Preferred Networks さんが作っています。

CuPy(クーパイ)というライブラリです。

www.preferred.jp

そして、話は戻りますが、Numpy.NETのIssuesにこんなIssueが立てられていました。

github.com

「Is there a way to support CuPy too? Their Python call APIs seem to be the same.」 (CuPyもサポートする方法はありますか?Pythonの呼び出しAPIは同じようです。)

「I am not gonna be able to do it for lack of time. Sorry, but if you are interested you could look at how I did it with Numpy and do the same.」 (時間がなくてできないんだ。申し訳ないが、もし興味があれば、僕がNumpyでどのようにやったか見て、同じようにやってみるといい。)

と言っていたので、まずはNumpy.NETを真似て、CuPy.NETという.NETライブラリを作ってみようということになりました。それが、去年2023年2月頃。

私のデスクトップPCに搭載されているGPUGeForce GTX 1080 でもはや化石です。最初はAWSGPU付きWindowsインスタンスを借りようということになりました。

いざ借りてみると月額2000~3000円かかります。ケチな私はAWSインスタンスを使うのを止め、myデスクトップPCでがんばることにします。

そして、月日は経ち、先日CuPy.NETのv2.1.0をリリースできました。

www.nuget.org

(まえおき長いよ!)

今週のお題

さて、CuPy.NETを作ったので、これを使って、Numpy互換のメソッドを触ってみようと思います。

今回のサンプルコードはこちら↓

github.com

サンプルコンソールアプリケーションプロジェクトを作り、NuGetでCuPy.NETをインストールします。

そして、Program.csに書くコードは以下の通り。

using Cupy;
using Python.Runtime;

Runtime.PythonDLL = @"C:\Users\boiler\AppData\Local\Programs\Python\Python311\python311.dll";
PythonEngine.Initialize();

// before starting the measurement, let us call CuPy once to get the setup checks done. 
cp.arange(1);

var a1 = cp.arange(60000).reshape(300, 200);
var a2 = cp.arange(80000).reshape(200, 400);

var result = cp.matmul(a1, a2);

Console.WriteLine(result.repr);

2つのNDarrayから行列積をとった結果をコンソールに表示するだけのサンプルです。

これを実行するとこのようになります。

サンプルコンソールアプリケーションを実行した結果

これを見ると、とりあえずNDarrayの各要素がきれいに並べて出力できていることがわかります。

このきれいに並べて出力するのに1年かかってしまったのです。。。自分って凡人だなぁ。。。

まあまあ、とりあえず第一歩は踏み出せたのではないでしょうか?

そう自分に言い聞かせることにします。

サンプルコードの解説

Runtime.PythonDLL = @"C:\Users\boiler\AppData\Local\Programs\Python\Python311\python311.dll";
PythonEngine.Initialize();

CuPy.NETが利用しているPythonnetライブラリを使う時は、Runtime.PythonDLLプロパティに端末にインストールされているPythonのDLLへのフルパスを指定してあげる必要があります。(あっ、もしかしたら環境変数PATHを通しておけば、フルパスでなくても大丈夫かも?未確認です。) その後の、PythonEngine.Initialize()を実行して文字通りPythonエンジンを初期化します。これで準備OKと言いたいところですが、CuPy.NETを使用するにも制約があります。

詳しくはCuPy.NETのREADMEをご覧いただきたいのですが、ハードウェアについてはもちろんNVIDIA GPUは必要ですね。そしてソフトウェアにもVisual Studio 2022、CUDA Toolkit 12.3、cuDNNのインストールが必要となっています。

github.com

このあたりの解説はまた別の機会にすることにしましょう。

次回予告

ゼロから作るディープラーニング③ ステップ52 GPU対応を読んでみると CPU演算しかできない時はNumpyを、GPUが使える時はCuPyを使うみたいな使い分けをしていました。私もそれを真似て、Numpy ✕ CuPyのハイブリットライブラリを作ってみようと足掻く話をしたいです。

抱き枕カバー2種届いた(2024/2/15)

季節柄、バレンタインデーって感じですかね。

食べられないけど。

そう、私の家に届いた2つの小包📦。

1つ目の小包を開けると

一ノ瀬アスナ抱きまくらカバー

しばらく前に予約注文していた、サークル「少女の杜」の一ノ瀬アスナ抱きまくらカバーだ!!!

晃田ヒカさんの生塩ノア抱きまくらカバーには随分とお世話になったが、アスナのも最高だ~

そして、2つ目の小包を開ける。

ガレヲン抱きまくらカバー

グラブルのガレヲン抱きまくらカバーだ!!!

グラブルフェス2023の目玉商品だったかな?

予約受付開始当初は予約できなかったのですが、

友人からガレヲン抱きまくらカバーが再販していると聞いて

すぐに予約したのが今来たという感じです。

なんかCDがおまけで付いているようです。

なんだろ~

とりあえず近日中に今使っている生塩ノアちゃんの抱きまくらカバーはおしゃれ着用洗剤で丁寧に揉み洗いして、新しい抱きまくらカバーを使おうかな。

そうだ、中身の枕部分はちょっと天日干ししたいけど。

花粉がなぁ。

【雀魂】イベントで白龍さまの真の姿を拝めた(2024/2/12)

こんにちは。

今日は雀魂のイベントについて熱く論じたいと思います。。。

雀魂とは

最近流行りの基本無料のネット麻雀ができるゲームです。

麻雀やってて知らない人はご高齢の方を除いていないのではと思うほど人気があるように感じます。

イベント「白龍の願い」

イベント「白龍の願い」の宣伝画像

イベント「白龍の願い」は2024年1月31日(水)から2月20日(火)5:59 まで催されている雀魂のイベントです。

私はこの宣伝画像の美人に心を打ち抜かれて、イベント内でなんとしても拝みたいッ、という一心でほぼ毎日イベントに参加しました。

イベント「白龍の願い」のメインページ

イベントのゲーム性としては、

  • 生産物の収穫、または探検による収穫をして生産物を手に入れる
  • 生産物は木材、石材、みかん、なし、りんご、小麦がある
  • 木材、石材、みかん、なし、りんごは生産設備や民家(龍の精の所持上限)の拡張や、献上品として使用する
  • 小麦は龍の精のスタミナとなる。1匹の1回の収穫当たり1つが必要
  • 生産拠点「みかん園」「なし園」「りんご園」はメンテナンスでプレイヤーアカウントにランダムで選択される模様。プレイヤーが選べない
  • 白龍の依頼というミッションがあり、小麦以外の生産物を白龍に献上すると龍の精を雇える
  • 生産設備は一定の数の生産物により拡張できる。拡張とは1回当たりの拡張で龍の精を追加で一匹配置できるようになること
  • 白龍の依頼をこなす、または生産設備を拡張することで村の繁栄度があがる
  • 村の繁栄度が40, 70, 100を達成するごとに、白龍さまの容姿が変化する
  • 繁栄度があがる、または一定回数収穫すると報酬がもらえる
  • デイリーミッションが「イベントクエスト」としてある。毎日3つラインナップされる。毎日午前6時に更新される。1つ達成するごとに収穫可能回数(いわゆるスタミナ)+2
  • 雀魂内のフレンドの村に探検できる。龍の精を1匹3収穫かけて提示されている品物を収穫しに行く。果物の1度の収穫可能数はフレンド自身の果樹園のレベルによる(フレンドの果樹園のレベルがあがれば、自分も収穫可能数の恩恵を受けられる)

みたいな感じでしょうか。

すべての報酬を受け取るためには、イベント開始日からイベント終了日まで毎日やらないと厳しいのではないかと思われます。やってることは単純ですが、イベントに参加しているフレンドがいないと他の果物を入手するのが大変です。

そして、大事なことですが、この記事を読んで今から始めるという方もいるかもしれませんが、繁栄度100達成は厳しいと思われます。このイベントは非常にスタミナ獲得がシビアです。

私は頑張ったので、白龍さまの真のご尊顔を拝謁することができたのでよかったです。逢えて良かった、イベントやって良かったと思います。

皆さんにもちょっとだけ私の白龍さまの真のお姿を見て欲しいと思って、スクショを用意しました。

どうぞ。

白龍の祠(白龍様の真の姿ver.)

白龍の祠を直接画面で見ると、Live2D技術?により動く白龍さまの真の姿が拝めます。みなさんは拝めましたか?

あと、白龍さまのお声も愛おしいです。

私の願い

白龍さま(真の姿ver.)がプレイアブルキャラになってガチャで引けますように。

あと、白龍さま(真の姿ver.)のブロマイドが欲しかったです。ローディング画面で白龍さまに応援されたい。。。

Yostarに届くといいなぁ。。。

以上、イベントレポートでした。

フィギュアが届いた(2024/2/11)

あみあみさんから予約してたフィギュアが届いたよー。

ブルーアーカイブ -Blue Archive- 一之瀬アスナ(バニーガール) Game Playing Ver. 1/7 完成品フィギュア[グッドスマイルアーツ上海]

https://www.amiami.jp/top/detail/detail?gcode=FIGURE-150878

パッケージはこんなかんじ↓

普通のフィギュアは箱の底に入れられている説明書だけど、

このフィギュアに関しては箱上部から蓋を開けると

取り出しやすい位置に説明書が安置されてあった。

この配慮👍

おや、細々とした小物が入っておる。

なるほど、カジノテーブルの上に配置するやつか。

緑地のテーブルの素材はふかふかしている。

アスナちゃんが座るにはちょうどよさそうな。

おっぱいに挟まれたい。おい、そこかわれ!

テキサスポーカーをやってる風を出すために、

小物の配置を工夫しました。

カジノテーブルの緑地がふかふかなため、

コインの塔は結構倒れやすいです。

固定するための穴とかは開いていないです。

地震があったら、このフィギュアの様子を見る必要がありそう。

一ノ瀬アスナ、2体目。

7体集めたら、嫁に来てくれるとか。

ないかぁ。

【MAUI】カルーセルコントロールを作ってみた

こんばんは。

今週のお題

今週はMAUIアプリでカルーセルコントロールを作ってみたので共有したいと思います。

まずはこちらの動画をどうぞ。

youtube.com

まるでKindleアプリのページめくりみたいじゃないですか?

ルーセル

ルーセルコントロールとは何でしょう?

ChatGPTに聞いてみました。

https://chat.openai.com/share/52830124-a93d-4209-8d09-896c4ae182ddchat.openai.com

ルーセルコントロールとは何でしょうか?>ChatGPT

水平方向に画像などを並べて、横方向にスクロールやスワイプして閲覧できるようにしたコントロールとのことなので、今回の「ページめくり」用途にはピッタリではないかと思うのです。

そのため、今回作ったコントロールはCarouselコントロールと名付けました。

ルーセルコントロールの実装

では次に、コントロールの実装をお見せします。

github.com

using Reactive.Bindings;
using Reactive.Bindings.Disposables;
using Reactive.Bindings.Extensions;
using System.Collections.ObjectModel;

namespace CarouselSample.Controls
{
    public class Carousel : AbsoluteLayout
    {
        private static readonly CompositeDisposable _disposables = new();
        private bool initGuard = true;

        public static readonly BindableProperty LeftObjectProperty = BindableProperty.Create(nameof(LeftObject), typeof(ImageSource), typeof(Carousel));

        public static readonly BindableProperty CenterObjectProperty = BindableProperty.Create(nameof(CenterObject), typeof(ImageSource), typeof(Carousel));

        public static readonly BindableProperty RightObjectProperty = BindableProperty.Create(nameof(RightObject), typeof(ImageSource), typeof(Carousel));

        public static readonly BindableProperty ImageSourcesProperty = BindableProperty.Create(nameof(ImageSources), typeof(ObservableCollection<ImageSource>), typeof(Carousel), propertyChanged: ImageSources_PropertyChanged);

        public static readonly BindableProperty IndexProperty = BindableProperty.Create(nameof(Index), typeof(int), typeof(Carousel), propertyChanged: Index_PropertyChanged);

        private static void Index_PropertyChanged(BindableObject bindable, object oldvalue, object newvalue)
        {
            var newIndex = (int)newvalue;
            if (bindable is Carousel { initGuard:true } c)
            {
                var collection = c.ImageSources;
                c.LeftControl.Source = newIndex - 1 >= 0 ? collection[newIndex - 1] : collection.Last();
                c.CenterControl.Source = collection[newIndex];
                c.RightControl.Source = newIndex + 1 > collection.Count - 1 ? collection.First() : collection[newIndex + 1];
                c.initGuard = false;
            }
        }

        private static void ImageSources_PropertyChanged(BindableObject bindable, object oldvalue, object newvalue)
        {
            var collection = newvalue as ObservableCollection<ImageSource>;
            if (bindable is Carousel c)
            {
                c.LeftControl.Source = c.Index - 1 >= 0 ? collection[c.Index - 1] : collection.Last();
                c.CenterControl.Source = collection[c.Index];
                c.RightControl.Source = c.Index + 1 > collection.Count - 1 ? collection.First() : collection[c.Index + 1];
            }
        }

        public ImageSource LeftObject
        {
            get { return (ImageSource)GetValue(LeftObjectProperty); }
            set { SetValue(LeftObjectProperty, value); }
        }

        public ImageSource CenterObject
        {
            get { return (ImageSource)GetValue(CenterObjectProperty); }
            set { SetValue(CenterObjectProperty, value); }
        }

        public ImageSource RightObject
        {
            get { return (ImageSource)GetValue(RightObjectProperty); }
            set { SetValue(RightObjectProperty, value); }
        }

        public ObservableCollection<ImageSource> ImageSources
        {
            get { return (ObservableCollection<ImageSource>)GetValue(ImageSourcesProperty); }
            set { SetValue(ImageSourcesProperty, value); }
        }

        public int Index
        {
            get { return (int)GetValue(IndexProperty); }
            set { SetValue(IndexProperty, value); }
        }

        public ReactiveCommandSlim LoadedCommand { get; } = new();

        public static readonly BindableProperty SwipeLeftCommandProperty = BindableProperty.Create(nameof(SwipeLeftCommand), typeof(ReactiveCommandSlim), typeof(Carousel));

        public static readonly BindableProperty SwipeRightCommandProperty = BindableProperty.Create(nameof(SwipeRightCommand), typeof(ReactiveCommandSlim), typeof(Carousel));

        public ReactiveCommandSlim SwipeLeftCommand
        {
            get { return (ReactiveCommandSlim)GetValue(SwipeLeftCommandProperty); }
            set { SetValue(SwipeLeftCommandProperty, value); }
        }

        public ReactiveCommandSlim SwipeRightCommand
        {
            get { return (ReactiveCommandSlim)GetValue(SwipeRightCommandProperty); }
            set { SetValue(SwipeRightCommandProperty, value); }
        }

        private ReactiveCommandSlim _SwipeLeftCommand { get; } = new();
        private ReactiveCommandSlim _SwipeRightCommand { get; } = new();

        public Image LeftControl { get; } = new();
        public Image CenterControl { get; } = new();
        public Image RightControl { get; } = new();

        public HorizontalStackLayout HorizontalStackLayout { get; } = new();

        private bool init = true;

        public Carousel()
        {
            SwipeLeftCommand = _SwipeLeftCommand = new();
            SwipeRightCommand = _SwipeRightCommand = new();
            
            HorizontalStackLayout.Children.Add(LeftControl);
            HorizontalStackLayout.Children.Add(CenterControl);
            HorizontalStackLayout.Children.Add(RightControl);
            this.Children.Add(HorizontalStackLayout);
            
            this.SizeChanged += (sender, args) =>
            {
                // Carouselのサイズに合わせて、コントロールのサイズを更新
                LeftControl.WidthRequest = this.Width;
                LeftControl.HeightRequest = this.Height;
                CenterControl.WidthRequest = this.Width;
                CenterControl.HeightRequest = this.Height;
                RightControl.WidthRequest = this.Width;
                RightControl.HeightRequest = this.Height;

                if (init)
                {
                    LeftControl.TranslationX = -LeftControl.Width;
                    CenterControl.TranslationX = 0;
                    RightControl.TranslationX = CenterControl.Width;

                    HorizontalStackLayout.TranslationX = -this.Width;
                    init = false;
                }
            };
            
            this.Loaded += (sender, args) =>
            {
                LeftControl.Aspect = Aspect.AspectFit;
                CenterControl.Aspect = Aspect.AspectFit;
                RightControl.Aspect = Aspect.AspectFit;

                initGuard = true;
            };
            this.Unloaded += (sender, args) =>
            {
                initGuard = true;
            };

            this.GestureRecognizers.Add(new SwipeGestureRecognizer() { Command = SwipeLeftCommand, Direction = SwipeDirection.Left });
            this.GestureRecognizers.Add(new SwipeGestureRecognizer() { Command = SwipeRightCommand, Direction = SwipeDirection.Right });

            //右から左にスワイプする
            _SwipeLeftCommand.Subscribe(async () =>
            {
                initGuard = false;

                RightControl.Opacity = 1;
                // アニメーションの完了を待機
                await HorizontalStackLayout.TranslateTo(-this.Width * 2, 0, 250, Easing.SinInOut);
                
                // Indexを更新
                Index = (Index + 1) % ImageSources.Count;
                
                // RightControlとCenterControlを更新
                LeftControl.Source = CenterControl.Source;
                
                CenterControl.Source = RightControl.Source;

                await Task.Delay(50);
                
                HorizontalStackLayout.TranslationX = -this.Width;
                
                // 新しいRightControlを設定
                RightControl.Source = ImageSources[(Index + 1) % ImageSources.Count];
                
            }).AddTo(_disposables);

            //左から右にスワイプする
            _SwipeRightCommand.Subscribe(async () =>
            {
                initGuard = false;

                LeftControl.Opacity = 1;
                // アニメーションの完了を待機
                await HorizontalStackLayout.TranslateTo(0, 0, 250, Easing.SinInOut);
                
                // Indexを更新
                Index = Index - 1 >= 0 ? Index - 1 : ImageSources.Count - 1;
                
                // LeftControlとCenterControlを更新
                RightControl.Source = CenterControl.Source;
                
                CenterControl.Source = LeftControl.Source;

                await Task.Delay(50);
                
                HorizontalStackLayout.TranslationX = -this.Width;

                // 新しいLeftControlを設定
                LeftControl.Source = ImageSources[Index - 1 >= 0 ? Index - 1 : ImageSources.Count - 1];

            }).AddTo(_disposables);
        }
    }
}

特徴(ChatGPTに解説してもらいました)

  1. リアクティブプログラミングの採用: Reactive.Bindings ライブラリを利用しており、MVVMパターンにおけるリアクティブプログラミングを実現しています。これにより、UIコントロールの状態やイベントの反応を、より宣言的に記述できます。

  2. バインダブルプロパティの定義: BindableProperty を使用しているため、XAMLや他のコードからバインド可能なプロパティ(例:LeftObject, CenterObject, RightObject, ImageSources, Index)を提供しています。これにより、外部からこれらのプロパティの変更を監視したり、逆にこれらのプロパティに外部から値を設定したりできます。

  3. ルーセルの動的な更新: ImageSources_PropertyChanged と Index_PropertyChanged メソッドを通じて、画像リストやインデックスが変更された際に自動的にカルーセルの表示を更新します。これは、ユーザーが画像をスワイプするたびに、次または前の画像が適切に表示されることを保証します。

  4. スワイプジェスチャーのサポート: 左へのスワイプと右へのスワイプを検出するために、SwipeGestureRecognizer を使用しています。これにより、ユーザーの操作に応じてカルーセル内の画像を動的に切り替えることができます。

  5. アニメーションの使用: 画像が切り替わる際にスムーズなアニメーション効果を提供します。これは、TranslateTo メソッドを使用して実現されており、カルーセルのユーザー体験を向上させています。

  6. リソース管理: CompositeDisposable を使用して、イベント購読などのリソースを管理し、カルーセルコントロールが不要になった時にこれらのリソースを適切に解放します。

簡単な解説

このCarouselコントロールは、画像を左、中央、右の3つの位置に表示し、ユーザーが左または右にスワイプすることで画像を切り替えることができます。

ビューポートは中央の画像の範囲のみで、左右にスワイプした時に中央の画像が左右の画像に押し出される形でアニメーションします。

アニメーションした後、右から左にスワイプした場合と左から右にスワイプした場合で処理がわかれます。

右から左にスワイプした場合

  1. 右の画像コントロールの透明度を100%にします。
  2. 3枚の画像全体を左に移動(アニメーション)します。
  3. インデックスをインクリメントします。
  4. 左の画像コントロールのソースに中央の画像コントロールのソースを代入します。
  5. 中央の画像コントロールのソースに右の画像コントロールのソースを代入します。
  6. 非同期で50ms待ちます
  7. 右の画像コントロールに、現在のインデックスを基準に+1の位置のソースを代入します。

左から右にスワイプした場合

  1. 左の画像コントロールの透明度を100%にします。
  2. 3枚の画像全体を右に移動(アニメーション)します。
  3. インデックスをデクリメントします。
  4. 右の画像コントロールのソースに中央の画像コントロールのソースを代入します。
  5. 中央の画像コントロールのソースに左の画像コントロールのソースを代入します。
  6. 非同期で50ms待ちます
  7. 左の画像コントロールに、現在のインデックスを基準に-1の位置のソースを代入します。

という感じでそれぞれReactiveCommandを仕掛けておき、GestureRecognizersに2つの左右にスワイプする時のコマンドを設定したSwipeGestureRecognizerインスタンスを追加します。 キモは非同期で50ms待つところですかね。待たないとちらつきが発生してしまいます。

次回予告

最近、C#でAIを作るためのライブラリ(GPU対応)を自分でも珍しく熱心に作っているので、その話題を記事にしようかな?

たぶん、複数記事に分かれると思います。作った(作っている)ライブラリが複数なので。長期に記事を書けたらいいな。

【MAUI】iOSアプリデバッグ時に何故かFileNotfoundExceptionが出る現象

こんにちは。

最近、記事を書くのがワクワクしています。

記事を書くのに忙しい時もあるので、忙しくない時に書き溜めるようにしています。

今週のお題

今日は.NET MAUI で iOSアプリで遭遇しがちな困った現象について解説します。

私の環境では、.NET MAUI でiOSアプリをデバッグしようとする時にAppクラスのInitializeComponentメソッドでFileNotFoundExceptionが発生することがあります。

で例外が発生したらデバッガによりブレークされますが、そこで続行ボタンを押さないと、アプリが勝手に終了してしまうのです。

youtu.be

もしかして、おま環か。。。?と思ってググってみたところ、同じ悩みを抱えている海外の方がいらっしゃる様子。。。

learn.microsoft.com

この記事を見ると、Answerがついていて、

Deleting the .vs folder for the solution fixed it.

訳).vsフォルダを削除すると直りました。

とありました。

私の環境でソリューションフォルダ内にある.vsフォルダを削除したところ、無事直りましたとさ。

めでたしめでたし。

で終わらせてはなりません。このような開発効率を落とす問題は根本的に解決されなければなりません。

マイクロソフトさん、なんとかなりませんか???

次回予告

.NET MAUIで自作のページめくりコントロールKindleみたいなページめくりをする時にアニメーションがつくやつ)を実装する前編をお送りする予定です。

【MAUI】アプリからPHPickerViewControllerを使ってみるサンプル

こんばんは。

今週はサンプル動画を作るために、試作中の画像ビューワーアプリをせこせこ開発しておりまして。

そのPCーiPad間のデータ転送の仕組みを再設計していたもので。。。

若干時間はかかりましたが、やっとブログ記事にまとまりそうなので書きます。

今週のお題

試作中の画像ビューワーは当然マルチプラットフォーム対応しようと思っていて、アプリに画像を取り込むために例えばユーザーがインポートフォルダを指定してあげてその中の画像ファイルを全部取り込むみたいなことが考えられます。インポートフォルダは複数指定できることが要件です。そこでWindowsならMicrosoft.Win32.OpenFolderDialogを無理やり使ったりできますね。(言葉足らずだったため、2024/1/29 1:14に追記)

var dialog = new OpenFolderDialog()
{
    InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.Desktop),
    Multiselect = true,
    Title = "インポートするフォルダの選択"
};
if (dialog.ShowDialog() == true)
{
    await Library.Value.ImportAsync(dialog.FolderNames);
}

みたいな?

iOSやiPadOSの場合はどうでしょう。。。?

iOSやiPadOSというと、システムが画像ピッカーのようなものを用意してくれているという印象がありました。

なのでそれに乗っかります。C#コードから。

特別にNuGetライブラリをインストールする必要はなさそうです。(間違ってたらごめんなさい)

どのようなコードをかけばいいかは以下に示します。

2つクラスが必要で、一つはPHPicerViewControllerを使用するクラス。

using PhotosUI;
using UIKit;

namespace boilersControl.Maui.MultiImagePicker.Platforms.iOS
{
    internal class MultiImagePicker : UIViewController
    {
        // PVCDelegateのインスタンスをクラスレベルで保持
        private PVCDelegate pickerDelegate;

        public async Task<IEnumerable<string>> PresentPhotoPickerAsync()
        {
            var tcs = new TaskCompletionSource<IEnumerable<string>>();

            await MainThread.InvokeOnMainThreadAsync(async () =>
            {
                var configuration = new PHPickerConfiguration();
                configuration.SelectionLimit = 0;
                configuration.Filter = PHPickerFilter.ImagesFilter;

                var picker = new PHPickerViewController(configuration);

                var currentUIViewController = WindowStateManager.Default.GetCurrentUIViewController();
                
                pickerDelegate = new PVCDelegate(tcs, currentUIViewController);
                picker.Delegate = pickerDelegate;
                await currentUIViewController.PresentViewControllerAsync(picker, true);
            });

            return await tcs.Task;
        }
    }
}

もう一つはPhotosUI.PHPickerViewControllerDelegateを継承するカスタムなデリゲートクラスです。

using Foundation;
using ObjCRuntime;
using PhotosUI;
using UIKit;
using UniformTypeIdentifiers;

namespace boilersControl.Maui.MultiImagePicker.Platforms.iOS
{
    internal class PVCDelegate : PhotosUI.PHPickerViewControllerDelegate
    {
        private readonly TaskCompletionSource<IEnumerable<string>> tcs;
        private readonly UIViewController _currentViewController;

        public bool IsCompleted { get; private set; } = false;

        public PVCDelegate(TaskCompletionSource<IEnumerable<string>> tcs,
            UIViewController currentViewController) : base()
        {
            this.tcs = tcs;
            _currentViewController = currentViewController;
        }

        public NativeHandle Handle { get; }
        public void Dispose()
        {
        }

        public override void DidFinishPicking(PHPickerViewController picker, PHPickerResult[] results)
        {
            var _imagePaths = new List<string>();

            MainThread.BeginInvokeOnMainThread(async () =>
            {
                await _currentViewController.DismissViewControllerAsync(true);
            });

            var tasks = results.Select(async result =>
            {
                var uiimage = await result.ItemProvider.LoadObjectAsync<UIImage>();
                var pngData = uiimage.AsPNG();
                var tempDirectoryURL = NSFileManager.DefaultManager.GetTemporaryDirectory();
                var filename = Guid.NewGuid().ToString() + ".png";
                var fileURL = tempDirectoryURL.AppendPathComponent(filename, UTType.CreateFromExtension("png"));

                WriteNSDataToFile(pngData, fileURL.Path);
                _imagePaths.Add(fileURL.Path);
            });

            Task.WhenAll(tasks).ContinueWith(t =>
            {
                IsCompleted = true;
                tcs.SetResult(_imagePaths);
            });
        }

        public void WriteNSDataToFile(NSData data, string filePath)
        {
            // NSDataからバイト配列への変換は、大きなファイルでメモリ消費が懸念されるため、
            // ストリームを使用してファイルに書き込む
            using (var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write))
            {
                data.AsStream().CopyTo(fileStream);
            }
        }
    }
}

処理の全体的な流れ

全体的な流れは

(1) UIViewControllerクラスを継承するMultiImagePickerクラスを作成します。

(2) MultiImagePicker.PresentPhotoPickerAsyncメソッド内の処理に入り、まずTaskCompletionSource<IEnumerable>インスタンスを作っておきます。 これは後ほど説明するカスタムデリゲートクラスのDidFinishPickingメソッド内の非同期処理が完了したタイミングでPresentPhotoPickerAsyncをreturnしたいためです。

(3)メインスレッドで以下を実行します。

(3-a) PHPickerConfigurationを作成して、SelectionLimitやFilterプロパティを設定します。

SelectionLimitは取得したい写真やビデオの数の上限数です。0を指定すると無制限になります。 Filterは取得したいファイルの種類を指定します。 なんかいろいろあります。

PHPickerFilterのプロパティ一覧

(3-b)PHPickerViewControllerクラスをnewします。この時コンストラクタの引数として(3-a)で作ったPHPickerConfigurationインスタンスを渡します。

(3-c)WindowStateManager.Default.GetCurrentUIViewControllerメソッドを呼び出し、カレントUIViewControllerを取得します。

(3-d)PHPickerViewController.Delegateプロパティにカスタムデリゲートクラスのインスタンスを渡します。コンストラクタでTaskCompletionSource<IEnumerable>インスタンスと、カレントUIViewControllerインスタンスを渡します。

(3-e)非同期でPresentViewControllerAsyncメソッドをコールします。

(4)TaskCompletionSource<IEnumerable>インスタンスのTaskプロパティをawaitしつつreturnします。

特筆すべき点

        // PVCDelegateのインスタンスをクラスレベルで保持
        private PVCDelegate pickerDelegate;

上記のような記述があると思います。なぜカスタムデリゲートクラスのインスタンスをクラスレベルで保持しなければいけないか。

PHPickerViewControllerインスタンスはMainThread.InvokeOnMainThreadAsyncメソッドに渡されるラムダ式のスコープに囚われています。

このスコープを出ると、ガベージコレクションに回収されてしまうおそれがあります。それはPHPickerViewControllerのプロパティに入っているインスタンスも同様です。

対処しないと、そのスコープを出た後にDidFinishPickingメソッドをコールするタイミングでObjectDisposedExceptionが出てしまいます。

なのでカスタムデリゲートクラスのインスタンスは、MultiImagePickerクラスのフィールドとして持たせることで、ガベージコレクションに回収されるのを防ぎます。

カスタムデリゲートクラス

PhotosUI.PHPickerViewControllerDelegateを継承すること!

ここ重要

まずPHPickerViewcontroller.Delegateプロパティに代入する、カスタムデリゲートクラスはPhotosUI.PHPickerViewControllerDelegateを継承してください。

作ったカスタムクラスにIPHPickerViewControllerDelegateインターフェースを実装しただけでは、画像ピッカーの追加、キャンセルボタンが機能しません。

DidFinishPickingメソッド

DidFinishPickingメソッドは画像を取得した後どのような処理をするかを書きます。パラメーターのPHPickerResult[]からは、UIImageオブジェクトを取得できますがこれはあまり外に出すものではなさそう?です。つまり、MAUIアプリのコードでUIImageオブジェクトを操作することははばかられるという意味で、できればこのDidFinishPickingメソッド内でUIImageデータを一時ファイルとして保存して、そのパスを返すような仕組みにしたほうが健全かなぁ?と思います。サンプルコードではAsPNGメソッドでNSDataオブジェクトを取得してそれを一時ファイルに書き込んでいます。そして一時ファイルの複数のパスをTaskCompletionSource<IEnumerable>にセットしてあげるという感じ。

サンプルの動画

youtu.be

次回予告

MAUIでiOSアプリをデバッグする時にFileNotFoundExceptionが発生する問題に戸惑ったので、その解決策について書きたいと思います。