From 10491af55b5a6c2d6c4f1a6ff71087d0c3b21ea4 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 8 Sep 2025 14:30:03 +0900 Subject: [PATCH] feat(perf): offload Android SMS parsing to Isolate and wrap pie chart with RepaintBoundary --- lib/services/sms_scanner.dart | 322 +++++++++--------- .../analysis/subscription_pie_chart_card.dart | 102 +++--- 2 files changed, 215 insertions(+), 209 deletions(-) diff --git a/lib/services/sms_scanner.dart b/lib/services/sms_scanner.dart index 613bf4e..1af7413 100644 --- a/lib/services/sms_scanner.dart +++ b/lib/services/sms_scanner.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/foundation.dart' show kIsWeb, compute; import 'package:flutter_sms_inbox/flutter_sms_inbox.dart'; import '../models/subscription_model.dart'; import '../utils/logger.dart'; @@ -7,38 +7,6 @@ import '../services/subscription_url_matcher.dart'; import '../utils/platform_helper.dart'; class SmsScanner { - // 반복 사용되는 리소스 상수화로 파싱 성능 최적화 - static const List _subscriptionKeywords = [ - '구독', - '결제', - '정기결제', - '자동결제', - '월정액', - 'subscription', - 'payment', - 'billing', - 'charge', - '넷플릭스', - 'Netflix', - '유튜브', - 'YouTube', - 'Spotify', - '멜론', - '웨이브', - 'Disney+', - '디즈니플러스', - 'Apple', - 'Microsoft', - 'GitHub', - 'Adobe', - 'Amazon' - ]; - static final List _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})*)'), // 결제 금액 - ]; final SmsQuery _query = SmsQuery(); Future> scanForSubscriptions() async { @@ -114,16 +82,21 @@ class SmsScanner { Future> _scanAndroidSms() async { try { final messages = await _query.getAllSms; - final smsList = >[]; - // SMS 메시지를 분석하여 구독 서비스 감지 + // Isolate로 넘길 수 있도록 직렬화 (plugin 객체 직접 전달 불가) + final serialized = >[]; for (final message in messages) { - final parsedData = _parseRawSms(message); - if (parsedData != null) { - smsList.add(parsedData); - } + serialized.add({ + 'body': message.body ?? '', + 'address': message.address ?? '', + 'dateMillis': (message.date ?? DateTime.now()).millisecondsSinceEpoch, + }); } + // 대량 파싱은 별도 Isolate로 오프로딩 + final List> smsList = + await compute(_parseRawSmsBatch, serialized); + return smsList; } catch (e) { Log.e('SmsScanner: Android SMS 스캔 실패', e); @@ -131,131 +104,7 @@ class SmsScanner { } } - // 실제 SMS 메시지를 파싱하여 구독 정보 추출 - Map? _parseRawSms(SmsMessage message) { - try { - final body = message.body ?? ''; - final sender = message.address ?? ''; - final date = message.date ?? DateTime.now(); - - // 구독 관련 키워드가 있는지 확인 - bool isSubscription = _subscriptionKeywords.any((keyword) => - body.toLowerCase().contains(keyword.toLowerCase()) || - sender.toLowerCase().contains(keyword.toLowerCase())); - - if (!isSubscription) { - return null; - } - - // 서비스명 추출 - String serviceName = _extractServiceName(body, sender); - - // 금액 추출 - double? amount = _extractAmount(body); - - // 결제 주기 추출 - String billingCycle = _extractBillingCycle(body); - - return { - 'serviceName': serviceName, - 'monthlyCost': amount ?? 0.0, - 'billingCycle': billingCycle, - 'message': body, - 'nextBillingDate': - _calculateNextBillingFromDate(date, billingCycle).toIso8601String(), - 'previousPaymentDate': date.toIso8601String(), - }; - } catch (e) { - Log.e('SmsScanner: SMS 파싱 실패', e); - return null; - } - } - - // 서비스명 추출 로직 - String _extractServiceName(String body, String sender) { - // 알려진 서비스 매핑 - final servicePatterns = { - 'netflix': '넷플릭스', - 'youtube': '유튜브 프리미엄', - 'spotify': 'Spotify', - 'disney': '디즈니플러스', - 'apple': 'Apple', - 'microsoft': 'Microsoft', - 'github': 'GitHub', - 'adobe': 'Adobe', - '멜론': '멜론', - '웨이브': '웨이브', - }; - - // 메시지나 발신자에서 서비스명 찾기 - final combinedText = '$body $sender'.toLowerCase(); - - for (final entry in servicePatterns.entries) { - if (combinedText.contains(entry.key)) { - return entry.value; - } - } - - // 찾지 못한 경우 - return _extractServiceNameFromSender(sender); - } - - // 발신자 정보에서 서비스명 추출 - String _extractServiceNameFromSender(String sender) { - // 숫자만 있으면 제거 - if (RegExp(r'^\d+$').hasMatch(sender)) { - return '알 수 없는 서비스'; - } - - // 특수문자 제거하고 서비스명으로 사용 - return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim(); - } - - // 금액 추출 로직 - double? _extractAmount(String body) { - // 다양한 금액 패턴 매칭(사전 컴파일) - for (final pattern in _amountPatterns) { - final match = pattern.firstMatch(body); - if (match != null) { - String amountStr = match.group(1) ?? ''; - amountStr = amountStr.replaceAll(',', ''); - return double.tryParse(amountStr); - } - } - - return null; - } - - // 결제 주기 추출 로직 - String _extractBillingCycle(String body) { - if (body.contains('월') || body.contains('monthly') || body.contains('매월')) { - return 'monthly'; - } else if (body.contains('년') || - body.contains('yearly') || - body.contains('annual')) { - return 'yearly'; - } else if (body.contains('주') || body.contains('weekly')) { - return 'weekly'; - } - - // 기본값 - return 'monthly'; - } - - // 다음 결제일 계산 - DateTime _calculateNextBillingFromDate( - DateTime lastDate, String billingCycle) { - switch (billingCycle) { - case 'monthly': - return DateTime(lastDate.year, lastDate.month + 1, lastDate.day); - case 'yearly': - return DateTime(lastDate.year + 1, lastDate.month, lastDate.day); - case 'weekly': - return lastDate.add(const Duration(days: 7)); - default: - return lastDate.add(const Duration(days: 30)); - } - } + // (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체 SubscriptionModel? _parseSms(Map sms, int repeatCount) { try { @@ -427,3 +276,148 @@ class SmsScanner { return 'KRW'; } } + +// ===== Isolate 오프로딩용 Top-level 파서 ===== + +// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환 +List> _parseRawSmsBatch( + List> messages) { + // 키워드/정규식은 Isolate 내에서 재생성 (간단 복제) + const subscriptionKeywords = [ + '구독', + '결제', + '정기결제', + '자동결제', + '월정액', + 'subscription', + 'payment', + 'billing', + 'charge', + '넷플릭스', + 'Netflix', + '유튜브', + 'YouTube', + 'Spotify', + '멜론', + '웨이브', + 'Disney+', + '디즈니플러스', + 'Apple', + 'Microsoft', + 'GitHub', + 'Adobe', + 'Amazon' + ]; + + final 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})*)'), // 결제 금액 + ]; + + final results = >[]; + + for (final m in messages) { + final body = (m['body'] as String?) ?? ''; + final sender = (m['address'] as String?) ?? ''; + final dateMillis = + (m['dateMillis'] as int?) ?? DateTime.now().millisecondsSinceEpoch; + final date = DateTime.fromMillisecondsSinceEpoch(dateMillis); + + final lowerBody = body.toLowerCase(); + final lowerSender = sender.toLowerCase(); + final isSubscription = subscriptionKeywords.any((k) => + lowerBody.contains(k.toLowerCase()) || + lowerSender.contains(k.toLowerCase())); + + if (!isSubscription) continue; + + final serviceName = _isoExtractServiceName(body, sender); + final amount = _isoExtractAmount(body, amountPatterns) ?? 0.0; + final billingCycle = _isoExtractBillingCycle(body); + final nextBillingDate = + _isoCalculateNextBillingFromDate(date, billingCycle); + + results.add({ + 'serviceName': serviceName, + 'monthlyCost': amount, + 'billingCycle': billingCycle, + 'message': body, + 'nextBillingDate': nextBillingDate.toIso8601String(), + 'previousPaymentDate': date.toIso8601String(), + }); + } + + return results; +} + +String _isoExtractServiceName(String body, String sender) { + final servicePatterns = { + 'netflix': '넷플릭스', + 'youtube': '유튜브 프리미엄', + 'spotify': 'Spotify', + 'disney': '디즈니플러스', + 'apple': 'Apple', + 'microsoft': 'Microsoft', + 'github': 'GitHub', + 'adobe': 'Adobe', + '멜론': '멜론', + '웨이브': '웨이브', + }; + + final combined = '$body $sender'.toLowerCase(); + for (final e in servicePatterns.entries) { + if (combined.contains(e.key)) return e.value; + } + return _isoExtractServiceNameFromSender(sender); +} + +String _isoExtractServiceNameFromSender(String sender) { + if (RegExp(r'^\d+$').hasMatch(sender)) { + return '알 수 없는 서비스'; + } + return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim(); +} + +double? _isoExtractAmount(String body, List patterns) { + for (final pattern in patterns) { + final match = pattern.firstMatch(body); + if (match != null) { + var amountStr = match.group(1) ?? ''; + amountStr = amountStr.replaceAll(',', ''); + final parsed = double.tryParse(amountStr); + if (parsed != null) return parsed; + } + } + return null; +} + +String _isoExtractBillingCycle(String body) { + if (body.contains('월') || + body.toLowerCase().contains('monthly') || + body.contains('매월')) { + return 'monthly'; + } else if (body.contains('년') || + body.toLowerCase().contains('yearly') || + body.toLowerCase().contains('annual')) { + return 'yearly'; + } else if (body.contains('주') || body.toLowerCase().contains('weekly')) { + return 'weekly'; + } + return 'monthly'; +} + +DateTime _isoCalculateNextBillingFromDate( + DateTime lastDate, String billingCycle) { + switch (billingCycle) { + case 'monthly': + return DateTime(lastDate.year, lastDate.month + 1, lastDate.day); + case 'yearly': + return DateTime(lastDate.year + 1, lastDate.month, lastDate.day); + case 'weekly': + return lastDate.add(const Duration(days: 7)); + default: + return lastDate.add(const Duration(days: 30)); + } +} diff --git a/lib/widgets/analysis/subscription_pie_chart_card.dart b/lib/widgets/analysis/subscription_pie_chart_card.dart index d991fff..37a40c4 100644 --- a/lib/widgets/analysis/subscription_pie_chart_card.dart +++ b/lib/widgets/analysis/subscription_pie_chart_card.dart @@ -10,6 +10,7 @@ import '../themed_text.dart'; import 'analysis_badge.dart'; import '../../l10n/app_localizations.dart'; import '../../providers/locale_provider.dart'; +import '../../utils/reduce_motion.dart'; /// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯 class SubscriptionPieChartCard extends StatefulWidget { @@ -312,58 +313,69 @@ class _SubscriptionPieChartCardState extends State { ); } - return PieChart( - PieChartData( - borderData: FlBorderData(show: false), - sectionsSpace: 2, - centerSpaceRadius: 60, - sections: - _applyTouchedState(snapshot.data!), - pieTouchData: PieTouchData( - enabled: true, - touchCallback: (FlTouchEvent event, - pieTouchResponse) { - // 터치 응답이 없거나 섹션이 없는 경우 - if (pieTouchResponse == null || - pieTouchResponse.touchedSection == - null) { - // 차트 밖으로 나갔을 때만 리셋 - if (_touchedIndex != -1) { - setState(() { - _touchedIndex = -1; - }); + return RepaintBoundary( + child: PieChart( + PieChartData( + borderData: FlBorderData(show: false), + sectionsSpace: 2, + centerSpaceRadius: 60, + sections: + _applyTouchedState(snapshot.data!), + pieTouchData: PieTouchData( + enabled: true, + touchCallback: (FlTouchEvent event, + pieTouchResponse) { + // 터치 응답이 없거나 섹션이 없는 경우 + if (pieTouchResponse == null || + pieTouchResponse + .touchedSection == + null) { + // 차트 밖으로 나갔을 때만 리셋 + if (_touchedIndex != -1) { + setState(() { + _touchedIndex = -1; + }); + } + return; } - return; - } - final touchedIndex = pieTouchResponse - .touchedSection! - .touchedSectionIndex; + final touchedIndex = + pieTouchResponse.touchedSection! + .touchedSectionIndex; - // 탭 이벤트 처리 (토글) - if (event is FlTapUpEvent) { - setState(() { - // 동일 섹션 탭하면 선택 해제, 아니면 선택 - _touchedIndex = (_touchedIndex == - touchedIndex) - ? -1 - : touchedIndex; - }); - return; - } - - // hover 이벤트 처리 (단순 표시) - if (event is FlPointerHoverEvent || - event is FlPointerEnterEvent) { - // 현재 인덱스와 다른 경우만 업데이트 - if (_touchedIndex != touchedIndex) { + // 탭 이벤트 처리 (토글) + if (event is FlTapUpEvent) { setState(() { - _touchedIndex = touchedIndex; + // 동일 섹션 탭하면 선택 해제, 아니면 선택 + _touchedIndex = + (_touchedIndex == + touchedIndex) + ? -1 + : touchedIndex; }); + return; } - } - }, + + // hover 이벤트 처리 (단순 표시) + if (event is FlPointerHoverEvent || + event is FlPointerEnterEvent) { + // 현재 인덱스와 다른 경우만 업데이트 + if (_touchedIndex != + touchedIndex) { + setState(() { + _touchedIndex = touchedIndex; + }); + } + } + }, + ), ), + swapAnimationDuration: + ReduceMotion.isEnabled(context) + ? const Duration(milliseconds: 0) + : const Duration( + milliseconds: 300), + swapAnimationCurve: Curves.easeOut, ), ); },