Flutter에서 크로스 플랫폼 코드와 플랫폼별 코드를 어떻게 작성하나요?
질문
Flutter에서 크로스 플랫폼 코드와 플랫폼별(iOS, Android) 코드를 작성하는 방법과 이를 효과적으로 통합하는 방법은 무엇인가요?
답변
Flutter의 강점 중 하나는, 단일 코드베이스로 iOS, Android, 웹, 데스크톱 등 여러 플랫폼에서 실행되는 앱을 만들 수 있다는 점입니다. 그러나 때로는 플랫폼별 API에 접근하거나 플랫폼별 동작을 구현해야 할 필요가 있습니다. Flutter는 이러한 상황에 대응할 수 있는 여러 방법을 제공합니다.
1. 플랫폼 감지하기
가장 기본적인 방법은 현재 실행 중인 플랫폼을 감지하고 그에 따라 다른 코드를 실행하는 것입니다.
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
Widget buildPlatformSpecificUI() {
if (kIsWeb) {
return WebSpecificWidget();
} else if (Platform.isAndroid) {
return AndroidSpecificWidget();
} else if (Platform.isIOS) {
return IOSSpecificWidget();
} else if (Platform.isMacOS) {
return MacOSSpecificWidget();
} else if (Platform.isWindows) {
return WindowsSpecificWidget();
} else if (Platform.isLinux) {
return LinuxSpecificWidget();
} else {
return FallbackWidget();
}
}
이 방법은 UI 컴포넌트를 플랫폼에 맞게 조정하는 간단한 경우에 유용합니다.
2. 플랫폼별 위젯 라이브러리 사용하기
Flutter는 각 플랫폼의 디자인 언어를 따르는 위젯 라이브러리를 제공합니다:
- Material Design(Android):
flutter/material.dart
- Cupertino(iOS):
flutter/cupertino.dart
Material과 Cupertino 위젯 함께 사용하기
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
class PlatformAdaptiveButton extends StatelessWidget {
final String text;
final VoidCallback onPressed;
PlatformAdaptiveButton({required this.text, required this.onPressed});
@override
Widget build(BuildContext context) {
return Platform.isIOS
? CupertinoButton(
onPressed: onPressed,
child: Text(text),
)
: ElevatedButton(
onPressed: onPressed,
child: Text(text),
);
}
}
전체 앱의 룩앤필 적용하기
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Platform.isIOS
? CupertinoApp(
theme: CupertinoThemeData(
primaryColor: Color(0xFF1BADF8),
brightness: Brightness.light,
),
home: HomeScreen(),
)
: MaterialApp(
theme: ThemeData(
primarySwatch: Colors.blue,
brightness: Brightness.light,
),
darkTheme: ThemeData(
primarySwatch: Colors.blue,
brightness: Brightness.dark,
),
home: HomeScreen(),
);
}
}
class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Platform.isIOS
? CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(
middle: Text('홈 화면'),
),
child: Center(
child: Text('iOS 스타일 UI'),
),
)
: Scaffold(
appBar: AppBar(
title: Text('홈 화면'),
),
body: Center(
child: Text('Material 스타일 UI'),
),
);
}
}
3. 플랫폼별 디자인 추상화 라이브러리 사용하기
위와 같은 분기 처리가 코드 전체에 산재하면 유지보수가 어려워집니다. 이를 효과적으로 관리하기 위한 라이브러리들이 있습니다.
flutter_platform_widgets 라이브러리
이 라이브러리는 플랫폼에 따라 알맞은 위젯을 반환하는 추상화 레이어를 제공합니다.
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PlatformApp(
material: (_, __) => MaterialAppData(
theme: ThemeData(
primarySwatch: Colors.blue,
brightness: Brightness.light,
),
),
cupertino: (_, __) => CupertinoAppData(
theme: CupertinoThemeData(
primaryColor: Color(0xFF1BADF8),
brightness: Brightness.light,
),
),
home: HomeScreen(),
);
}
}
class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PlatformScaffold(
appBar: PlatformAppBar(
title: Text('홈 화면'),
),
body: Center(
child: PlatformButton(
onPressed: () {},
child: Text('플랫폼 적응형 버튼'),
),
),
);
}
}
4. 플랫폼 채널을 사용한 네이티브 코드 통합
Flutter는 MethodChannel
, EventChannel
등의 플랫폼 채널을 통해 네이티브 코드(Java/Kotlin 또는 Objective-C/Swift)와 통신할 수 있습니다.
Flutter 측 코드 (Dart)
import 'package:flutter/services.dart';
class BatteryService {
static const MethodChannel _channel = MethodChannel('samples.flutter.io/battery');
// 배터리 레벨을 가져오는 메서드
Future<int> getBatteryLevel() async {
try {
final int result = await _channel.invokeMethod('getBatteryLevel');
return result;
} on PlatformException catch (e) {
print("배터리 레벨을 가져오는데 실패했습니다: '${e.message}'.");
return -1;
}
}
}
Android 측 코드 (Kotlin)
// MainActivity.kt
import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
class MainActivity: FlutterActivity() {
private val CHANNEL = "samples.flutter.io/battery"
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
if (call.method == "getBatteryLevel") {
val batteryLevel = getBatteryLevel()
if (batteryLevel != -1) {
result.success(batteryLevel)
} else {
result.error("UNAVAILABLE", "배터리 레벨을 가져올 수 없습니다.", null)
}
} else {
result.notImplemented()
}
}
}
private fun getBatteryLevel(): Int {
val batteryLevel: Int
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
val batteryManager = getSystemService(Context.BATTERY_SERVICE) as BatteryManager
batteryLevel = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
} else {
val intent = ContextWrapper(applicationContext).registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
batteryLevel = intent!!.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) * 100 / intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
}
return batteryLevel
}
}
iOS 측 코드 (Swift)
// AppDelegate.swift
import UIKit
import Flutter
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
let batteryChannel = FlutterMethodChannel(name: "samples.flutter.io/battery",
binaryMessenger: controller.binaryMessenger)
batteryChannel.setMethodCallHandler({
[weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
guard call.method == "getBatteryLevel" else {
result(FlutterMethodNotImplemented)
return
}
self?.receiveBatteryLevel(result: result)
})
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
private func receiveBatteryLevel(result: FlutterResult) {
let device = UIDevice.current
device.isBatteryMonitoringEnabled = true
if device.batteryState == UIDevice.BatteryState.unknown {
result(FlutterError(code: "UNAVAILABLE",
message: "배터리 정보를 사용할 수 없습니다.",
details: nil))
} else {
result(Int(device.batteryLevel * 100))
}
}
}
5. 플러그인 사용하기
많은 네이티브 기능은 이미 커뮤니티나 Flutter 팀에서 만든 플러그인을 통해 사용할 수 있습니다. 직접 플랫폼 채널을 구현하기 전에 기존 플러그인을 살펴보는 것이 좋습니다.
// pubspec.yaml에 플러그인 추가
// dependencies:
// camera: ^0.9.4
// url_launcher: ^6.0.10
// shared_preferences: ^2.0.8
import 'package:camera/camera.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:shared_preferences/shared_preferences.dart';
class PlatformServices {
// 카메라 접근
Future<void> initializeCamera() async {
final cameras = await availableCameras();
final firstCamera = cameras.first;
// 카메라 컨트롤러 초기화...
}
// URL 실행 (플랫폼별 처리는 플러그인 내부에서 진행)
Future<void> openUrl(String url) async {
if (await canLaunch(url)) {
await launch(url);
} else {
throw 'URL을 열 수 없습니다: $url';
}
}
// 데이터 저장 (플랫폼별 저장소 사용)
Future<void> saveData(String key, String value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(key, value);
}
}
6. FFI (Foreign Function Interface)를 사용한 네이티브 코드 접근
Flutter 1.20부터는 Dart FFI를 사용하여 C 라이브러리에 직접 접근할 수 있습니다. 이는 고성능 계산이나 특정 네이티브 라이브러리를 사용할 때 유용합니다.
import 'dart:ffi';
import 'dart:io' show Platform;
// C 라이브러리의 함수 시그니처 정의
typedef NativeAddFunc = Int32 Function(Int32 a, Int32 b);
typedef DartAddFunc = int Function(int a, int b);
class NativeAdder {
// 동적 라이브러리 로드
final DynamicLibrary nativeAddLib = Platform.isAndroid
? DynamicLibrary.open("libnative_add.so")
: DynamicLibrary.open("native_add.framework/native_add");
// 네이티브 함수 참조 가져오기
late final DartAddFunc _add = nativeAddLib
.lookup<NativeFunction<NativeAddFunc>>('native_add')
.asFunction();
// 네이티브 함수 호출
int add(int a, int b) {
return _add(a, b);
}
}
7. 플랫폼별 에셋 및 리소스 관리
플랫폼별로 다른 에셋(이미지, 폰트 등)이 필요한 경우, 다음과 같이 관리할 수 있습니다.
# pubspec.yaml
flutter:
assets:
- assets/common/
- assets/android/
- assets/ios/
import 'dart:io' show Platform;
String getAssetPath(String assetName) {
if (Platform.isIOS) {
return 'assets/ios/$assetName';
} else if (Platform.isAndroid) {
return 'assets/android/$assetName';
} else {
return 'assets/common/$assetName';
}
}
// 사용 예
Image.asset(getAssetPath('logo.png'))
8. 플랫폼별 런타임 설정
TargetPlatform
을 사용하여 런타임에 플랫폼 설정을 변경할 수 있습니다.
import 'package:flutter/material.dart';
class PlatformAdaptiveTheme {
static ThemeData getThemeData(BuildContext context) {
switch (Theme.of(context).platform) {
case TargetPlatform.android:
return _androidTheme;
case TargetPlatform.iOS:
return _iOSTheme;
default:
return _defaultTheme;
}
}
static final ThemeData _androidTheme = ThemeData(
primarySwatch: Colors.blue,
buttonTheme: ButtonThemeData(
buttonColor: Colors.blue,
textTheme: ButtonTextTheme.primary,
),
);
static final ThemeData _iOSTheme = ThemeData(
primaryColor: Color(0xFF1BADF8),
buttonTheme: ButtonThemeData(
buttonColor: Color(0xFF1BADF8),
textTheme: ButtonTextTheme.primary,
),
);
static final ThemeData _defaultTheme = ThemeData.light();
}
실제 프로젝트에서의 크로스 플랫폼 통합 전략
대규모 Flutter 앱을 개발할 때는 플랫폼별 코드를 체계적으로 관리하는 것이 중요합니다. 다음은 이를 위한 전략입니다.
1. 아키텍처 레이어 설계
클린 아키텍처나 MVVM과 같은 계층형 아키텍처를 사용하여 플랫폼별 코드를 격리하세요.
lib/
├── presentation/ # UI 레이어 (플랫폼 적응형 위젯)
├── application/ # 비즈니스 로직 (크로스 플랫폼)
├── domain/ # 도메인 모델, 인터페이스 (크로스 플랫폼)
└── infrastructure/ # 구현체, 플랫폼별 코드
├── common/ # 공통 구현
├── android/ # Android 전용 구현
└── ios/ # iOS 전용 구현
2. 인터페이스와 구현체 분리
도메인 레이어에서 인터페이스를 정의하고, 인프라스트럭처 레이어에서 플랫폼별 구현을 제공하세요.
// domain/services/location_service.dart
abstract class LocationService {
Future<Position> getCurrentPosition();
}
// infrastructure/android/location_service_android.dart
class LocationServiceAndroid implements LocationService {
@override
Future<Position> getCurrentPosition() async {
// Android 특화 구현
}
}
// infrastructure/ios/location_service_ios.dart
class LocationServiceIOS implements LocationService {
@override
Future<Position> getCurrentPosition() async {
// iOS 특화 구현
}
}
// infrastructure/location_service_factory.dart
class LocationServiceFactory {
static LocationService create() {
if (Platform.isAndroid) {
return LocationServiceAndroid();
} else if (Platform.isIOS) {
return LocationServiceIOS();
} else {
throw UnsupportedError('현재 플랫폼에서는 위치 서비스를 사용할 수 없습니다.');
}
}
}
// 사용 예시
final locationService = LocationServiceFactory.create();
final position = await locationService.getCurrentPosition();
3. 의존성 주입 활용
의존성 주입을 사용하여 플랫폼별 구현체를 쉽게 교체할 수 있도록 합니다.
// 의존성 주입 설정
void setupDependencies() {
// 플랫폼별 서비스 등록
if (Platform.isAndroid) {
getIt.registerSingleton<LocationService>(LocationServiceAndroid());
} else if (Platform.isIOS) {
getIt.registerSingleton<LocationService>(LocationServiceIOS());
}
// 나머지 공통 의존성 등록
getIt.registerSingleton<UserRepository>(UserRepositoryImpl(getIt<ApiClient>()));
}
// 의존성 활용
class LocationViewModel {
final LocationService _locationService;
LocationViewModel(this._locationService);
Future<Position> getCurrentLocation() {
return _locationService.getCurrentPosition();
}
}
4. 플랫폼별 코드의 테스트 전략
플랫폼별 코드를 효과적으로 테스트하려면 다음 접근법을 고려하세요:
- 모킹을 통한 플랫폼 독립적 테스트: 플랫폼별 서비스 인터페이스를 모킹하여 플랫폼에 관계없이 테스트
- 플랫폼별 통합 테스트: 각 플랫폼에서 실제 구현을 테스트
- 공용 비즈니스 로직은 플랫폼 독립적 테스트 집중: 핵심 비즈니스 로직은 플랫폼에 독립적으로 테스트
void main() {
group('LocationViewModel 테스트', () {
late LocationViewModel viewModel;
late MockLocationService mockLocationService;
setUp(() {
mockLocationService = MockLocationService();
viewModel = LocationViewModel(mockLocationService);
});
test('getCurrentLocation은 LocationService의 getCurrentPosition을 호출해야 함', () async {
// 준비
final expectedPosition = Position(latitude: 37.7749, longitude: -122.4194);
when(mockLocationService.getCurrentPosition())
.thenAnswer((_) async => expectedPosition);
// 실행
final result = await viewModel.getCurrentLocation();
// 검증
expect(result, equals(expectedPosition));
verify(mockLocationService.getCurrentPosition()).called(1);
});
});
}
결론
Flutter에서 크로스 플랫폼 코드와 플랫폼별 코드를 효과적으로 통합하는 것은 품질 높은 앱을 개발하는 핵심입니다. 가능한 한 많은 코드를 크로스 플랫폼으로 유지하면서, 필요할 때만 플랫폼별 구현을 제공하는 것이 이상적입니다.
이를 위해 다음 원칙을 따르는 것이 좋습니다:
- 인터페이스를 통한 추상화로 플랫폼별 구현 숨기기
- 클린 아키텍처를 사용하여 플랫폼별 코드 격리
- 플랫폼별 구현이 필요할 때는 적절한 도구(플랫폼 채널, FFI 등) 선택
- 가능하면 기존 플러그인 활용
- 테스트 용이성 확보
이러한 접근 방식을 통해 코드의 재사용성을 극대화하면서도 각 플랫폼의 고유한 기능과 UX 패턴을 활용할 수 있습니다.