앱을 만들때는 정상적인 시나리오를 벗어난 사용 혹은 상황에 따라 다양한 에러를 만날 경우가 많다. 예를 들면 어떤 목록을 불러와 화면에 리스트에 뿌린다고 했을 때, 제일먼저 인터넷이 연결 안된 상황, 혹은 불러올 데이터가 하나도 없는 상황등이다. 그렇것을 예외라고 할 수 있다. Flutter 에서 만날 수 있는 다양한 에러를 다뤄보자.
가장 기본적인 방법으로는 assert()
함수를 이용해 특정 조건을 검사하는 것이다. someCondition
이 false일 때를 검사해 내는 경우를 예를 들어보자.
assert(someCondition, "someCondition is false, Something wrong!");
이런 방법은 디버깅 목적이기 때문에 프로덕션빌드에서는 assert
를 꺼두어야 한다.
네트워크에 쿼리요청을 보내거나 데이터베이스에서 데이터를 로드하는 경우와 같은 비동기 루틴에서는 다양한 네트워크 상황 때문에 unhandled exception 이 발생하기 쉽상이다. 이런 비동기적인 에러(Asynchronous Errors)는 try-catch
블록이나 Future
API를 사용해 다룰 수 있다.
try {
var result = await NetworkRequestOperation();
// Use the result
} catch (e) {
// Handle the asynchronous error
}
try {...}
블록에서 발생하는 어떤 예외든 catch(e) {...}
블록이 잡아내 처리할 수 있게 된다.
이 기법은 비동기 루틴에서 독립적으로 작동할 수 있기 때문에 메인 루틴을 벗어나 제어하기 어렵게 만들 수 있다. 이때는 Flutter가 제공하는 Future
나 Stream
매커니즘을 활용해 에러를 좀 더 효과적으로 다룰 수 있다.
이것은 어떤 단일 값의 변수나 객체가 즉시 사용되지 않는 다는 것을 표현하기 때문에 어떤 비동기 특정 시점에서 종료가 될 것이다. 이런 경우에는 .catchError()
함수를 통해서 감지하고 미래의 에러에 대처할 수 있게 된다.
Stream
은 Collection의 하나로 지속적으로 데이터를 방출하는 비동기 이벤트이다. .listen()
함수를 통해 구독하는 루틴이 이를 사용할 수 있다. 만일 에러가 발생하면 .onError()함수를 통해 하나의 Stream
에서 에러를 잡아 처리할 수 있게 된다.
Stream Example | .dart |
Future<int> fetchUserData() async {
// Process...
// Error! -> Jump to catchError()
// Process...
return true; // If it is successful, respond something
}
void main() {
// Handling errors in a Future
fetchUserData()
.then((value) => print('User data: $value'))
.catchError((error) => print('Error fetching user data: $error'));
// Handling errors in a Stream
Stream<int>.periodic(Duration(seconds: 1), (count) => count)
.map((count) {
// Process...
// Error! -> Jump to listen()
// Process...
return count;
})
.listen(
(data) => print('Stream data: $data'),
onError: (error) => print('Error in stream: $error'),
);
}
여러가지 처리되지 않은 예외적인 에러들을 잡아 처리할 위젯을 만들고 거기에 에러를 넘겨주어 사용자에게 좋은 UX를 줄 수 있다.
Flutter 프레임워크에서는 어떤 예외적 에러들이 발생할 때 FlutterError.onError 메서드가 호출된다. 이것을 커스텀하여 특정 에러 처리루틴을 제공할 수 있다.
Error Handling Widget | .dart |
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'My App',
home: ErrorHandlerWidget( // POINT: Wrap a widget
child: MyHomePage(),
),
);
}
}
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Main Page')),
body: Center(
child: ElevatedButton(
onPressed: () {
// Process...
// If Error
// Process...
Navigator.push(
context,
MaterialPageRoute(builder: (context) => SecondPage()),
);
},
child: Text('Trigger Error'),
),
),
);
}
}
class SecondPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Second Page')),
body: Center(
child: ElevatedButton(
onPressed: () {
// Process...
// If Error
// Process...
},
child: Text('Trigger Another Error'),
),
),
);
}
}
class ErrorHandlerWidget extends StatefulWidget {
final Widget child;
ErrorHandlerWidget({required this.child});
@override
_ErrorHandlerWidgetState createState() => _ErrorHandlerWidgetState();
}
class _ErrorHandlerWidgetState extends State<ErrorHandlerWidget> {
// Error handling logic
void onError(FlutterErrorDetails errorDetails) {
// Add your error handling logic here,
// ex., logging, reporting to a server, etc.
print('Caught error: ${errorDetails.exception}');
}
@override
Widget build(BuildContext context) {
return ErrorWidgetBuilder(
builder: (context, errorDetails) {
// Display a user-friendly error UI
return Scaffold(
appBar: AppBar(title: Text('Error')),
body: Center(
child: Text('Something went wrong. Please try again later.'),
),
);
},
onError: onError,
child: widget.child,
);
}
}
class ErrorWidgetBuilder extends StatefulWidget {
final Widget Function(BuildContext, FlutterErrorDetails) builder;
final void Function(FlutterErrorDetails) onError;
final Widget child;
ErrorWidgetBuilder({
required this.builder,
required this.onError,
required this.child,
});
@override
_ErrorWidgetBuilderState createState() => _ErrorWidgetBuilderState();
}
class _ErrorWidgetBuilderState extends State<ErrorWidgetBuilder> {
@override
void initState() {
super.initState();
// Set up global error handling
FlutterError.onError = widget.onError;
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}
이 간단한 예에서는 ErrorHandlerWidget
이 앱의 시작 지점 위젯을 감싸면서 앱 동작 중 발생하는 에러를 잡아낼 수 있게 된다. 만일 위의 코드처럼 클릭 이벤트 처리도중 에러가 발생하면 외부의 에러 로직을 실행하며 ErrorWidgetBuilder
에서 정의된 UI를 사용자에게 보여줌으로써 좀 더 사용자 친화적인 처리가된다. 이 부분에 팝업 다이어로그처럼 에러를 띄워도 좋다. 에러 로직에는 에러를 보고할 각종 트래킹 혹은 에러리포트 도구를 호출해 NewRelic처럼 로깅 전용 서비스에 전달해 줄 수 있다.
인터넷이 없는 지역이나 모바일 네트워크가 불가능한 곳이라면 인터넷 연결성 문제가 우리의 앱에도 발생할 수 있다.
간단하게 검사할 수 있는 방법으로 Connectvity 패키지를 이용해 정상 처리전 연결성을 검사하는 것이다. 플러터 코어패키지의 Connectivity는 더이상 진행되지 않으니, 다음 서드파티 패키지를 추가한다.
먼저 pubspec.yaml 의존성 목록에 추가하고 사용해 보자.
var connectivityResult = await (Connectivity().checkConnectivity());
ConnectivityResult는 enum 타입으로 다음과 같은 값을 처리할 수 있다.
wifi
: Connected to a WiFi network.mobile
: Connected to a mobile network.none
: Not connected to any network.none
값을 검사해 No Internet 상황을 다룰 수 있다.
final connectivityResult = await Connectivity().checkConnectivity();
if (connectivityResult == ConnectivityResult.none) {
// Show UI like SnackBar or showDialog
// "You are not connected. No Internet."
} else {
// Normal Netwrok Requests ongoing...
}
비동기 루틴과 함께 다음과 같이 사용할 수 있을 것이다.
Network Connection Check | .dart |
class MyHomePage extends StatelessWidget {
Future<void> checkInternetConnection(BuildContext context) async {
final connectivityResult = await Connectivity().checkConnectivity();
if (connectivityResult == ConnectivityResult.none) {
// Display a "No Internet Connection" message
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('No Internet Connection'),
content: Text('Please check your internet connection and try again.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('OK'),
),
],
),
);
} else {
// Perform the network request
// ...
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Network Error Handling')),
body: Center(
child: ElevatedButton(
onPressed: () => checkInternetConnection(context),
child: Text('Check Internet Connection'),
),
),
);
}
생성자에서 함수를 매개변수로 전달하는 NetworkErrorDialog 위젯을 만들어 보자.
NetworkErrorDialog | .dart |
lass NetworkErrorDialog extends StatelessWidget {
const NetworkErrorDialog({Key? key, this.onPressed}) : super(key: key);
final Function()? onPressed;
@override
Widget build(BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10.0)),
content: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"Check your connection and try again.",
style: TextStyle(fontSize: 12),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
child: const Text("Try Again"),
onPressed: onPressed,
)
],
),
);
}
}
이제, builder 속성에 다음과 같이 전달할 수 있다.
...
child: ElevatedButton(
child: const Text("Check Connection"),
onPressed: () async {
final connectivityResult = await Connectivity().checkConnectivity();
if (connectivityResult == ConnectivityResult.none) {
showDialog(
barrierDismissible: false,
context: context,
builder: (_) => const NetworkErrorDialog(),
);
} else {
// Network Requests...
}
},
),
그럼 함수를 매개변수로 전달해 보자.
...
if (connectivityResult == ConnectivityResult.none) {
showDialog(
barrierDismissible: false,
context: context,
builder: (_) => const NetworkErrorDialog(
onPressed: () async {
// POINT: Try Again, Retry and Refresh Action..
},
),
);
} else {
// Network Requests...
}
...
Connectivity
의 onConnectivityChanged
프로퍼티를 사용하면 네트워크 상태 변경을 감시할 수 있다. 이 프로퍼티는 Stream 타입으로 Stream의 listen
메서드에서 상태가 변경될 때 함수를 전달하는 것이다. listen
의 매개변수로 ConnectivityResult 타입을 가질 수 있도록 하고, listen
의 내부 구현 블록에 상태 변화 로직을 구성한다. 지속적으로 결과를 갱신할 것이다.
이벤트를 구독하는 방식이므로 더 이상 사용하지 않을 경우에는 위젯의 dispose()
생명 주기 내에서 구독을 취소하도록 한다.
Subscription and Listen | .dart |
ConnectivityResult? _connectivityResult;
late StreamSubscription _connectivitySubscription;
@override
initState() {
super.initState();
_connectivitySubscription =
Connectivity().onConnectivityChanged.listen((
ConnectivityResult result
) {
print('Current connectivity status: $result');
_connectivityResult = result;
},
);
}
@override
dispose() {
super.dispose();
_connectivitySubscription.cancel();
}
상태관리 패키지로 많이 사용되는 Riverpod 과 함께 다뤄보자.
먼저 네트워크 연결 상태들을 정의할 enum 열거형을 사용하자.
enum ConnectivityStatus { NotDetermined, isConnected, isDisonnected }
class ConnectivityStatusNotifier extends Notifier<ConnectivityStatus> {}
ConnectivityResult 변화에 따른 청취(Listen)를 위해 ConnectivityStatusNotifier를 작성해 준다.
Co | nnectivityStatus.dart |
ConnectivityStatus? lastResult;
ConnectivityStatus? newState;
ConnectivityStatusNotifier() : super(ConnectivityStatus.isConnected) {
if (state == ConnectivityStatus.isConnected) {
lastResult = ConnectivityStatus.isConnected;
} else {
lastResult = ConnectivityStatus.isDisonnected;
}
lastResult = ConnectivityStatus.NotDetermined;
Connectivity().onConnectivityChanged.listen((ConnectivityResult result) {
switch (result) {
case ConnectivityResult.mobile:
case ConnectivityResult.wifi:
newState = ConnectivityStatus.isConnected;
break;
case ConnectivityResult.none:
newState = ConnectivityStatus.isDisonnected;
break;
}
if (newState != lastResult) {
state = newState!;
lastResult = newState;
}
});
}
상태가 변경될 때 lastState
와 newState
를 갱신할 것이다.
// Final Global Variable which will expose the state.
// Should be outside of the class.
final connectivityStatusProviders = NotifierProvider((ref) {
return ConnectivityStatusNotifier();
});
프로바이더의 목적은 노티파이어에 의해 노출된 상태 이벤트를 청취하는 것이다.
이제 사용자 화면에서 ref.watch
를 사용해 변화에 따른 ConnectivityStatus
의 상태를 비교할 수 있게 된다.
Consumer UI | .dart |
class MainScreen extends ConsumerWidget {
const MainScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
var connectivityStatusProvider = ref.watch(connectivityStatusProviders);
return Scaffold(
backgroundColor: Colors.black54,
appBar: AppBar(
title: const Text( 'Network Connectivity'),
),
body: ...
connectivityStatusProvider == ConnectivityStatus.isConnected
? 'Connected to Internet'
: 'No Internet',
...
));
}
}
물론 최 상단의 앱은 ProviderScope
로 감싸여 있어야 프로바이더로부터 값을 제대로 읽을 수 있게 된다. 주로 main()
코드에서 구성하게 된다.
class ConnectivityCheck extends StatelessWidget {
final Widget child;
ConnectivityCheck({@required this.child});
@override
Widget build(BuildContext context) {
return StreamBuilder<ConnectivityResult>(
stream: Connectivity().onConnectivityChanged,
builder: (context, snapshot) {
if (!snapshot.hasData ||
snapshot.data == ConnectivityResult.none) {
return Center(
child: Text("No Network"),
);
} else {
return child;
}
});
}
}
또다른 방법으로 코어 패키지에 lookup을 사용해 접근 가능한 호스트가 넘어온다면 연결에 문제가 없으므로 이것으로 네트워크 연결 에러를 다룰 수 도 있다.
bool? _isConnectionSuccessful;
Future<void> _tryConnection() async {
try {
final response = await InternetAddress.lookup('www.yoursite.com');
setState(() {
_isConnectionSuccessful = response.isNotEmpty;
});
} on SocketException catch (e) {
setState(() {
_isConnectionSuccessful = false;
});
}
}
호스트를 연결하지 못하면 예외처리에서 플래그를 통해 상태를 결정할 수 있다.
서버에 연결 요청을 할 때 문제가 없다면 상태 코드로 200을 반환한다. 에러가 발생하면 페이지가 없는 경우에 404, 서버의 내부 오류의 경우 500등의 대표적인 에러코드로 서버는 앱에 응답(response)하게 된다.
Server Status Code | .dart |
...
class MyHomePage extends StatelessWidget {
Future<void> fetchData() async {
try {
// Perform the network request
final response = await http.get(Uri.parse('https://example.com/api/get'));
if (response.statusCode == 200) {
// Handle successful response
// Process...
} else {
// Handle server error(like 404,500...)
// Show Error Dialog or SnapBar
}
} catch (e) {
// Handle other errors like exceptions
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Server Error Handling')),
body: Center(
child: ElevatedButton(
onPressed: () => fetchData(),
child: Text('Fetch Data'),
),
),
);
}
}
시간 제한을 걸어 특정시간에 timeout하도록 하는 것도 좋다.
http.get('https://google.com').timeout(Duration(seconds: 5);
"If you would thoroughly know anything, teach it to other."
- Tryon Edwards -