Unityを触り始めたころは、つい何でもかんでも Update に書いてしまいがちですよね。入力処理も、UI更新も、セーブも、例外処理も、全部ひとつのスクリプトに押し込んでしまう…。
その結果、どこで何が起きているのか分からなくなり、いざバグやクラッシュが起きたときに「ログが追えない」「再現できない」という地獄にハマりがちです。
そこでこの記事では、「クラッシュやエラーが起きたときの対応だけ」を担当する小さなコンポーネントとして、CrashReporter を用意します。
ゲームが例外で止まったり、深刻なエラーが出たときに、その情報を自動で開発者サーバーに送信する仕組みをコンポーネント化しておくと、巨大なGodクラスにせずに、プロジェクト全体の監視体制をスッキリ整えられます。
【Unity】クラッシュを自動で見逃さない!「CrashReporter」コンポーネント
ここでは、
- Unityのログコールバック(
Application.logMessageReceived)を使ってエラー・例外を検知 - 重要な情報(メッセージ、スタックトレース、プラットフォーム、ビルド番号など)をまとめる
- 開発者サーバーに HTTP POST で送信する
- エディタ実行中はテストのためにログをコンソールにも出す
という流れを、ひとつのコンポーネントに閉じ込めます。
フルコード:CrashReporter.cs
using System;
using System.Collections;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
/// <summary>
/// クラッシュやエラー発生時に、ログを開発者サーバーへ送信するコンポーネント。
/// ゲーム全体で1つだけ配置しておき、ログコールバックをフックします。
/// </summary>
public class CrashReporter : MonoBehaviour
{
// ====== 設定項目(インスペクタから編集) ======
[Header("送信先設定")]
[SerializeField]
private string reportEndpointUrl = "https://example.com/api/crash-report";
[Tooltip("レポート送信時に付与するゲームIDやプロジェクト名")]
[SerializeField]
private string gameId = "MyAwesomeGame";
[Tooltip("このビルドのバージョン。Application.versionと合わせてもOK")]
[SerializeField]
private string buildVersion = "1.0.0";
[Header("送信条件")]
[Tooltip("Error, Assert, Exception ログを送信対象にするか")]
[SerializeField]
private bool reportOnError = true;
[Tooltip("Warning ログも送信対象にするか(ノイズが増えるので通常はOFF推奨)")]
[SerializeField]
private bool reportOnWarning = false;
[Header("動作オプション")]
[Tooltip("エディタ上でのプレイ中にも送信するか(通常はテスト用にON)")]
[SerializeField]
private bool enableInEditor = true;
[Tooltip("1セッション中に送信する最大レポート件数(無限送信を防ぐため)")]
[SerializeField]
private int maxReportsPerSession = 5;
[Tooltip("レポート送信に失敗したときにリトライするか")]
[SerializeField]
private bool retryOnFailure = true;
[Tooltip("リトライする場合の最大試行回数")]
[SerializeField]
private int maxRetryCount = 3;
[Tooltip("リトライ間隔(秒)")]
[SerializeField]
private float retryIntervalSeconds = 5f;
[Header("デバッグ表示")]
[Tooltip("送信内容をコンソールに表示するか(開発中に便利)")]
[SerializeField]
private bool logToConsole = true;
// ====== 内部状態 ======
// このセッション中に送信したレポート件数
private int _sentReportCount = 0;
// 既に破棄されているか(OnDestroy後にコールバックが飛んでくるのを防ぐフラグ)
private bool _isShuttingDown = false;
// ログコールバックを多重登録しないためのフラグ
private bool _isRegistered = false;
// Unity標準のログタイプをJSONに載せるためのシリアライズ用クラス
[Serializable]
private class CrashReportPayload
{
public string gameId;
public string buildVersion;
public string unityVersion;
public string platform;
public string deviceModel;
public string deviceName;
public string systemLanguage;
public string logType;
public string logMessage;
public string stackTrace;
public string timestampUtc;
public string sessionId;
}
// セッションID(アプリ起動ごとに一意になるようにGUIDを生成)
private string _sessionId;
private void Awake()
{
// シーンをまたいでも生き続けるレポーターにしたい場合はコメントアウトを外す
// DontDestroyOnLoad(gameObject);
_sessionId = Guid.NewGuid().ToString();
RegisterLogCallback();
}
private void OnEnable()
{
RegisterLogCallback();
}
private void OnDisable()
{
UnregisterLogCallback();
}
private void OnDestroy()
{
_isShuttingDown = true;
UnregisterLogCallback();
}
/// <summary>
/// ログコールバックを登録する。
/// Awake/OnEnable から呼ばれるが、多重登録を避ける。
/// </summary>
private void RegisterLogCallback()
{
if (_isRegistered) return;
Application.logMessageReceived += HandleLogMessage;
_isRegistered = true;
}
/// <summary>
/// ログコールバック登録を解除する。
/// </summary>
private void UnregisterLogCallback()
{
if (!_isRegistered) return;
Application.logMessageReceived -= HandleLogMessage;
_isRegistered = false;
}
/// <summary>
/// Unityのログが出力されたときに呼ばれるコールバック。
/// ここでエラーや例外ログを検知してレポート送信をトリガーします。
/// </summary>
private void HandleLogMessage(string condition, string stackTrace, LogType type)
{
#if UNITY_EDITOR
// エディタで無効化されている場合は何もしない
if (!enableInEditor)
{
return;
}
#endif
if (_isShuttingDown)
{
return;
}
// ログタイプに応じて送信対象かどうか判定
if (!ShouldReport(type))
{
return;
}
// セッション上限を超えていたら送信しない
if (_sentReportCount >= maxReportsPerSession)
{
if (logToConsole)
{
Debug.Log($"[CrashReporter] セッション中の最大送信件数({maxReportsPerSession})に達したため、これ以上は送信しません。");
}
return;
}
// レポート用ペイロードを組み立てる
var payload = BuildPayload(condition, stackTrace, type);
// 送信処理をコルーチンで実行
StartCoroutine(SendReportCoroutine(payload));
}
/// <summary>
/// このログタイプをレポート対象にするかどうか。
/// </summary>
private bool ShouldReport(LogType type)
{
switch (type)
{
case LogType.Error:
case LogType.Assert:
case LogType.Exception:
return reportOnError;
case LogType.Warning:
return reportOnWarning;
case LogType.Log:
default:
return false;
}
}
/// <summary>
/// 送信用のペイロードを構築する。
/// </summary>
private CrashReportPayload BuildPayload(string message, string stackTrace, LogType type)
{
return new CrashReportPayload
{
gameId = gameId,
buildVersion = string.IsNullOrEmpty(buildVersion) ? Application.version : buildVersion,
unityVersion = Application.unityVersion,
platform = Application.platform.ToString(),
deviceModel = SystemInfo.deviceModel,
deviceName = SystemInfo.deviceName,
systemLanguage = Application.systemLanguage.ToString(),
logType = type.ToString(),
logMessage = message,
stackTrace = stackTrace,
timestampUtc = DateTime.UtcNow.ToString("o"), // ISO 8601 形式
sessionId = _sessionId
};
}
/// <summary>
/// クラッシュレポートをHTTP POSTで送信するコルーチン。
/// 必要に応じてリトライも行う。
/// </summary>
private IEnumerator SendReportCoroutine(CrashReportPayload payload)
{
_sentReportCount++;
// JSONにシリアライズ
string json = JsonUtility.ToJson(payload);
if (logToConsole)
{
Debug.Log($"[CrashReporter] レポート送信開始: {payload.logType} / {payload.logMessage}");
Debug.Log($"[CrashReporter] Payload JSON: {json}");
}
int attempt = 0;
bool success = false;
while (attempt <= maxRetryCount && !success)
{
attempt++;
using (var request = new UnityWebRequest(reportEndpointUrl, UnityWebRequest.kHttpVerbPOST))
{
byte[] bodyRaw = Encoding.UTF8.GetBytes(json);
request.uploadHandler = new UploadHandlerRaw(bodyRaw);
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Content-Type", "application/json");
yield return request.SendWebRequest();
#if UNITY_2020_2_OR_NEWER
bool hasNetworkError = request.result == UnityWebRequest.Result.ConnectionError ||
request.result == UnityWebRequest.Result.ProtocolError;
#else
bool hasNetworkError = request.isNetworkError || request.isHttpError;
#endif
if (hasNetworkError)
{
if (logToConsole)
{
Debug.LogWarning($"[CrashReporter] レポート送信失敗 (試行 {attempt}/{maxRetryCount + 1}) : {request.error}");
}
if (!retryOnFailure || attempt > maxRetryCount)
{
// リトライしない or 最大試行回数を超えたら終了
yield break;
}
}
else
{
if (logToConsole)
{
Debug.Log($"[CrashReporter] レポート送信成功 (ステータスコード: {request.responseCode})");
}
success = true;
}
}
// 成功していない && まだリトライする場合は待機
if (!success && retryOnFailure && attempt <= maxRetryCount)
{
yield return new WaitForSeconds(retryIntervalSeconds);
}
}
}
// ====== テスト用メソッド(任意で使用) ======
/// <summary>
/// 人為的に例外を投げて、CrashReporterの動作をテストするためのメソッド。
/// デバッグ用にインスペクタから呼び出しても良いです。
/// </summary>
[ContextMenu("Test Crash Report (Throw Exception)")]
private void TestCrashReport()
{
throw new Exception("CrashReporter テスト用の例外です。");
}
}
使い方の手順
-
コンポーネントを作成する
上記のコードをCrashReporter.csという名前でAssets/フォルダ内に保存します。 -
監視用オブジェクトを用意する
シーン内に空の GameObject を作成し、名前をCrashReporterなどにします。
その GameObject にCrashReporterコンポーネントをアタッチします。
(ゲーム全体で1つだけあればよいので、タイトルシーンや常駐マネージャーシーンに置くのがおすすめです) -
送信先URLなどを設定する
インスペクタから以下を設定します:Report Endpoint Url:クラッシュレポートを受け取る自前サーバーのURL(例:https://your-domain.com/api/crash)。Game Id:プロジェクト名やゲームID。Build Version:ビルド番号(空ならApplication.versionが使われます)。Max Reports Per Session:1起動あたり何件まで送るか。Enable In Editor:エディタ実行中にも送るかどうか。
-
実際のゲームオブジェクトに影響を与えずにテストする
- インスペクタのコンテキストメニューから
Test Crash Report (Throw Exception)を実行すると、意図的に例外を発生させて送信フローを確認できます。 - または、プレイヤーや敵のスクリプト内で
throw new Exception("テスト");を書いて、クラッシュ時の動作を確認してもOKです。
- インスペクタのコンテキストメニューから
例として:
- プレイヤーコンポーネントの中で、セーブデータ読み込みに失敗したときに
Debug.LogExceptionを呼ぶと、そのログが CrashReporter にフックされて自動送信されます。 - 敵AI の状態遷移で想定外のパスに入ったときに、
Debug.LogError("Unexpected state ...");を出しておけば、そのエラーもサーバー側で集計できます。 - 動く床 やギミックのスクリプトで、物理挙動の不整合を検知したときに
Debug.LogWarningを出すようにしておき、Report On Warningをオンにすれば、ソフトクラッシュ(ゲームは止まらないが危険な状態)も拾えます。
メリットと応用
CrashReporter をひとつのコンポーネントとして分離しておくと、
- 各プレイヤー/敵/UIスクリプトは「自分の責務」である挙動だけに集中できる
- 例外や致命的エラーが起きても、「どこでどう送るか」を毎回書かなくてよい
- プレハブやシーンの増減に関係なく、「CrashReporter を1つ置いておけば監視は完了」というシンプルな構成になる
- 本番ビルドでのみ送信、有料版のみ送信、といった切り替えも CrashReporter 1か所の設定で制御できる
レベルデザイン面でも、たとえば「特定のステージでだけクラッシュが多い」といった情報をサーバー側で可視化できるようになるため、問題のあるステージを素早く特定して修正できます。
また、プレイヤーからの「落ちました」という報告だけに頼らず、実際のスタックトレース付きで原因を追えるのは大きなメリットです。
応用として、例えば「特定のエラーだけは即時ポップアップを出す」といった UI 連携も簡単に追加できます。
下記は、致命的な例外が来たときだけゲームを一時停止する改造案です。
/// <summary>
/// 致命的な例外が来たときにゲームを一時停止する例。
/// CrashReporter 内の HandleLogMessage の最後に追加するイメージです。
/// </summary>
private void PauseGameOnException(LogType type)
{
if (type == LogType.Exception)
{
// Time.timeScale を 0 にしてゲームを一時停止
Time.timeScale = 0f;
if (logToConsole)
{
Debug.Log("[CrashReporter] 例外発生のためゲームを一時停止しました。");
}
}
}
このように、CrashReporter をベースに、「ログをどう扱うか」「どの種類のエラーでどんな挙動を取るか」を少しずつ拡張していくと、
巨大なGodクラスに頼らずとも、堅牢で観測可能なゲーム基盤を作っていけます。
