「.NET MAUI の AvatarView に画像が出ない」「カメラで撮ったのに UI が更新されない」――この現象は、バインディングや権限よりも “画像の渡し方(スキーム・キャッシュ・パス)” が原因であることが多いです。本記事は file:// スキーム問題を中心に、ローカル表示からクラウド運用まで、実務で“確実に反映される”設計とコードを一気通貫で解説します。
.NET MAUI で AvatarView に画像が表示されない問題の全体像
次のような構成で、AvatarView の ImageSource に LocalUser.ImagePath をバインドしているケースを想定します。
- ViewModel は
ObservableObject(CommunityToolkit.Mvvm)を継承 - 写真撮影(
TakePicture)や画像選択(ChangePicture)後にLocalUser.ImagePathを更新 - 値はローカル保存し、
LocalUser.ImagePath = new Uri(localFilePath).AbsoluteUri(file://形式)を設定
「値は変わっているのに UI が変わらない」「Android では出るが iOS では出ない」などの症状は、以下の原因が重なって発生します。
主な原因と押さえるべきポイント
file:// スキーム非対応(またはプラットフォーム差)の落とし穴
AvatarView は内部的に Image を使いますが、ファイルパス文字列(例:/data/user/0/.../photo.jpg)や HTTP/HTTPS URL を想定しています。file:// スキームの URI を渡すと、プラットフォームやバージョンによって解釈に失敗し、描画されないことがあります。特に iOS は URI とパスの扱いに厳密で、file:// ではなく 生のファイルパス を渡すのが安全です。
同一パスのキャッシュ問題
同じパス(同じファイル名)に上書き保存すると、Image 側のキャッシュやネイティブレベルの最適化で「同じ画像」とみなされ、UI が更新されないことがあります。これは「プロパティ値は変えているが文字列として同一」だったり、「内部キャッシュが有効」のどちらでも発生します。
クラウドにデバイス固有パスを保存する設計ミス
Firestore 等に /data/user/0/.../photo.jpg のようなローカルパスを保存しても、他端末からは無意味です。端末をまたいで同じアイコンを表示したい場合は、クラウドストレージに画像をアップロードし、HTTPS のダウンロード URL を保存・配布する必要があります。
| 項目 | よくある誤り | 正しい方針 |
|---|---|---|
| ローカル表示 | file://... を渡す | 生のファイルパスを渡す(/path/to/photo.jpg) |
| 更新直後に反映 | 同一パスに上書きして通知 | ファイル名を都度変える / キャッシュバスト / ImageSource を差し替える |
| 端末間共有 | Firestore にローカルパス保存 | Firebase Storage 等に画像アップロード → HTTPS URL を保存 |
最短で解決する 3 ステップ
| ステップ | 内容 | ポイント |
|---|---|---|
| ① 応急対応 | URI 変換をやめて LocalUser.ImagePath = localFilePath; と生のファイルパスを渡す | file:// を避け、MAUI がそのまま読める形式に |
| ② 切り分け | FileSystem.AppDataDirectory に置いた test.jpg のフルパスを直指定して表示確認 | バインディング・権限・ファイル有無などの切り分けに最適 |
| ③ 本番運用 | 画像を Firebase Storage にアップロードし、得られた HTTPS URL を保存&バインド | 端末間で同一 URL を参照でき、表示の安定性が高い |
まずは XAML と ViewModel の基本形を確認
XAML(CommunityToolkit.Maui の AvatarView)
<ContentPage
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
x:Class="SampleApp.Pages.ProfilePage"
x:DataType="vm:ProfileViewModel">
<Button Grid.Row="1" Text="写真を撮る" Command="{Binding TakePictureCommand}" />
<Button Grid.Row="2" Text="画像を選ぶ" Command="{Binding ChangePictureCommand}" />
<Label Grid.Row="3" Text="{Binding LocalUser.ImagePath}" FontSize="10" Opacity="0.6"/>
ViewModel(ObservableObject)
public partial class LocalUser : ObservableObject
{
[ObservableProperty]
private string? imagePath;
}
public partial class ProfileViewModel : ObservableObject
{
public LocalUser LocalUser { get; } = new();
public ProfileViewModel()
{
// 起動直後の初期画像(存在すれば表示)
var candidate = Path.Combine(FileSystem.AppDataDirectory, "avatar.jpg");
if (File.Exists(candidate))
LocalUser.ImagePath = candidate; // 生のパスを入れる
}
[RelayCommand]
private async Task TakePicture() => await AvatarService.UpdateByCameraAsync(LocalUser);
[RelayCommand]
private async Task ChangePicture() => await AvatarService.UpdateByPickerAsync(LocalUser);
}
生のファイルパスに切り替える:確実に表示させるローカル最短コード
ポイントは 3 つです。
- Picker/Camera から得た画像は 必ず AppDataDirectory にコピー保存(一部プラットフォームでは
FullPathを直接参照できない) - ファイル名は毎回ユニークにしてキャッシュを避ける
LocalUser.ImagePathには 生のパス(file://を付けない)を渡す
public static class AvatarService
{
public static async Task UpdateByCameraAsync(LocalUser user)
{
if (!MediaPicker.Default.IsCaptureSupported) return;
var fileResult = await MediaPicker.Default.CapturePhotoAsync();
if (fileResult is null) return;
var savedPath = await SaveToAppDataAsync(fileResult);
// キャッシュ回避のため、毎回ユニーク名で保存して差し替え
await MainThread.InvokeOnMainThreadAsync(() => user.ImagePath = savedPath);
}
public static async Task UpdateByPickerAsync(LocalUser user)
{
var fileResult = await MediaPicker.Default.PickPhotoAsync();
if (fileResult is null) return;
var savedPath = await SaveToAppDataAsync(fileResult);
await MainThread.InvokeOnMainThreadAsync(() => user.ImagePath = savedPath);
}
private static async Task<string> SaveToAppDataAsync(FileResult fileResult)
{
// App 専用領域へコピー(クロスプラットフォームで安全)
var ext = Path.GetExtension(fileResult.FileName);
var fileName = $"avatar_{DateTime.UtcNow:yyyyMMdd_HHmmssfff}{ext}";
var destPath = Path.Combine(FileSystem.AppDataDirectory, fileName);
using var src = await fileResult.OpenReadAsync();
using var dst = File.OpenWrite(destPath);
await src.CopyToAsync(dst);
return destPath;
}
}
この実装により、AvatarView は ローカルパス=画像ファイルとして素直に読み込み、file:// スキーム起因の非表示を回避できます。さらに毎回ユニークなファイル名で保存するため、同一パス上書きによるキャッシュ問題も避けられます。
それでも更新されない? キャッシュと通知の落とし穴を潰す
プロパティ通知が同値として握り潰される問題
[ObservableProperty] が生成する SetProperty は、値が同じなら PropertyChanged を発火しません。例えば同一の URL 文字列や同一のファイルパスに上書き保存した場合は通知が起きず、UI が更新されません。
対処としては次のいずれかを選びます。
- ファイル名を変える(おすすめ)
- URL の末尾にクエリ
?v=timestampを付けて キャッシュバストする - 一度
nullを入れてから新しい値を再セット(最終手段)
// 最終手段:一旦 null にしてから再代入(連打は避ける)
user.ImagePath = null;
user.ImagePath = newPath;
ImageSource を直接差し替える設計
パス文字列ではなく ImageSource を公開し、ImageSource.FromFile や UriImageSource を生成して差し替えるのも堅い方法です。キャッシュ制御がしやすく、同値判定の影響も受けにくくなります。
public partial class LocalUser : ObservableObject
{
[ObservableProperty] private ImageSource? avatarSource;
}
public static class AvatarService2
{
public static async Task UpdateLocalAsync(LocalUser user, string filePath)
{
var src = ImageSource.FromFile(filePath);
await MainThread.InvokeOnMainThreadAsync(() => user.AvatarSource = src);
}
public static async Task UpdateRemoteAsync(LocalUser user, string url)
{
var src = new UriImageSource { Uri = new Uri(url), CachingEnabled = true, CacheValidity = TimeSpan.FromDays(7) };
await MainThread.InvokeOnMainThreadAsync(() => user.AvatarSource = src);
}
}
実運用:Firebase Storage にアップロードして HTTPS URL をバインド
ユーザー同士、端末をまたいで同じアイコンを表示するには、画像自体をクラウドへ保存し、ダウンロード URL(HTTPS)を LocalUser.ImagePath に格納するのが王道です。以下は Firebase Storage を使う例です(FirebaseStorage パッケージを利用)。
[RelayCommand]
public async Task TakePicture()
{
if (!MediaPicker.Default.IsCaptureSupported) return;
var photo = await MediaPicker.Default.CapturePhotoAsync();
if (photo is null) return;
// 1) まずは AppData に保存(ローカル表示の即時反映にも使える)
var localPath = await AvatarService.SaveToAppDataAsync(photo);
// 2) Firebase Storage にアップロード
using var src = File.OpenRead(localPath);
var storage = new FirebaseStorage("your-bucket"); // 例: "your-project-id.appspot.com"
var fileName = $"{Auth.UserId}/{DateTime.UtcNow:yyyyMMdd_HHmmss}.jpg";
// アップロード
var upload = await storage.Child("profile_pics").Child(fileName).PutAsync(src);
// ダウンロード URL を取得(認証/ルール設定はプロジェクト要件に合わせる)
var download = await storage.Child("profile_pics").Child(fileName).GetDownloadUrlAsync();
// 3) 取得した HTTPS URL を保存&バインド
await MainThread.InvokeOnMainThreadAsync(() => LocalUser.ImagePath = download);
// 4) 任意:ローカルの古い一時ファイルを削除
TryDeleteOldAvatarFiles();
}
この設計にすると、アプリを再インストールしても、別端末でも、同じ URL を参照するだけでアイコンを表示できます。さらに URL 末尾に ?v=<ticks> を付ければ、更新の即時反映も簡単です。
権限・配置・プラットフォーム差の実務ノウハウ
Android 13+ の権限
Android 13(API 33)以降はメディア権限が細分化されました。ギャラリーから選ぶ場合は READ_MEDIA_IMAGES が必要です。カメラは CAMERA を宣言します。
<manifest ...>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- Android 12 以前も対応するなら -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
</manifest>
iOS の権限
iOS では Info.plist に利用目的説明が必要です。
<key>NSCameraUsageDescription</key><string>プロフィール写真を撮影します。</string>
<key>NSPhotoLibraryUsageDescription</key><string>プロフィール写真を選択します。</string>
<key>NSPhotoLibraryAddUsageDescription</key><string>撮影した写真を保存します。</string>
スキームとパスの早見表
| OS | Picker/Capture の戻り | 安全な渡し方 | 注意点 |
|---|---|---|---|
| Android | content:// や実ファイルパス | AppDataDirectory にコピーして生パス | 同名上書きはキャッシュに注意 |
| iOS | 一時領域のパス | AppDataDirectory にコピーして生パス | file:// より生パスが無難 |
| Windows | ファイルパス | 生パス(\\ ではなく API に任せる) | 一時領域は清掃される可能性 |
| MacCatalyst | ファイルパス | 生パス | 権限記述とサンドボックスに注意 |
デバッグのためのサニティチェック
症状の切り分けは「既知の良いファイルを既知の良い場所から読めるか」を確認することから始めます。
テスト手順
FileSystem.AppDataDirectoryにtest.jpgを配置LocalUser.ImagePath = Path.Combine(FileSystem.AppDataDirectory, "test.jpg");を実行- 表示されれば「AvatarView とバインディング」は問題なし → 撮影/選択/保存のどこかに原因がある
よくある落とし穴と対処表
| 症状 | 想定原因 | 対処 |
|---|---|---|
| 表示されない(null 例外なし) | file:// スキーム/存在しないパス | 生パスに変更/File.Exists で確認 |
| 撮影後だけ反映されない | 同名上書きでキャッシュ | ファイル名をタイムスタンプでユニークに |
| ギャラリー選択で失敗 | 権限不足(Android 13+) | READ_MEDIA_IMAGES を宣言・リクエスト |
| iOS でだけ出ない | file:// 解釈/一時領域 | AppData にコピーし、生パスを渡す |
| 更新しても古い画像のまま | URL/パスが同一で通知されない | キャッシュバスト/ファイル名変更/null→再代入 |
UX を上げる実装テクニック
アップロード前の即時プレビュー
撮影・選択直後にローカルパスを ImageSource に設定 → 画面上は即時更新。アップロード完了後、HTTPS URL に差し替える 2 段階表示にすると体感が軽くなります。
// 1) 即時プレビュー
user.ImagePath = localPath;
// 2) バックグラウンドでアップロード完了後
user.ImagePath = $"{downloadUrl}?v={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
Orientation 問題(回転画像)
EXIF の回転情報が無視されて縦横が崩れる場合があります。サーバー保存前にライブラリで自動回転して正規化しておくと安定します(例:SkiaSharp / ImageSharp などで読み込み → 回転補正 → エンコード)。
メモリ圧迫対策
- 保存前に長辺を 1024〜2048px 程度に縮小
- JPEG 品質 80–90 程度で十分
- サムネイル用に別途小サイズを生成
View の更新を“確実に”起こすための設計チェックリスト
- パスの表記:
file://ではなく、生のファイルパスを渡しているか - 通知の発火:
SetPropertyによりPropertyChangedが発火しているか(同値で抑制されていないか) - キャッシュ:同一パス上書きになっていないか(ファイル名をユニークにしているか)
- 権限:Android13+ で
READ_MEDIA_IMAGES、iOS のInfo.plistなどを宣言しているか - 保存先:
AppDataDirectoryにコピーしてから表示しているか - クラウド運用:Firebase Storage 等で HTTPS URL を配布しているか
「実装丸ごと」サンプル(ローカル→クラウド切替まで)
以下は、撮影・選択・ローカル保存・アップロード・URL 置き換えまでを一体化した ViewModel 例です。
public partial class ProfileViewModel : ObservableObject
{
public LocalUser LocalUser { get; } = new();
[ObservableProperty] private bool isUploading;
[ObservableProperty] private string? errorMessage;
[RelayCommand]
public async Task TakePicture()
{
try
{
ErrorMessage = null;
if (!MediaPicker.Default.IsCaptureSupported) return;
var photo = await MediaPicker.Default.CapturePhotoAsync();
if (photo is null) return;
// 即時プレビュー
var local = await AvatarService.SaveToAppDataAsync(photo);
LocalUser.ImagePath = local;
// バックグラウンドアップロード
IsUploading = true;
var url = await UploadToFirebaseAsync(local);
LocalUser.ImagePath = $"{url}?v={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
}
catch (Exception ex)
{
ErrorMessage = ex.Message;
}
finally
{
IsUploading = false;
}
}
[RelayCommand]
public async Task ChangePicture()
{
try
{
ErrorMessage = null;
var picked = await MediaPicker.Default.PickPhotoAsync();
if (picked is null) return;
var local = await AvatarService.SaveToAppDataAsync(picked);
LocalUser.ImagePath = local;
IsUploading = true;
var url = await UploadToFirebaseAsync(local);
LocalUser.ImagePath = $"{url}?v={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
}
catch (Exception ex)
{
ErrorMessage = ex.Message;
}
finally
{
IsUploading = false;
}
}
private async Task<string> UploadToFirebaseAsync(string localPath)
{
using var stream = File.OpenRead(localPath);
var storage = new FirebaseStorage("your-bucket");
var fileName = $"{Auth.UserId}/{DateTime.UtcNow:yyyyMMdd_HHmmss}.jpg";
var upload = await storage.Child("profile_pics").Child(fileName).PutAsync(stream);
var download = await storage.Child("profile_pics").Child(fileName).GetDownloadUrlAsync();
return download;
}
}
このパターンは、即時表示(ローカル)と永続共有(クラウド)の双方を満たしつつ、キャッシュ問題も吸収します。
より堅牢に:テストとログで “どこで止まったか” を可視化する
- 存在確認:
File.Exists(LocalUser.ImagePath)を UI スレッドで評価し、Labelに表示 - 例外ログ:
try-catchで出力し、Android の場合はadb logcat、iOS はConsoleを確認 - PropertyChanged トレース:
WeakReferenceMessengerで変更イベントを拾い、バインドが動作しているか検証
partial void OnImagePathChanged(string? oldValue, string? newValue)
{
Debug.WriteLine($"Avatar changed: {oldValue} -> {newValue}, exists={File.Exists(newValue ?? string.Empty)}");
}
パフォーマンスとキャッシュの考え方
リモート URL を使う場合は UriImageSource の CachingEnabled / CacheValidity を活用し、サーバー側も CDN キャッシュを活用します。更新頻度が低いプロフィール画像は、長めのキャッシュ+クエリでバストの併用が最もシンプルです。
var src = new UriImageSource {
Uri = new Uri($"{downloadUrl}?v={user.AvatarVersion}"),
CachingEnabled = true,
CacheValidity = TimeSpan.FromDays(30)
};
LocalUser.AvatarSource = src;
セキュリティと保守
- ストレージルール:ユーザーが自分の画像のみ読書きできるルールを設定
- 公開 URL の扱い:意図せぬ共有を避けるため、必要に応じて期限付き URL を用意
- 後片付け:古いバージョンの画像やローカル一時ファイルを定期削除
まとめ:この順番で直すと早い
- file:// をやめ、生のファイルパスを渡す
- AppDataDirectory にコピーしてから表示する
- ファイル名を毎回ユニークにしてキャッシュを避ける(or URL にバージョン付与)
- HTTPS URL(Firebase Storage 等)で端末間共有を設計する
この 4 点を守れば、AvatarView に撮影/選択した画像が確実に反映され、かつ他端末でも同じ URL で共有できる運用に移行できます。
付録:最小再現からの段階的チェック用コード
1) 既知の画像を表示できるか
var test = Path.Combine(FileSystem.AppDataDirectory, "test.jpg");
LocalUser.ImagePath = File.Exists(test) ? test : null;
2) カメラ/ピッカー結果をそのまま読めるか
var result = await MediaPicker.Default.PickPhotoAsync();
using var s = await result.OpenReadAsync();
Debug.WriteLine($"Length={s.Length}, Name={result.FileName}");
3) コピー保存後に確実に表示できるか
var path = await AvatarService.SaveToAppDataAsync(result);
LocalUser.ImagePath = path;
Debug.WriteLine($"Exists: {File.Exists(path)}");
4) URL 置き換えで更新されるか
LocalUser.ImagePath = $"{downloadUrl}?v={DateTime.UtcNow.Ticks}";
FAQ
Q. ImageSource に Stream を渡したい
ImageSource.FromStream(() => stream) は、デリゲートが呼ばれる時点で Stream が生きている必要があります。外側で using してすぐ破棄すると描画時に例外になるため、new MemoryStream() に詰め替えて返すのが安全です。
ImageSource FromSafeStream(Stream original)
{
var ms = new MemoryStream();
original.CopyTo(ms);
ms.Position = 0;
return ImageSource.FromStream(() => ms);
}
Q. Windows だけで動かしたい
Windows はローカルパスに寛容ですが、将来のモバイル展開を考えると AppData 保存+生パス表示のパターンに寄せておくと移植が容易です。
Q. FFImageLoading を使うべき?
画像のキャッシュ・変換・プレースホルダーなどが必要なら導入の価値があります。一方で、AvatarView のプロフィール用途は画像サイズが小さく、まずは標準の ImageSource 設計+ファイル名バージョニングで十分に実用的です。
結論
「AvatarView に画像が出ない」問題の多くは、file:// を渡している/同一パスに上書きしていることが原因です。生パス+ユニーク名のローカル表示にまず切り替え、次に Firebase Storage の HTTPS URL を保存・配布する設計へ移行しましょう。これで UI 反映の確実性と、ユーザー体験・保守性の両立が実現できます。

コメント