MAUIでインストール時に1回だけ表示する画面を作る方法

背景

アプリの使い方など、アプリを初めて起動したときに1回だけ表示する機能を作りたい!
という事で、初回起動時に1回だけ表示させるための方法について調べました。

結論

Microsoft.Maui.Storage」にあるPreferencesを使うことで、アプリを初めて起動したときに1回だけ表示する機能の作成ができそうでした。
なお、Preferencesの意味は、「環境設定」という意味で、アプリの基本設定をKey/Valueストアに保存するためのクラスのようです。

Preferencesの使い方

using Microsoft.Maui.Storage;

public class PreferencesUtil
{
    public static void Set()
    {
        // Setの第1引数は、基本設定を保存するためのキー名称であり、値を取り出す際に使用する
        // Setの第2引数は、基本設定として保存する値
       // 下記処理では、testKeyというキー名称で、bool値"false"を保存している
        Preferences.Default.Set("isFirstOpen", false);
    }

    public static bool Get(string keyName)
    {
        // Getの第1引数は、基本設定に保存されているキー名称を指定する(この情報をもとにキーに紐づく値を取得する)
        // Getの第2引数はデフォルト値であり、第1引数に指定したキーが見つからなかった場合にreturnされる値となる
        // 下記処理では、アプリの基本設定に保存されている情報から、引数の"keyName"と一致するキーに紐づく値を取得しようとしている
        // また、引数で指定したキーが見つからなかった場合は、"true"を返すようになっている
        return Preferences.Default.Get<bool>(keyName, true);
    }
}

初回起動時に1回だけ表示させるために

上記の[Preferencesの使い方]で記載したPreferencesUtilクラスのGetメソッドの引数に"isFirstOpen"を指定して呼び出し、戻り値がtrueの場合に初回起動時にのみ行いたい処理を実行します。
そして、初回起動時に1回だけ実行したい処理が完了した後で、PreferencesUtilクラスのSetメソッドを実行します。
これにより、アプリの基本設定のisFirstOpenがfalseとなるため、2回目以降の起動でPreferencesUtilクラスのGetメソッドの引数に"isFirstOpen"を指定して呼び出すと、戻り値がfalseとなり、2回目以降は初回起動時にのみ行いたい処理が実行されなくなります。

イメージ

public partial class MainPage : ContentPage
{
    protected override void OnAppearing()
    {
        base.OnAppearing();

        // アプリの基本設定から"isFirstOpen"に保存されている値を取得する
        var isFirstLoading = PreferencesUtil.Get("isFirstOpen");
        if(isFirstLoading)
        {
            // 初回起動時にのみ実行したい処理

            // 初回起動時にのみ実行したい処理完了後に"isFirstOpen"に保存されている値をfalseに更新する
            // これにより、次回起動時以降はこのif分の中の処理が実行されなくなる
            PreferencesUtil.Set();
        }
    }

}

MAUI で iOS アプリをデバッグしたら "[UIPickerView setFrame:]: invalid size {320, 216} pinned to {320, 216}"の例外が出た

概要

MAUI で iOS アプリのデバッグをしらた以下の例外が発生し、デバッグに失敗しました。
今回は、この例外を解決するためにやったことを書いておこうと思います。

  • [UIPickerView setFrame:]: invalid size {320, 216} pinned to {320, 216}

結論

エラーメッセージに出ていた UIPickerView は関係ありませんでした。
そして、実際の原因は、Load 処理で実装していた SQLite の Connection 生成処理で例外が発生していたことでした。

例外解決のためにやったこと

1. Xaml の Picker を消してデバッグしてみた
UIPickerView は iOS のピッカーです。
そのピッカーでエラーが発生しているようでしたので、
まずはピッカーを削除してみました。

そして、ピッカーを削除してデバッグしたところ、" [UIPickerView setFrame:]: invalid size {320, 216} pinned to {320, 216}" の例外は発生しなくなりました。
しかし、他の例外が発生し、結局デバッグはできませんでした。

2. ロード処理の削除
ピッカーを削除しても別の例外が発生するため、そもそもロード処理で失敗しているのではないかと考え、
とりあえずロード処理を削除してみました。

また、削除していたピッカーはいったん元に戻しました。

結果、画面が表示されました。

??

" [UIPickerView setFrame:]: invalid size {320, 216} pinned to {320, 216}" はデバッグの失敗と関係ない?

。。。

とりあえずデバッグ実行してみました。

非同期で SQLite にアクセスしているところでアプリが止まっているように見える。。。

