feat(perf): offload Android SMS parsing to Isolate and wrap pie chart with RepaintBoundary
This commit is contained in:
@@ -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 'package:flutter_sms_inbox/flutter_sms_inbox.dart';
|
||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
import '../utils/logger.dart';
|
import '../utils/logger.dart';
|
||||||
@@ -7,38 +7,6 @@ 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 {
|
||||||
@@ -114,16 +82,21 @@ class SmsScanner {
|
|||||||
Future<List<dynamic>> _scanAndroidSms() async {
|
Future<List<dynamic>> _scanAndroidSms() async {
|
||||||
try {
|
try {
|
||||||
final messages = await _query.getAllSms;
|
final messages = await _query.getAllSms;
|
||||||
final smsList = <Map<String, dynamic>>[];
|
|
||||||
|
|
||||||
// SMS 메시지를 분석하여 구독 서비스 감지
|
// Isolate로 넘길 수 있도록 직렬화 (plugin 객체 직접 전달 불가)
|
||||||
|
final serialized = <Map<String, dynamic>>[];
|
||||||
for (final message in messages) {
|
for (final message in messages) {
|
||||||
final parsedData = _parseRawSms(message);
|
serialized.add({
|
||||||
if (parsedData != null) {
|
'body': message.body ?? '',
|
||||||
smsList.add(parsedData);
|
'address': message.address ?? '',
|
||||||
}
|
'dateMillis': (message.date ?? DateTime.now()).millisecondsSinceEpoch,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 대량 파싱은 별도 Isolate로 오프로딩
|
||||||
|
final List<Map<String, dynamic>> smsList =
|
||||||
|
await compute(_parseRawSmsBatch, serialized);
|
||||||
|
|
||||||
return smsList;
|
return smsList;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Log.e('SmsScanner: Android SMS 스캔 실패', e);
|
Log.e('SmsScanner: Android SMS 스캔 실패', e);
|
||||||
@@ -131,131 +104,7 @@ class SmsScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 실제 SMS 메시지를 파싱하여 구독 정보 추출
|
// (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체
|
||||||
Map<String, dynamic>? _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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SubscriptionModel? _parseSms(Map<String, dynamic> sms, int repeatCount) {
|
SubscriptionModel? _parseSms(Map<String, dynamic> sms, int repeatCount) {
|
||||||
try {
|
try {
|
||||||
@@ -427,3 +276,148 @@ class SmsScanner {
|
|||||||
return 'KRW';
|
return 'KRW';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Isolate 오프로딩용 Top-level 파서 =====
|
||||||
|
|
||||||
|
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
|
||||||
|
List<Map<String, dynamic>> _parseRawSmsBatch(
|
||||||
|
List<Map<String, dynamic>> messages) {
|
||||||
|
// 키워드/정규식은 Isolate 내에서 재생성 (간단 복제)
|
||||||
|
const subscriptionKeywords = [
|
||||||
|
'구독',
|
||||||
|
'결제',
|
||||||
|
'정기결제',
|
||||||
|
'자동결제',
|
||||||
|
'월정액',
|
||||||
|
'subscription',
|
||||||
|
'payment',
|
||||||
|
'billing',
|
||||||
|
'charge',
|
||||||
|
'넷플릭스',
|
||||||
|
'Netflix',
|
||||||
|
'유튜브',
|
||||||
|
'YouTube',
|
||||||
|
'Spotify',
|
||||||
|
'멜론',
|
||||||
|
'웨이브',
|
||||||
|
'Disney+',
|
||||||
|
'디즈니플러스',
|
||||||
|
'Apple',
|
||||||
|
'Microsoft',
|
||||||
|
'GitHub',
|
||||||
|
'Adobe',
|
||||||
|
'Amazon'
|
||||||
|
];
|
||||||
|
|
||||||
|
final 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 results = <Map<String, dynamic>>[];
|
||||||
|
|
||||||
|
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<RegExp> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../themed_text.dart';
|
|||||||
import 'analysis_badge.dart';
|
import 'analysis_badge.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../providers/locale_provider.dart';
|
import '../../providers/locale_provider.dart';
|
||||||
|
import '../../utils/reduce_motion.dart';
|
||||||
|
|
||||||
/// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯
|
/// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯
|
||||||
class SubscriptionPieChartCard extends StatefulWidget {
|
class SubscriptionPieChartCard extends StatefulWidget {
|
||||||
@@ -312,7 +313,8 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return PieChart(
|
return RepaintBoundary(
|
||||||
|
child: PieChart(
|
||||||
PieChartData(
|
PieChartData(
|
||||||
borderData: FlBorderData(show: false),
|
borderData: FlBorderData(show: false),
|
||||||
sectionsSpace: 2,
|
sectionsSpace: 2,
|
||||||
@@ -325,7 +327,8 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
pieTouchResponse) {
|
pieTouchResponse) {
|
||||||
// 터치 응답이 없거나 섹션이 없는 경우
|
// 터치 응답이 없거나 섹션이 없는 경우
|
||||||
if (pieTouchResponse == null ||
|
if (pieTouchResponse == null ||
|
||||||
pieTouchResponse.touchedSection ==
|
pieTouchResponse
|
||||||
|
.touchedSection ==
|
||||||
null) {
|
null) {
|
||||||
// 차트 밖으로 나갔을 때만 리셋
|
// 차트 밖으로 나갔을 때만 리셋
|
||||||
if (_touchedIndex != -1) {
|
if (_touchedIndex != -1) {
|
||||||
@@ -336,15 +339,16 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final touchedIndex = pieTouchResponse
|
final touchedIndex =
|
||||||
.touchedSection!
|
pieTouchResponse.touchedSection!
|
||||||
.touchedSectionIndex;
|
.touchedSectionIndex;
|
||||||
|
|
||||||
// 탭 이벤트 처리 (토글)
|
// 탭 이벤트 처리 (토글)
|
||||||
if (event is FlTapUpEvent) {
|
if (event is FlTapUpEvent) {
|
||||||
setState(() {
|
setState(() {
|
||||||
// 동일 섹션 탭하면 선택 해제, 아니면 선택
|
// 동일 섹션 탭하면 선택 해제, 아니면 선택
|
||||||
_touchedIndex = (_touchedIndex ==
|
_touchedIndex =
|
||||||
|
(_touchedIndex ==
|
||||||
touchedIndex)
|
touchedIndex)
|
||||||
? -1
|
? -1
|
||||||
: touchedIndex;
|
: touchedIndex;
|
||||||
@@ -356,7 +360,8 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
if (event is FlPointerHoverEvent ||
|
if (event is FlPointerHoverEvent ||
|
||||||
event is FlPointerEnterEvent) {
|
event is FlPointerEnterEvent) {
|
||||||
// 현재 인덱스와 다른 경우만 업데이트
|
// 현재 인덱스와 다른 경우만 업데이트
|
||||||
if (_touchedIndex != touchedIndex) {
|
if (_touchedIndex !=
|
||||||
|
touchedIndex) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_touchedIndex = touchedIndex;
|
_touchedIndex = touchedIndex;
|
||||||
});
|
});
|
||||||
@@ -365,6 +370,13 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
swapAnimationDuration:
|
||||||
|
ReduceMotion.isEnabled(context)
|
||||||
|
? const Duration(milliseconds: 0)
|
||||||
|
: const Duration(
|
||||||
|
milliseconds: 300),
|
||||||
|
swapAnimationCurve: Curves.easeOut,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user