Files
fanuzu/app/test/perf_paint_test.dart
T

134 lines
6.1 KiB
Dart

import 'dart:ui' as ui;
import 'package:fancake/features/eclipse/eclipse_data.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/galaxy/galaxy_screen.dart';
import 'package:fancake/features/galaxy/widgets/galaxy_field.dart';
import 'package:fancake/features/planet/planet_data.dart';
import 'package:fancake/features/planet/widgets/fpi_hexagon.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// 그리기 비용 측정 — 09-03 GALAXY 2.5D 시연의 프레임 예산을 지키는지 본다.
///
/// **왜 이걸 재는가.** 갤럭시 맵은 매 프레임 별밭 220개와 궤도·행성을 다시 그린다.
/// 한 프레임의 예산은 60fps 기준 16.7ms 이고, 그 안에 레이아웃·합성·래스터화가 전부
/// 들어가야 한다. 그리기 하나가 예산을 다 먹으면 맵이 끊긴다.
///
/// 여기서 재는 것은 **[CustomPainter.paint] 가 표시 목록을 만드는 데 드는 CPU 시간**이다.
/// GPU 래스터화 시간은 기기에서만 잴 수 있으므로 이 수치가 곧 프레임 시간은 아니다.
/// 다만 이 값이 예산을 넘으면 어떤 기기에서도 60fps 가 나오지 않는다 — 하한 검증이다.
/// 표시 목록 생성 시간의 중앙값(마이크로초).
int medianPaintMicros(
CustomPainter painter,
Size size, {
int warmup = 20,
int samples = 60,
}) {
// 첫 몇 회는 JIT 예열이라 버린다. 넣으면 중앙값이 실제보다 나쁘게 나온다.
for (var i = 0; i < warmup; i++) {
_paintOnce(painter, size);
}
final measured = <int>[
for (var i = 0; i < samples; i++) _paintOnce(painter, size),
]..sort();
return measured[measured.length ~/ 2];
}
int _paintOnce(CustomPainter painter, Size size) {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final watch = Stopwatch()..start();
painter.paint(canvas, size);
watch.stop();
recorder.endRecording().dispose();
return watch.elapsedMicroseconds;
}
/// 60fps 프레임 예산. 그리기 하나가 이 절반을 넘으면 여유가 없다고 본다.
const int frameBudgetMicros = 16700;
const int paintBudgetMicros = frameBudgetMicros ~/ 2;
void main() {
test('갤럭시 맵 한 프레임 그리기가 예산 안에 든다', () {
const planets = [
PlanetNode(id: 'bts', name: 'BTS', rank: 1, fpi: 284310, members: 12840, online: 421, isMine: true),
PlanetNode(id: 'newjeans', name: 'NewJeans', rank: 2, fpi: 231000, members: 9800, online: 388),
PlanetNode(id: 'seventeen', name: 'SEVENTEEN', rank: 3, fpi: 198400, members: 8600, online: 254),
PlanetNode(id: 'ive', name: 'IVE', rank: 4, fpi: 176200, members: 7400, online: 199),
PlanetNode(id: 'aespa', name: 'aespa', rank: 5, fpi: 155900, members: 6900, online: 174),
PlanetNode(id: 'lesserafim', name: 'LE SSERAFIM', rank: 6, fpi: 141300, members: 6100, online: 143),
PlanetNode(id: 'txt', name: 'TXT', rank: 7, fpi: 128700, members: 5500, online: 121),
PlanetNode(id: 'straykids', name: 'Stray Kids', rank: 8, fpi: 119400, members: 5200, online: 108),
PlanetNode(id: 'enhypen', name: 'ENHYPEN', rank: 9, fpi: 104800, members: 4700, online: 96),
PlanetNode(id: 'zerobaseone', name: 'ZEROBASEONE', rank: 10, fpi: 98200, members: 4100, online: 87),
PlanetNode(id: 'riize', name: 'RIIZE', rank: 11, fpi: 90500, members: 3800, online: 74),
PlanetNode(id: 'boynextdoor', name: 'BOYNEXTDOOR', rank: 12, fpi: 82100, members: 3400, online: 61),
];
final micros = medianPaintMicros(
galaxyBackdropPainterForTest(planets: planets),
const Size(GalaxyField.extent, GalaxyField.extent),
);
// ignore: avoid_print — 성능 리포트가 이 값을 그대로 인용한다.
print('[perf] galaxy-backdrop median paint: ${micros}us');
expect(micros, lessThan(paintBudgetMicros),
reason: '갤럭시 맵 그리기가 프레임 예산의 절반을 넘었습니다');
});
test('행성 수를 배로 늘려도 예산 안에 있다', () {
final planets = [
for (var i = 0; i < 24; i++)
PlanetNode(
id: 'f$i', name: 'F$i', rank: i + 1, fpi: 300000 - i * 9000,
members: 12000 - i * 300, online: 400 - i * 12, isMine: i == 0,
),
];
final micros = medianPaintMicros(
galaxyBackdropPainterForTest(planets: planets),
const Size(GalaxyField.extent, GalaxyField.extent),
);
print('[perf] galaxy-backdrop(24) median paint: ${micros}us');
expect(micros, lessThan(paintBudgetMicros));
});
test('FPI 육각형 그래프 그리기 비용', () {
const scores = [
FpiScore(metric: FpiMetric.bpm, value: 82400, rank: 1, trend: 1240, myTopPercent: 3),
FpiScore(metric: FpiMetric.health, value: 61200, rank: 2, trend: -320, myTopPercent: 12),
FpiScore(metric: FpiMetric.referral, value: 38900, rank: 4, trend: 640, myTopPercent: 21),
FpiScore(metric: FpiMetric.community, value: 55100, rank: 2, trend: 210, myTopPercent: 8),
FpiScore(metric: FpiMetric.contribution, value: 46710, rank: 3, trend: 980, myTopPercent: 5),
];
final micros = medianPaintMicros(
fpiHexagonPainterForTest(scores: scores, max: 82400),
const Size(320, 320),
);
print('[perf] fpi-hexagon median paint: ${micros}us');
expect(micros, lessThan(paintBudgetMicros));
});
testWidgets('갤럭시 맵이 두 프레임 안에 선다', (tester) async {
// 앱 시작 시간은 여기서 재지 않는다. 테스트 하네스가 바인딩을 세우는 비용이
// 섞여 기기 실측과 자릿수가 다르다 — 시작 시간은 기기에서만 잰다.
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
home: GalaxyScreen(
repository: SampleGalaxyRepository(),
planetRepository: samplePlanetRepository,
eclipse: SampleEclipseRepository(),
),
),
);
await tester.pump(const Duration(milliseconds: 16));
await tester.pump(const Duration(milliseconds: 16));
expect(find.byType(GalaxyField), findsOneWidget);
});
}