Webエンジニアが.NET MAUIによるネイティブアプリ開発をしてみた

※この記事は「エムティーアイ Blog Summer 2025」の 8/4 分の記事です。

はじめに

こんにちは。エンジニアの加藤です。
私が現在担当しているプロダクトには、専属のアプリ開発担当者がいるときと、不在のときがあり、不在の場合は常駐エンジニアの中からできる人が着手するといった環境になっています。
私はWebアプリケーション開発のチーム管理・実装が主業務であり、ネイティブアプリのコードは簡単な調査のみで、業務でコードを書いた経験がありません。
そこで、今後、開発することを見据えて、ネイティブアプリ開発のいろはを学ぶことにしました。

※ 本稿では、初学者が独学で覚えたものを記載しているため、間違いがある可能性があることご留意ください。
※ iOS向けのビルドができなかったので、動作確認はAndroidのみで実施しています。

目次

学習方法

偶然ですが、タイミングよく学生の頃からの友人との話で簡単なアプリを作る話が持ち上がりました。
この友人からの持ち込み企画で、チェスクロックアプリを作ることになりました。
いわゆる「対局時計」というものです。
背景としては、この友人が囲碁をよくやる人物で、囲碁部のOB/OGの集まり等で対局する際に既存のアプリを使って持ち時間を測るものの、使い心地があまり良くなく、自分でカスタマイズしたものを使いたい、というものでした。
そこで、毎週1~2時間程度、ドライバを交代しながらペアプロで作っていくという方針で開発を開始しました。
(もちろん(?)、フルタイム会社員が毎週時間が合うはずもなく、月に2回程度で進行しました)

本題

要件定義

これは友人が簡単に作ってくれて、Notionにまとめてくれたものがあるのですが、本稿に書くと長くなってしまうので省略します。
(必須非機能要件として、習得したい技術領域となる.NET MAUIを使うことはポイントになっています。)

画面設計

友人が、初回に2人で作業するまでに、もともとあった構想のコーディング済みデザインをGitプロジェクトにPushしてくれました。
(GitHubに私物のPCでPushするまでに少し難儀した話も機会があれば書きたいと思います・・・)
それがこちらのようなデザインになります。
(左側に無駄な余白があるのは私のエミュレータ設定影響だと思われるので無視してください。。)

initialize

スタート時点でここまでUIが作ってあったので、あとはこれを動かすだけです。

設定画面

一番はじめに設定画面に着手しました。 普通、カウントダウンのタイマー処理とかからやるでしょ、と思うところですが、まず設定値の保存をやってみたいという気持ち優先でここからはじめました。
C#でのコーディング経験は豊富なことに加え、社外秘情報も何も無いので、生成AIも使い放題だし、大したことはないだろうと思っていたのですが、そこそこ苦労しました。
まず、UIを作りますが、HTMLじゃない。当然なんですが、XAMLで書かないといけません。
(XAMLは「eXtensible Application Markup Language(拡張可能アプリケーションマークアップ言語)」の略)
入力操作を受け付けないといけないのですが、プルダウンを出すだけで勝手が違うので早速つまずいていました。
VerticalStackLayout?、Grid?、Picker?みたいになってました。
適当にタグ(正確にはXAMLエレメントらしい)を配置しても、余白が空きすぎたり、要素が要素の裏に隠れたりしてうまく配置できませんでした。
なんの事前学習もせずに、ChatGPTにプルダウンのタグよこせみたいな簡素なプロンプトで要素だけコピーして勘で配置したらそうなりますよね。

例: 先手番の持ち時間(分)を指定するプルダウンのエレメント

<Picker x:Name="FirstMinutesPicker"
    Title="Minutes"
    ItemsSource="{Binding Minutes}"
    SelectedIndex="{Binding SelectedFirstPlayerMinute}"
    WidthRequest="100"
    HorizontalTextAlignment="Center"/>

ただ、試行錯誤していると許せるくらいの配置になったので、これで良しとしました。

