iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
AI Engineering

從行程到應援:30 天做出追星實用工具箱系列 第 25 篇

Day 25:例外處理與使用者體驗防呆機制

  • 分享至 

  • xImage
  •  

在使用過程中,常常會出現各種無意的誤操作或系統權限問題,例如:

  1. 麥克風權限未開啟:用戶剛安裝 App 就按下錄音,直接崩潰或靜默無反應。
  2. 連點與誤觸:用戶在「開始 / 停止錄音」或「生成摘要」按鈕上連續狂點,觸發多次重複請求。
  3. API Key 填錯或額度耗盡 (401/429 Error):後端退件但前端沒有給出友善提示。
  4. 極短語音音訊切片:講不到 0.1 秒就按停,導致音訊有空白請求。

今天我們就來寫一套完整的 AppException 體系與 UI 防呆機制!

一、今日目標

  1. 建立統一例外處理類別,分類管理權限、網路、API 額度與音訊錯誤。
  2. 實作 UI 防呆機制 (Debounce & Throttle):防止連擊按鈕與極短音訊傳送。
  3. 友善 Toast / SnackBar 提示:用使用者聽得懂的話代替冷冰冰的 Exception 堆疊。

二、今日任務

1.建立全域例外處理機制

在 lib/core/errors/建立 app_exception.dart

/// 全域統一例外處理模型
sealed class AppException implements Exception {
  final String message;
  final String code;

  AppException(this.message, this.code);

  @override
  String toString() => '[$code] $message';
}

/// 權限類例外 (麥克風未授權)
class PermissionDeniedException extends AppException {
  PermissionDeniedException([String message = '未取得麥克風使用權限,請至手機設定開啟'])
      : super(message, 'PERMISSION_DENIED');
}

/// API 額度或密鑰例外 (401 / 429)
class ApiKeyOrQuotaException extends AppException {
  ApiKeyOrQuotaException([String message = 'API 金鑰無效或額度已耗盡'])
      : super(message, 'API_QUOTA_EXCEEDED');
}

/// 音訊過短例外
class AudioTooShortException extends AppException {
  AudioTooShortException([String message = '錄音時間過短,請再試一次'])
      : super(message, 'AUDIO_TOO_SHORT');
}

/// 網路連線例外
class NetworkException extends AppException {
  NetworkException([String message = '網路連線異常,請檢查您的網路狀態'])
      : super(message, 'NETWORK_ERROR');
}

2.實作按鈕防連擊 (Debouncer) 與防呆控制

建立 lib/core/utils/debouncer.dart,避免使用者在按下「一鍵生成摘要」或「啟動錄音」時連續猛點

import 'dart:async';

class Debouncer {
  final Duration delay;
  Timer? _timer;

  Debouncer({this.delay = const Duration(milliseconds: 500)});

  /// 在指定的 delay 時間內重複觸發只會執行最後一次
  void run(Function action) {
    _timer?.cancel();
    _timer = Timer(delay, () => action());
  }

  void dispose() {
    _timer?.cancel();
  }
}

3.在 AudioRecorderService 加入權限與長度防呆

import 'package:permission_handler/permission_handler.dart';
import '../../core/errors/app_exception.dart';

class AudioRecorderService {
  DateTime? _recordingStartTime;

  /// 啟動錄音(帶有權限防呆)
  Future<void> startChunkedRecording(Function(Uint8List chunk) onAudioChunkReceived) async {
    // 1. 檢查並請求麥克風權限
    final status = await Permission.microphone.request();
    if (status.isDenied || status.isPermanentlyDenied) {
      throw PermissionDeniedException(); // 拋出友善的自訂 Exception
    }

    _recordingStartTime = DateTime.now();

    // ... 原本的啟動錄音邏輯 ...
  }

  /// 停止錄音(帶有時間長度防呆)
  Future<void> stopRecording() async {
    if (_recordingStartTime != null) {
      final duration = DateTime.now().difference(_recordingStartTime!);
      // 2. 錄音少於 0.8 秒視為誤觸,自動攔截
      if (duration.inMilliseconds < 800) {
        _recordingStartTime = null;
        throw AudioTooShortException();
      }
    }
    
    _recordingStartTime = null;
    // ... 原本的停止錄音邏輯 ...
  }
}

4.在 UI 層全域捕捉 Exception 並顯示友善提示

更新 multi_lang_page.dart 的事件處理邏輯,優化防呆與 Exception 攔截

final Debouncer _btnDebouncer = Debouncer(delay: Duration(milliseconds: 600));

// 啟動錄音按鈕觸發事件
void _handleToggleRecording() async {
  // 1. 使用 Debouncer 避免連點
  _btnDebouncer.run(() async {
    try {
      if (_isRecording) {
        await _audioRecorderService.stopRecording();
        setState(() => _isRecording = false);
      } else {
        await _audioRecorderService.startChunkedRecording((chunk) {
          _processChunk(chunk);
        });
        setState(() => _isRecording = true);
      }
    } on PermissionDeniedException catch (e) {
      _showErrorSnackBar(e.message, isSettingsAction: true);
    } on AudioTooShortException catch (e) {
      _showErrorSnackBar(e.message);
    } on ApiKeyOrQuotaException catch (e) {
      _showErrorSnackBar('${e.message}');
    } catch (e) {
      _showErrorSnackBar('發生未知錯誤,請稍後再試');
    }
  });
}

/// 友善的 SnackBar UI 顯示工具
void _showErrorSnackBar(String message, {bool isSettingsAction = false}) {
  if (!mounted) return;
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: Text(message),
      backgroundColor: Colors.redAccent.shade700,
      behavior: SnackBarBehavior.floating,
      action: isSettingsAction
          ? SnackBarAction(
              label: '前往設定',
              textColor: Colors.amber,
              onPressed: () => openAppSettings(),
            )
          : null,
    ),
  );
}

檢查清單

  • 連擊測試:在「開始錄音」按鈕上快速連點 5 次,Debouncer 成功發揮作用,只發送 1 次請求,畫面順暢不卡。
  • 誤觸測試:按下錄音後 0.2 秒立刻按停止,觸發錯誤資訊,底部彈出警示 「錄音時間過短,請再試一次」,不會拋出後端 API 錯誤。
  • 權限拒絕測試:故意拒絕麥克風權限,底部精準顯示 「未取得麥克風權限」 並附帶 [前往設定] 一鍵跳轉按鈕。

上一篇
Day 24:網路不穩定與噪音環境下的翻譯表現
下一篇
Day 26:實作「自訂專屬字典」管理介面
系列文
從行程到應援:30 天做出追星實用工具箱 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言