3. SQLite の async をやめる
非同期がよくないのかと思い、とりあえずSQLite のアクセスを非同期でのアクセスから同期処理でのアクセスに変更してみました。
その結果、以下の例外が出ていることがわかりました。

  • The type initializer for 'SQLite.SQLiteConnection' threw an exception.

どうやら、MAUI を使用して iOSSQLite を使うためには、NuGet パッケージが sqlite-net-pcl だけでは足りなかったようです。
というわけで、以下の NuGet パッケージの追加と AppDelegate への処理の追加をし、デバッグしてみました。
結果、デバッグに成功し、画面が表示できました。

  • SQLitePCLRaw.bundle_green(Version="2.1.4")
  • SQLitePCLRaw.provider.sqlite3(Version="2.1.4")
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
	protected override MauiApp CreateMauiApp()
	{
		raw.SetProvider(new SQLite3Provider_sqlite3());
        return MauiProgram.CreateMauiApp();
	}
}

ちなみに、SQLite のアクセスは、非同期での悪性巣に戻し、
また、sqlite-net-pcl のバージョンも "1.8.116" にアップデートしてから実行しました。

MAUI で iOS アプリがデバッグしたら、「アプリが終了しました」と表示されてアプリが終了する

MAUI で iOS アプリをデバッグしたら、Visual Studio の出力に以下のメッセージが表示されデバッグができませんでした。
今回は、以下のメッセージが表示されたときにデバッグできるようにするためにやったことを備忘録的に書いておこうと思います。

2023-03-05 16:27:57.705 <<App Name>>[79702:13346471] warning: Can't find custom attr constructor image: data-0x153da0000 mtoken: 0x0a000005 due to: Could not load file or assembly 'Xamarin.HotReload.Contracts, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' or one of its dependencies.
2023-03-05 16:27:57.705 <<App Name>>[79702:13346471] warning: Can't find custom attr constructor image: data-0x153da0000 mtoken: 0x0a000005 due to: Could not load file or assembly 'Xamarin.HotReload.Contracts, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' or one of its dependencies.
2023-03-05 16:27:57.705 <<App Name>>[79702:13346471] warning: Can't find custom attr constructor image: data-0x153da0000 mtoken: 0x0a000005 due to: Could not load file or assembly 'Xamarin.HotReload.Contracts, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' or one of its dependencies.
2023-03-05 16:27:57.705 <<App Name>>[79702:13346471] warning: Can't find custom attr constructor image: d
ata-0x153da0000 mtoken: 0x0a000005 due to: Could not load file or assembly 'Xamarin.HotReload.Contracts, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' or one of its dependencies.
2023-03-05 16:27:57.705 <<App Name>>[79702:13346471] warning: Can't find custom attr constructor image: data-0x155e24000 mtoken: 0x0a00000d due to: Could not resolve type with token 0100000f from typeref (expected class 'System.Resources.NeutralResourcesLanguageAttribute' in assembly 'netstandard, Version=2.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51') assembly:netstandard, Version=2.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51 type:System.Resources.NeutralResourcesLanguageAttribute member:(null)

**System.TypeLoadException:** 'Could not resolve type with token 0100000f from typeref (expected class 'System.Resources.NeutralResourcesLanguageAttribute' in assembly 'netstandard, Version=2.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51')'

アプリが終了しました。

結論から言うと、
XAML のホットリロードを止める
だけでデバッグできました。

Visual Studio の出力には、最後に System.TypeLoadException と表示され assembly ".NetStandard" がなんか悪さをしてるように出ています。
そのため、".NetStandard" の Version が2.0 出ないといけないのかと思いました。

しかし、MAUI は ".NetStandard" 使っていないはずなんだよな。。。
という事で、なんでやねんと思いながら Visual Studio の出力をさかのぼってみると、
以下の警告が発生していました。

2023-03-05 16:27:57.355 LifestyleHouseholdBook[79702:13346471] warning: Can't find custom attr constructor image: data-0x153da0000 mtoken: 0x0a000005 due to: Could not load file or assembly 'Xamarin.HotReload.Contracts, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' or one of its dependencies.

なんか HotReload で警告が出ているようでした。
そして、いろいろ調べた結果 XAML のホットリロードを止めることでデバッグできるようになりました。

まあ、ソリューションを作成して、そのままデバッグして画面を表示させることができただけで、自作の画面はまだ表示できていないのですが。。。

Xamarin.Forms FlyoutPageをPushModalAsyncした後の画面で使ってみた

最初に