settings

加えて、もちろんですが、JavaScriptも使えないので、SettingsPage.xamlというXAMLで記述するファイルに付属するSettingsPage.xaml.cs という拡張子が .xaml.cs というビハインドコードファイルからViewModelへバインドするという概念も学習する必要がありました。

画面遷移もURLを使ったHTTPアクセスではないので、画面遷移イベントを起こして別の画面を表示するという動作をさせます。
このとき、もともと表示していた画面の上に新しいレイヤーを表示して、画面をスタックする形で表示しているというのもイメージしていた動きと違いました。
新しい画面をpushして表示、前の画面に戻るにはpop、最初の画面に戻るにはレイヤーのリセットでもよいということでした。

MVVM

作り始めて全然プロジェクト構成が違うので、しぶしぶ設計パターンを勉強しました。
WEBではMVCがよく使われると思いますが、ネイティブアプリでは MVVM(Model-View-ViewModel) で構成するのが一般的のようです。
Modelsフォルダ、ViewModelsフォルダ、Viewsフォルダの3つが主になるようです。
ViewsフォルダにXAMLファイルとビハインドファイルが入るので、これは2つで1セットとしてViewになります。
XAMLでマークアップして、ビハインドファイルでバックエンド(?)であるViewModelとバインドして、ViewModelにUI関連ロジック、Modelに本ロジックを書く、という構成のようです。
ただ、今回の我々のアプリではそこまで複雑な処理はすることはなく、Modelでロジックを書くほどでもなかったので、 ほぼ、XAMLで必要な要素を画面に足して、ViewModelで処理させるという形で進行しました。

設定値保存

なんとか画面ができたので、設定画面のSAVEボタンを押したときに設定値が端末内に保存されて、タイマー画面で呼び出せるようにします。
WebであればCookieかLocalStorage、APIを呼び出してDBに保存といった手段を使いますが、ネイティブアプリの画面遷移に合わせていちいちAPI呼び出すのは違和感があったので、端末の保存領域を使って設定値を保持する方法を調べました。
もともとSQL Liteが使えるというのは知っていたのですが、別のやり方があるみたいでした。
Microsoft.Maui.Storage.Preferences というクラスが標準であるので、これで値を保持できました。
使い方は簡単で、Key-Valueで保存できるので、以下のようにKeyを設定して、値を渡すだけです。

Preferences.Set(FirstPlayerMinutesKey, FirstPlayerMinutes);

保存されている値を呼び出したいときはSetではなくGetを呼ぶだけです。
存在しないキーを指定しちゃったときのデフォルト値が第2引数です。

Preferences.Get(FirstPlayerMinutesKey, 0);

(なんとなくServiceクラスへ切り出して、実装しました。)

設定値の別画面への反映

ここまでで、各プレイヤーの持ち時間を設定することができるようになりました。
タイマー画面へ戻り、カウントダウン処理の実装をしたいところですが、 カウントダウン処理の変数は、まだ設定した秒数を持っていません。
端末には保存されているので、タイマーのViewModelがPreference.getで取得する必要があります。
タイマー画面に、設定値の読み込みボタンをつけるような実装でも可能ですが、 設定画面からタイマー画面に戻るのと同時に読み込みしてしまえばいいので、 設定画面のBackボタンにトリガーを実装します。

下準備として、タイマー画面側に イベント購読 を設定しておきます。
(※ これは古い実装で現在は非推奨らしいです。)

