unity-lifecycle について
unity-lifecycle は、Unity 開発者向けに MonoBehaviour のライフサイクルとスクリプト実行順序の正確な扱い方をまとめたスキルガイドです。破棄のタイミング、初期化、エディターと実行時の違いに焦点を当てています。
主な機能
- 01Unity の fake-null 判定パターン
- 02遅延 Destroy と安全な反復処理
- 03Awake、Start、有効化のタイミング
- 04イベントの登録と解除
- 05エディターコールバックと実行順序
- 06アプリ終了と非同期破棄への対応
活用シーン
- 01MissingReferenceException の調査
- 02コンポーネント初期化依存の順序付け
- 03オブジェクトプールでのイベント登録管理
- 04エディターとビルドの挙動差の回避
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ nice-wolf-studio/ unity-claude-skills --skill unity-lifecycleインストール後、Claude Code で/unity-lifecycleを入力して読み込まれたか確認します。
同梱ファイルreferences/editor-runtime-init.md
詳細
Unity のライフサイクルと実行順序 — 正確性を確保するパターン
前提となるスキル:
unity-scripting(MonoBehaviour のライフサイクル、コルーチン)、unity-foundations(GameObject、コンポーネント)
これらのパターンは、初期化のバグ、破棄のタイミングに起因する null 参照例外、「エディターでは動くのに、ビルドでは動かない」問題を引き起こす、エディターと実行時の微妙な違いを対象としています。
パターン:フェイク null の落とし穴(破棄されたオブジェクトに対する ?. と ??)
WHEN:破棄されている可能性のある Unity オブジェクトの null チェック
WRONG(Claude の既定):
// C# null-conditional and null-coalescing bypass Unity's == override
myComponent?.DoSomething(); // May call method on destroyed object!
var fallback = myComponent ?? other; // May return a destroyed "fake-null" object!
RIGHT:
// Unity overrides == to return true for destroyed objects
// Always use == null or implicit bool conversion
if (myComponent != null)
myComponent.DoSomething();
// Or use the implicit bool operator (equivalent to != null for UnityEngine.Object)
if (myComponent)
myComponent.DoSomething();
落とし穴:Unity がオブジェクトを破棄すると、C# の参照は残りますが、Unity はそれを「フェイク null」として扱います。== 演算子はこれを処理するようオーバーライドされていますが、?.、??、is null、is not null は C# 標準の null チェックを使うため、有効な(null ではない)参照として扱います。これは MissingReferenceException の最大の原因です。パターンマッチング(obj is MyType t)もオーバーライドを回避するため、if (obj != null && obj is MyType t) を使用してください。
パターン:Destroy は遅延実行される
WHEN:オブジェクトを破棄し、同じフレーム内でアクセスする場合
WRONG(Claude の既定):
// Expecting immediate removal
Destroy(enemy);
enemies.Remove(enemy); // enemy still exists this frame
Debug.Log(enemies.Count); // Count is correct, but enemy is "alive" until end of frame
// Iterating and destroying
foreach (var e in enemies)
if (e.health <= 0)
Destroy(e.gameObject); // Modifying collection during iteration = crash
RIGHT:
// Destroy happens at END of current frame (after all Updates complete)
Destroy(enemy);
// enemy is still accessible this frame, but == null returns true
// Safe iteration: collect then destroy
var toDestroy = enemies.Where(e => e.health <= 0).ToList();
foreach (var e in toDestroy)
{
enemies.Remove(e);
Destroy(e.gameObject);
}
// If you truly need immediate destruction (EDITOR ONLY):
// DestroyImmediate(obj); // Never use in runtime code
落とし穴:Destroy はフレームの終了時に破棄を予約します。Destroy() の直後はオブジェクトの == null チェックが true を返しますが、OnDisable と OnDestroy は後から実行されます。DestroyImmediate は同期的に実行されますが、エディタースクリプトでのみ安全です。実行時に使用すると、デバッグが難しい順序の問題が発生します。Destroy(obj, delay) は、破棄を予約するまで delay 秒待機します。
パターン:無効化されたコンポーネントでも Awake は実行される
WHEN:Inspector でコンポーネントのチェックボックスをオフにして開始する場合
WRONG(Claude の既定):
// Assuming Awake is skipped for disabled components
// "My Awake runs even though the component is disabled -- bug?"
RIGHT:
// Awake ALWAYS runs if the GAMEOBJECT is active (regardless of component enabled state)
// Start is SKIPPED if the component is disabled at Start time
// Start runs later when the component is first enabled
void Awake()
{
// This runs even if this component is disabled
// Use for self-initialization (cache references, set defaults)
_rb = GetComponent<Rigidbody>();
}
void Start()
{
// This is DEFERRED until the component is first enabled
// Use for cross-references that depend on other objects being initialized
_target = FindObjectOfType<Player>();
}
void OnEnable()
{
// Runs every time the component is enabled (including the first time)
// Runs AFTER Awake but BEFORE Start on first enable
SubscribeToEvents();
}
落とし穴:重要な違いは、Awake が GameObject のアクティブ状態に依存し、Start と OnEnable が コンポーネント の有効状態に依存することです。GameObject が非アクティブ(SetActive(false))の状態で開始した場合、GameObject がアクティブになるまで Awake も Start も実行されません。GameObject がアクティブになると、Awake は直ちに実行され、コンポーネントが有効なら OnEnable が実行され、コンポーネントが有効なら次のフレームに Start が実行されます。
パターン:イベントの登録には OnEnable/OnDisable を使う
WHEN:イベント、デリゲート、または Unity コールバックを登録する場合
WRONG(Claude の既定):
void Start()
{
EventManager.OnPlayerDied += HandlePlayerDied;
}
void OnDestroy()
{
EventManager.OnPlayerDied -= HandlePlayerDied;
}
// BUG: If object is disabled/re-enabled, events accumulate
// BUG: If scene reloads, Start doesn't re-run for DontDestroyOnLoad objects
RIGHT:
void OnEnable()
{
EventManager.OnPlayerDied += HandlePlayerDied;
SceneManager.sceneLoaded += OnSceneLoaded;
}
void OnDisable()
{
EventManager.OnPlayerDied -= HandlePlayerDied;
SceneManager.sceneLoaded -= OnSceneLoaded;
}
// Correctly handles: disable/enable cycles, scene reloads, destruction
落とし穴:OnEnable/OnDisable は対になるメソッドです。コンポーネントの有効化/無効化、GameObject のアクティブ化/非アクティブ化、シーンの読み込み/アンロード、そして OnDestroy の前に実行されます。オブジェクトがプールされている場合(破棄せずに無効化/有効化する場合)や、DontDestroyOnLoad オブジェクトがシーンの再読み込み後も存続する場合、Start/OnDestroy を使う方法では問題が発生します。
パターン:OnValidate はエディター専用
WHEN:OnValidate を使ってコンポーネントの状態を初期化または検証する場合
WRONG(Claude の既定):
// Relying on OnValidate for runtime initialization
void OnValidate()
{
_maxHealth = Mathf.Max(1, _maxHealth);
_currentHealth = _maxHealth; // This never runs in builds!
}
RIGHT:
// OnValidate: Editor-only, for Inspector feedback and clamping serialized fields
#if UNITY_EDITOR
void OnValidate()
{
_maxHealth = Mathf.Max(1, _maxHealth);
}
#endif
// Runtime initialization belongs in Awake or Reset
void Awake()
{
_currentHealth = _maxHealth;
}
// Reset: Editor-only, called when component is first added or Reset from context menu
void Reset()
{
_maxHealth = 100;
}
落とし穴:ビルドでは OnValidate は完全に除外されます。Inspector でシリアライズされたフィールドが変更されたとき、プレハブが変更されたとき、またはスクリプトが再コンパイルされたときにエディターで実行されます。プレイモードの開始時には実行されません。OnValidate 内で GetComponent を呼び出すのは危険です。コンポーネントがまだ完全に初期化されていない場合があります。副作用のない検証は #if UNITY_EDITOR で囲んでください。
パターン:[ExecuteAlways] の更新タイミング
WHEN:編集モードでの動作に [ExecuteAlways] または [ExecuteInEditMode] を使う場合
WRONG(Claude の既定):
[ExecuteAlways]
public class LookAtTarget : MonoBehaviour
{
[SerializeField] Transform target;
void Update()
{
// Expecting this to run every frame in edit mode
transform.LookAt(target);
}
}
RIGHT:
[ExecuteAlways]
public class LookAtTarget : MonoBehaviour
{
[SerializeField] Transform target;
void Update()
{
// In Edit mode: Update only runs when the Scene view repaints
// (camera moves, something changes, inspector edited)
// NOT every frame like Play mode
if (!target) return; // Safety: references may not exist in edit mode
#if UNITY_EDITOR
if (!Application.isPlaying)
{
// Edit-mode-specific logic
transform.LookAt(target);
return;
}
#endif
// Play-mode logic (runs every frame as normal)
transform.LookAt(target);
}
}
落とし穴:編集モードでは、Update はフレームごとではなく、Scene ビューが再描画されるときにのみ実行されます。編集モードでの Time.deltaTime は信頼できません。Application.isPlaying を使ってエディターとプレイモードを区別します。[ExecuteAlways](Unity 2018.3 以降)は [ExecuteInEditMode] より推奨されます。古い属性には、プレハブを単独で編集する際に問題があります。シーンが編集モードで部分的に読み込まれている場合があるため、[ExecuteAlways] を持つコンポーネントは null 参照を安全に処理する必要があります。
パターン:[RuntimeInitializeOnLoadMethod] のタイミング
条件:シーンの読み込み前または後に実行する必要がある静的初期化を使用する場合
誤り(Claude のデフォルト):
// Assuming it runs before all Awake calls
[RuntimeInitializeOnLoadMethod]
static void Initialize()
{
// Default timing is AfterSceneLoad -- Awake has ALREADY run!
Debug.Log("This runs AFTER Awake, not before");
}
正しい例:
// Explicit timing control
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStaticState()
{
// Earliest: runs before domain reload completes
// Use for clearing static fields (critical for Enter Play Mode options)
_instances.Clear();
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
static void InitBeforeScene()
{
// Runs before any Awake in the first scene
// Use for system bootstrap (creating manager objects)
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void InitAfterScene()
{
// Runs after all Awake/OnEnable/Start in the first scene
// Default if no parameter specified
}
注意点:完全な順序は次のとおりです:SubsystemRegistration -> AfterAssembliesLoaded -> BeforeSplashScreen -> BeforeSceneLoad -> (シーンが読み込まれ、Awake/OnEnable が実行される)-> AfterSceneLoad。ドメインリロードを無効にして「Enter Play Mode Options」を使用する場合、静的状態をクリアするために SubsystemRegistration が重要です。
パターン:スクリプトの実行順序
条件:あるスクリプトの初期化が別のスクリプトに依存する場合
誤り(Claude のデフォルト):
// Assuming scripts execute in a predictable order
public class GameManager : MonoBehaviour
{
void Awake() { Instance = this; }
}
public class Player : MonoBehaviour
{
void Awake()
{
GameManager.Instance.Register(this); // May be null if Player.Awake runs first!
}
}
正しい例:
// Option 1: [DefaultExecutionOrder] attribute
[DefaultExecutionOrder(-100)] // Negative = runs earlier
public class GameManager : MonoBehaviour
{
public static GameManager Instance;
void Awake() { Instance = this; }
}
[DefaultExecutionOrder(0)] // Default
public class Player : MonoBehaviour
{
void Start() // Use Start for cross-references, not Awake
{
GameManager.Instance.Register(this);
}
}
// Option 2: Awake for self-init, Start for cross-references
// This is the intended pattern -- Awake before Start is guaranteed
注意点:明示的に順序を指定しない場合、異なるスクリプト間で同じコールバックが実行される順序は非決定的です(ビルド、プラットフォーム、ドメインリロードによって変わることがあります)。すべてのスクリプトにおいて Awake が Start より先に実行される保証があるため、Awake で自己初期化し、Start で相互参照を設定するパターンは信頼できます。[DefaultExecutionOrder] はクラス単位で設定されます。Project Settings > Script Execution Order も、エディターでクラス単位に設定します。
パターン:OnApplicationQuit と OnDestroy
条件:アプリケーション終了時にデータを保存する、またはクリーンアップする場合
誤り(Claude のデフォルト):
void OnDestroy()
{
SavePlayerData(); // May fail: other objects might already be destroyed
// Order of OnDestroy across objects is NOT guaranteed
}
正しい例:
void OnApplicationQuit()
{
// Fires BEFORE OnDisable/OnDestroy on all objects
// All objects still exist and are accessible
SavePlayerData();
}
void OnDisable()
{
UnsubscribeFromEvents(); // Still safe during quit sequence
}
void OnDestroy()
{
// Cleanup own resources only (don't access other objects)
// No guarantee other objects still exist
_nativeArray.Dispose();
}
注意点:終了シーケンスは、OnApplicationQuit(すべてのオブジェクト)-> OnDisable(オブジェクトごと)-> OnDestroy(オブジェクトごと)です。エディターでプレイモードを終了した場合も、同じシーケンスが実行されます。モバイルでは OnApplicationQuit が呼び出されないことがあります(アプリがバックグラウンドに移行するため)。モバイルでの保存処理には OnApplicationPause(true) を使用してください。Application.wantsToQuit = false を設定すると、OnApplicationQuit をキャンセルできます。
パターン:非同期メソッドとオブジェクトの破棄
条件:MonoBehaviour で async メソッドを使用する場合
誤り(Claude のデフォルト):
async void Start()
{
await Awaitable.WaitForSecondsAsync(5f);
// Object may be destroyed by now!
transform.position = Vector3.zero; // MissingReferenceException
}
正しい例:
async Awaitable Start()
{
try
{
await Awaitable.WaitForSecondsAsync(5f, destroyCancellationToken);
transform.position = Vector3.zero; // Safe: would have thrown if destroyed
}
catch (OperationCanceledException)
{
// Object was destroyed during the wait -- expected, not an error
}
}
// For methods called from elsewhere:
public async Awaitable DoAsyncWork()
{
var token = destroyCancellationToken;
await Awaitable.NextFrameAsync(token);
// After each await, the token ensures we don't continue on a destroyed object
token.ThrowIfCancellationRequested();
_data.Process();
}
注意点:destroyCancellationToken は OnDestroy の開始時に発生します。必ず Awaitable メソッドに渡してください。async void メソッドでは例外を伝播できず、アプリがクラッシュします。代わりに、Unity のフレームループと統合される async Awaitable(または async Awaitable<T>)を使用してください。非同期処理の正確性について詳しくは、unity-async-patterns スキルを参照してください。
ライフサイクルのタイミング早見表
| コールバック | 発生するタイミング | 頻度 | 用途 |
|---|---|---|---|
Awake | スクリプトのインスタンスが読み込まれたとき(GO がアクティブな場合) | 1 回 | 自己初期化 |
OnEnable | コンポーネント/GO が有効になったとき | 有効化のたび | イベントの購読 |
Start | 最初の Update の前(有効な場合) | 1 回 | 相互参照 |
FixedUpdate | 固定タイムステップごと | フレームごとに 0~N 回 | 物理演算 |
Update | 毎フレーム | フレームごとに 1 回 | ゲームロジック |
LateUpdate | すべての Update の後 | フレームごとに 1 回 | カメラ、追従 |
OnDisable | コンポーネント/GO が無効になったとき | 無効化のたび | イベントの購読解除 |
OnDestroy | オブジェクトが破棄されたとき | 1 回 | 独自リソースのクリーンアップ |
OnApplicationQuit | アプリの終了時 | 1 回 | データの保存 |
OnValidate | Inspector の変更時(エディター専用) | 複数回 | フィールドの範囲制限 |
Reset | コンポーネントの追加/リセット時(エディター専用) | 手動 | デフォルト値 |
関連スキル
- unity-scripting -- MonoBehaviour のライフサイクル図、コルーチンのライフサイクル、Awaitable API
- unity-foundations -- GameObject のアクティブ化、コンポーネントの有効化/無効化 API
- unity-async-patterns -- async/await の正確性に関する詳しいパターン