[Flutter] 휴대폰의 가속도계, 자이로스코프 완전정복

 

[Flutter] 휴대폰의 가속도계, 자이로스코프, Sensor_Plus 패키지 완전정복

📱 Flutter에서 모바일 센서를 활용한 앱 개발의 모든 것


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 시뮬레이터에서는 실제 센서 데이터 제공 안 함

마무리 및 추가 학습 자료

🎯 핵심 정리

이 가이드에서 다룬 주요 내용을 정리하면:

  1. Sensor_Plus 패키지는 Flutter에서 모바일 센서를 활용하는 가장 효과적인 방법입니다
  2. 가속도계는 선형 움직임과 중력을 감지하여 기울기, 걸음 수 등을 측정할 수 있습니다
  3. 자이로스코프는 회전 움직임을 감지하여 게임 컨트롤, VR/AR 등에 활용됩니다
  4. 성능 최적화에러 처리는 실제 앱에서 필수적인 요소입니다

📚 추가 학습 자료

공식 문서 및 패키지

관련 패키지

dependencies:
  sensors_plus: ^4.0.2
  geolocator: ^9.0.2        # GPS 위치 센서
  camera: ^0.10.5           # 카메라 센서
  flutter_compass: ^0.7.0   # 디지털 나침반
  pedometer: ^3.0.0         # 걸음 수 측정

실전 프로젝트 아이디어

  1. 3D 미로 게임: 자이로스코프로 공을 굴리는 게임
  2. 피트니스 트래커: 가속도계로 걸음 수와 활동량 측정
  3. AR 나침반: 자력계와 카메라를 결합한 증강현실 나침반
  4. 지진 감지기: 가속도계로 진동 패턴 분석
  5. VR 컨트롤러: 자이로스코프로 3D 공간 제스처 인식

🏆 베스트 프랙티스 요약

DO (권장사항)

  • ✅ 적절한 샘플링 레이트 사용 (20-50Hz)
  • ✅ 메모리 효율적인 데이터 버퍼링
  • ✅ 에러 처리 및 재연결 로직 구현
  • ✅ 백그라운드에서 센서 빈도 조절
  • ✅ 센서 데이터 캘리브레이션 적용

DON'T (피해야 할 것)

  • ❌ 불필요하게 높은 샘플링 레이트 사용
  • ❌ UI 업데이트를 센서 이벤트와 직접 연결
  • ❌ 메모리 누수를 유발하는 무제한 데이터 저장
  • ❌ 에러 처리 없는 센서 리스닝
  • ❌ 플랫폼별 차이점 무시

💡 향후 발전 방향

모바일 센서 기술은 지속적으로 발전하고 있습니다:

  • AI 통합: 머신러닝을 활용한 센서 데이터 분석
  • 센서 융합: 여러 센서 데이터를 결합한 정확도 향상
  • 저전력 기술: 더 효율적인 센서 활용 방법
  • 새로운 센서: LiDAR, ToF 카메라 등 신기술 센서 지원

Flutter와 Sensor_Plus를 활용하여 혁신적인 모바일 앱을 개발해보세요! 이 가이드가 여러분의 센서 기반 앱 개발에 도움이 되기를 바랍니다.


다음 이전