公式ドキュメントでは、FlyoutPageはルートページで使用することを前提としているとの記載があります。
そのため、ここで記載する方法により予期せぬ動作を起こす可能性があります。

一覧画面で選択した項目をFlyoutPageで作りたかった

現在作成しているアプリの画面構成の中に、一覧画面で選択した項目に対して複数の操作を行いたい画面が存在しました。
そして、その複数の操作について、明確な順番を設けずに、ユーザーが自由に各操作画面を行き来できるようにしたいと考えていました。

そのため、一覧画面からの遷移後にMenuが横から出てくる画面を作りたいなと考えました。
そして、そのような画面を作る方法としてXamarinにはFlyoutPageがあったため、試してみようと考えました。

公式ではルートページで使用するとあるが、、、

公式では、ルートページで使うようにと記載されているが、画面遷移後のページで使ってしまって大丈夫なのでしょうか。
ということで、実際にやってみました。

まず、画面遷移後に表示する画面についてです。
画面遷移後に表示する画面をPushModalAsyncを使用して、モーダルページとして画面表示をした場合、ModalStack、NavigationStackともに、カウントが0となっています。
f:id:b-kimagure:20210725213902p:plain:w500
これはもしかして、PushModalAsyncで画面表示した場合はルートページになるのでは?

この仮説を基に、サンプルコードを書いてみることにしました。
(といっても、ほぼVisual Studioが自動生成したコードのみですが。。。)

  • 最初に表示する画面

画面にボタンを配置しています。
そして、ボタンクリックイベントで、以下の通りPushModalAsyncを使用した画面遷移を行っています。

    public partial class MainPage : ContentPage
    {
        public MainPage()
        {
            InitializeComponent();
        }

        private void Button_Clicked(object sender, EventArgs e)
        {
            Navigation.PushModalAsync(new NavigationPage(new ModalPageFlyout()));
        }
    }
  • FlyoutPage画面

VisualStudioで追加したFlyoutPageをほぼそのまま使用しています。
ただし、モーダルページとしてFlyoutPage画面を表示したため、元の画面に戻るための機能の追加を行っています。

戻る機能は、横から出てくるMenuに"Exit"として追加しています。
追加を行うためにまずは、自動生成されるXXXFlyout.xaml.csファイルの修正を行います。
自動生成されたXXXFlyout.xaml.csファイルには、インナークラスとしてViewModelが定義されており、そのコンストラクタで横から出てくるメニューに表示するアイテムを設定しています。
そのため、コンストラクタで定義しているメニュー項目に"Exit"を追加します。

    public partial class ModalPageFlyoutFlyout : ContentPage
    {
        public ListView ListView;

        public ModalPageFlyoutFlyout()
        {
            InitializeComponent();

            BindingContext = new ModalPageFlyoutFlyoutViewModel();
            ListView = MenuItemsListView;
        }

        class ModalPageFlyoutFlyoutViewModel : INotifyPropertyChanged
        {
            public ObservableCollection<ModalPageFlyoutFlyoutMenuItem> MenuItems { get; set; }

            public ModalPageFlyoutFlyoutViewModel()
            {
                MenuItems = new ObservableCollection<ModalPageFlyoutFlyoutMenuItem>(new[]
                {
                    new ModalPageFlyoutFlyoutMenuItem { Id = 0, Title = "Page 1", TargetType = typeof(ModalPageFlyoutDetail) },
                    new ModalPageFlyoutFlyoutMenuItem { Id = 1, Title = "Page 2", TargetType = typeof(ModalPageFlyoutDetail) },
                    new ModalPageFlyoutFlyoutMenuItem { Id = 2, Title = "Page 3", TargetType = typeof(ModalPageDetail2) },
                    new ModalPageFlyoutFlyoutMenuItem { Id = 3, Title = "Page 4", TargetType = typeof(ModalPageFlyoutDetail) },
                    new ModalPageFlyoutFlyoutMenuItem { Id = 4, Title = "Page 5", TargetType = typeof(ModalPageDetail2) },
                    new ModalPageFlyoutFlyoutMenuItem { Id = 5, Title = "Exit" },
                });
            }

            #region INotifyPropertyChanged Implementation
            public event PropertyChangedEventHandler PropertyChanged;
            void OnPropertyChanged([CallerMemberName] string propertyName = "")
            {
                if (PropertyChanged == null)
                    return;

                PropertyChanged.Invoke(this, new PropertyChangedEventArgs(propertyName));
            }
            #endregion
        }
    }

