[Flutter] 휴대폰의 가속도계, 자이로스코프, Sensor_Plus 패키지 완전정복
📱 Flutter에서 모바일 센서를 활용한 앱 개발의 모든 것
센서의 이해와 활용 분야
🔍 모바일 센서란?
모바일 디바이스에는 다양한 MEMS(Micro-Electro-Mechanical Systems) 센서가 내장되어 있습니다. 이러한 센서들은 물리적 움직임과 환경 변화를 감지하여 앱에서 활용할 수 있는 데이터를 제공합니다.
📊 주요 센서 종류와 활용 분야
| 센서 종류 | 측정 단위 | 주요 활용 분야 |
|---|---|---|
| 가속도계 | m/s² | 화면 회전, 걸음 수 측정, 충격 감지 |
| 자이로스코프 | rad/s | 게임 조작, 카메라 안정화, VR/AR |
| 자력계 | μT | 나침반, 내비게이션 |
| 기압계 | hPa | 고도 측정, 날씨 예측 |
🎮 실제 앱 활용 사례
- 게임: 기울기를 이용한 자동차 조작 게임
- 피트니스: 걸음 수, 운동량 측정 앱
- AR/VR: 3D 공간에서의 디바이스 orientation 추적
- 유틸리티: 수평계, 나침반 앱
- 보안: 기기 떨림 감지를 통한 보안 알림
Sensor_Plus 패키지 소개
✨ Sensor_Plus 패키지의 장점
Sensor_Plus는 Flutter 커뮤니티에서 가장 널리 사용되는 센서 패키지로, 다음과 같은 장점을 제공합니다:
- 크로스 플랫폼: iOS, Android, Web 모두 지원
- 실시간 스트리밍: Stream 기반의 실시간 센서 데이터 제공
- 풍부한 센서 지원: 가속도계, 자이로스코프, 자력계 등 다양한 센서
- 활발한 커뮤니티: 지속적인 업데이트와 버그 픽스
- 간단한 API: 직관적이고 사용하기 쉬운 인터페이스
📈 패키지 인기도 및 안정성
# pub.dev 기준 (2025년 6월)
- Pub Points: 140/140
- Popularity: 98%
- Likes: 1,000+
- Weekly Downloads: 500,000+
프로젝트 설정 및 패키지 설치
🛠️ 1단계: pubspec.yaml 설정
dependencies:
flutter:
sdk: flutter
sensors_plus: ^6.1.0 # 최신 버전 확인 필요
dev_dependencies:
flutter_test:
sdk: flutter
📱 2단계: 플랫폼별 권한 설정
Android 설정 (android/app/src/main/AndroidManifest.xml)
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 센서 사용 권한 -->
<uses-permission android:name="android.permission.SENSORS" />
<!-- 특정 센서 필수 요구사항 (선택사항) -->
<uses-feature android:name="android.hardware.sensor.accelerometer" android:required="true" />
<uses-feature android:name="android.hardware.sensor.gyroscope" android:required="true" />
<application>
<!-- 앱 설정 -->
</application>
</manifest>
iOS 설정 (ios/Runner/Info.plist)
<dict>
<!-- 모션 및 피트니스 사용 권한 -->
<key>NSMotionUsageDescription</key>
<string>이 앱은 기기 움직임을 감지하여 기능을 제공합니다</string>
<!-- 기타 앱 설정 -->
</dict>
💻 3단계: 패키지 import
import 'package:sensors_plus/sensors_plus.dart';
// 중력을 반영한 가속도계 값
accelerometerEventStream().listen((AccelerometerEvent event) {
print(event.x); // x축 수치
print(event.y); // y축 수치
print(event.z); // z축 수치
});
// 중력을 반영하지 않은 순수 사용자의 힘에 의한 가속도계 값
userAccelerometerEventStream().listen((UserAccelerometerEvent event) {
print(event.x); // x축 수치
print(event.y); // y축 수치
print(event.z); // z축 수치
});
// 각속도(Angular Velocity)를 측정 (기기가 얼마나 빠르게 회전하는지를 감지
gyroscopeEventStream().listen((GyroscopeEvent event) {
print(event.x); // x축 수치
print(event.y); // y축 수치
print(event.z); // z축 수치
});
가속도계(Accelerometer) 완전정복
🧭 가속도계의 원리와 좌표계
가속도계는 3축 좌표계를 사용하여 기기의 가속도를 측정합니다:
- X축: 기기의 좌우 방향 (오른쪽이 양수)
- Y축: 기기의 상하 방향 (위쪽이 양수)
- Z축: 기기의 앞뒤 방향 (사용자 쪽이 양수)
📐 중력과 선형 가속도
// 중력을 포함한 전체 가속도
accelerometerEvents.listen((AccelerometerEvent event) {
print('전체 가속도 - X: ${event.x}, Y: ${event.y}, Z: ${event.z}');
});
// 중력을 제외한 사용자 가속도 (선형 가속도)
userAccelerometerEvents.listen((UserAccelerometerEvent event) {
print('선형 가속도 - X: ${event.x}, Y: ${event.y}, Z: ${event.z}');
});
🔄 가속도계 활용 예제: 기기 기울기 감지
class AccelerometerWidget extends StatefulWidget {
@override
_AccelerometerWidgetState createState() => _AccelerometerWidgetState();
}
class _AccelerometerWidgetState extends State<AccelerometerWidget> {
double _x = 0.0, _y = 0.0, _z = 0.0;
StreamSubscription<AccelerometerEvent>? _streamSubscription;
@override
void initState() {
super.initState();
_startListening();
}
void _startListening() {
_streamSubscription = accelerometerEvents.listen(
(AccelerometerEvent event) {
setState(() {
_x = event.x;
_y = event.y;
_z = event.z;
});
},
onError: (error) {
print('가속도계 오류: $error');
},
);
}
@override
void dispose() {
_streamSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('가속도계 데이터', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
SizedBox(height: 20),
_buildDataCard('X축 (좌우)', _x, Colors.red),
_buildDataCard('Y축 (상하)', _y, Colors.green),
_buildDataCard('Z축 (앞뒤)', _z, Colors.blue),
SizedBox(height: 20),
_buildTiltIndicator(),
],
);
}
Widget _buildDataCard(String label, double value, Color color) {
return Card(
child: ListTile(
leading: CircleAvatar(backgroundColor: color, child: Text(label[0])),
title: Text(label),
trailing: Text('${value.toStringAsFixed(2)} m/s²'),
),
);
}
Widget _buildTiltIndicator() {
// 기울기 각도 계산 (라디안을 도로 변환)
double tiltX = atan2(_y, _z) * 180 / pi;
double tiltY = atan2(_x, _z) * 180 / pi;
return Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
Text('기기 기울기', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
SizedBox(height: 10),
Text('X축 기울기: ${tiltX.toStringAsFixed(1)}°'),
Text('Y축 기울기: ${tiltY.toStringAsFixed(1)}°'),
],
),
),
);
}
}
🚶♂️ 걸음 수 측정 알고리즘
class StepCounter {
static const double THRESHOLD = 12.0; // 걸음 감지 임계값
static const int TIME_THRESHOLD = 250; // 최소 걸음 간격 (ms)
int _stepCount = 0;
double _lastMagnitude = 0.0;
int _lastStepTime = 0;
bool _isAboveThreshold = false;
int get stepCount => _stepCount;
void processAccelerometerData(double x, double y, double z) {
// 가속도 벡터의 크기 계산
double magnitude = sqrt(x * x + y * y + z * z);
// 현재 시간
int currentTime = DateTime.now().millisecondsSinceEpoch;
// 임계값을 넘었다가 다시 아래로 떨어지는 패턴 감지
if (magnitude > THRESHOLD && !_isAboveThreshold) {
_isAboveThreshold = true;
} else if (magnitude < THRESHOLD && _isAboveThreshold) {
_isAboveThreshold = false;
// 최소 시간 간격 확인
if (currentTime - _lastStepTime > TIME_THRESHOLD) {
_stepCount++;
_lastStepTime = currentTime;
}
}
_lastMagnitude = magnitude;
}
void reset() {
_stepCount = 0;
_lastMagnitude = 0.0;
_lastStepTime = 0;
_isAboveThreshold = false;
}
}
자이로스코프(Gyroscope) 완전정복
🌪️ 자이로스코프의 원리
자이로스코프는 각속도(Angular Velocity)를 측정하는 센서로, 기기가 얼마나 빠르게 회전하는지를 감지합니다:
- 단위: rad/s (라디안/초)
- X축 회전: Pitch (상하 회전)
- Y축 회전: Roll (좌우 회전)
- Z축 회전: Yaw (수평 회전)
🎯 자이로스코프 활용 예제: 3D 회전 시각화
class GyroscopeWidget extends StatefulWidget {
@override
_GyroscopeWidgetState createState() => _GyroscopeWidgetState();
}
class _GyroscopeWidgetState extends State<GyroscopeWidget> {
double _x = 0.0, _y = 0.0, _z = 0.0;
double _rotationX = 0.0, _rotationY = 0.0, _rotationZ = 0.0;
StreamSubscription<GyroscopeEvent>? _streamSubscription;
DateTime _lastUpdate = DateTime.now();
@override
void initState() {
super.initState();
_startListening();
}
void _startListening() {
_streamSubscription = gyroscopeEvents.listen(
(GyroscopeEvent event) {
setState(() {
_x = event.x;
_y = event.y;
_z = event.z;
// 각속도를 적분하여 회전각 계산
DateTime now = DateTime.now();
double dt = (now.millisecondsSinceEpoch - _lastUpdate.millisecondsSinceEpoch) / 1000.0;
_rotationX += event.x * dt;
_rotationY += event.y * dt;
_rotationZ += event.z * dt;
_lastUpdate = now;
});
},
onError: (error) {
print('자이로스코프 오류: $error');
},
);
}
@override
void dispose() {
_streamSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('자이로스코프 데이터', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
SizedBox(height: 20),
_buildAngularVelocityCards(),
SizedBox(height: 20),
_buildRotationCards(),
SizedBox(height: 20),
_build3DVisualizer(),
SizedBox(height: 20),
ElevatedButton(
onPressed: _resetRotation,
child: Text('회전 리셋'),
),
],
);
}
Widget _buildAngularVelocityCards() {
return Column(
children: [
Text('각속도 (rad/s)', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
_buildDataCard('Pitch (X)', _x, Colors.red),
_buildDataCard('Roll (Y)', _y, Colors.green),
_buildDataCard('Yaw (Z)', _z, Colors.blue),
],
);
}
Widget _buildRotationCards() {
return Column(
children: [
Text('누적 회전각 (라디안)', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
_buildDataCard('X축 회전', _rotationX, Colors.red),
_buildDataCard('Y축 회전', _rotationY, Colors.green),
_buildDataCard('Z축 회전', _rotationZ, Colors.blue),
],
);
}
Widget _buildDataCard(String label, double value, Color color) {
return Card(
child: ListTile(
leading: CircleAvatar(backgroundColor: color, child: Text(label.split(' ')[0][0])),
title: Text(label),
trailing: Text(value.toStringAsFixed(3)),
),
);
}
Widget _build3DVisualizer() {
return Container(
height: 200,
width: 200,
child: CustomPaint(
painter: RotationPainter(_rotationX, _rotationY, _rotationZ),
),
);
}
void _resetRotation() {
setState(() {
_rotationX = 0.0;
_rotationY = 0.0;
_rotationZ = 0.0;
});
}
}
class RotationPainter extends CustomPainter {
final double rotationX, rotationY, rotationZ;
RotationPainter(this.rotationX, this.rotationY, this.rotationZ);
@override
void paint(Canvas canvas, Size size) {
final Paint paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3.0;
final double centerX = size.width / 2;
final double centerY = size.height / 2;
final double radius = min(centerX, centerY) - 20;
// X축 회전을 빨간색 원으로 표시
paint.color = Colors.red;
canvas.drawArc(
Rect.fromCenter(center: Offset(centerX, centerY), width: radius * 2, height: radius * 2),
-pi / 2,
rotationX,
false,
paint,
);
// Y축 회전을 초록색 원으로 표시
paint.color = Colors.green;
canvas.drawArc(
Rect.fromCenter(center: Offset(centerX, centerY), width: radius * 1.5, height: radius * 1.5),
-pi / 2,
rotationY,
false,
paint,
);
// Z축 회전을 파란색 원으로 표시
paint.color = Colors.blue;
canvas.drawArc(
Rect.fromCenter(center: Offset(centerX, centerY), width: radius, height: radius),
-pi / 2,
rotationZ,
false,
paint,
);
// 중심점 표시
paint.style = PaintingStyle.fill;
paint.color = Colors.black;
canvas.drawCircle(Offset(centerX, centerY), 5, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
🎮 게임 컨트롤러 구현
class GameController {
static const double SENSITIVITY = 2.0;
static const double DEAD_ZONE = 0.1; // 작은 움직임 무시
double _steeringAngle = 0.0;
double _throttle = 0.0;
double get steeringAngle => _steeringAngle;
double get throttle => _throttle;
void updateFromGyroscope(double x, double y, double z) {
// Y축 회전을 조향 각도로 사용
if (y.abs() > DEAD_ZONE) {
_steeringAngle = (y * SENSITIVITY).clamp(-1.0, 1.0);
} else {
_steeringAngle = 0.0;
}
}
void updateFromAccelerometer(double x, double y, double z) {
// Y축 기울기를 가속/감속으로 사용
if (y.abs() > DEAD_ZONE) {
_throttle = (-y * SENSITIVITY).clamp(-1.0, 1.0);
} else {
_throttle = 0.0;
}
}
}
실전 예제: 센서 데이터 시각화 앱
🚀 완전한 센서 대시보드 구현
import 'package:flutter/material.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'dart:async';
import 'dart:math';
class SensorDashboard extends StatefulWidget {
@override
_SensorDashboardState createState() => _SensorDashboardState();
}
class _SensorDashboardState extends State<SensorDashboard>
with TickerProviderStateMixin {
// 센서 데이터 변수들
AccelerometerEvent? _accelerometerEvent;
GyroscopeEvent? _gyroscopeEvent;
MagnetometerEvent? _magnetometerEvent;
// 스트림 구독 관리
final List<StreamSubscription<dynamic>> _streamSubscriptions = [];
// 애니메이션 컨트롤러
late AnimationController _animationController;
// 데이터 히스토리 (차트용)
List<double> _accelerometerHistory = [];
List<double> _gyroscopeHistory = [];
static const int MAX_HISTORY_LENGTH = 50;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: Duration(milliseconds: 100),
vsync: this,
);
_startListening();
}
void _startListening() {
// 가속도계 리스너
_streamSubscriptions.add(
accelerometerEvents.listen(
(AccelerometerEvent event) {
setState(() {
_accelerometerEvent = event;
_updateHistory(_accelerometerHistory,
sqrt(event.x * event.x + event.y * event.y + event.z * event.z));
});
_animationController.forward(from: 0);
},
onError: (error) => _showError('가속도계 오류: $error'),
),
);
// 자이로스코프 리스너
_streamSubscriptions.add(
gyroscopeEvents.listen(
(GyroscopeEvent event) {
setState(() {
_gyroscopeEvent = event;
_updateHistory(_gyroscopeHistory,
sqrt(event.x * event.x + event.y * event.y + event.z * event.z));
});
},
onError: (error) => _showError('자이로스코프 오류: $error'),
),
);
// 자력계 리스너
_streamSubscriptions.add(
magnetometerEvents.listen(
(MagnetometerEvent event) {
setState(() {
_magnetometerEvent = event;
});
},
onError: (error) => _showError('자력계 오류: $error'),
),
);
}
void _updateHistory(List<double> history, double value) {
history.add(value);
if (history.length > MAX_HISTORY_LENGTH) {
history.removeAt(0);
}
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
}
@override
void dispose() {
for (StreamSubscription subscription in _streamSubscriptions) {
subscription.cancel();
}
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('센서 대시보드'),
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
actions: [
IconButton(
icon: Icon(Icons.refresh),
onPressed: () {
setState(() {
_accelerometerHistory.clear();
_gyroscopeHistory.clear();
});
},
),
],
),
body: SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSensorCard(
'🏃♂️ 가속도계',
_accelerometerEvent,
Colors.red,
'X: ${_accelerometerEvent?.x.toStringAsFixed(2) ?? 'N/A'} m/s²\n'
'Y: ${_accelerometerEvent?.y.toStringAsFixed(2) ?? 'N/A'} m/s²\n'
'Z: ${_accelerometerEvent?.z.toStringAsFixed(2) ?? 'N/A'} m/s²',
),
SizedBox(height: 16),
_buildSensorCard(
'🌪️ 자이로스코프',
_gyroscopeEvent,
Colors.green,
'X: ${_gyroscopeEvent?.x.toStringAsFixed(3) ?? 'N/A'} rad/s\n'
'Y: ${_gyroscopeEvent?.y.toStringAsFixed(3) ?? 'N/A'} rad/s\n'
'Z: ${_gyroscopeEvent?.z.toStringAsFixed(3) ?? 'N/A'} rad/s',
),
SizedBox(height: 16),
_buildSensorCard(
'🧭 자력계',
_magnetometerEvent,
Colors.blue,
'X: ${_magnetometerEvent?.x.toStringAsFixed(1) ?? 'N/A'} μT\n'
'Y: ${_magnetometerEvent?.y.toStringAsFixed(1) ?? 'N/A'} μT\n'
'Z: ${_magnetometerEvent?.z.toStringAsFixed(1) ?? 'N/A'} μT',
),
SizedBox(height: 16),
_buildChartCard('📊 가속도계 히스토리', _accelerometerHistory, Colors.red),
SizedBox(height: 16),
_buildChartCard('📊 자이로스코프 히스토리', _gyroscopeHistory, Colors.green),
SizedBox(height: 16),
_buildCompassCard(),
],
),
),
);
}
Widget _buildSensorCard(String title, dynamic event, Color color, String data) {
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: 1.0 + (_animationController.value * 0.05),
child: Card(
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
gradient: LinearGradient(
colors: [color.withOpacity(0.1), Colors.white],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
padding: EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(
event != null ? Icons.sensors : Icons.sensors_off,
color: Colors.white,
),
),
SizedBox(width: 15),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
event != null ? '활성' : '비활성',
style: TextStyle(
color: event != null ? Colors.green : Colors.red,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
SizedBox(height: 15),
Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Text(
data,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 14,
color: Colors.black87,
),
),
),
],
),
),
),
);
},
);
}
Widget _buildChartCard(String title, List<double> data, Color color) {
return Card(
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
child: Padding(
padding: EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
SizedBox(height: 15),
Container(
height: 100,
child: CustomPaint(
painter: LineChartPainter(data, color),
child: Container(),
),
),
],
),
),
);
}
Widget _buildCompassCard() {
double heading = 0.0;
if (_magnetometerEvent != null) {
heading = atan2(_magnetometerEvent!.y, _magnetometerEvent!.x) * 180 / pi;
if (heading < 0) heading += 360;
}
return Card(
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
child: Padding(
padding: EdgeInsets.all(20),
child: Column(
children: [
Text(
'🧭 디지털 나침반',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
SizedBox(height: 20),
Container(
width: 150,
height: 150,
child: CustomPaint(
painter: CompassPainter(heading),
),
),
SizedBox(heading: 10),
Text(
'방위각: ${heading.toStringAsFixed(1)}°',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
}
// 차트 그리기 클래스
class LineChartPainter extends CustomPainter {
final List<double> data;
final Color color;
LineChartPainter(this.data, this.color);
@override
void paint(Canvas canvas, Size size) {
if (data.isEmpty) return;
final Paint paint = Paint()
..color = color
..strokeWidth = 2.0
..style = PaintingStyle.stroke;
final Paint fillPaint = Paint()
..color = color.withOpacity(0.2)
..style = PaintingStyle.fill;
// 데이터 정규화
double maxValue = data.reduce(max);
double minValue = data.reduce(min);
double range = maxValue - minValue;
if (range == 0) range = 1;
// 경로 생성
Path path = Path();
Path fillPath = Path();
for (int i = 0; i < data.length; i++) {
double x = (i / (data.length - 1)) * size.width;
double y = size.height - ((data[i] - minValue) / range) * size.height;
if (i == 0) {
path.moveTo(x, y);
fillPath.moveTo(x, size.height);
fillPath.lineTo(x, y);
} else {
path.lineTo(x, y);
fillPath.lineTo(x, y);
}
}
fillPath.lineTo(size.width, size.height);
fillPath.close();
// 그리기
canvas.drawPath(fillPath, fillPaint);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
// 나침반 그리기 클래스
class CompassPainter extends CustomPainter {
final double heading;
CompassPainter(this.heading);
@override
void paint(Canvas canvas, Size size) {
final Paint circlePaint = Paint()
..color = Colors.grey[300]!
..style = PaintingStyle.stroke
..strokeWidth = 3.0;
final Paint needlePaint = Paint()
..color = Colors.red
..style = PaintingStyle.fill;
final Paint northPaint = Paint()
..color = Colors.blue
..style = PaintingStyle.fill;
double centerX = size.width / 2;
double centerY = size.height / 2;
double radius = min(centerX, centerY) - 10;
// 외곽 원 그리기
canvas.drawCircle(Offset(centerX, centerY), radius, circlePaint);
// 방향 표시 (N, E, S, W)
TextPainter textPainter = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
// N (북쪽)
textPainter.text = TextSpan(
text: 'N',
style: TextStyle(color: Colors.blue, fontSize: 20, fontWeight: FontWeight.bold),
);
textPainter.layout();
textPainter.paint(canvas, Offset(centerX - 10, centerY - radius - 30));
// E (동쪽)
textPainter.text = TextSpan(
text: 'E',
style: TextStyle(color: Colors.green, fontSize: 16, fontWeight: FontWeight.bold),
);
textPainter.layout();
textPainter.paint(canvas, Offset(centerX + radius + 10, centerY - 10));
// S (남쪽)
textPainter.text = TextSpan(
text: 'S',
style: TextStyle(color: Colors.orange, fontSize: 16, fontWeight: FontWeight.bold),
);
textPainter.layout();
textPainter.paint(canvas, Offset(centerX - 10, centerY + radius + 10));
// W (서쪽)
textPainter.text = TextSpan(
text: 'W',
style: TextStyle(color: Colors.purple, fontSize: 16, fontWeight: FontWeight.bold),
);
textPainter.layout();
textPainter.paint(canvas, Offset(centerX - radius - 30, centerY - 10));
// 나침반 바늘 그리기
canvas.save();
canvas.translate(centerX, centerY);
canvas.rotate(-heading * pi / 180);
// 북쪽을 가리키는 바늘 (빨간색)
Path needlePath = Path();
needlePath.moveTo(0, -radius + 20);
needlePath.lineTo(-8, -10);
needlePath.lineTo(8, -10);
needlePath.close();
canvas.drawPath(needlePath, needlePaint);
// 남쪽을 가리키는 바늘 (회색)
needlePaint.color = Colors.grey;
Path southNeedlePath = Path();
southNeedlePath.moveTo(0, radius - 20);
southNeedlePath.lineTo(-6, 10);
southNeedlePath.lineTo(6, 10);
southNeedlePath.close();
canvas.drawPath(southNeedlePath, needlePaint);
canvas.restore();
// 중심점
Paint centerPaint = Paint()
..color = Colors.black
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(centerX, centerY), 5, centerPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
성능 최적화 및 베스트 프랙티스
⚡ 센서 데이터 처리 최적화
1. 적절한 샘플링 레이트 설정
class OptimizedSensorManager {
static const Duration SENSOR_INTERVAL = Duration(milliseconds: 50); // 20Hz
Timer? _sensorTimer;
AccelerometerEvent? _lastAccelerometerEvent;
GyroscopeEvent? _lastGyroscopeEvent;
void startOptimizedListening() {
// 센서 이벤트를 버퍼링하고 일정 간격으로 처리
accelerometerEvents.listen((event) {
_lastAccelerometerEvent = event;
});
gyroscopeEvents.listen((event) {
_lastGyroscopeEvent = event;
});
// 일정 간격으로 UI 업데이트
_sensorTimer = Timer.periodic(SENSOR_INTERVAL, (timer) {
_processSensorData();
});
}
void _processSensorData() {
if (_lastAccelerometerEvent != null) {
// 가속도계 데이터 처리
_processAccelerometer(_lastAccelerometerEvent!);
}
if (_lastGyroscopeEvent != null) {
// 자이로스코프 데이터 처리
_processGyroscope(_lastGyroscopeEvent!);
}
}
void stopListening() {
_sensorTimer?.cancel();
}
}
2. 메모리 효율적인 데이터 관리
class SensorDataBuffer {
static const int MAX_BUFFER_SIZE = 100;
final Queue<SensorReading> _buffer = Queue<SensorReading>();
void addReading(double x, double y, double z, DateTime timestamp) {
_buffer.add(SensorReading(x, y, z, timestamp));
// 버퍼 크기 제한
if (_buffer.length > MAX_BUFFER_SIZE) {
_buffer.removeFirst();
}
}
List<SensorReading> getRecentReadings(Duration duration) {
final cutoffTime = DateTime.now().subtract(duration);
return _buffer.where((reading) => reading.timestamp.isAfter(cutoffTime)).toList();
}
void clear() {
_buffer.clear();
}
}
class SensorReading {
final double x, y, z;
final DateTime timestamp;
SensorReading(this.x, this.y, this.z, this.timestamp);
}
🛡️ 에러 처리 및 예외 상황 대응
class RobustSensorManager {
static const int MAX_RETRY_COUNT = 3;
static const Duration RETRY_DELAY = Duration(seconds: 2);
int _retryCount = 0;
bool _isListening = false;
Future<void> startSafeListening() async {
try {
await _startListening();
_retryCount = 0; // 성공 시 리셋
} catch (error) {
await _handleSensorError(error);
}
}
Future<void> _startListening() async {
if (_isListening) return;
// 센서 가용성 확인
if (!await _checkSensorAvailability()) {
throw SensorException('필요한 센서를 사용할 수 없습니다');
}
_isListening = true;
accelerometerEvents.listen(
(event) => _handleAccelerometerData(event),
onError: (error) => _handleSensorError(error),
cancelOnError: false,
);
}
Future<bool> _checkSensorAvailability() async {
try {
// 센서 테스트 - 짧은 시간 동안 리스닝
bool accelerometerAvailable = false;
final subscription = accelerometerEvents.take(1).listen(
(event) => accelerometerAvailable = true,
);
await Future.delayed(Duration(milliseconds: 500));
subscription.cancel();
return accelerometerAvailable;
} catch (e) {
return false;
}
}
Future<void> _handleSensorError(dynamic error) async {
print('센서 오류 발생: $error');
_isListening = false;
if (_retryCount < MAX_RETRY_COUNT) {
_retryCount++;
print('센서 재연결 시도 $_retryCount/$MAX_RETRY_COUNT');
await Future.delayed(RETRY_DELAY);
await startSafeListening();
} else {
print('센서 연결 최대 재시도 횟수 초과');
_showErrorToUser('센서를 사용할 수 없습니다. 기기를 재시작해 주세요.');
}
}
void _showErrorToUser(String message) {
// 사용자에게 오류 알림
}
}
class SensorException implements Exception {
final String message;
SensorException(this.message);
@override
String toString() => 'SensorException: $message';
}
🔋 배터리 최적화
class PowerEfficientSensorManager {
bool _isInBackground = false;
bool _isLowPowerMode = false;
// 앱 상태에 따른 센서 관리
void onAppStateChanged(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.paused:
case AppLifecycleState.detached:
_isInBackground = true;
_adjustSensorFrequency();
break;
case AppLifecycleState.resumed:
_isInBackground = false;
_adjustSensorFrequency();
break;
default:
break;
}
}
void _adjustSensorFrequency() {
if (_isInBackground || _isLowPowerMode) {
// 백그라운드에서는 센서 빈도 줄이기
_setSensorFrequency(Duration(milliseconds: 200)); // 5Hz
} else {
// 포그라운드에서는 정상 빈도
_setSensorFrequency(Duration(milliseconds: 50)); // 20Hz
}
}
void enableLowPowerMode(bool enabled) {
_isLowPowerMode = enabled;
_adjustSensorFrequency();
}
void _setSensorFrequency(Duration interval) {
// 센서 샘플링 간격 조정 로직
}
}
트러블슈팅 가이드
🔧 자주 발생하는 문제와 해결책
1. 센서 데이터가 수신되지 않는 경우
// 문제 진단 코드
class SensorDiagnostic {
static Future<Map<String, bool>> checkSensorStatus() async {
Map<String, bool> sensorStatus = {
'accelerometer': false,
'gyroscope': false,
'magnetometer': false,
};
// 각 센서별 테스트
try {
await accelerometerEvents.first.timeout(Duration(seconds: 2));
sensorStatus['accelerometer'] = true;
} catch (e) {
print('가속도계 오류: $e');
}
try {
await gyroscopeEvents.first.timeout(Duration(seconds: 2));
sensorStatus['gyroscope'] = true;
} catch (e) {
print('자이로스코프 오류: $e');
}
try {
await magnetometerEvents.first.timeout(Duration(seconds: 2));
sensorStatus['magnetometer'] = true;
} catch (e) {
print('자력계 오류: $e');
}
return sensorStatus;
}
static void printDeviceInfo() {
print('플랫폼: ${Platform.operatingSystem}');
print('플랫폼 버전: ${Platform.operatingSystemVersion}');
}
}
해결 방법:
- 디바이스에서 센서 지원 여부 확인
- 권한 설정 재확인
- 앱 재시작 또는 디바이스 재부팅
2. 센서 데이터가 부정확한 경우
class SensorCalibration {
// 가속도계 캘리브레이션
static Vector3 calibrateAccelerometer(Vector3 rawData, Vector3 bias) {
return Vector3(
rawData.x - bias.x,
rawData.y - bias.y,
rawData.z - bias.z,
);
}
// 자이로스코프 드리프트 보정
static Vector3 compensateGyroscopeDrift(Vector3 rawData, Vector3 driftRate, double deltaTime) {
return Vector3(
rawData.x - (driftRate.x * deltaTime),
rawData.y - (driftRate.y * deltaTime),
rawData.z - (driftRate.z * deltaTime),
);
}
// 노이즈 필터링 (이동 평균)
static double applyMovingAverage(List<double> values, double newValue, int windowSize) {
values.add(newValue);
if (values.length > windowSize) {
values.removeAt(0);
}
return values.reduce((a, b) => a + b) / values.length;
}
}
class Vector3 {
final double x, y, z;
Vector3(this.x, this.y, this.z);
}
3. 성능 문제 해결
class PerformanceMonitor {
static final Stopwatch _processingTime = Stopwatch();
static int _eventCount = 0;
static DateTime _lastReport = DateTime.now();
static void startProcessing() {
_processingTime.start();
}
static void endProcessing() {
_processingTime.stop();
_eventCount++;
// 1초마다 성능 리포트
if (DateTime.now().difference(_lastReport).inSeconds >= 1) {
_printPerformanceReport();
_resetCounters();
}
}
static void _printPerformanceReport() {
double avgProcessingTime = _processingTime.elapsedMicroseconds / _eventCount / 1000.0;
print('평균 처리 시간: ${avgProcessingTime.toStringAsFixed(2)}ms');
print('초당 이벤트 수: $_eventCount');
if (avgProcessingTime > 16.67) { // 60fps 기준
print('⚠️ 성능 경고: 처리 시간이 너무 깁니다');
}
}
static void _resetCounters() {
_processingTime.reset();
_eventCount = 0;
_lastReport = DateTime.now();
}
}
📋 플랫폼별 주의사항
Android
-
권한:
SENSORS권한이 자동으로 부여되지만, 일부 제조사에서는 추가 설정 필요 - 절전 모드: Doze 모드에서 센서 액세스가 제한될 수 있음
- 제조사별 차이: 삼성, LG 등 제조사별로 센서 구현이 다를 수 있음
iOS
- Privacy: iOS 14+에서 모션 데이터 접근 시 사용자 동의 필요
- Background: 백그라운드에서 센서 사용 시 특별한 권한 필요
- 시뮬레이터: iOS 시뮬레이터에서는 실제 센서 데이터 제공 안 함
마무리 및 추가 학습 자료
🎯 핵심 정리
이 가이드에서 다룬 주요 내용을 정리하면:
- Sensor_Plus 패키지는 Flutter에서 모바일 센서를 활용하는 가장 효과적인 방법입니다
- 가속도계는 선형 움직임과 중력을 감지하여 기울기, 걸음 수 등을 측정할 수 있습니다
- 자이로스코프는 회전 움직임을 감지하여 게임 컨트롤, VR/AR 등에 활용됩니다
- 성능 최적화와 에러 처리는 실제 앱에서 필수적인 요소입니다
📚 추가 학습 자료
공식 문서 및 패키지
관련 패키지
dependencies:
sensors_plus: ^4.0.2
geolocator: ^9.0.2 # GPS 위치 센서
camera: ^0.10.5 # 카메라 센서
flutter_compass: ^0.7.0 # 디지털 나침반
pedometer: ^3.0.0 # 걸음 수 측정
실전 프로젝트 아이디어
- 3D 미로 게임: 자이로스코프로 공을 굴리는 게임
- 피트니스 트래커: 가속도계로 걸음 수와 활동량 측정
- AR 나침반: 자력계와 카메라를 결합한 증강현실 나침반
- 지진 감지기: 가속도계로 진동 패턴 분석
- VR 컨트롤러: 자이로스코프로 3D 공간 제스처 인식
🏆 베스트 프랙티스 요약
DO (권장사항)
- ✅ 적절한 샘플링 레이트 사용 (20-50Hz)
- ✅ 메모리 효율적인 데이터 버퍼링
- ✅ 에러 처리 및 재연결 로직 구현
- ✅ 백그라운드에서 센서 빈도 조절
- ✅ 센서 데이터 캘리브레이션 적용
DON'T (피해야 할 것)
- ❌ 불필요하게 높은 샘플링 레이트 사용
- ❌ UI 업데이트를 센서 이벤트와 직접 연결
- ❌ 메모리 누수를 유발하는 무제한 데이터 저장
- ❌ 에러 처리 없는 센서 리스닝
- ❌ 플랫폼별 차이점 무시
💡 향후 발전 방향
모바일 센서 기술은 지속적으로 발전하고 있습니다:
- AI 통합: 머신러닝을 활용한 센서 데이터 분석
- 센서 융합: 여러 센서 데이터를 결합한 정확도 향상
- 저전력 기술: 더 효율적인 센서 활용 방법
- 새로운 센서: LiDAR, ToF 카메라 등 신기술 센서 지원
Flutter와 Sensor_Plus를 활용하여 혁신적인 모바일 앱을 개발해보세요! 이 가이드가 여러분의 센서 기반 앱 개발에 도움이 되기를 바랍니다.

