
서버 없이, 디바이스 내부에서 직접 알림을 보내는 기능
푸시(Firebase 등) ❌
앱 내부 로직으로 알림 ⭕️
Flutter 프레임워크 자체는 알림 기능 미지원
각 플랫폼 네이티브 API를 사용해야 함
| 플랫폼 | 네이티브 API |
|---|---|
| Android | NotificationCompat API |
| iOS | UserNotification API |
이걸 Flutter와 연결해 둔 패키지가
flutter_local_notifications
flutter pub add flutter_local_notifications
Android / iOS 모두 지원
알림 표시, 클릭 이벤트 처리 가능
백그라운드 콜백 지원
📁 ios/Runner/AppDelegate.swift
① 패키지 import
import flutter_local_notifications
② 알림 클릭 시 Flutter와 연결
FlutterLocalNotificationsPlugin.setPluginRegistrantCallback { (registry) in
GeneratedPluginRegistrant.register(with: registry)
}
③ 알림 델리게이트 설정 (iOS 10+)
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate =
self as? UNUserNotificationCenterDelegate
}
import Flutter
import UIKit
import flutter_local_notifications
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
FlutterLocalNotificationsPlugin.setPluginRegistrantCallback { (registry) in
GeneratedPluginRegistrant.register(with: registry)
}
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
}
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
의미
백그라운드 / 종료 상태에서 알림 클릭 가능
Flutter ↔ iOS 네이티브 브릿지 연결
📁 android/app/build.gradle
compileSdk = 34
...
coreLibraryDesugaringEnabled true
...
multiDexEnabled true
...
targetSdk = 34
...
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.2'
implementation 'androidx.window:window:1.0.0'
implementation 'androidx.window:window-java:1.0.0'
}
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.flutter_noti_example"
compileSdk = 34 // 💛 여기
ndkVersion = flutter.ndkVersion
compileOptions {
coreLibraryDesugaringEnabled true // 💛 여기
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
multiDexEnabled true // 💛 여기
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.flutter_noti_example"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = 34 // 💛 여기
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
// 💛 여기
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.2'
implementation 'androidx.window:window:1.0.0'
implementation 'androidx.window:window-java:1.0.0'
}
flutter {
source = "../.."
}
Android 13+ 알림 대응
최신 알림 API 사용 가능
📁 AndroidManifest.xml
권한 설정
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
android:showWhenLocked="true"
android:turnScreenOn="true"
알림 리시버 등록
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
</intent-filter>
</receiver>
기기 재부팅 후에도 예약 알림 유지
알림 클릭 / 스케줄 알림 정상 동작
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<application
android:label="flutter_noti_example"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
</intent-filter>
</receiver>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
📁 notification_helper.dart
('vm:entry-point')
void notificationTapBackground(NotificationResponse notificationResponse) {
// 백그라운드에서 푸시알림 클릭했을 때 실행할 로직 작성
}
@pragma('vm:entry-point')
왜 필요할까??
릴리즈(AOT) 모드에서 함수 제거 방지
백그라운드 isolate에서 실행되기 때문
static Future<void> initiailize() async {
// 안드로이드 초기화 설정
// @mipmap/ic_launcher => android/src/main/mipmap/ic_launcher.png
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
// iOS에서 알림 초기화 설정
final DarwinInitializationSettings initializationSettingsDarwin =
DarwinInitializationSettings(
// 알림 알림 권한을 요청
requestAlertPermission: true,
// 배지 권한을 요청
requestBadgePermission: true,
// 사운드 권한을 요청
requestSoundPermission: true,
);
final InitializationSettings initializationSettings =
InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsDarwin,
);
// 초기화
await flutterLocalNotificationsPlugin.initialize(
settings: initializationSettings,
onDidReceiveNotificationResponse: (noti) {
// 포그라운드에서 알림 터치했을 때
print(noti.payload);
},
onDidReceiveBackgroundNotificationResponse: notificationTapBackground,
);
// 안드로이드 33 부터는 권한 요청해줘야함!
await _requestAndroidPermissionForOver33();
}
역할
Android / iOS 알림 초기화
알림 클릭 이벤트 등록
Android 13+ 권한 요청
static Future<void> show(String title, String content) async {
return flutterLocalNotificationsPlugin.show(
id: 0, // 알림 ID (중복된 알림을 관리하기 위한 고유 ID)
title: title, // 알림 제목
body: content, // 알림 내용
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
'test channel id', // 안드로이드 8.0 이상에서 알림을 그룹화하고 분류하는 용도. 고유한 값으로 설정
'General Notifications', // 알림 채널 이름. 사용자가 설정에서 채널별로 알림 끄고 킬 수 있슴
importance: Importance.high, // 알림의 우선순위
playSound: true, // 알림 소리 재생 여부
),
iOS: DarwinNotificationDetails(
presentSound: true, // 알림 소리 재생 여부
presentAlert: true, // 알림 표시 여부
presentBadge: true, // 배지 표시 여부
),
),
// 알림의 부가적인 데이터
// 포그라운드, 백그라운드에서 알림 터치했을 때 실행될 함수에 전달됨
payload: 'Open from Local Notification',
);
}
핵심 포인트
채널 ID: Android 8.0 이상 필수
payload: 알림 클릭 시 전달할 데이터
import 'package:flutter/material.dart';
import 'package:flutter_noti_example/notification_helper.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await NotificationHelper.initiailize();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
home: HomePage(),
);
}
}
class HomePage extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await NotificationHelper.show('title', 'content');
},
),
);
}
}
실행 흐름
앱 시작
알림 시스템 초기화
버튼 클릭 → 로컬 알림 즉시 표시
TensorFlow Lite (feat. YOLOv8)
Google에서 개발한 오픈 소스 머신러닝 라이브러리
인간의 뇌(신경망 구조)를 모방한 알고리즘
복잡한 패턴을 스스로 학습하고 예측 가능
이미지 인식
자연어 처리
음성 인식
강화 학습 등
왜 Lite가 필요할까?
일반 TensorFlow는:
무겁고
연산량이 많고
모바일 환경에 부적합
👉 그래서 나온 게 TensorFlow Lite
앱 개발자가 직접 학습 ❌
이미 학습된 모델 사용 ⭕️
이미지 분류
객체 감지
텍스트 분류 등
👉 이번 실습에서는 객체 감지 모델 YOLO 사용
YOLO (You Only Look Once)
이미지를 한 번에 분석하여 객체 탐지
실시간 처리에 적합
이미지 전체에서 여러 객체 동시 감지 가능
YOLO 모델이 학습된 데이터셋
실생활 객체 중심
총 80개 클래스
사람, 동물, 차량, 가구 등
전체 흐름 요약
프로젝트 생성
flutter_yolo_example
assets 추가
labels.txt 파일 다운받은 후 assets/ 폴더에 추가https://github.com/this-is-spartaa/YOLO-v8-model
assets/yolov8n.tflite
assets/labels.txt
📌 labels.txt
모델이 반환하는 클래스 인덱스 → 실제 이름 매핑용
assets:
- assets/
tflite_flutter
TensorFlow Lite 공식 Flutter 패키지
모델 추론 담당
flutter pub add tflite_flutter
image
YOLO 입력 사이즈 맞추기용
이미지 리사이즈 (640×640)
flutter pub add image
image_picker
갤러리에서 이미지 선택
flutter pub add image_picker
yolo_helper
YOLO 출력 결과를 사람이 쓰기 좋은 형태로 변환
pub.dev 업로드 되어있지 않아 GitHub 직접 연결
yolo_helper:
git:
url: https://github.com/fhelper/flutter-yolo-helper.git
ref: main
📄 yolo_detection.dart
AI 모델과 관련된 모든 책임을 가진 클래스
import 'package:flutter/services.dart';
import 'package:image/image.dart';
import 'package:tflite_flutter/tflite_flutter.dart';
import 'package:yolo_helper/yolo_helper.dart';
class YoloDetection {
int? _numClasses;
List<String>? _labels;
Interpreter? _interpreter;
String label(int index) => _labels?[index] ?? '';
bool get isInitialized => _interpreter != null && _labels != null;
// 1. 모델 불러오기
Future<void> initialize() async {
_interpreter = await Interpreter.fromAsset('assets/yolov8n.tflite');
final labelAsset = await rootBundle.loadString('assets/labels.txt');
_labels = labelAsset.split('\n');
_numClasses = _labels!.length;
}
// 2. 이미지 입력받아서 추론
List<DetectedObject> runInference(Image image) {
if (!isInitialized) {
throw Exception('The model must be initialized');
}
// 3. 이미지를 YOLO v8 input 에 맞게 640x640 사이즈로 변환
final imgResized = copyResize(image, width: 640, height: 640);
// 4. 변환된 이미지 픽셀 nomalize(정규화)
// 640x640 이미지에서 각 픽셀값을 가져와서
// 0~255 사이의 값인 RGB 값을 0~1 로 변환
final imgNormalized = List.generate(
640,
(y) => List.generate(640, (x) {
final pixel = imgResized.getPixel(x, y);
return [pixel.rNormalized, pixel.gNormalized, pixel.bNormalized];
}),
);
final output = [
List<List<double>>.filled(4 + _numClasses!, List<double>.filled(8400, 0)),
];
_interpreter!.run([imgNormalized], output);
// 원본 이미지 사이즈 넘기기!!!
return YoloHelper.parse(output[0], image.width, image.height);
}
}
YOLO 모델 담당 파일
YOLO tflite 모델 + 라벨 로딩
이미지 → 640×640 변환
정규화 후 모델 실행
객체 탐지 결과(List<DetectedObject>) 반환
👉 UI랑 완전 무관, AI 로직만 있음
📄 lib/home_page.dart
class HomePage extends StatefulWidget {
const HomePage({super.key});
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final YoloDetection model = YoloDetection();
final ImagePicker picker = ImagePicker();
List<DetectedObject>? detectedObjects;
Uint8List? imageBytes;
int? imageWidth;
int? imageHeight;
void initState() {
super.initState();
model.initialize();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: GestureDetector(
onTap: () async {
if (!model.isInitialized) {
return;
}
final XFile? newImageFile = await picker.pickImage(
source: ImageSource.gallery,
);
if (newImageFile != null) {
final bytes = await newImageFile.readAsBytes();
final image = img.decodeImage(bytes)!;
imageWidth = image.width;
imageHeight = image.height;
setState(() {
imageBytes = bytes;
});
detectedObjects = model.runInference(image);
}
},
child: ListView(
children: [
if (imageBytes == null)
const Icon(Icons.file_open_outlined, size: 80)
else
Stack(
children: [
AspectRatio(
aspectRatio: imageWidth! / imageHeight!,
child: Image.memory(imageBytes!, fit: BoxFit.cover),
),
if (detectedObjects != null)
...detectedObjects!.map(
// (e) => Text('${model.label(e.labelIndex)} ${e.score}'),
(e) => Bbox(
detectedObject: e,
imageWidth: imageWidth!,
imageHeight: imageHeight!,
label: model.label(e.labelIndex),
),
),
],
),
],
),
),
);
}
}
메인 화면 + 전체 흐름 제어
앱 시작 시 YOLO 모델 초기화
화면 터치 → 갤러리 이미지 선택
이미지 화면에 표시
YOLO 추론 실행
결과를 박스로 화면에 표시
👉 “유저 행동 ↔ 모델 연결하는 중심 역할”
📄 lib/bbox.dart
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:yolo_helper/yolo_helper.dart';
class Bbox extends StatelessWidget {
final DetectedObject detectedObject;
final int imageWidth;
final int imageHeight;
final String label;
const Bbox({
super.key,
required this.detectedObject,
required this.imageWidth,
required this.imageHeight,
required this.label,
});
Widget build(BuildContext context) {
final deviceWidth = MediaQuery.of(context).size.width;
final resizedFactor = deviceWidth / imageWidth;
// 객체를 감싸고 있는 박스의 중간 좌표
final resizedX = detectedObject.x * resizedFactor;
final resizedY = detectedObject.y * resizedFactor;
// 박스의 크기
final resizedW = detectedObject.width * resizedFactor;
final resizedH = detectedObject.height * resizedFactor;
final random = Random();
return Positioned(
left: resizedX - (resizedW / 2),
top: resizedY - (resizedH / 2),
child: Container(
width: resizedW,
height: resizedH,
decoration: BoxDecoration(
border: Border.all(
color: Color(0xFF00FF00 + random.nextInt(0xFFFFFF)),
width: 3.0,
),
),
child: Text(
label,
style: const TextStyle(
color: Colors.red,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
);
}
}
탐지 결과를 화면에 그리는 위젯
YOLO 좌표 → 화면 좌표로 변환
객체 크기/위치 계산
이미지 위에 네모 박스 + 라벨 표시
👉 계산 + 그리기만 담당
StatefulWidget이 생성 → 화면에 표시 → 업데이트 → 제거되는 전체 흐름
“언제 무엇을 초기화하고, 언제 정리해야 하는가”를 알려주는 생명주기

createState
↓
initState
↓
didChangeDependencies
↓
build (여러 번 호출)
↓
didUpdateWidget (부모로부터 변경 발생 시)
↓
deactivate
↓
dispose
StatefulWidget이 처음 생성될 때
State 객체를 생성
거의 수정할 일 없음
State 객체 생성 직후 단 한 번만 호출
초기화 작업 담당
void initState() {
super.initState();
}
InheritedWidget 의 값이 변경되었을 때 호출
Theme, MediaQuery, Provider, Riverpod 등 의존성 변경 감지
void didChangeDependencies() {
super.didChangeDependencies();
}
위젯 트리
Flutter는 모든 위젯을 트리 구조로 관리
BuildContext
위젯이 트리 안에서 어디에 위치하는지에 대한 정보
이 정보를 통해 상위 위젯 데이터 접근 가능
Theme.of(context)
MediaQuery.of(context)
UI를 그리는 메서드
아주 자주 호출됨
호출 시점
최초 렌더링
setState() 호출
didUpdateWidget() 이후
주의
여기서 로직 처리 ✘
가벼운 UI 구성만
void didUpdateWidget(covariant FadeInText oldWidget) {
super.didUpdateWidget(oldWidget);
}
위젯이 트리에서 제거되기 직전
거의 사용 안 함
재삽입 가능성이 있는 경우만 의미 있음
위젯이 완전히 제거되기 직전
리소스 해제 필수
void dispose() {
controller.dispose();
super.dispose();
}
반드시 해제해야 할 것
❗ 안 하면 메모리 누수
initState → 한 번
build → 매우 자주
didUpdateWidget → 부모 값 변경 대응
dispose → 정리 담당
운영체제로부터 자원을 할당받아 실행 중인 프로그램
예) 실행된 크롬 브라우저 = 하나의 프로세스
하나의 프로세스 안에서 실행되는 작업 단위
멀티 스레드 환경에서는
파일 다운로드
동영상 재생
→ 각각 다른 스레드에서 실행
운영체제가 시분할 방식으로 스레드를 번갈아 실행
→ 동시에 실행되는 것처럼 보임
Flutter는 이벤트 큐 + 이벤트 루프 방식 사용
| 개념 | 설명 |
|---|---|
| 이벤트 큐 | 실행할 작업들이 대기하는 공간 (Queue, FIFO) |
| 이벤트 루프 | 큐에서 이벤트를 하나씩 꺼내 실행 |
멀티 스레드 쓰면 더 빠르지 않나?
Flutter는 이를 피하기 위해
싱글 스레드 + 이벤트 루프 구조 채택
UI 스레드에서 무거운 작업을 하면?
터치 이벤트 안에서
대용량 연산 (예: 30MB JSON 파싱)
이때 필요한 것이 Isolate
Dart에서 제공하는 독립적인 작업 단위
스레드 ❌
메모리를 공유하지 않음
| 구분 | 스레드 | Isolate |
|---|---|---|
| 메모리 | 공유 | 완전 분리 |
| 데이터 접근 | 자유 | 불가능 |
| 안전성 | 낮음 | 매우 높음 |
다른 Isolate는
Main Isolate의 객체 접근 ❌ (반대도 동일)
SendPort / ReceivePort 사용
직렬화 가능한 데이터만 전달 가능
Main Isolate ↔ SendPort / ReceivePort ↔ Worker Isolate
대용량 JSON 파싱
이미지 처리
복잡한 계산
데이터 변환 작업
Isolate.spawn
Isolate에서 실행할 함수
void isolateTask(SendPort mainSendPort) async {
// 무거운 작업 처리
mainSendPort.send("result");
}
⚠️ 주의
rootBundle, BuildContext 사용 불가