ガチャメモリリーク 実機解析ツール検討260618
Description
INS-864 は、平賀さんが修正加える前の事前準備検討のまとめ、調査のための課題 整理まで
ここからは平賀さんの修正踏まえての解析・メモリリークログ差し込み・ツール調整・対策
Comments (10)
ローカル実機検証用に、apps/client/src/lib/leak-tracker.ts 作成
// INS-862 ガチャ画面メモリリーク調査用の観測フック集。
// import "@/lib/leak-tracker" するだけで window.__leak が生える。
// 解析が終わったら main.tsx の import 1行をコメントアウトすれば全フックが切れる。
//
// 設計方針:
// - 値は呼び出し時に毎回読む(スナップショットは F の snap/trail で別途)
// - 既存 __pixiSound / __pixiAssets / __pixiApp / __imageCache は読むだけ(二重公開しない)
// - setTimeout / addEventListener の上書きパッチは入れない(副作用回避、最終手段として温存)
// - createElement の WeakRef パッチだけは E のオプトインで提供(呼ぶまで副作用なし)
//
// 使い方(Safari console):
// window.__leak.snap("before-gacha")
// // …10連ガチャを引く…
// window.__leak.snap("after-gacha")
// window.__leak.trail()
// window.__leak.diff("before-gacha", "after-gacha")
//
// window.__leak.dom.animating() // 動いてる DOM 要素数
// window.__leak.pixi.soundsCount() // 保持中 AudioBuffer 数
// window.__leak.caches.imageCacheBytes() // useImageCache の累計バイト
//
// window.__leak.enableDetachedDomTracking()
// // …往復・ガチャを引く…
// window.__leak.dom.detached() // { alive, detached, gc }
//
// // mount/unmount サイクルで GC されているか確認:
// useEffect(() => {
// const probe = {};
// window.__leak?.registerProbe(probe, "char-obtained-overlay-1");
// return () => { /* 数秒後に [leak/gc] released: ... が出れば OK */ };
// }, []);
type SnapValue = number | null;
type Snap = {
ts: number;
label: string;
dom_total: SnapValue;
img: SnapValue;
canvas: SnapValue;
anim: SnapValue;
detached: SnapValue;
sounds: SnapValue;
soundsMB: SnapValue;
assets: SnapValue;
managedTex: SnapValue;
imgCache: SnapValue;
imgCacheMB: SnapValue;
};
const MAX_TRAIL = 200;
const trail: Snap[] = [];
let detachedRefs: Set<WeakRef<Element>> | null = null;
let origCreateElement:
| (<K extends keyof HTMLElementTagNameMap>(
tagName: K,
options?: ElementCreationOptions
) => HTMLElementTagNameMap[K])
| null = null;
const gcRegistry =
typeof FinalizationRegistry !== "undefined"
? new FinalizationRegistry<string>((label) => {
console.log(`[leak/gc] released: ${label}`);
})
: null;
type SoundBuffer = {
duration: number;
sampleRate: number;
numberOfChannels: number;
};
type PixiSound = { _sounds?: Record<string, { media?: { buffer?: SoundBuffer } }> };
type PixiAssets = { cache?: { _cache?: Map<unknown, unknown> } };
type PixiApp = {
renderer?: { texture?: { managedTextures?: unknown[] } };
};
type ImageCacheAPI = {
size: () => number;
sizeBytes: () => number;
};
const w = () => window as unknown as {
__pixiSound?: PixiSound;
__pixiAssets?: PixiAssets;
__pixiApp?: PixiApp | null;
__imageCache?: ImageCacheAPI;
};
const dom = {
total: () => document.querySelectorAll("*").length,
images: () => document.querySelectorAll("img").length,
canvases: () => document.querySelectorAll("canvas").length,
animating: () => document.querySelectorAll('[style*="animation"]').length,
detached: (): { alive: number; detached: number; gc: number } | null => {
if (!detachedRefs) return null;
let detached = 0;
let alive = 0;
let gc = 0;
for (const ref of [...detachedRefs]) {
const el = ref.deref();
if (!el) {
gc++;
detachedRefs.delete(ref);
continue;
}
if (!document.contains(el)) detached++;
else alive++;
}
return { alive, detached, gc };
},
};
const pixi = {
soundsCount: (): number | null => {
const s = w().__pixiSound?._sounds;
return s ? Object.keys(s).length : null;
},
soundsBytes: (): number | null => {
const s = w().__pixiSound?._sounds;
if (!s) return null;
let bytes = 0;
for (const snd of Object.values(s)) {
const buf = snd?.media?.buffer;
if (!buf) continue;
bytes += buf.duration * buf.sampleRate * buf.numberOfChannels * 4;
}
return bytes;
},
assetsCacheCount: (): number | null =>
w().__pixiAssets?.cache?._cache?.size ?? null,
managedTextures: (): number | null =>
w().__pixiApp?.renderer?.texture?.managedTextures?.length ?? null,
};
const caches = {
imageCacheCount: (): number | null => w().__imageCache?.size() ?? null,
imageCacheBytes: (): number | null => w().__imageCache?.sizeBytes() ?? null,
};
const toMB = (bytes: number | null): number | null =>
bytes === null ? null : +(bytes / 1024 / 1024).toFixed(2);
const snap = (label: string): Snap => {
const detachedInfo = dom.detached();
const s: Snap = {
ts: Date.now(),
label,
dom_total: dom.total(),
img: dom.images(),
canvas: dom.canvases(),
anim: dom.animating(),
detached: detachedInfo?.detached ?? null,
sounds: pixi.soundsCount(),
soundsMB: toMB(pixi.soundsBytes()),
assets: pixi.assetsCacheCount(),
managedTex: pixi.managedTextures(),
imgCache: caches.imageCacheCount(),
imgCacheMB: toMB(caches.imageCacheBytes()),
};
trail.push(s);
if (trail.length > MAX_TRAIL) trail.shift();
return s;
};
const showTrail = () => console.table(trail);
const diff = (a: string, b: string) => {
const A = trail.find((s) => s.label === a);
const B = trail.find((s) => s.label === b);
if (!A || !B) {
console.warn(`[leak] snap not found: ${!A ? a : b}`);
return;
}
const out: Record<string, number> = {};
for (const k of Object.keys(A) as (keyof Snap)[]) {
const va = A[k];
const vb = B[k];
if (typeof va === "number" && typeof vb === "number") {
out[k] = vb - va;
}
}
console.table(out);
};
const registerProbe = (obj: object, label: string) => {
gcRegistry?.register(obj, label);
};
const enableDetachedDomTracking = () => {
if (detachedRefs) {
console.warn("[leak] detached-dom tracking already enabled");
return;
}
detachedRefs = new Set();
origCreateElement = document.createElement.bind(document) as typeof origCreateElement;
document.createElement = ((tagName: string, options?: ElementCreationOptions) => {
const el = (origCreateElement as (t: string, o?: ElementCreationOptions) => Element)(
tagName,
options
);
detachedRefs?.add(new WeakRef(el));
return el;
}) as typeof document.createElement;
console.log("[leak] detached-dom tracking enabled (createElement patched)");
};
const disableDetachedDomTracking = () => {
if (!origCreateElement) {
console.warn("[leak] detached-dom tracking not enabled");
return;
}
document.createElement = origCreateElement as typeof document.createElement;
origCreateElement = null;
detachedRefs = null;
console.log("[leak] detached-dom tracking disabled");
};
const api = {
dom,
pixi,
caches,
snap,
trail: showTrail,
diff,
registerProbe,
enableDetachedDomTracking,
disableDetachedDomTracking,
_trail: () => trail,
};
if (typeof window !== "undefined") {
(window as unknown as { __leak?: typeof api }).__leak = api;
}
export type LeakTracker = typeof api;
ローカル実機環境 Safari スニペット実行 動作確認OK。 ーーーー 検証
ガチャ画面に遷移 → window.__leak.snap("gacha-top")
10連を引く → window.__leak.snap("after-10")
window.__leak.diff("gacha-top", "after-10") で差分確認
ガチャ画面に遷移 → window.__leak.snap("gacha-top")
10連を引く → window.__leak.snap("after-10")
window.__leak.diff("gacha-top", "after-10") で差分確認
ーーー
ガチャ画面に遷移 → window.__leak.snap("gacha-top")
Object = $2
anim: 45
assets: 6
canvas: 1
detached: null
dom_total: 347
img: 39
imgCache: 0
imgCacheMB: 0
label: "gacha-top"
managedTex: null
sounds: 13
soundsMB: 80.4
ts: 1781758537674
Objectプロトタイプ
10連を引く → window.__leak.snap("after-10")
Object = $3
anim: 241
assets: 6
canvas: 1
detached: null
dom_total: 651
img: 62
imgCache: 8
imgCacheMB: 0.4
label: "after-10"
managedTex: null
sounds: 13
soundsMB: 80.4
ts: 1781758651628
Objectプロトタイプ
window.__leak.diff("gacha-top", "after-10") で差分確認
ts
113954
dom_total
304
anim
196
img
23
imgCache
8
imgCacheMB
0.4
soundsMB
0
sounds
0
canvas
0
assets
0
window.__leak.snap("gacha-end-top") 10連ガチャが終わってガチャTOP画面に戻った時点
Object = $4
anim: 45
assets: 6
canvas: 1
detached: null
dom_total: 347
img: 39
imgCache: 8
imgCacheMB: 0.4
label: "gacha-end-top"
managedTex: null
sounds: 13
soundsMB: 80.4
ts: 1781758718879
Objectプロトタイプ
window.__leak.snap("gacha-end-home") 10連ガチャが終わってホーム画面に戻った時点
Object = $5
anim: 0
assets: 0
canvas: 1
detached: null
dom_total: 239
img: 30
imgCache: 8
imgCacheMB: 0.4
label: "gacha-end-home"
managedTex: null
sounds: 21
soundsMB: 81.75
ts: 1781758778581
Objectプロトタイプ
10連 1回 調査結果
| 指標 | gacha-top | after-10 | gacha-end-top | gacha-end-home | 解釈 |
|---|---|---|---|---|---|
| dom_total | 347 | 651 | 347 | 239 | ピーク+304→完全に解放 ✅ |
| anim | 45 | 241 | 45 | 0 | 同上、リークなし ✅ |
| img | 39 | 62 | 39 | 30 | 解放されてる ✅ |
| imgCache | 0 | 8 | 8 | 8 | 0.4MB誤差レベル、無視可 |
| sounds | 13 | 13 | 13 | 21 | Home戻りで +8 |
| soundsMB | 80.4 | 80.4 | 80.4 | 81.75 | 🚨 常時80MB滞留 |
| assets | 6 | 6 | 6 | 0 | Home戻りで解放 ✅ |
| canvas | 1 | 1 | 1 | 1 | 全画面で常駐(要調査) |
一番目立つ発見:AudioBuffer 80.4MB
13 sounds で平均 6.2MB/sound = 大物が混ざってる可能性大。スマホの実RAM圧として80MBは無視できない。
ガチャ画面の DOM・anim は完璧に解放されているのに対し、AudioBuffer は 一度ロードされたら基本ずっと滞留しています。引き継ぎドキュメントが「未観測の領域」に挙げていた @pixi/sound AudioBuffer 滞留 が、定量化できた
> Object.entries(window.__pixiSound._sounds)
.map(([k, v]) => {
const b = v.media?.buffer;
const mb = b ? +((b.duration * b.sampleRate * b.numberOfChannels * 4) / 1024 / 1024).toFixed(2) : 0;
return { alias: k, mb, sec: b?.duration?.toFixed(1) ?? null, ch: b?.numberOfChannels ?? null };
})
.sort((a, b) => b.mb - a.mb);
< Array (21) = $6
0
{alias: "sounds/bgm/home.m4a", mb: 66.71, sec: "182.2", ch: 2}
1
{alias: "voices/luke/home_5.m4a", mb: 1.36, sec: "7.4", ch: 1}
2
{alias: "voices/luke/home_8.m4a", mb: 1.11, sec: "6.1", ch: 1}
3
{alias: "se/melt-weapon.mp3", mb: 1.1, sec: "3.0", ch: 2}
4
{alias: "voices/luke/home_1.m4a", mb: 1.09, sec: "6.0", ch: 1}
5
{alias: "voices/luke/home_4.m4a", mb: 1.09, sec: "6.0", ch: 1}
6
{alias: "voices/luke/home_2.m4a", mb: 1.05, sec: "5.8", ch: 1}
7
{alias: "voices/luke/home_7.m4a", mb: 1.02, sec: "5.6", ch: 1}
8
{alias: "voices/luke/home_3.m4a", mb: 0.92, sec: "5.0", ch: 1}
9
{alias: "se/charin.mp3", mb: 0.86, sec: "2.4", ch: 2}
10
{alias: "voices/luke/home_6.m4a", mb: 0.85, sec: "4.7", ch: 1}
11
{alias: "se/confetti.m4a", mb: 0.84, sec: "2.3", ch: 2}
12
{alias: "se/start.mp3", mb: 0.73, sec: "2.0", ch: 2}
13
{alias: "se/fanfare.mp3", mb: 0.71, sec: "1.9", ch: 2}
14
{alias: "se/blessing-levelup.mp3", mb: 0.69, sec: "1.9", ch: 2}
15
{alias: "se/chapter-unlock.m4a", mb: 0.67, sec: "1.8", ch: 2}
16
{alias: "se/upgrade-fail.mp3", mb: 0.37, sec: "1.0", ch: 2}
17
{alias: "voices/freya/call_title.m4a", mb: 0.31, sec: "1.7", ch: 1}
18
{alias: "voices/alfred/call_instansys.m4a", mb: 0.18, sec: "1.0", ch: 1}
19
{alias: "se/cancel.m4a", mb: 0.05, sec: "0.1", ch: 2}
20
{alias: "se/click.m4a", mb: 0.05, sec: "0.1", ch: 2}
Arrayプロトタイプ
ホーム画面で、66MB
これは、ガチャ画面でも同じ要因の問題を孕むことになる 本来 3MB の m4aファイルだが、
3MB → 66.7MB の 22倍膨張は WebAudio API の AudioBuffer 仕様そのもの
ディスク上: home.m4a = 3MB(AAC圧縮)
↓ @pixi/sound が WebAudio API で decodeAudioData
RAM上: AudioBuffer = 66.7MB(非圧縮 float32 PCM)
= 182.2秒 × 44.1kHz × 2ch × 4byte
これは @pixi/sound のデフォルト挙動(WebAudioMedia)。一旦 decode したら GC されるまで RAM に居座る。stop() してもメモリは解放されません。
ガチャでも同じ問題を抱えたままとなる
累積系障害との関連(累積はなかったので関連なさそうだが)
- home.m4a が 66.7MB 滞留
- 同じノリで他の BGM(megami-sama.m4a, battle01.m4a 等)が再生されると それぞれ数十MB ずつ追加
- ファイル一覧見ると BGM 全10本。全部 decode したら 100MB級になる可能性
- iOS WKWebView の WebAudio process は数百MB 上限
ではあるが、 BGMが非圧縮で重いとはいえ、累積はしていなかった。