.NET MAUI に統合された “プラットフォーム‑ヘルパー”(Xamarin.Essentials 由来の API 群)は、IConnectivity や ILauncher などの公式インターフェイスが用意されているにもかかわらず、既定では依存性注入(DI)に自動登録されません。本稿では、その設計意図とメリット、現場での最適な登録パターン、テスト容易性、運用上の落とし穴までを一気に整理します。
.NET MAUI のプラットフォーム‑ヘルパーと DI ― 何が起きているのか
MAUI では、ネットワーク状態、ランチャー、位置情報、セキュアストレージ、共有、ブラウザ、メール送信といった OS 連携の共通機能が Essentials として統合されています。これらは「静的 API(例:Connectivity.Current、Launcher.Default)」と「インターフェイス(例:IConnectivity、ILauncher)」の二系統で提供され、アプリはどちらでも利用できます。
しかし、MauiApp を生成した直後のテンプレートでは インターフェイスの DI 登録は空 です。開発者が DI による依存関係解決を望む場合のみ、MauiProgram.cs 等で明示的に登録します。つまり、MAUI の既定戦略は DI を“必須”にしない Opt‑in です。
なぜ自動登録しないのか:設計意図とそのメリット
| 観点 | 理由・メリット | 現場で効くポイント |
|---|---|---|
| Opt‑in 方式 | 静的アクセスを第一に据え、DI を前提にしない。テンプレート段階で不要な依存を増やさない。 | 既存資産やサンプルがそのまま動く。学習コストが低い。 |
| 性能とサイズ | 未使用サービスの登録を避け、起動時間・メモリを節約。リンカと組み合わせてバイナリを小型化。 | モバイルで効く。AOT/トリミング構成でも安全に最小化。 |
| 自由度(拡張性) | ライフサイクル(Singleton/Transient)や代替実装(モック/装飾)の選択権をアプリ側に残す。 | プロジェクトの DI 方針(Prism、CommunityToolkit.Mvvm 等)と衝突しない。 |
| 後方互換 | 静的 API を第一級として残し、Xamarin 時代の呼び出しを保全。 | 移行時の変更差分を最小に抑えられる。 |
要するに、「必要なときだけ自分で入れる」 という選択肢を守るための設計です。フレームワークがあらかじめ全部をコンテナに詰め込むより、アプリの構成・規模・テスト方針に応じて“最小限だけ”選ぶ方が、長期運用での恩恵が大きくなります。
最小限から始める具体的な登録方法
基本:テンプレートに 2 行足すだけ
// MauiProgram.cs
using Microsoft.Maui.Networking;
using Microsoft.Maui.ApplicationModel;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
// ... 既存設定 ...
// Essentials インターフェイスを DI に登録(Opt-in)
builder.Services.AddSingleton<IConnectivity>(_ => Connectivity.Current);
builder.Services.AddSingleton<ILauncher>(_ => Launcher.Default);
return builder.Build();
}
}
登録先のスコープは Singleton が基本です。Connectivity.Current や Launcher.Default は OS の背後実装にブリッジしており、アプリ内で状態を共有した方が一貫性が取れるためです。
一括登録の拡張メソッド化
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddMauiEssentials(this IServiceCollection services)
{
services.AddSingleton<IConnectivity>(_ => Connectivity.Current);
services.AddSingleton<ILauncher>(_ => Launcher.Default);
// 必要に応じて増やす(例)
services.AddSingleton<IGeolocation>(_ => Geolocation.Default);
services.AddSingleton<ISecureStorage>(_ => SecureStorage.Default);
services.AddSingleton<IClipboard>(_ => Clipboard.Default);
services.AddSingleton<IShare>(_ => Share.Default);
services.AddSingleton<IBrowser>(_ => Browser.Default);
services.AddSingleton<IEmail>(_ => Email.Default);
services.AddSingleton<IPhoneDialer>(_ => PhoneDialer.Default);
services.AddSingleton<IMap>(_ => Map.Default);
return services;
}
}
// 呼び出し側(MauiProgram.cs)
builder.Services.AddMauiEssentials();
アプリに必要なインターフェイスだけをここへ列挙しておき、増減を Pull Request で管理すると保守が容易になります。
静的 API vs DI:どちらを選ぶべきか
| 用途 | 静的 API(*.Current/*.Default) | DI(I*) |
|---|---|---|
| 素早いプロトタイピング | ◎ 単純で依存が少ない。サンプルコードが豊富。 | ○ 多少のセットアップが必要。 |
| ViewModel の単体テスト | △ 静的呼び出しの差し替えが難しい。 | ◎ モック/フェイクを注入して検証可能。 |
| 大規模化・保守性 | △ 隠れた結合が増えやすい。 | ◎ 依存が明示化され、差し替え・監視が容易。 |
| アプリサイズ・起動時間 | ◎ 余計なサービスを増やさない。 | ○ 必要分だけ登録すれば同等を維持可能。 |
結論: UI レイヤのみ・小規模なら静的 API、テストや拡張性を重視するなら DI。混在も問題ありません(コアは DI、スクリーンショット等の簡易ユースは静的など)。
代表的なインターフェイスと補足事項
| インターフェイス | 既定実装(静的) | 主な用途 | 備考(権限/注意点) |
|---|---|---|---|
IConnectivity | Connectivity.Current | 接続状態・ネットワーク変更イベント | バックグラウンド監視は電池消費に注意 |
ILauncher | Launcher.Default | 外部 URL/アプリ起動 | ユーザー操作起点が必要なケースあり |
IGeolocation | Geolocation.Default | 現在位置取得 | 位置情報権限。端末設定で無効時の扱いを設計 |
ISecureStorage | SecureStorage.Default | 機密データ保存 | OS のキーチェーン/Keystore に依存 |
IClipboard | Clipboard.Default | クリップボード操作 | 一部 OS で通知が出る挙動に配慮 |
IShare | Share.Default | 共有シートの表示 | UI スレッドで呼ぶのが安全 |
IBrowser | Browser.Default | アプリ内/外ブラウザ起動 | カスタムタブ/SFSafariViewController |
IEmail | Email.Default | メール作成画面の起動 | 未設定端末やシミュレーターは例外 |
IMap | Map.Default | 地図アプリでの座標表示 | アプリ未インストール時のフォールバック設計 |
IPhoneDialer | PhoneDialer.Default | ダイヤラー起動 | 通話権限/タブレットでの非対応に注意 |
すべてを登録する必要はありません。“使う分だけ” 列挙していけば十分です。
ViewModel での活用:コンストラクタ注入の基本形
public sealed class DetailViewModel
{
private readonly IConnectivity _connectivity;
private readonly ILauncher _launcher;
public DetailViewModel(IConnectivity connectivity, ILauncher launcher)
{
_connectivity = connectivity;
_launcher = launcher;
}
public async Task OpenDocsAsync(Uri uri)
{
if (_connectivity.NetworkAccess != NetworkAccess.Internet)
throw new InvalidOperationException("オフラインのため開けません。");
await _launcher.OpenAsync(uri);
}
}
この構成なら、単体テストでは IConnectivity と ILauncher をモックに置き換えるだけで分岐を網羅できます。UI スレッド制約のある API はコマンド発火側で MainThread.InvokeOnMainThreadAsync を使うなど、呼び出し元で吸収するのが安全です。
単体テストのモック例(外部ライブラリ不要)
file sealed class FakeConnectivity : IConnectivity
{
public NetworkAccess NetworkAccess { get; set; } = NetworkAccess.Internet;
public IEnumerable<ConnectionProfile> ConnectionProfiles => Array.Empty<ConnectionProfile>();
public event EventHandler<ConnectivityChangedEventArgs> ConnectivityChanged;
public void RaiseChanged(NetworkAccess access)
{
NetworkAccess = access;
ConnectivityChanged?.Invoke(this, new ConnectivityChangedEventArgs(access, ConnectionProfiles));
}
}
file sealed class StubLauncher : ILauncher
{
public Uri LastOpened { get; private set; }
public Task CanOpenAsync(Uri uri) => Task.FromResult(true);
public Task OpenAsync(Uri uri)
{
LastOpened = uri;
return Task.FromResult(true);
}
}
// テスト
[Fact]
public async Task オフラインなら起動を拒否する()
{
var fakeConn = new FakeConnectivity { NetworkAccess = NetworkAccess.None };
var stubLauncher = new StubLauncher();
var vm = new DetailViewModel(fakeConn, stubLauncher);
await Assert.ThrowsAsync<InvalidOperationException>(() => vm.OpenDocsAsync(new Uri("https://example")));
}
外部モックフレームワークに頼らずとも、インターフェイスのおかげで十分に検証できます。イベントを露出する IConnectivity はフェイクで RaiseChanged を用意しておくと UI の自動再接続ロジックもテストしやすくなります。
運用で効く設計パターン
装飾(Decorator)での機能注入
標準の ILauncher をそのまま使う代わりに、ロギング・レート制限・同意 UI 表示などを付けたいときは装飾パターンが有効です。
public sealed class LoggingLauncher : ILauncher
{
private readonly ILauncher _inner;
private readonly ILogger<LoggingLauncher> _logger;
public LoggingLauncher(ILauncher inner, ILogger<LoggingLauncher> logger)
=> (_inner, _logger) = (inner, logger);
public Task<bool> CanOpenAsync(Uri uri) => _inner.CanOpenAsync(uri);
public async Task<bool> OpenAsync(Uri uri)
{
_logger.LogInformation("Open: {Uri}", uri);
return await _inner.OpenAsync(uri);
}
}
// 登録(順序に注意)
builder.Services.AddSingleton(_ => Launcher.Default);
builder.Services.Decorate(); // Scrutor 等の拡張で提供
プラットフォーム差異の吸収
一部 API は OS により挙動が異なります。DI なら、プラットフォーム別の実装を差し替えるだけで UI コードを汚さずに済みます。
#if ANDROID
builder.Services.AddSingleton<IBrowser>(_ => new CustomTabsBrowser());
#else
builder.Services.AddSingleton<IBrowser>(_ => Browser.Default);
#endif
トリミングとサイズ最適化:DI とどう付き合うか
MAUI はトリミング(リンカ)により未使用コードを削除してパッケージを小型化します。Essentials のインターフェイスをむやみに全登録すると「コード参照はないのにコンテナ経由で到達可能」と見なされ、不要コードの温存につながることがあります。使う分だけ登録するのがベストです。
<PropertyGroup>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>link</TrimMode>
<InvariantGlobalization>true</InvariantGlobalization> <!-- 必要に応じて -->
</PropertyGroup>
反対に、リフレクションやコンテナ登録で到達性が曖昧になる場合は DynamicDependency 属性やルート記述(トリマー設定)で保護する方針を検討します。とはいえ、プラットフォーム‑ヘルパーの範囲では 「必要分のみ登録」 が最も安全で単純です。
ありがちな落とし穴と対策
- 解決不能(
InvalidOperationException): 登録漏れのまま ViewModel でIConnectivityを要求。対策:MauiProgram.csに明示登録、または静的 API を使う。 - UI スレッド制約:
ILauncher.OpenAsyncや共有シートは UI スレッドでの呼び出しが安全。対策:MainThread.InvokeOnMainThreadAsync経由で呼ぶ。 - プラットフォーム未対応: シミュレーターや権限無しでの例外。対策:
CanOpenAsync等の事前チェックと丁寧なエラーメッセージ。 - 過剰登録: 使わない API まで一括登録してサイズ増。対策:最小主義・段階追加。
- イベントのリーク:
IConnectivity.ConnectivityChangedを購読しっぱなし。対策:View のライフサイクルに合わせて解除、もしくはWeakEventManagerを活用。
中〜大規模向け:登録ガイドライン(実践テンプレ)
- 方針決定: 「テスト対象(ViewModel/サービス)では DI、軽いワンショットは静的 API」を原則にする。
- 拡張メソッド集中化:
AddMauiEssentials()に“必須最小セット”だけ定義。足りなければ PR で追記。 - TryAdd 系で衝突回避: 既に他の層が登録している可能性を考慮。
- プラットフォーム分岐はコンテナで:
#ifかIMauiInitializeServiceを使い、呼び出し側を汎化。 - テレメトリ装飾: ログ/メトリクスは Decorator で横断注入。ビジネスロジックと分離。
using Microsoft.Extensions.DependencyInjection.Extensions;
public static class MauiEssentialsRegistration
{
public static IServiceCollection AddMauiEssentialsCore(this IServiceCollection services)
{
services.TryAddSingleton<IConnectivity>(_ => Connectivity.Current);
services.TryAddSingleton<ILauncher>(_ => Launcher.Default);
// ...他、必要最小限...
return services;
}
}
ケーススタディ:オフライン対応と外部ブラウズを MVVM で仕上げる
要件
- オフライン時は「再試行」UI を出し、オンライン復帰を自動検知したらコンテンツを再取得。
- 「詳細を開く」リンクはアプリ内ブラウザ(可能なら)で、不可なら OS 既定ブラウザ。
DI 構成
builder.Services.AddMauiEssentials(); // IConnectivity / IBrowser / ILauncher など
builder.Services.AddSingleton<IOnlineState, OnlineStateService>(); // IConnectivity を監視して公開
実装骨子
public sealed class OnlineStateService : IOnlineState, IDisposable
{
private readonly IConnectivity _connectivity;
private readonly BehaviorSubject<bool> _online;
public IObservable<bool> Online => _online;
public OnlineStateService(IConnectivity connectivity)
{
_connectivity = connectivity;
_online = new BehaviorSubject<bool>(connectivity.NetworkAccess == NetworkAccess.Internet);
_connectivity.ConnectivityChanged += OnChanged;
}
private void OnChanged(object s, ConnectivityChangedEventArgs e)
=> _online.OnNext(e.NetworkAccess == NetworkAccess.Internet);
public void Dispose()
=> _connectivity.ConnectivityChanged -= OnChanged;
}
public sealed class ArticleViewModel
{
private readonly IBrowser _browser;
private readonly ILauncher _launcher;
public ArticleViewModel(IBrowser browser, ILauncher launcher)
=> (_browser, _launcher) = (browser, launcher);
public async Task OpenAsync(Uri uri)
{
// まずはアプリ内ブラウザ
try
{
await _browser.OpenAsync(uri, new BrowserLaunchOptions { LaunchMode = BrowserLaunchMode.SystemPreferred });
}
catch
{
// フォールバック:既定ブラウザ
await _launcher.OpenAsync(uri);
}
}
}
この分離により、UI は「オンライン/オフライン表示」と「リンクを開く」だけに専念できます。ネットワークの監視・UI スレッドの取り扱い・フォールバック戦略はそれぞれのサービスに閉じ込められ、テストも容易です。
権限・例外・ユーザー体験:本番品質のポイント
- 権限は体験の一部: 位置情報や通知など事前説明(教育的 UI)を挟む。DI で権限サービスを抽象化し、ダイアログ文言を一元化。
- 失敗前提のフロー:
IEmailなどは設定の有無で失敗しうる。Can*系の事前確認と、代替手段(クリップボードコピーなど)を用意。 - 例外は業務に翻訳: 端末設定、企業ポリシー、機内モードなど、ユーザーが取れる行動をメッセージで示す。
- ロギング: 外部アプリ起動・共有などの操作は匿名化してイベント計測。装飾パターンで非侵襲に実装。
FAQ:よくある疑問を先回りで解消
Q. 公式が DI に全部登録してくれた方が楽では?
A. 小規模では便利に見えますが、中大規模では“勝手に入る既定値”が足かせになります。必要最小限の Opt‑in が保守性とパフォーマンスの両方を守ります。
Q. すべての Essentials にインターフェイスがある?
A. 多くは用意されていますが、API により提供形態が異なります。前提にせず、必要な型だけを AddMauiEssentials に列挙しましょう。
Q. ライフサイクルは全部 Singleton?
A. ほとんどが Singleton で問題ありません。OS リソースにブリッジする性質上、ステートの分断は不利です。再入不可な API を並列呼び出しする必要があれば、SemaphoreSlim 等で呼び出し側を調整します。
Q. 既存フレームワーク(Prism/ReactiveUI など)と競合しない?
A. 競合しません。TryAdd* で既存登録を尊重し、必要箇所だけ差し替えれば共存可能です。
チェックリスト:プロジェクトに組み込む前に
- 使う Essentials を洗い出し、
AddMauiEssentials()に列挙したか - 登録は Singleton が基本方針になっているか
- 権限要求・失敗時メッセージ・フォールバックを設計したか
- UI スレッド制約を守るためのユーティリティを整備したか
- プラットフォーム差異をコンテナで吸収できる構造か
- 単体テスト用のモック/フェイクを用意したか
- トリミング/AOT と相性の悪い登録をしていないか(最小主義)
まとめ:DI は “選べる自由”。最小から賢く拡張する
.NET MAUI のプラットフォーム‑ヘルパーは、静的 API を優先しつつ、必要ならインターフェイスで DI できる 二本立ての設計です。既定で自動登録しないのは、速度・サイズ・自由度・互換性のバランスを取るため。現場では 最小構成の Opt‑in を出発点に、テスト、ロギング、プラットフォーム差異など“必要になった瞬間”にだけ拡張してください。それが最終的に、軽く、テストしやすく、長持ちする MAUI アプリを実現します。
付録:よく使う登録スニペット集
// 最小セット:ネットワーク + 外部起動
builder.Services.AddSingleton<IConnectivity>(_ => Connectivity.Current);
builder.Services.AddSingleton<ILauncher>(_ => Launcher.Default);
// 共有・ブラウズ・クリップボード
builder.Services.AddSingleton(_ => Share.Default);
builder.Services.AddSingleton(_ => Browser.Default);
builder.Services.AddSingleton(_ => Clipboard.Default);
// 位置情報・地図
builder.Services.AddSingleton(_ => Geolocation.Default);
builder.Services.AddSingleton(_ => Map.Default);
// セキュアストレージ
builder.Services.AddSingleton(_ => SecureStorage.Default);
// 事前条件チェックの例
public static async Task EnsureOnlineAsync(IConnectivity c)
{
if (c.NetworkAccess != NetworkAccess.Internet)
throw new InvalidOperationException("ネットワーク接続がありません。");
}
// UI スレッドでの呼び出しユーティリティ
public static Task OpenOnMainThreadAsync(ILauncher launcher, Uri uri)
=> MainThread.InvokeOnMainThreadAsync(() => launcher.OpenAsync(uri));

コメント