次に、XXXFlyout.xamlとXXXDetail.xamlが乗っかる画面の修正をします。
VisualStudioで自動生成したこの画面では、メニュー選択時の動作が実装されています。
そのため、"Exit"メニューが選択された場合の動作をメニュー選択時の動作の中に追加します。

今回は、id=5の項目をExitとしたため、メニューで選択した項目のid=5の場合にPopModalAsyncで画面を閉じています。
そして、そのままreturnすることで、以降の画面選択処理を実行せずに処理を終了させています。

    public partial class ModalPageFlyout : FlyoutPage
    {
        public ModalPageFlyout()
        {
            InitializeComponent();
            FlyoutPage.ListView.ItemSelected += ListView_ItemSelected;
        }

        private void ListView_ItemSelected(object sender, SelectedItemChangedEventArgs e)
        {
            var item = e.SelectedItem as ModalPageFlyoutFlyoutMenuItem;
            if (item == null)
                return;

            if (item.Id == 5)
            {
                Navigation.PopModalAsync();
                return;
            }

            var page = (Page)Activator.CreateInstance(item.TargetType);

            page.Title = item.Title;

            Detail = new NavigationPage(page);
            IsPresented = false;

            FlyoutPage.ListView.SelectedItem = null;
        }
    }

やってみた結果

やってみた結果、iOSAndroidともにそれっぽく動かすことができました。

最後に

とりあえず、動くことは確認できました。
しかし、公式ドキュメントでは、ルートページで使うことが前提となっています。
(アプリのルートページとは記載されていないようでした)

そのため、この実装方法が本当に問題ないかはもう少し調べてみる必要があるかもしれません。

アプリ開発日記 #212 画面構成の変更を開始する

これまでの作業

ざっくり家計簿の修正が一段落しました。
そして、アプリのリリース準備を行い、審査へ提出しました。

結果、無事ざっくり家計簿のリリースが行われました。

今回は、証明書の期限が迫っていたため、リリース時に証明書の更新もしました。
証明書の更新は久しぶりであったため、かなり手間取ってしまいました。

たまにしかやらないことは、ちゃんとメモしとかないといけないなという出来事でした。

メモるの忘れてたけど。。。

今日の作業

今日からは、別のアプリの改修を始めました。
改修するアプリは、がんばるその前にです。
そして、改修内容は、画面構成の変更です。

そんなわけで、久しぶりにちゃんとソースを見てみたのですが、結構前に書かれたソースで今とだいぶ書き方が変わっていました。
また、画面ごとにビジネスロジックを書いていたため、今回の改修では、ビジネスロジック側も含めて大きな修正になりそうな感じです。

テンション下がるな~

ただ、今の画面構成の場合、画面遷移が多く少し面倒なことと、新たに作成したやることリストの消込機能の追加が難しいことから、画面構成の変更がどうしても必要なため、画面構成の変更を行おうとしています。

明日の予定

  • 変更後の画面構成での機能の設計を行う
  • ソース修正を行う

アプリ開発日記 #211 テスト完了

今日の作業

Android版および、iOS版の両方でエミュレータを使ってAPPのテストを行いました。
今日行ったテストの内容は、どちらかというと総合テストのような、実際の運用を意識したようなテストです。

そして、テストを行っている中で、操作方法によってはおかしな動作となるパターンがあることがわかりました。
そのため、おかしな動作となりそうな箇所の修正を行いました。

今回のバグは本来ちゃんと詳細設計をしていれば、組み込むことがなかった内容でした。
リファクタリングの前に修正範囲をきちんと決め、修正範囲内について詳細設計をやっておけばよかった。。。

あと、修正範囲の詳細設計をしっかりやるためには、画面間や機能間の動作について、基本設計でちゃんとつめておく必要がありそうです。
現在は作ること優先で、設計書が甘くなっているので、設計書もちゃんと整備しておきたいですね。。。

明日の予定

  • ブランチのマージをする
  • リリースの準備をする

アプリ開発日記 #210 テストを開始

今日の作業

登録処理を行うテストコードの追加を行いました。
今日、テストコードを追加したことで、大体の処理を網羅できるテストコードになりました。

今後、小さい単位でのテストコードを追加することで、さらに修正がしやすくなる見込みです。

そして、今日から実際に動かしてみてのテストを開始しました。
とりあえず今日はデバック実行を行いました。

その結果、いくつかバグが見つかりました。
大体の処理はテストコードで網羅していたつもりでしたが、その範囲外の箇所でとても分かりやすいバグが見つかってしまいました。

テストコードはちゃんと網羅的に書くべきですね。。。

明日の予定

  • アプリをテストする