- 설정 화면에 SMS 권한 카드 추가: 상태 표시(허용/미허용/영구 거부), 권한 요청/설정 이동 지원\n- 기존 알림 권한 카드 스타일과 일관성 유지 feat(permissions): 최초 실행 시 SMS 권한 온보딩 화면 추가 및 Splash에서 라우팅 (Android) - 권한 필요 이유/수집 범위 현지화 문구 추가\n- 거부/영구거부 케이스 처리 및 설정 이동 chore(codex): AGENTS.md/체크 스크립트/CI/프롬프트 템플릿 추가 - AGENTS.md, scripts/check.sh, scripts/fix.sh, .github/workflows/flutter_ci.yml, .claude/agents/codex.md, 문서 템플릿 추가 refactor(logging): 경로별 print 제거 후 경량 로거(Log) 도입 - SMS 스캐너/컨트롤러, URL 매처, 데이터 리포지토리, 내비게이션, 메모리/성능 유틸 등 핵심 경로 치환 feat(exchange): 환율 API URL을 --dart-define로 오버라이드 가능 + 폴백 로깅 강화 test: URL 매처/환율 스모크 테스트 추가 chore(android): RECEIVE_SMS 권한 제거 (READ_SMS만 유지) fix(lints): dart fix + 수동 정리로 경고 대폭 감소, 비동기 context(mounted) 보강 fix(deprecations):\n- flutter_local_notifications의 androidAllowWhileIdle → androidScheduleMode 전환\n- WillPopScope → PopScope 교체 i18n: SMS 권한 온보딩/설정 문구 현지화 키 추가
284 lines
7.3 KiB
Dart
284 lines
7.3 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'logger.dart';
|
|
import 'dart:async';
|
|
|
|
/// 메모리 관리를 위한 헬퍼 클래스
|
|
class MemoryManager {
|
|
static final MemoryManager _instance = MemoryManager._internal();
|
|
factory MemoryManager() => _instance;
|
|
MemoryManager._internal();
|
|
|
|
// 캐시 관리
|
|
final Map<String, _CacheEntry> _cache = {};
|
|
final int _maxCacheSize = 100;
|
|
final Duration _defaultTTL = const Duration(minutes: 5);
|
|
|
|
// 이미지 캐시 관리
|
|
static const int maxImageCacheSize = 50 * 1024 * 1024; // 50MB
|
|
static const int maxImageCacheCount = 100;
|
|
|
|
// 위젯 참조 추적
|
|
final Map<String, WeakReference<State>> _widgetReferences = {};
|
|
|
|
/// 캐시에 데이터 저장
|
|
void cacheData<T>({
|
|
required String key,
|
|
required T data,
|
|
Duration? ttl,
|
|
}) {
|
|
_cleanupExpiredCache();
|
|
|
|
if (_cache.length >= _maxCacheSize) {
|
|
_evictOldestEntry();
|
|
}
|
|
|
|
_cache[key] = _CacheEntry(
|
|
data: data,
|
|
timestamp: DateTime.now(),
|
|
ttl: ttl ?? _defaultTTL,
|
|
);
|
|
}
|
|
|
|
/// 캐시에서 데이터 가져오기
|
|
T? getCachedData<T>(String key) {
|
|
final entry = _cache[key];
|
|
if (entry == null) return null;
|
|
|
|
if (entry.isExpired) {
|
|
_cache.remove(key);
|
|
return null;
|
|
}
|
|
|
|
entry.lastAccess = DateTime.now();
|
|
return entry.data as T?;
|
|
}
|
|
|
|
/// 캐시 비우기
|
|
void clearCache() {
|
|
_cache.clear();
|
|
if (kDebugMode) {
|
|
Log.d('🧹 메모리 캐시가 비워졌습니다.');
|
|
}
|
|
}
|
|
|
|
/// 특정 패턴의 캐시 제거
|
|
void clearCacheByPattern(String pattern) {
|
|
final keysToRemove =
|
|
_cache.keys.where((key) => key.contains(pattern)).toList();
|
|
|
|
for (final key in keysToRemove) {
|
|
_cache.remove(key);
|
|
}
|
|
}
|
|
|
|
/// 만료된 캐시 정리
|
|
void _cleanupExpiredCache() {
|
|
final expiredKeys = _cache.entries
|
|
.where((entry) => entry.value.isExpired)
|
|
.map((entry) => entry.key)
|
|
.toList();
|
|
|
|
for (final key in expiredKeys) {
|
|
_cache.remove(key);
|
|
}
|
|
}
|
|
|
|
/// 가장 오래된 캐시 항목 제거
|
|
void _evictOldestEntry() {
|
|
if (_cache.isEmpty) return;
|
|
|
|
var oldestKey = _cache.keys.first;
|
|
var oldestTime = _cache[oldestKey]!.lastAccess;
|
|
|
|
for (final entry in _cache.entries) {
|
|
if (entry.value.lastAccess.isBefore(oldestTime)) {
|
|
oldestKey = entry.key;
|
|
oldestTime = entry.value.lastAccess;
|
|
}
|
|
}
|
|
|
|
_cache.remove(oldestKey);
|
|
}
|
|
|
|
/// 이미지 캐시 최적화
|
|
static void optimizeImageCache() {
|
|
PaintingBinding.instance.imageCache.maximumSize = maxImageCacheCount;
|
|
PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize;
|
|
}
|
|
|
|
/// 이미지 캐시 상태 확인
|
|
static ImageCacheStatus getImageCacheStatus() {
|
|
final cache = PaintingBinding.instance.imageCache;
|
|
return ImageCacheStatus(
|
|
currentSize: cache.currentSize,
|
|
maximumSize: cache.maximumSize,
|
|
currentSizeBytes: cache.currentSizeBytes,
|
|
maximumSizeBytes: cache.maximumSizeBytes,
|
|
);
|
|
}
|
|
|
|
/// 이미지 캐시 비우기
|
|
static void clearImageCache() {
|
|
PaintingBinding.instance.imageCache.clear();
|
|
PaintingBinding.instance.imageCache.clearLiveImages();
|
|
if (kDebugMode) {
|
|
Log.d('🖼️ 이미지 캐시가 비워졌습니다.');
|
|
}
|
|
}
|
|
|
|
/// 위젯 참조 추적
|
|
void trackWidget(String key, State widget) {
|
|
_widgetReferences[key] = WeakReference(widget);
|
|
}
|
|
|
|
/// 위젯 참조 제거
|
|
void untrackWidget(String key) {
|
|
_widgetReferences.remove(key);
|
|
}
|
|
|
|
/// 살아있는 위젯 수 확인
|
|
int getAliveWidgetCount() {
|
|
return _widgetReferences.values.where((ref) => ref.target != null).length;
|
|
}
|
|
|
|
/// 메모리 압박 시 대응
|
|
void handleMemoryPressure() {
|
|
// 캐시 50% 제거
|
|
final keysToRemove = _cache.keys.take(_cache.length ~/ 2).toList();
|
|
for (final key in keysToRemove) {
|
|
_cache.remove(key);
|
|
}
|
|
|
|
// 이미지 캐시 축소
|
|
final imageCache = PaintingBinding.instance.imageCache;
|
|
imageCache.maximumSize = maxImageCacheCount ~/ 2;
|
|
imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2;
|
|
|
|
if (kDebugMode) {
|
|
Log.w('메모리 압박 대응: 캐시 크기 감소');
|
|
}
|
|
}
|
|
|
|
/// 자동 메모리 정리 시작
|
|
Timer? _cleanupTimer;
|
|
|
|
void startAutoCleanup({Duration interval = const Duration(minutes: 1)}) {
|
|
_cleanupTimer?.cancel();
|
|
_cleanupTimer = Timer.periodic(interval, (_) {
|
|
_cleanupExpiredCache();
|
|
|
|
// 죽은 위젯 참조 제거
|
|
final deadKeys = _widgetReferences.entries
|
|
.where((entry) => entry.value.target == null)
|
|
.map((entry) => entry.key)
|
|
.toList();
|
|
|
|
for (final key in deadKeys) {
|
|
_widgetReferences.remove(key);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 자동 메모리 정리 중지
|
|
void stopAutoCleanup() {
|
|
_cleanupTimer?.cancel();
|
|
_cleanupTimer = null;
|
|
}
|
|
|
|
/// 메모리 사용량 리포트
|
|
Map<String, dynamic> getMemoryReport() {
|
|
return {
|
|
'cacheSize': _cache.length,
|
|
'maxCacheSize': _maxCacheSize,
|
|
'aliveWidgets': getAliveWidgetCount(),
|
|
'totalWidgetReferences': _widgetReferences.length,
|
|
'imageCacheStatus': getImageCacheStatus().toJson(),
|
|
};
|
|
}
|
|
}
|
|
|
|
/// 캐시 항목 클래스
|
|
class _CacheEntry {
|
|
final dynamic data;
|
|
final DateTime timestamp;
|
|
final Duration ttl;
|
|
DateTime lastAccess;
|
|
|
|
_CacheEntry({
|
|
required this.data,
|
|
required this.timestamp,
|
|
required this.ttl,
|
|
}) : lastAccess = timestamp;
|
|
|
|
bool get isExpired => DateTime.now().difference(timestamp) > ttl;
|
|
}
|
|
|
|
/// 이미지 캐시 상태 클래스
|
|
class ImageCacheStatus {
|
|
final int currentSize;
|
|
final int maximumSize;
|
|
final int currentSizeBytes;
|
|
final int maximumSizeBytes;
|
|
|
|
ImageCacheStatus({
|
|
required this.currentSize,
|
|
required this.maximumSize,
|
|
required this.currentSizeBytes,
|
|
required this.maximumSizeBytes,
|
|
});
|
|
|
|
double get sizeUsagePercentage => (currentSize / maximumSize) * 100;
|
|
double get bytesUsagePercentage =>
|
|
(currentSizeBytes / maximumSizeBytes) * 100;
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'currentSize': currentSize,
|
|
'maximumSize': maximumSize,
|
|
'currentSizeBytes': currentSizeBytes,
|
|
'maximumSizeBytes': maximumSizeBytes,
|
|
'sizeUsagePercentage': sizeUsagePercentage.toStringAsFixed(2),
|
|
'bytesUsagePercentage': bytesUsagePercentage.toStringAsFixed(2),
|
|
};
|
|
}
|
|
|
|
/// 메모리 효율적인 리스트 뷰
|
|
class MemoryEfficientListView<T> extends StatefulWidget {
|
|
final List<T> items;
|
|
final Widget Function(BuildContext, T) itemBuilder;
|
|
final int cacheExtent;
|
|
final ScrollPhysics? physics;
|
|
|
|
const MemoryEfficientListView({
|
|
super.key,
|
|
required this.items,
|
|
required this.itemBuilder,
|
|
this.cacheExtent = 250,
|
|
this.physics,
|
|
});
|
|
|
|
@override
|
|
State<MemoryEfficientListView<T>> createState() =>
|
|
_MemoryEfficientListViewState<T>();
|
|
}
|
|
|
|
class _MemoryEfficientListViewState<T> extends State<MemoryEfficientListView<T>>
|
|
with AutomaticKeepAliveClientMixin {
|
|
@override
|
|
bool get wantKeepAlive => false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
|
|
return ListView.builder(
|
|
itemCount: widget.items.length,
|
|
cacheExtent: widget.cacheExtent.toDouble(),
|
|
physics: widget.physics ?? const BouncingScrollPhysics(),
|
|
itemBuilder: (context, index) {
|
|
return widget.itemBuilder(context, widget.items[index]);
|
|
},
|
|
);
|
|
}
|
|
}
|