์๋ฌ ์ฒ๋ฆฌ์ ๋ก๋ฉ ์ํ UI
์ง๋ ๊ธ์์ FutureBuilder์ AsyncValue๋ก ๋ก๋ฉ, ๋ฐ์ดํฐ, ์๋ฌ๋ฅผ ๋๋ ๊ทธ๋ฆฌ๋ ๋ฒ์ ๋ดค๋ค. ๊ทธ๋ฐ๋ฐ ์๋ฌ๋ฅผ ์ด๋์ ์ด๋ป๊ฒ ๋ค๋ฃฐ์ง๋ ์์ง ์ ํ์ง ์์๋ค. ์ฌ์ฉ์ ๋ชฉ๋ก์ ๋ถ๋ฌ์ค๋ ํจ์๋ฅผ ํํ ์ด๋ ๊ฒ ์ฐ๊ฒ ๋๋ค. ์ฑ์ด ์ฃฝ๋ ๊ฒ ์ซ์ด์ try/catch๋ก ๊ฐ์ธ๊ณ , ์คํจํ๋ฉด ๋น ๋ชฉ๋ก์ ๋๋ ค์ค๋ค.
Future<List<User>> fetchUsers() async {
try {
final response = await dio.get('/users');
final list = response.data as List;
return list.map((e) => User.fromJson(e as Map<String, dynamic>)).toList();
} catch (e) {
print(e); // ์ฝ์์๋ง ์ฐ๊ณ
return []; // ๋น ๋ชฉ๋ก์ผ๋ก ๋์ ํจ
}
}์ฑ์ ์ฃฝ์ง ์๋๋ค. ๊ทธ๋ฐ๋ฐ ๋นํ๊ธฐ ๋ชจ๋์์ ์ด ํ๋ฉด์ ์ด๋ฉด "๋ชฉ๋ก์ด ๋น์ด ์์ต๋๋ค"๊ฐ ๋ฌ๋ค. ์ฌ์ฉ์๋ ๋ฐ์ดํฐ๊ฐ ์ ๋ง ์๋ ๊ฑด์ง, ์ธํฐ๋ท์ด ๋๊ธด ๊ฑด์ง ์ ์ ์๋ค. ๋ค์ ๋ถ๋ฌ์ฌ ๋ฐฉ๋ฒ๋ ์๋ค.
์์ธ: ์คํจ์ ๋น ๊ฒฐ๊ณผ๋ฅผ ๊ฐ์ ๊ฒ์ผ๋ก ํํํ๋ค
catch์์ ์๋ฌ๋ฅผ ์ผํค๊ณ []๋ฅผ ๋๋ ค์ฃผ๋ฉด, ํ๋ฉด์๋ "์คํจ"์ "๋ฐ์ดํฐ ์์"์ด ๋๊ฐ์ ๋ชจ์ต์ผ๋ก ๋์จ๋ค. ์๋ฌ๋ฅผ UI๊น์ง ์ ๋ฌํ์ง ๋ชปํ๋ ํ๋ฉด์ด ํ ์ ์๋ ๊ฒ ์๋ค. ์๋ฌ๋ ์ก์์ ์จ๊ธฐ๋ ๊ฒ ์๋๋ผ, ํ๋ฉด์ด ์์๋ณผ ์ ์๋ ์ํ๋ก ์ ๋ฌํด์ผ ํ๋ค.
ํด๊ฒฐ 1: ์๋ฌ๋ฅผ ๊ทธ๋๋ก ๋์ง๊ณ , ํ๋ฉด์์ ๋ค ๊ฐ์ง ์ํ๋ก ๋๋๋ค
๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ค๋ ์ชฝ์์๋ try/catch๋ฅผ ๋นผ๊ณ ์๋ฌ๊ฐ ๊ทธ๋๋ก ์ฌ๋ผ๊ฐ๊ฒ ๋๋ค. Riverpod์ FutureProvider๋ ๋์ ธ์ง ์๋ฌ๋ฅผ AsyncValue์ ์๋ฌ ์ํ๋ก ๋ฐ์์ค๋ค.
final usersProvider = FutureProvider<List<User>>((ref) async {
final response = await dio.get('/users');
final list = response.data as List;
return list.map((e) => User.fromJson(e as Map<String, dynamic>)).toList();
});ํ๋ฉด์ ๋ค ๊ฐ์ง ์ํ๋ฅผ ๋ชจ๋ ๊ทธ๋ฆฐ๋ค. ์ง๋ ๊ธ์ ๋ก๋ฉ, ๋ฐ์ดํฐ, ์๋ฌ์ "๋ฐ์ดํฐ๊ฐ ๋น์ด ์์"์ด ์ถ๊ฐ๋๋ค.
class UserListView extends ConsumerWidget {
const UserListView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final users = ref.watch(usersProvider);
return users.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => ErrorView(
message: errorMessage(e),
onRetry: () => ref.invalidate(usersProvider),
),
data: (list) {
if (list.isEmpty) {
return const Center(child: Text('์์ง ์ฌ์ฉ์๊ฐ ์์ด์')); // ์ง์ง ๋น ๊ฒฝ์ฐ
}
return ListView.builder(
itemCount: list.length,
itemBuilder: (context, i) => ListTile(title: Text(list[i].name)),
);
},
);
}
}์ด์ "๋ฐ์ดํฐ ์์"์ data ์์ isEmpty๋ก๋ง ๋ํ๋๊ณ , ์คํจ๋ error๋ก ๋๋๋ค. ๋ ๊ฒฝ์ฐ๊ฐ ์์ด์ง ์๋๋ค.
์๋ฌ ํ๋ฉด์๋ ์ด์ ์ ๋ค์ ์๋ ๋ฒํผ์ ๋ฃ๋๋ค
class ErrorView extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const ErrorView({super.key, required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message),
const SizedBox(height: 12),
ElevatedButton(onPressed: onRetry, child: const Text('๋ค์ ์๋')),
],
),
);
}
}VoidCallback์ ํจ์ ๊ธ์์ ๋ณธ () -> Unit์ ํด๋นํ๋ ์ฝ๋ฐฑ ํ์
์ด๋ค. ๋ค์ ์๋๋ฅผ ๋๋ฅด๋ฉด ref.invalidate(usersProvider)๊ฐ ๋ถ๋ ค์ ์์ฒญ์ ์๋ก ๋ณด๋ธ๋ค. ํ ๋ฒ ๋ง๋ ErrorView๋ ์ฑ์ ์ด๋ ํ๋ฉด์์๋ ์ฌ์ฌ์ฉํ๋ค.
์ฌ์ฉ์์๊ฒ e.toString()์ ๊ทธ๋๋ก ๋ณด์ฌ์ฃผ์ง ์๋๋ค
Text('์๋ฌ: $e')๋ก ์์ธ๋ฅผ ๊ทธ๋๋ก ๋ณด์ฌ์ฃผ๋ฉด DioException [connection error]: ... ๊ฐ์ ๊ธด ๋ฌธ์ฅ์ด ๋์จ๋ค. ์๋ฌ์ ์ข
๋ฅ๋ฅผ ๋ณด๊ณ ์ฌ์ฉ์๊ฐ ์ดํดํ ๋ฌธ๊ตฌ๋ก ๋ฐ๊ฟ์ฃผ๋ ํจ์๋ฅผ ํ ๊ณณ์ ๋ง๋ ๋ค.
String errorMessage(Object error) {
if (error is DioException) {
return switch (error.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.receiveTimeout =>
'์๋ต์ด ๋๋ฌด ๋ฆ์ด์. ์ ์ ํ ๋ค์ ์๋ํด ์ฃผ์ธ์',
DioExceptionType.connectionError => '์ธํฐ๋ท ์ฐ๊ฒฐ์ ํ์ธํด ์ฃผ์ธ์',
DioExceptionType.badResponse =>
'์๋ฒ์ ๋ฌธ์ ๊ฐ ์์ด์ (${error.response?.statusCode})',
_ => '์ ์ ์๋ ์ค๋ฅ๊ฐ ๋ฌ์ด์',
};
}
return '์ ์ ์๋ ์ค๋ฅ๊ฐ ๋ฌ์ด์';
}HTTP ํต์ ๊ธ์์ ๋ณธ e.type์ด ์ฌ๊ธฐ์ ์ฐ์ธ๋ค. error is DioException์ "์ด ์๋ฌ๊ฐ dio๊ฐ ๋์ง ๊ฒ์ธ๊ฐ"๋ฅผ ํ์ธํ๋ is ๊ฒ์ฌ์ด๊ณ , ๊ทธ ์์์๋ error๋ฅผ DioException์ผ๋ก ๋ฐ๋ก ์ธ ์ ์๋ค.
๋น๊ฒจ์ ์๋ก๊ณ ์นจ: RefreshIndicator
RefreshIndicator(
onRefresh: () async {
ref.invalidate(usersProvider);
await ref.read(usersProvider.future); // ์ ๋ฐ์ดํฐ๊ฐ ์ฌ ๋๊น์ง ๊ธฐ๋ค๋ฆผ
},
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(), // ํญ๋ชฉ์ด ์ ์ด๋ ๋น๊ฒจ์ง๊ฒ
itemCount: list.length,
itemBuilder: (context, i) => ListTile(title: Text(list[i].name)),
),
)onRefresh๊ฐ Future๋ฅผ ๋๋ ค์ฃผ๋ ๋์ ๋๊ทธ๋ผ๋ฏธ๊ฐ ๋๋ค. ๊ทธ๋ฆฌ๊ณ ํ ๊ฐ์ง ํจ์ ์ด ์๋ค. ํญ๋ชฉ์ด ํ๋ฉด๋ณด๋ค ์ ์ด์ ๋ชฉ๋ก์ด ์คํฌ๋กค๋์ง ์์ผ๋ฉด ๋น๊ฒจ๋ ์๋ก๊ณ ์นจ์ด ์ ๋๋ค. AlwaysScrollableScrollPhysics๋ฅผ ๋ฌ์์ ํญ์ ๋น๊ฒจ์ง๊ฒ ํด์ผ ํ๋ค. RefreshIndicator์ child๋ ์คํฌ๋กค๋๋ ์์ ฏ์ด์ด์ผ ํ๋ค.
ํ๋ฉด ์ ์ฒด๊ฐ ์๋๋ผ ์ผ๋ถ๋ถ์ด ์คํจํ์ ๋: SnackBar
๋ชฉ๋ก ๋ถ๋ฌ์ค๊ธฐ๊ฐ ์คํจํ๋ฉด ํ๋ฉด ์ ์ฒด๋ฅผ ์๋ฌ๋ก ๋ฐ๊พธ์ง๋ง, ๋ฒํผ์ ๋๋ฌ ์ ์ฅํ๋ ๋์์ด ์คํจํ์ ๋๋ ํ๋ฉด์ ๋ฐ๊พธ์ง ์๊ณ ์ ๊น ์๋ฆผ๋ง ๋์ด๋ค.
ElevatedButton(
onPressed: () async {
try {
await saveUser();
} catch (e) {
if (!context.mounted) return; // ๊ธฐ๋ค๋ฆฌ๋ ๋์ ํ๋ฉด์ด ์ฌ๋ผ์ก์ ์ ์์
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('์ ์ฅ์ ์คํจํ์ด์')),
);
}
},
child: const Text('์ ์ฅ'),
)await ๋ค์์ context๋ฅผ ์ฐ๊ธฐ ์ ์ context.mounted๋ฅผ ํ์ธํ๋ค. ์๋ฒ ์๋ต์ ๊ธฐ๋ค๋ฆฌ๋ ๋์ ์ฌ์ฉ์๊ฐ ๋ค๋ก ๊ฐ๊ธฐ๋ฅผ ๋๋ฌ์ ์ด ํ๋ฉด์ด ์ด๋ฏธ ์ฌ๋ผ์ก์ ์ ์๊ณ , ์ฌ๋ผ์ง ํ๋ฉด์ context๋ก ์ค๋ต๋ฐ๋ฅผ ๋์ฐ๋ฉด ์๋ฌ๊ฐ ๋๋ค. ์๋ํฐ๋ ์ด๋ฐ ์ฝ๋์ "๋น๋๊ธฐ ๊ตฌ๊ฐ ๊ฑด๋ BuildContext๋ฅผ ์ฐ์ง ๋ง๋ผ"๋ ๊ฒฝ๊ณ ๋ฅผ ๋์ด๋ค.
- ๋ชฉ๋ก์ฒ๋ผ ํ๋ฉด์ ๋ด์ฉ ์์ฒด๋ฅผ ๋ชป ๊ฐ์ ธ์๋ค๋ฉด: ํ๋ฉด ์ ์ฒด ์๋ฌ + ๋ค์ ์๋ ๋ฒํผ
- ์ ์ฅ, ์ญ์ ์ฒ๋ผ ๋์ ํ๋๊ฐ ์คํจํ๋ค๋ฉด: ํ๋ฉด์ ๊ทธ๋๋ก ๋๊ณ
SnackBar
๋ง์ง๋ง ์์ ๋ง: ์ด๋์๋ ์ก์ง ๋ชปํ ์๋ฌ
try/catch์ AsyncValue๋ก ๋ค๋ฃฌ ์๋ฌ ๋ง๊ณ , ์์ ๋ชป ํ ์๋ฌ๊ฐ ์ฑ์ ์ฃฝ์ด๊ฑฐ๋ ๋นจ๊ฐ ํ๋ฉด์ ๋์ธ ์ ์๋ค. Flutter๋ ์ด๋ฐ ์๋ฌ๋ฅผ ํ๊ณณ์์ ๋ฐ๋ ์๋ฆฌ๋ฅผ ๋ ๊ฐ ์ค๋ค.
import 'dart:ui'; // PlatformDispatcher
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Flutter๊ฐ ์ก์ ์๋ฌ(์์ ฏ์ ๊ทธ๋ฆฌ๋ค ๋ ์๋ฌ ๋ฑ)
FlutterError.onError = (details) {
FlutterError.presentError(details); // ๊ธฐ๋ณธ ๋์ ์ ์ง
// ์ฌ๊ธฐ์ ์ค๋ฅ ์์ง ์๋น์ค๋ก ๋ณด๋ธ๋ค
};
// Flutter ์ฝ๋ฐฑ ๋ฐ์์ ๋ ์๋ฌ(๋น๋๊ธฐ, ํ๋ซํผ ํธ์ถ ๋ฑ)
PlatformDispatcher.instance.onError = (error, stack) {
// ์ฌ๊ธฐ์๋ ์ค๋ฅ ์์ง ์๋น์ค๋ก ๋ณด๋ธ๋ค
return true; // ์ฒ๋ฆฌํ๋ค๊ณ ์๋ฆผ
};
runApp(const MyApp());
}FlutterError.onError: ํ๋ ์์ํฌ๊ฐ ์ก์ ๋ชจ๋ ์๋ฌ๊ฐ ๋ชจ์ด๋ ๊ณณ์ด๋ค. ๊ธฐ๋ณธ๊ฐ์ ์๋ฌ๋ฅผ ๊ธฐ๊ธฐ ๋ก๊ทธ์ ์ถ๋ ฅํ๋คPlatformDispatcher.instance.onError: Flutter ์ฝ๋ฐฑ ์์์ ๋ ๊ฒ ์๋ ์๋ฌ๊ฐ ๋ชจ์ด๋ ๊ณณ์ด๋ค.Future์์์ ์กํ์ง ์์ ์์ธ ๋ฑ์ด ์ฌ๊ธฐ๋ก ์จ๋ค
์ด ๋ ๊ณณ์ ํฌ๋์ ๋ฆฌํฌํธ ์๋น์ค๋ฅผ ์ฐ๊ฒฐํ๋ฉด ์ฌ์ฉ์ ๊ธฐ๊ธฐ์์ ๋ ์๋ฌ๋ฅผ ๋ด๊ฐ ๋ฐ์๋ณผ ์ ์๋ค. ์์ ฏ์ ๊ทธ๋ฆฌ๋ค ์คํจํ์ ๋ ๋ณด์ด๋ ์๋ฌ ํ๋ฉด๋ ๋ฐ๊ฟ ์ ์๋ค.
ErrorWidget.builder = (details) {
return const Scaffold(
body: Center(child: Text('๋ฌธ์ ๊ฐ ์๊ฒผ์ด์. ์ฑ์ ๋ค์ ์คํํด ์ฃผ์ธ์')),
);
};๊ธฐ๋ณธ๊ฐ์ ๊ฐ๋ฐ ์ค์๋ ๋นจ๊ฐ ์๋ฌ ํ๋ฉด์ด ๋จ๋๋ฐ, ์ฌ์ฉ์์๊ฒ๋ ์ด๋ ๊ฒ ์ฝ์ ์ ์๋ ๋ฌธ๊ตฌ๋ฅผ ๋ณด์ฌ์ฃผ๋ ๊ฒ ๋ซ๋ค.
Compose, SwiftUI์ ๋น๊ตํ๋ฉด
// Compose - UiState + Snackbar
when (val s = state) {
is UiState.Loading -> CircularProgressIndicator()
is UiState.Error -> ErrorView(s.message, onRetry = viewModel::retry)
is UiState.Success -> if (s.items.isEmpty()) EmptyView() else ItemList(s.items)
}
scope.launch { snackbarHostState.showSnackbar("์ ์ฅ์ ์คํจํ์ด์") }// SwiftUI - enum ์ํ + alert
switch state {
case .loading: ProgressView()
case .error(let message): ErrorView(message: message, retry: load)
case .loaded(let items): items.isEmpty ? AnyView(EmptyView()) : AnyView(ItemList(items))
}
.alert("์ ์ฅ์ ์คํจํ์ด์", isPresented: $showAlert) { Button("ํ์ธ") {} }- ๋ค ๊ฐ์ง ์ํ ๋๋๊ธฐ: Flutter
AsyncValue.when+isEmpty/ Composesealed UiState์when/ SwiftUIenum๊ณผswitch - ๊ฐ๋ฒผ์ด ์๋ฆผ: Flutter
SnackBar/ ComposeSnackbarHost/ SwiftUI.alert๋ ํ ์คํธ ์ง์ ๊ตฌํ - ์๋ก๊ณ ์นจ: Flutter
RefreshIndicator/ ComposePullToRefreshBox/ SwiftUI.refreshable - ๋น๋๊ธฐ ํ ํ๋ฉด ํ์ธ: Flutter
context.mounted/ Compose๋ ์ฝ๋ฃจํด ์ทจ์๊ฐ ์๋ / SwiftUITask๊ฐ ๋ทฐ ์๋ช ์ ๋ฌถ์ - ์ ์ญ ์๋ฌ: Flutter
FlutterError.onError,PlatformDispatcher.instance.onError/ ์๋๋ก์ด๋UncaughtExceptionHandler/ iOS๋ ํฌ๋์ ๋ฆฌํฌํฐ ์ฐ๋
์ธ ๊ณณ ๋ชจ๋ ์ํ๋ฅผ ๋ก๋ฉ, ์๋ฌ, ๋น ํ๋ฉด, ๋ฐ์ดํฐ๋ก ๋๋ ๊ทธ๋ฆฌ๋ ๋ฐ์์ ๊ฐ๋ค. Flutter๋ง์ ํน์ง์ await ๋ค์ context.mounted๋ฅผ ์ง์ ํ์ธํด์ผ ํ๋ค๋ ์ ์ด๋ค.
์ ๋ฆฌ
catch์์ ์๋ฌ๋ฅผ ์ผํค๊ณ ๋น ๋ชฉ๋ก์ ๋๋ ค์ฃผ๋ฉด "์คํจ"์ "๋ฐ์ดํฐ ์์"์ด ๊ตฌ๋ถ๋์ง ์์ผ๋ฏ๋ก, ์๋ฌ๋ฅผ ๋์ ธ์ ํ๋ฉด๊น์ง ์ ๋ฌํ๋ค- ํ๋ฉด์ ๋ก๋ฉ, ์๋ฌ, ๋น ๊ฒฐ๊ณผ, ๋ฐ์ดํฐ ๋ค ๊ฐ์ง๋ฅผ ๋ชจ๋ ๊ทธ๋ฆฌ๊ณ , ์๋ฌ ํ๋ฉด์๋ ์ด์ ์
๋ค์ ์๋๋ฒํผ์ ๋ฃ๋๋ค e.toString()์ ๊ทธ๋๋ก ๋ณด์ด์ง ์๊ณ , ์๋ฌ ์ข ๋ฅ(DioExceptionType)์ ๋ฐ๋ผ ์ฌ์ฉ์ ๋ฌธ๊ตฌ๋ก ๋ฐ๊พธ๋ ํจ์๋ฅผ ํ๊ณณ์ ๋๋คRefreshIndicator๋ ์คํฌ๋กค ์์ ฏ์ด ํ์ํ๊ณ , ํญ๋ชฉ์ด ์ ์ ๋๋ ๋น๊ฒจ์ง๋ ค๋ฉดAlwaysScrollableScrollPhysics๋ฅผ ๋จ๋ค- ํ๋ฉด ์ ์ฒด๋ฅผ ๋ชป ๊ฐ์ ธ์์ผ๋ฉด ์๋ฌ ํ๋ฉด, ๋์ ํ๋๊ฐ ์คํจํ์ผ๋ฉด
SnackBar๋ฅผ ์ฐ๊ณ ,await๋ค์์๋context.mounted๋ฅผ ๋จผ์ ํ์ธํ๋ค - ์์ ๋ชป ํ ์๋ฌ๋
FlutterError.onError์PlatformDispatcher.instance.onError์์ ํ๊ณณ์ผ๋ก ๋ชจ์ ์ค๋ฅ ์์ง ์๋น์ค์ ๋ณด๋ธ๋ค
๋ค์ ๊ธ๋ถํฐ๋ 5๋จ๊ณ๋ก ๋์ด๊ฐ์, ์ฑ์ด ์ปค์ก์ ๋ ํ์ผ์ ์ด๋ป๊ฒ ๋๋์ง, ํด๋ ๊ตฌ์กฐ(Feature-first์ Layer-first)๋ฅผ ๋ค๋ค๋ณด๊ฒ ๋ค.
