perf: 파싱/렌더 최적화 다수 적용

- SmsScanner 키워드/정규식 상수화로 반복 컴파일 제거\n- 리스트에 prototypeItem 추가, 카드 RepaintBoundary 적용\n- 차트 영역 RepaintBoundary로 페인트 분리\n- GlassmorphicScaffold 파티클 수를 disableAnimations에 따라 감소\n- 캐시 초기화 플래그를 --dart-define로 제어(CLEAR_CACHE_ON_STARTUP)
This commit is contained in:
JiWoong Sul
2025-09-07 23:28:18 +09:00
parent d37f66d526
commit 84b3fdd530
7 changed files with 203 additions and 188 deletions

View File

@@ -45,8 +45,15 @@ Future<void> main() async {
try { try {
// 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비 // 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비
// 오래된 디스크 캐시 파일만 지우기 (새로운 것은 유지) // 캐시 전체 삭제는 큰 I/O 부하를 유발할 수 있어 비활성화
await DefaultCacheManager().emptyCache(); // 필요 시 환경 플래그로 제어하거나 주기적 백그라운드 정리로 전환하세요.
const bool clearCacheOnStartup = bool.fromEnvironment(
'CLEAR_CACHE_ON_STARTUP',
defaultValue: false,
);
if (clearCacheOnStartup) {
await DefaultCacheManager().emptyCache();
}
if (kDebugMode) { if (kDebugMode) {
Log.d('이미지 캐시 관리 초기화 완료'); Log.d('이미지 캐시 관리 초기화 완료');

View File

@@ -93,32 +93,32 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
value: '#1976D2', value: '#1976D2',
child: Text( child: Text(
AppLocalizations.of(context).colorBlue, AppLocalizations.of(context).colorBlue,
style: style: const TextStyle(
const TextStyle(color: AppColors.darkNavy))), color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#4CAF50', value: '#4CAF50',
child: Text( child: Text(
AppLocalizations.of(context).colorGreen, AppLocalizations.of(context).colorGreen,
style: style: const TextStyle(
const TextStyle(color: AppColors.darkNavy))), color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#FF9800', value: '#FF9800',
child: Text( child: Text(
AppLocalizations.of(context).colorOrange, AppLocalizations.of(context).colorOrange,
style: style: const TextStyle(
const TextStyle(color: AppColors.darkNavy))), color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#F44336', value: '#F44336',
child: Text( child: Text(
AppLocalizations.of(context).colorRed, AppLocalizations.of(context).colorRed,
style: style: const TextStyle(
const TextStyle(color: AppColors.darkNavy))), color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#9C27B0', value: '#9C27B0',
child: Text( child: Text(
AppLocalizations.of(context).colorPurple, AppLocalizations.of(context).colorPurple,
style: style: const TextStyle(
const TextStyle(color: AppColors.darkNavy))), color: AppColors.darkNavy))),
], ],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {

View File

@@ -533,9 +533,8 @@ class SettingsScreen extends StatelessWidget {
onPressed: () async { onPressed: () async {
await permission.openAppSettings(); await permission.openAppSettings();
}, },
child: Text( child: Text(AppLocalizations.of(context)
AppLocalizations.of(context) .openSettings),
.openSettings),
) )
: ElevatedButton( : ElevatedButton(
onPressed: () async { onPressed: () async {
@@ -545,11 +544,13 @@ class SettingsScreen extends StatelessWidget {
final newStatus = await permission final newStatus = await permission
.Permission.sms.status; .Permission.sms.status;
if (newStatus.isPermanentlyDenied) { if (newStatus.isPermanentlyDenied) {
await permission.openAppSettings(); await permission
.openAppSettings();
} }
} }
if (context.mounted) { if (context.mounted) {
(context as Element).markNeedsBuild(); (context as Element)
.markNeedsBuild();
} }
}, },
child: Text(AppLocalizations.of(context) child: Text(AppLocalizations.of(context)

View File

@@ -7,6 +7,38 @@ import '../services/subscription_url_matcher.dart';
import '../utils/platform_helper.dart'; import '../utils/platform_helper.dart';
class SmsScanner { class SmsScanner {
// 반복 사용되는 리소스 상수화로 파싱 성능 최적화
static const List<String> _subscriptionKeywords = [
'구독',
'결제',
'정기결제',
'자동결제',
'월정액',
'subscription',
'payment',
'billing',
'charge',
'넷플릭스',
'Netflix',
'유튜브',
'YouTube',
'Spotify',
'멜론',
'웨이브',
'Disney+',
'디즈니플러스',
'Apple',
'Microsoft',
'GitHub',
'Adobe',
'Amazon'
];
static final List<RegExp> _amountPatterns = <RegExp>[
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액
];
final SmsQuery _query = SmsQuery(); final SmsQuery _query = SmsQuery();
Future<List<SubscriptionModel>> scanForSubscriptions() async { Future<List<SubscriptionModel>> scanForSubscriptions() async {
@@ -106,35 +138,8 @@ class SmsScanner {
final sender = message.address ?? ''; final sender = message.address ?? '';
final date = message.date ?? DateTime.now(); final date = message.date ?? DateTime.now();
// 구독 서비스 키워드 매칭
final subscriptionKeywords = [
'구독',
'결제',
'정기결제',
'자동결제',
'월정액',
'subscription',
'payment',
'billing',
'charge',
'넷플릭스',
'Netflix',
'유튜브',
'YouTube',
'Spotify',
'멜론',
'웨이브',
'Disney+',
'디즈니플러스',
'Apple',
'Microsoft',
'GitHub',
'Adobe',
'Amazon'
];
// 구독 관련 키워드가 있는지 확인 // 구독 관련 키워드가 있는지 확인
bool isSubscription = subscriptionKeywords.any((keyword) => bool isSubscription = _subscriptionKeywords.any((keyword) =>
body.toLowerCase().contains(keyword.toLowerCase()) || body.toLowerCase().contains(keyword.toLowerCase()) ||
sender.toLowerCase().contains(keyword.toLowerCase())); sender.toLowerCase().contains(keyword.toLowerCase()));
@@ -208,15 +213,8 @@ class SmsScanner {
// 금액 추출 로직 // 금액 추출 로직
double? _extractAmount(String body) { double? _extractAmount(String body) {
// 다양한 금액 패턴 매칭 // 다양한 금액 패턴 매칭(사전 컴파일)
final patterns = [ for (final pattern in _amountPatterns) {
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액
];
for (final pattern in patterns) {
final match = pattern.firstMatch(body); final match = pattern.firstMatch(body);
if (match != null) { if (match != null) {
String amountStr = match.group(1) ?? ''; String amountStr = match.group(1) ?? '';

View File

@@ -154,99 +154,101 @@ class MonthlyExpenseChartCard extends StatelessWidget {
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// 바 차트 // 바 차트 (RepaintBoundary로 페인트 분리)
AspectRatio( RepaintBoundary(
aspectRatio: 1.6, child: AspectRatio(
child: BarChart( aspectRatio: 1.6,
BarChartData( child: BarChart(
alignment: BarChartAlignment.spaceAround, BarChartData(
maxY: _calculateChartMaxY( alignment: BarChartAlignment.spaceAround,
monthlyData.fold<double>( maxY: _calculateChartMaxY(
0, monthlyData.fold<double>(
(max, data) => math.max( 0,
max, data['totalExpense'] as double)), (max, data) => math.max(
locale), max, data['totalExpense'] as double)),
barGroups: _getMonthlyBarGroups(locale), locale),
gridData: FlGridData( barGroups: _getMonthlyBarGroups(locale),
show: true, gridData: FlGridData(
drawVerticalLine: false, show: true,
horizontalInterval: _calculateGridInterval( drawVerticalLine: false,
_calculateChartMaxY( horizontalInterval: _calculateGridInterval(
monthlyData.fold<double>( _calculateChartMaxY(
0, monthlyData.fold<double>(
(max, data) => math.max(max, 0,
data['totalExpense'] as double)), (max, data) => math.max(max,
locale), data['totalExpense'] as double)),
CurrencyUtil.getDefaultCurrency(locale)), locale),
getDrawingHorizontalLine: (value) { CurrencyUtil.getDefaultCurrency(locale)),
return FlLine( getDrawingHorizontalLine: (value) {
color: return FlLine(
AppColors.navyGray.withValues(alpha: 0.1), color:
strokeWidth: 1, AppColors.navyGray.withValues(alpha: 0.1),
); strokeWidth: 1,
}, );
), },
titlesData: FlTitlesData(
show: true,
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
), ),
bottomTitles: AxisTitles( titlesData: FlTitlesData(
sideTitles: SideTitles( show: true,
showTitles: true, topTitles: const AxisTitles(
getTitlesWidget: (value, meta) { sideTitles: SideTitles(showTitles: false),
return Padding( ),
padding: const EdgeInsets.only(top: 8), bottomTitles: AxisTitles(
child: ThemedText.caption( sideTitles: SideTitles(
text: monthlyData[value.toInt()] showTitles: true,
['monthName'], getTitlesWidget: (value, meta) {
style: const TextStyle( return Padding(
fontSize: 12, padding: const EdgeInsets.only(top: 8),
fontWeight: FontWeight.bold, child: ThemedText.caption(
text: monthlyData[value.toInt()]
['monthName'],
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
), ),
);
},
),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(show: false),
barTouchData: BarTouchData(
enabled: true,
touchTooltipData: BarTouchTooltipData(
tooltipBgColor: AppColors.darkNavy,
tooltipRoundedRadius: 8,
getTooltipItem:
(group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'${monthlyData[group.x]['monthName']}\n',
const TextStyle(
color: AppColors.pureWhite,
fontWeight: FontWeight.bold,
), ),
children: [
TextSpan(
text: CurrencyUtil
.formatTotalAmountWithLocale(
monthlyData[group.x]
['totalExpense'] as double,
locale),
style: const TextStyle(
color: Color(0xFFFBBF24),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
); );
}, },
), ),
), ),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(show: false),
barTouchData: BarTouchData(
enabled: true,
touchTooltipData: BarTouchTooltipData(
tooltipBgColor: AppColors.darkNavy,
tooltipRoundedRadius: 8,
getTooltipItem:
(group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'${monthlyData[group.x]['monthName']}\n',
const TextStyle(
color: AppColors.pureWhite,
fontWeight: FontWeight.bold,
),
children: [
TextSpan(
text: CurrencyUtil
.formatTotalAmountWithLocale(
monthlyData[group.x]
['totalExpense'] as double,
locale),
style: const TextStyle(
color: Color(0xFFFBBF24),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
);
},
),
), ),
), ),
), ),

View File

@@ -177,10 +177,13 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
child: AnimatedBuilder( child: AnimatedBuilder(
animation: _particleController, animation: _particleController,
builder: (context, child) { builder: (context, child) {
final media = MediaQuery.maybeOf(context);
final reduce = media?.disableAnimations ?? false;
final count = reduce ? 10 : 30;
return CustomPaint( return CustomPaint(
painter: ParticlePainter( painter: ParticlePainter(
animation: _particleController, animation: _particleController,
particleCount: 30, particleCount: count,
), ),
); );
}, },

View File

@@ -71,6 +71,7 @@ class SubscriptionListWidget extends StatelessWidget {
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true, shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
prototypeItem: const SizedBox(height: 156),
itemCount: subscriptions.length, itemCount: subscriptions.length,
itemBuilder: (context, subIndex) { itemBuilder: (context, subIndex) {
// 각 구독의 지연값 계산 (순차적으로 나타나도록) // 각 구독의 지연값 계산 (순차적으로 나타나도록)
@@ -98,60 +99,63 @@ class SubscriptionListWidget extends StatelessWidget {
child: StaggeredAnimationItem( child: StaggeredAnimationItem(
index: subIndex, index: subIndex,
delay: const Duration(milliseconds: 50), delay: const Duration(milliseconds: 50),
child: SwipeableSubscriptionCard( child: RepaintBoundary(
subscription: subscriptions[subIndex], child: SwipeableSubscriptionCard(
onTap: () { subscription: subscriptions[subIndex],
Log.d( onTap: () {
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨'); Log.d(
AppNavigator.toDetail( '[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
context, subscriptions[subIndex]); AppNavigator.toDetail(
}, context, subscriptions[subIndex]);
onDelete: () async { },
// 현재 로케일에 맞는 서비스명 가져오기 onDelete: () async {
final localeProvider = // 현재 로케일에 맞는 서비스명 가져오기
Provider.of<LocaleProvider>( final localeProvider =
context, Provider.of<LocaleProvider>(
listen: false,
);
final locale =
localeProvider.locale.languageCode;
final displayName = await SubscriptionUrlMatcher
.getServiceDisplayName(
serviceName:
subscriptions[subIndex].serviceName,
locale: locale,
);
// 삭제 확인 다이얼로그 표시
if (!context.mounted) return;
final shouldDelete =
await DeleteConfirmationDialog.show(
context: context,
serviceName: displayName,
);
if (!context.mounted) return;
if (shouldDelete) {
// 사용자가 확인한 경우에만 삭제 진행
final provider =
Provider.of<SubscriptionProvider>(
context, context,
listen: false, listen: false,
); );
await provider.deleteSubscription( final locale =
subscriptions[subIndex].id, localeProvider.locale.languageCode;
final displayName =
await SubscriptionUrlMatcher
.getServiceDisplayName(
serviceName:
subscriptions[subIndex].serviceName,
locale: locale,
); );
if (context.mounted) { // 삭제 확인 다이얼로그 표시
AppSnackBar.showError( if (!context.mounted) return;
context: context, final shouldDelete =
message: AppLocalizations.of(context) await DeleteConfirmationDialog.show(
.subscriptionDeleted(displayName), context: context,
icon: Icons.delete_forever_rounded, serviceName: displayName,
);
if (!context.mounted) return;
if (shouldDelete) {
// 사용자가 확인한 경우에만 삭제 진행
final provider =
Provider.of<SubscriptionProvider>(
context,
listen: false,
); );
await provider.deleteSubscription(
subscriptions[subIndex].id,
);
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: AppLocalizations.of(context)
.subscriptionDeleted(displayName),
icon: Icons.delete_forever_rounded,
);
}
} }
} },
}, ),
), ),
), ),
), ),