MessagingCenter.Subscribe<SettingsService, string>(this, "ClickBackButton", (sender, message) =>
{
    ResetTimer();
}

設定画面の戻るボタンにはこのように設定しました。

MessagingCenter.Send(this, "ClickBackButton", "None");

これにより、Backボタンにより、表示されている画面が、設定画面からタイマー画面に戻るのと同時に、 タイマー画面側のリセットタイマー関数が呼ばれ、設定値を読み込むことができるようになります。

しかし、この実装で動作確認したところ、対局の途中で、設定値を確認しようと設定画面へ遷移すると
戻ったときにリセットされてしまうというUXの悪さが発見されたので、Backボタンではなく、 Saveボタンで動作するように変更しました。


余談:
実装の途中で、イベント購読をうまく使えばプルダウンで秒数をセットしたのと同時に、即時タイマー画面の変数に読み込むようにできるよね、というやりとりがペアプロ中にありましたが、この実装にしてしまうと、対局が終わったあと、同じ設定でもう一戦やろうと思ったときに、設定画面で再度プルダウンを無駄に操作して再セットしなければいけないことに気づいたため、設定値は端末に保存するようにしました。
また、この判断の副次的な要素として、アプリを閉じたあと、再起動してもこの保存した値は保持されているので、よく使う設定に一度設定したらすぐに使えるようになりました。
ここまで見越していたわけではなく、要求・要件としても定義していなかったので、偶然の産物でした。


カウントダウン処理

設定した時間から1秒ずつデクリメントする処理を書くことにしました。
AIに聞いて一撃で主実装を終わらせて、継ぎ足しで書いていきました。
行き当たりばったりで、実装がひどすぎて笑いながらペアプロしていましたが、動作だけはまともになりました。

抜粋すると、

コンストラクタでタイマーのセットアップをします。

_timer = Dispatcher.CreateTimer();
_timer.Interval = TimeSpan.FromSeconds(1);
_timer.Tick += OnTick; // private async void OnTick(object sender, EventArgs e){ /*省略*/} は別途実装

余談:
あまり、タイマーでのイベント発火のようなコードをC#で扱った経験がなく、
なぜ、 += なんだろうと疑問に思いました。
調べてみると、 += はイベントハンドラーを追加という式らしく、以下のようにすれば、 1秒カウントごとに、 1 と 2 の両方が同時に発火するようにイベントを"追加"しているということでした。

_timer.Tick += OnTick_1;
_timer.Tick += OnTick_2;

残り時間を囲んでいる青いサークル内をタップすると、タップしたのと反対側(相手側)のカウントがスタートします。

_timer.Start()

あとは1秒毎に OnTick 関数が動くので、先手か後手のどちらをデクリメントするかをコントロールしてカウントダウンしています。
このあたりはアプリの実装というよりは、C#の実装ですね。

重要なのは、 _timer.Start() をどう呼び出すのかです。

青いサークルとその中身のXAML実装です。

<!-- 左のタイマー -->
<StackLayout Grid.Column="0"
             HorizontalOptions="Center"
             VerticalOptions="Center">
    <!-- 外側のフレーム(青い線部分) -->
    <Frame CornerRadius="160"
           WidthRequest="320"
           HeightRequest="320"
           BackgroundColor="#4285F4"
           BorderColor="#4285F4"
           Padding="10"
           HasShadow="False">
        <!-- 内側のフレーム(実際のタイマー表示部分) -->
        <Frame CornerRadius="130"
               WidthRequest="260"
               HeightRequest="260"
               BackgroundColor="#333333"
               BorderColor="Transparent"
               Padding="0"
               HasShadow="False"
               >

            <!-- タップ可能にするために GestureRecognizer を追加 -->
            <Frame.GestureRecognizers>
                <TapGestureRecognizer
                Command="{Binding StartCountDownCommand}"
                CommandParameter="1" />
            </Frame.GestureRecognizers>

            <!-- タイマーラベル -->
            <Label x:Name="LeftCountdownLabel"
               Text="{Binding FirstPlayerCountdownText}"
               FontSize="78"
               HorizontalOptions="Center"
               VerticalOptions="Center"
               TextColor="White"
                />
        </Frame>
    </Frame>
</StackLayout>

この中で、カウントダウン開始に関わる部分はここになります。

Command="{Binding StartCountDownCommand}"
CommandParameter="1" />

これは、バインドされたViewModelの StartCountDownCommand を実行する、そして引数は1という設定をしています。
(この引数の1は、Player1側のボタンがタップされたことを表しているため、もう片方のボタンは2になっています。)

ViewModel側はクラス変数として、ICommandを定義し、このコマンドが呼び出されたときに、 実行する関数を合わせて定義しています。

クラス変数

public ICommand StartCountDownCommand { get; }

コンストラクタ

StartCountDownCommand = new Command<string>(OnCountDownControll);

これらにより、ボタンがタップされたら、以下の関数が引数"1"で実行されます。

private void OnCountDownControll(string clickEventString){ /*省略*/}

余談:
画面を実装したときに、StartCountDownCommandで作っちゃったからそのままでいいやってなってます。
実際は手番交代操作のボタンタップが起こったときの処理は全部ここが起点なので、名前はおかしいです。


次に、カウントダウンして、減った秒数を表示する処理が必要です。
XAMLでいうと、この部分が該当します。

Text="{Binding FirstPlayerCountdownText}"

当該の変数に対して、表示したい文字列を入れて、 OnPropertyChangedメソッドへ引数として渡すと、その内容で書き換えてくれます。

FirstPlayerCountdownText = TimeFormat(_firstPlayerCountdownValue);
OnPropertyChanged(nameof(FirstPlayerCountdownText));

ここまでの内容で、ボタン操作からロジックの動作、その結果を画面へ表示するという基本的なアプリとしての動きができるようになりました。

秒読みの音声読み上げ

さて、次は0秒になったときの終了動作実装や、中断やリトライの実装が続くのですが、 この記事の要旨からは離れてしまうので、ここは割愛し、アプリ開発だから躓いた部分に焦点を当てたいと思います。

一通りの実装が終わり、残り秒数が少なくなったら、音声で知らせる機能を実装することにしました。
このとき、事前に音声ファイルをmp3やwavで用意しておき、これを再生する想定でいたのですが、これができませんでした。
できなかったというよりはコストが高めなので、他の手段にしました。

経緯としては、音声の再生はスピーカーを使ったメディアの再生なので、このようなI/Oを利用するネイティブAPIに依存するため、 MAUIで実装していても、iPhone、Androidそれぞれのために実装しなければいけないという制約がありました。
代替手段を調べたところ、 TextToSpeech クラスというMAUIで使える音声読み上げライブラリがありましたので、こちらを使用しました。

ロケールを日本語に設定して、読み上げさせることができ、動作としては問題なかったのですが、 画面に表示される残り秒数と、読み上げている音声がズレて、まだ秒読み中なのに試合が終わるという不具合がありました。
これは、一番はじめに読み上げるタイミングで、ロケールの読み込みをしてしまっていた都合で、一番最初の実行が遅れ、その後の1秒ごとの読み上げ全てが後ろへ押されることとなっていました。
事前に読み込みしておくことで、秒読みのズレも解消しました。

まとめ

.NET MAUIによるネイティブアプリ開発を実践してみました。
簡単なアプリではありましたが、多くの学びを貰える内容になっていたと思います。
今回の実装で、
XAMLの書き方、ビハインドファイルとは何なのか、MVVMの思想、端末へのKey-Valueでの値の保存・読み出し、画面からのロジック呼び出し、ロジックからの画面書き換え方法、低レベルI/Oを使うときの制約 といったことが理解できました。
今後は、HTTPによる外部APIの呼び出しといった実務でよく使う部分や、XAMLを学び、表現方法の手札を増やすといった部分を実践したいと思います。

おまけ

対戦モードの変更や持ち時間の設定。

min

gametype

10秒の秒読み設定で、持ち時間0でスタートすると、10秒以内に1手打つ設定になります。
(1手打つと自分の持ち時間が10秒にリセットされます。)
持ち時間60分のような設定でスタートしたときも、秒読みが10秒設定であれば、60分使い切ったあとは1手10秒になります。

10sec

持ち時間が切れると --:-- になり、リスタートボタンが出ます。

finish

以上。